diff --git a/docs_src/src/pages/documentation/example_app/index.mdx b/docs_src/src/pages/documentation/example_app/index.mdx index aa23ad606..12ffe8e0c 100644 --- a/docs_src/src/pages/documentation/example_app/index.mdx +++ b/docs_src/src/pages/documentation/example_app/index.mdx @@ -25,12 +25,25 @@ Batman wanted to create a Robyn app and was about to create an `src/app.py` befo $ python -m robyn --create ``` -This, would result in the following output. +You can choose to have a simple barebones format or an structured scaffold (recommended) ike so... + +```bash +$ python -m robyn --create +? Directory Path: myproject +? Need Docker? (Y/N) Y +? Please choose if you'd like the scaffold to be a simple starter kit or an opinionated structure + Simple +❯ Structured +``` + + +This, would result in the following output if you choose scaffold type as `simple` ```bash $ python3 -m robyn --create ? Directory Path: . ? Need Docker? (Y/N) Y +? Please choose if you would like the scaffold to be a barebones starter kit or a recommended structure ? Please select project type (Mongo/Postgres/Sqlalchemy/Prisma): ❯ No DB Sqlite @@ -40,12 +53,7 @@ $ python3 -m robyn --create Prisma ``` -and the following directory structure. - - -Batman was asked a set of questions to configure the application. He chose to use the default values for most of the questions. - -And he was done! The Robyn CLI created a new application with the following structure. +and the following directory structure ```bash @@ -65,3 +73,134 @@ And he was done! The Robyn CLI created a new application with the following stru /> +If you choose to go with the structured scaffold, this is how your project will look like + +> note: at the moment, only no-db and sqlalchemy are supported here, you can always plug in other integrations as you see fit + +```bash +├── adaptors +│   ├── __init__.py +│   ├── models +│   │   ├── __init__.py +│   │   └── user.py +│   ├── mutators +│   │   └── __init__.py +│   └── selectors +│   ├── __init__.py +│   └── misc.py +├── alembic.ini +├── api +│   ├── handlers +│   │   ├── __init__.py +│   │   ├── probes.py +│   │   └── sample.py +│   └── middlewares +│   └── __init__.py +├── conf.py +├── config.env +├── devops +│   ├── Dockerfile +│   ├── Dockerfile.src +│   └── docker-compose.yaml +├── migrations +│   ├── README +│   ├── env.py +│   ├── script.py.mako +│   └── versions +│   └── __init__.py +├── requirements.txt +├── server.py +└── utils + ├── __init__.py + └── db.py + +12 directories, 24 files +``` + +Here's what each of these stand for + +- server.py + +This is where you instantiate your robyn server and inject global dependencies + +```python +from robyn.helpers import discover_routes +from robyn import Robyn + +from utils.db import get_pool +from conf import settings + +app: Robyn = discover_routes("api.handlers") +# note: if you prefer to manuall refine routes, use your build_routes function instead + +app.inject_global(pool=get_pool()) + + +if __name__ == "__main__": + app.start(host="0.0.0.0", port=settings.service_port) +``` + +- conf.py/config.env + +Comes with initial settings you need to work with the database. The `BaseConfig` class slight enhancement on pydantic-settings's `BaseSettings` class + +- Your endpoint and middleware handlers live under the `api` package. +> note: the example where the handlers are in a class as static methods is completely up to the developer preference and doesn't impact the actual routing in any way + +- It also comes pre-configured with alembic for database migrations + +- The database mutations and queries will be living under the `adaptors` package where you would do the following + - define your sqlalchemy and pydantic models + - define selectors for reusable query functions + - define mutators for any mutations over the model along with related functionality, usually in a transaction block + +> note: by default, we include sqlalchemy async pool, you can always change it as per your requirements + +Once you have set up your project like so, make sure to update the `sqlalchemy.url` field in your `alembic.ini`file to point to your DB like so... + +``` +sqlalchemy.url = postgresql+psycopg://consoleuser:buddy123@localhost:5432/console +``` + +Then, once you are done writing your sqlalchemy model classes, you can go ahead and generate your migration files like so + +```bash +alembic revision --autogenerate -m initial +``` +You should see an output like so + +``` +/home/batman/.virtualenvs/base/lib/python3.12/site-packages/pydantic/_internal/_config.py:334: UserWarning: Valid config keys have changed in V2: +* 'orm_mode' has been renamed to 'from_attributes' + warnings.warn(message, UserWarning) +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.autogenerate.compare] Detected added table 'users' +INFO [alembic.autogenerate.compare] Detected added index ''ix_users_id'' on '('id',)' +INFO [alembic.autogenerate.compare] Detected added index ''ix_users_username'' on '('username',)' +INFO [alembic.ddl.postgresql] Detected sequence named 'transactions_id_seq' as owned by integer column 'transactions(id)', assuming SERIAL and omitting +INFO [alembic.autogenerate.compare] Detected removed index 'idx_user_id' on 'transactions' +INFO [alembic.autogenerate.compare] Detected removed table 'transactions' + Generating /home/ashu/Desktop/aaa/migrations/versions/cefc632435c7_initial.py ... done +``` + +This will generate the migration files like so + +```bash +$ tree migrations/ +migrations/ +├── env.py +├── README +├── script.py.mako +└── versions + ├── cefc632435c7_initial.py + └── __init__.py + +2 directories, 5 files +``` + +Now you can apply the migrations with `alembic upgrade head` + +Batman was asked a set of questions to configure the application. He chose to use the default values for most of the questions. + +And he was done! The Robyn CLI created a new application with the following structure. diff --git a/integration_tests/test_cli.py b/integration_tests/test_cli.py index 2ee94204b..7c369d5ee 100644 --- a/integration_tests/test_cli.py +++ b/integration_tests/test_cli.py @@ -4,11 +4,27 @@ # Unit tests -def test_create_robyn_app(): +def test_create_robyn_app_simple(): with patch("robyn.cli.prompt") as mock_prompt: mock_prompt.return_value = { "directory": "test_dir", "docker": "N", + "scaffold_type": "simple", + "project_type": "no-db", + } + with patch("robyn.cli.os.makedirs") as mock_makedirs: + with patch("robyn.cli.shutil.copytree") as mock_copytree, patch("robyn.os.remove") as _mock_remove: + create_robyn_app() + mock_makedirs.assert_called_once() + mock_copytree.assert_called_once() + + +def test_create_robyn_app_structured(): + with patch("robyn.cli.prompt") as mock_prompt: + mock_prompt.return_value = { + "directory": "test_dir", + "docker": "N", + "scaffold_type": "structured", "project_type": "no-db", } with patch("robyn.cli.os.makedirs") as mock_makedirs: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..924e51a7d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,56 @@ +argcomplete==2.0.6 ; python_version >= "3.8" and python_version < "4.0" +attrs==24.2.0 ; python_version >= "3.8" and python_version < "4.0" +black==23.1.0 ; python_version >= "3.8" and python_version < "4.0" +certifi==2024.8.30 ; python_version >= "3.8" and python_version < "4" +cffi==1.15.1 ; python_version >= "3.8" and python_version < "4.0" +cfgv==3.4.0 ; python_version >= "3.8" and python_version < "4.0" +charset-normalizer==2.1.1 ; python_version >= "3.8" and python_version < "4.0" +click==8.1.7 ; python_version >= "3.8" and python_version < "4.0" +colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" +colorlog==6.8.2 ; python_version >= "3.8" and python_version < "4.0" +commitizen==2.40.0 ; python_version >= "3.8" and python_version < "4.0" +decli==0.5.2 ; python_version >= "3.8" and python_version < "4.0" +dill==0.3.8 ; python_version >= "3.8" and python_version < "4.0" +distlib==0.3.8 ; python_version >= "3.8" and python_version < "4.0" +exceptiongroup==1.2.2 ; python_version >= "3.8" and python_version < "3.11" +filelock==3.16.1 ; python_version >= "3.8" and python_version < "4.0" +identify==2.6.1 ; python_version >= "3.8" and python_version < "4.0" +idna==3.10 ; python_version >= "3.8" and python_version < "4" +iniconfig==2.0.0 ; python_version >= "3.8" and python_version < "4.0" +inquirerpy==0.3.4 ; python_version >= "3.8" and python_version < "4.0" +isort==5.11.5 ; python_version >= "3.8" and python_version < "4.0" +jinja2==3.0.1 ; python_version >= "3.8" and python_version < "4.0" +markupsafe==2.1.5 ; python_version >= "3.8" and python_version < "4.0" +maturin==0.14.12 ; python_version >= "3.8" and python_version < "4.0" +multiprocess==0.70.14 ; python_version >= "3.8" and python_version < "4.0" +mypy-extensions==1.0.0 ; python_version >= "3.8" and python_version < "4.0" +nestd==0.3.1 ; python_version >= "3.8" and python_version < "4.0" +nodeenv==1.9.1 ; python_version >= "3.8" and python_version < "4.0" +nox==2023.4.22 ; python_version >= "3.8" and python_version < "4.0" +orjson==3.10.7 ; python_version >= "3.8" and python_version < "4.0" +packaging==24.1 ; python_version >= "3.8" and python_version < "4.0" +pathspec==0.12.1 ; python_version >= "3.8" and python_version < "4.0" +pfzy==0.3.4 ; python_version >= "3.8" and python_version < "4.0" +platformdirs==4.3.6 ; python_version >= "3.8" and python_version < "4.0" +pluggy==1.5.0 ; python_version >= "3.8" and python_version < "4.0" +pre-commit==2.21.0 ; python_version >= "3.8" and python_version < "4.0" +prompt-toolkit==3.0.48 ; python_version >= "3.8" and python_version < "4.0" +pycparser==2.22 ; python_version >= "3.8" and python_version < "4.0" +pytest-codspeed==1.2.2 ; python_version >= "3.8" and python_version < "4.0" +pytest==7.2.1 ; python_version >= "3.8" and python_version < "4.0" +pyyaml==6.0.2 ; python_version >= "3.8" and python_version < "4.0" +questionary==1.10.0 ; python_version >= "3.8" and python_version < "4.0" +requests==2.28.2 ; python_version >= "3.8" and python_version < "4" +ruff==0.1.3 ; python_version >= "3.8" and python_version < "4.0" +rustimport==1.5.0 ; python_version >= "3.8" and python_version < "4.0" +termcolor==2.4.0 ; python_version >= "3.8" and python_version < "4.0" +toml==0.10.2 ; python_version >= "3.8" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.8" and python_version < "3.11" +tomlkit==0.13.2 ; python_version >= "3.8" and python_version < "4.0" +typing-extensions==4.12.2 ; python_version >= "3.8" and python_version < "4.0" +urllib3==1.26.20 ; python_version >= "3.8" and python_version < "4" +uvloop==0.19.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.8" and python_version < "4.0" +virtualenv==20.26.6 ; python_version >= "3.8" and python_version < "4.0" +watchdog==4.0.1 ; python_version >= "3.8" and python_version < "4.0" +wcwidth==0.2.13 ; python_version >= "3.8" and python_version < "4.0" +websocket-client==1.5.0 ; python_version >= "3.8" and python_version < "4.0" diff --git a/robyn/cli.py b/robyn/cli.py index 2466361e0..e83b13d25 100644 --- a/robyn/cli.py +++ b/robyn/cli.py @@ -38,24 +38,53 @@ def create_robyn_app(): }, { "type": "list", - "message": "Please select project type (Mongo/Postgres/Sqlalchemy/Prisma): ", - "choices": [ - Choice("no-db", name="No DB"), - Choice("sqlite", name="Sqlite"), - Choice("postgres", name="Postgres"), - Choice("mongo", name="MongoDB"), - Choice("sqlalchemy", name="SqlAlchemy"), - Choice("prisma", name="Prisma"), - Choice("sqlmodel", name="SQLModel"), - ], - "default": Choice("no-db", name="No DB"), - "name": "project_type", + "name": "scaffold_type", + "choices": [Choice("simple", name="Simple"), Choice("structured", name="Structured")], + "message": "Please choose if you'd like the scaffold to be a simple starter kit or an opinionated structure", }, ] result = prompt(questions=questions) project_dir_path = Path(str(result["directory"])).resolve() docker = result["docker"] - project_type = str(result["project_type"]) + scaffold_type: str = str(result["scaffold_type"]) + if scaffold_type == "simple": + scaffold_type: str = "simple" + result = prompt( + questions=[ + { + "type": "list", + "message": "Please select project type (Mongo/Postgres/Sqlalchemy/Prisma): ", + "choices": [ + Choice("no-db", name="No DB"), + Choice("sqlite", name="Sqlite"), + Choice("postgres", name="Postgres"), + Choice("mongo", name="MongoDB"), + Choice("sqlalchemy", name="SqlAlchemy"), + Choice("prisma", name="Prisma"), + Choice("sqlmodel", name="SQLModel"), + ], + "default": Choice("no-db", name="No DB"), + "name": "project_type", + } + ] + ) + project_type = str(result["project_type"]) + else: + result = prompt( + questions=[ + { + "type": "list", + "message": "Please select project type (Mongo/Postgres/Sqlalchemy/Prisma): ", + "choices": [ + Choice("no-db", name="No DB"), + Choice("sqlalchemy", name="SqlAlchemy"), + ], + "default": Choice("no-db", name="No DB"), + "name": "project_type", + } + ] + ) + project_type = str(result["project_type"]) final_project_dir_path = (CURRENT_WORKING_DIR / project_dir_path).resolve() @@ -64,12 +93,15 @@ def create_robyn_app(): # Create a new directory for the project os.makedirs(final_project_dir_path, exist_ok=True) - selected_project_template = (SCAFFOLD_DIR / Path(project_type)).resolve() + selected_project_template = (SCAFFOLD_DIR / Path(scaffold_type) / Path(project_type)).resolve() shutil.copytree(str(selected_project_template), str(final_project_dir_path), dirs_exist_ok=True) # If docker is not needed, delete the docker file if docker == "N": - os.remove(f"{final_project_dir_path}/Dockerfile") + if scaffold_type == "simple": + os.remove(f"{final_project_dir_path}/Dockerfile") + else: + shutil.rmtree(f"{final_project_dir_path}/devops", ignore_errors=True) print(f"New Robyn project created in '{final_project_dir_path}' ") diff --git a/robyn/helpers.py b/robyn/helpers.py new file mode 100644 index 000000000..f0459cf95 --- /dev/null +++ b/robyn/helpers.py @@ -0,0 +1,43 @@ +import importlib +import logging +import pkgutil +from typing import Any, Tuple, Type + +from pydantic import ConfigDict +from pydantic_settings import BaseSettings, EnvSettingsSource, PydanticBaseSettingsSource + +from robyn import Robyn + +logger = logging.getLogger(__name__) + + +def discover_routes(handler_path: str = "api.handlers") -> Robyn: + mux: Robyn = Robyn(__file__) + package = importlib.import_module(handler_path) + for _, module_name, _ in pkgutil.iter_modules(package.__path__, package.__name__ + "."): + module = importlib.import_module(module_name) + logger.info(f"member: {module}") + mux.include_router(module.router) + return mux + + +class AcceptArrayEnvsSource(EnvSettingsSource): + def prepare_field_value(self, field_name: str, field: Any, value: Any, value_is_complex: bool) -> Any: + if isinstance(field.annotation, type) and issubclass(field.annotation, list) and isinstance(value, str): + return [x.strip() for x in value.split(",") if x] + return value + + +class BaseConfig(BaseSettings): + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + return (AcceptArrayEnvsSource(settings_cls),) + + model_config = ConfigDict(extra="ignore") # Ignore extra environment variables diff --git a/robyn/scaffold/mongo/Dockerfile b/robyn/scaffold/simple/mongo/Dockerfile similarity index 100% rename from robyn/scaffold/mongo/Dockerfile rename to robyn/scaffold/simple/mongo/Dockerfile diff --git a/robyn/scaffold/mongo/app.py b/robyn/scaffold/simple/mongo/app.py similarity index 100% rename from robyn/scaffold/mongo/app.py rename to robyn/scaffold/simple/mongo/app.py diff --git a/robyn/scaffold/mongo/requirements.txt b/robyn/scaffold/simple/mongo/requirements.txt similarity index 100% rename from robyn/scaffold/mongo/requirements.txt rename to robyn/scaffold/simple/mongo/requirements.txt diff --git a/robyn/scaffold/no-db/Dockerfile b/robyn/scaffold/simple/no-db/Dockerfile similarity index 100% rename from robyn/scaffold/no-db/Dockerfile rename to robyn/scaffold/simple/no-db/Dockerfile diff --git a/robyn/scaffold/no-db/app.py b/robyn/scaffold/simple/no-db/app.py similarity index 100% rename from robyn/scaffold/no-db/app.py rename to robyn/scaffold/simple/no-db/app.py diff --git a/robyn/scaffold/no-db/requirements.txt b/robyn/scaffold/simple/no-db/requirements.txt similarity index 100% rename from robyn/scaffold/no-db/requirements.txt rename to robyn/scaffold/simple/no-db/requirements.txt diff --git a/robyn/scaffold/postgres/Dockerfile b/robyn/scaffold/simple/postgres/Dockerfile similarity index 100% rename from robyn/scaffold/postgres/Dockerfile rename to robyn/scaffold/simple/postgres/Dockerfile diff --git a/robyn/scaffold/postgres/app.py b/robyn/scaffold/simple/postgres/app.py similarity index 100% rename from robyn/scaffold/postgres/app.py rename to robyn/scaffold/simple/postgres/app.py diff --git a/robyn/scaffold/postgres/requirements.txt b/robyn/scaffold/simple/postgres/requirements.txt similarity index 100% rename from robyn/scaffold/postgres/requirements.txt rename to robyn/scaffold/simple/postgres/requirements.txt diff --git a/robyn/scaffold/postgres/supervisord.conf b/robyn/scaffold/simple/postgres/supervisord.conf similarity index 100% rename from robyn/scaffold/postgres/supervisord.conf rename to robyn/scaffold/simple/postgres/supervisord.conf diff --git a/robyn/scaffold/prisma/Dockerfile b/robyn/scaffold/simple/prisma/Dockerfile similarity index 100% rename from robyn/scaffold/prisma/Dockerfile rename to robyn/scaffold/simple/prisma/Dockerfile diff --git a/robyn/scaffold/prisma/app.py b/robyn/scaffold/simple/prisma/app.py similarity index 100% rename from robyn/scaffold/prisma/app.py rename to robyn/scaffold/simple/prisma/app.py diff --git a/robyn/scaffold/prisma/requirements.txt b/robyn/scaffold/simple/prisma/requirements.txt similarity index 100% rename from robyn/scaffold/prisma/requirements.txt rename to robyn/scaffold/simple/prisma/requirements.txt diff --git a/robyn/scaffold/prisma/schema.prisma b/robyn/scaffold/simple/prisma/schema.prisma similarity index 100% rename from robyn/scaffold/prisma/schema.prisma rename to robyn/scaffold/simple/prisma/schema.prisma diff --git a/robyn/scaffold/sqlalchemy/Dockerfile b/robyn/scaffold/simple/sqlalchemy/Dockerfile similarity index 100% rename from robyn/scaffold/sqlalchemy/Dockerfile rename to robyn/scaffold/simple/sqlalchemy/Dockerfile diff --git a/robyn/scaffold/sqlalchemy/__init__.py b/robyn/scaffold/simple/sqlalchemy/__init__.py similarity index 100% rename from robyn/scaffold/sqlalchemy/__init__.py rename to robyn/scaffold/simple/sqlalchemy/__init__.py diff --git a/robyn/scaffold/sqlalchemy/app.py b/robyn/scaffold/simple/sqlalchemy/app.py similarity index 100% rename from robyn/scaffold/sqlalchemy/app.py rename to robyn/scaffold/simple/sqlalchemy/app.py diff --git a/robyn/scaffold/sqlalchemy/models.py b/robyn/scaffold/simple/sqlalchemy/models.py similarity index 100% rename from robyn/scaffold/sqlalchemy/models.py rename to robyn/scaffold/simple/sqlalchemy/models.py diff --git a/robyn/scaffold/sqlalchemy/requirements.txt b/robyn/scaffold/simple/sqlalchemy/requirements.txt similarity index 100% rename from robyn/scaffold/sqlalchemy/requirements.txt rename to robyn/scaffold/simple/sqlalchemy/requirements.txt diff --git a/robyn/scaffold/sqlite/Dockerfile b/robyn/scaffold/simple/sqlite/Dockerfile similarity index 100% rename from robyn/scaffold/sqlite/Dockerfile rename to robyn/scaffold/simple/sqlite/Dockerfile diff --git a/robyn/scaffold/sqlite/app.py b/robyn/scaffold/simple/sqlite/app.py similarity index 100% rename from robyn/scaffold/sqlite/app.py rename to robyn/scaffold/simple/sqlite/app.py diff --git a/robyn/scaffold/sqlite/requirements.txt b/robyn/scaffold/simple/sqlite/requirements.txt similarity index 100% rename from robyn/scaffold/sqlite/requirements.txt rename to robyn/scaffold/simple/sqlite/requirements.txt diff --git a/robyn/scaffold/sqlmodel/Dockerfile b/robyn/scaffold/simple/sqlmodel/Dockerfile similarity index 100% rename from robyn/scaffold/sqlmodel/Dockerfile rename to robyn/scaffold/simple/sqlmodel/Dockerfile diff --git a/robyn/scaffold/sqlmodel/app.py b/robyn/scaffold/simple/sqlmodel/app.py similarity index 100% rename from robyn/scaffold/sqlmodel/app.py rename to robyn/scaffold/simple/sqlmodel/app.py diff --git a/robyn/scaffold/sqlmodel/models.py b/robyn/scaffold/simple/sqlmodel/models.py similarity index 100% rename from robyn/scaffold/sqlmodel/models.py rename to robyn/scaffold/simple/sqlmodel/models.py diff --git a/robyn/scaffold/sqlmodel/requirements.txt b/robyn/scaffold/simple/sqlmodel/requirements.txt similarity index 100% rename from robyn/scaffold/sqlmodel/requirements.txt rename to robyn/scaffold/simple/sqlmodel/requirements.txt diff --git a/robyn/scaffold/structured/no-db/api/handlers/__init__.py b/robyn/scaffold/structured/no-db/api/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/no-db/api/handlers/probes.py b/robyn/scaffold/structured/no-db/api/handlers/probes.py new file mode 100644 index 000000000..898c18f99 --- /dev/null +++ b/robyn/scaffold/structured/no-db/api/handlers/probes.py @@ -0,0 +1,13 @@ +from robyn import SubRouter + +router = SubRouter(__name__, prefix="/") + + +@router.get("/livez/") +def livez() -> str: + return "live" + + +@router.get("/healthz/") +def healthz() -> str: + return "healthy" diff --git a/robyn/scaffold/structured/no-db/api/handlers/sample.py b/robyn/scaffold/structured/no-db/api/handlers/sample.py new file mode 100644 index 000000000..f4bce023e --- /dev/null +++ b/robyn/scaffold/structured/no-db/api/handlers/sample.py @@ -0,0 +1,16 @@ +from robyn import SubRouter + +router = SubRouter(__name__, "/sample") + + +@router.post("/one") +def one(): ... + + +@router.get("/two") +def two(): ... + + +@router.get("three/") +def three(): + return {} diff --git a/robyn/scaffold/structured/no-db/api/middlewares/__init__.py b/robyn/scaffold/structured/no-db/api/middlewares/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/no-db/conf.py b/robyn/scaffold/structured/no-db/conf.py new file mode 100644 index 000000000..d2b535143 --- /dev/null +++ b/robyn/scaffold/structured/no-db/conf.py @@ -0,0 +1,8 @@ +from robyn.helpers import BaseConfig + + +class Settings(BaseConfig): + service_port: int + + +settings = Settings() diff --git a/robyn/scaffold/structured/no-db/config.env b/robyn/scaffold/structured/no-db/config.env new file mode 100644 index 000000000..92ad6152c --- /dev/null +++ b/robyn/scaffold/structured/no-db/config.env @@ -0,0 +1 @@ +SERVICE_PORT=3000 diff --git a/robyn/scaffold/structured/no-db/devops/Dockerfile b/robyn/scaffold/structured/no-db/devops/Dockerfile new file mode 100644 index 000000000..903705ca0 --- /dev/null +++ b/robyn/scaffold/structured/no-db/devops/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-bookworm AS builder + +WORKDIR /workspace + +COPY . . +RUN pip install --no-cache-dir --upgrade -r requirements.txt --target=/workspace/deps + +FROM gcr.io/distroless/python3-debian11 + +WORKDIR /workspace +COPY --from=builder /workspace /workspace +ENV PYTHONPATH=/workspace/deps + +CMD ["server.py", "--log-level=DEBUG"] diff --git a/robyn/scaffold/structured/no-db/devops/Dockerfile.src b/robyn/scaffold/structured/no-db/devops/Dockerfile.src new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/no-db/devops/docker-compose.yaml b/robyn/scaffold/structured/no-db/devops/docker-compose.yaml new file mode 100644 index 000000000..66ebcc9e2 --- /dev/null +++ b/robyn/scaffold/structured/no-db/devops/docker-compose.yaml @@ -0,0 +1,11 @@ +version: '3' +services: + + service: + image: service # build an image with this tag + container_name: "service" + env_file: + - ../config.env + network_mode: host + + diff --git a/robyn/scaffold/structured/no-db/requirements.txt b/robyn/scaffold/structured/no-db/requirements.txt new file mode 100644 index 000000000..4ec60ebb2 --- /dev/null +++ b/robyn/scaffold/structured/no-db/requirements.txt @@ -0,0 +1,3 @@ +robyn +pydantic~=2.7.4 +pydantic-settings~=2.2.1 diff --git a/robyn/scaffold/structured/no-db/server.py b/robyn/scaffold/structured/no-db/server.py new file mode 100644 index 000000000..b3f391b14 --- /dev/null +++ b/robyn/scaffold/structured/no-db/server.py @@ -0,0 +1,11 @@ +from conf import settings + +from robyn import Robyn +from robyn.helpers import discover_routes + +app: Robyn = discover_routes("api.handlers") +# note: if you prefer to manuall refine routes, use your build_routes function instead + + +if __name__ == "__main__": + app.start(host="0.0.0.0", port=settings.service_port) diff --git a/robyn/scaffold/structured/no-db/utils/__init__.py b/robyn/scaffold/structured/no-db/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/__init__.py b/robyn/scaffold/structured/sqlalchemy/adaptors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/models/__init__.py b/robyn/scaffold/structured/sqlalchemy/adaptors/models/__init__.py new file mode 100644 index 000000000..11a6a00d9 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/adaptors/models/__init__.py @@ -0,0 +1,3 @@ +from .user import Base as user + +metadata = [user.metadata] diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/models/user.py b/robyn/scaffold/structured/sqlalchemy/adaptors/models/user.py new file mode 100644 index 000000000..48d651359 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/adaptors/models/user.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + username: Mapped[str] = mapped_column(String, unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) + + +class UserRead(BaseModel): + id: int + username: str + is_active: bool + is_superuser: bool + + class Config: + orm_mode = True + + +# Pydantic model for creating/updating a user +class UserCreate(BaseModel): + username: str + password: str + + class Config: + orm_mode = True diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/mutators/__init__.py b/robyn/scaffold/structured/sqlalchemy/adaptors/mutators/__init__.py new file mode 100644 index 000000000..558aa5e9c --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/adaptors/mutators/__init__.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from pydantic import BaseModel + + +class Mutator(ABC): + @abstractmethod + def create(self, model: BaseModel): ... + + @abstractmethod + def update(self, **kwargs): ... + + @abstractmethod + def delete(self, **kwargs): ... diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/__init__.py b/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/__init__.py new file mode 100644 index 000000000..8cc4c497e --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/__init__.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + + +class Mutator(ABC): + @abstractmethod + def retrieve(self, **kwargs): ... + + @abstractmethod + def list(self, **kwargs): ... diff --git a/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/misc.py b/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/misc.py new file mode 100644 index 000000000..d45e51aa2 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/adaptors/selectors/misc.py @@ -0,0 +1,5 @@ +from sqlalchemy import text + + +async def sample_selector(conn): + await conn.execute(text("select * from sample;")) diff --git a/robyn/scaffold/structured/sqlalchemy/alembic.ini b/robyn/scaffold/structured/sqlalchemy/alembic.ini new file mode 100644 index 000000000..723353d5c --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/alembic.ini @@ -0,0 +1,132 @@ +# Alembic helps manage database schema changes over time, offering: + +# 1. Schema Versioning: +# # Tracks different versions of the schema, allowing easy upgrades and rollbacks. + +# 2. Migration Creation & Execution: +# # Enables creation of migration scripts to define changes and applies them to the database. + +# 3. Rollback Capabilities: +# # Supports rolling back to previous schema versions in case of issues. + +# 4. SQLAlchemy Integration: +# # Works seamlessly with SQLAlchemy ORM for automatic migration generation based on model changes. + +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Before running alembic commands... please change this databsae url accordingly +sqlalchemy.url = driver://user:pass@localhost/dbname +# sqlalchemy.url = postgresql+psycopg://user:password@localhost:5432/yourdb + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/robyn/scaffold/structured/sqlalchemy/api/handlers/__init__.py b/robyn/scaffold/structured/sqlalchemy/api/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/api/handlers/probes.py b/robyn/scaffold/structured/sqlalchemy/api/handlers/probes.py new file mode 100644 index 000000000..6311e7ee8 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/api/handlers/probes.py @@ -0,0 +1,13 @@ +from robyn import SubRouter + +router = SubRouter(__name__, prefix="/") + + +@router.get("livez/") +def livez() -> str: + return "live" + + +@router.get("healthz/") +def healthz() -> str: + return "healthy" diff --git a/robyn/scaffold/structured/sqlalchemy/api/handlers/sample.py b/robyn/scaffold/structured/sqlalchemy/api/handlers/sample.py new file mode 100644 index 000000000..3b668fb70 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/api/handlers/sample.py @@ -0,0 +1,26 @@ +from adaptors.selectors.misc import sample_selector +from utils.db import get_db_connection + +from robyn import SubRouter + +router = SubRouter(__name__, prefix="/sample/") + + +@router.post("one/") +async def one(global_dependencies): + pool = global_dependencies.get("db_connection_pool") + async with get_db_connection(pool) as conn: + # invoke your mutators/selectors here + res = await sample_selector(conn) + print(res) + return {} + + +@router.get("two/") +def two(): + return {} + + +@router.get("three/") +def three(): + return {} diff --git a/robyn/scaffold/structured/sqlalchemy/api/middlewares/__init__.py b/robyn/scaffold/structured/sqlalchemy/api/middlewares/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/conf.py b/robyn/scaffold/structured/sqlalchemy/conf.py new file mode 100644 index 000000000..8b2050d67 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/conf.py @@ -0,0 +1,14 @@ +from robyn.helpers import BaseConfig + + +class Settings(BaseConfig): + service_port: int + database_url: str + db_pool_size: int + db_pool_max_overflow: int + db_pool_timeout: int + db_pool_recycle: int + db_pool_echo: bool + + +settings = Settings() diff --git a/robyn/scaffold/structured/sqlalchemy/config.env b/robyn/scaffold/structured/sqlalchemy/config.env new file mode 100644 index 000000000..5fa8db65e --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/config.env @@ -0,0 +1,9 @@ +SERVICE_PORT=3000 +# change the engine as per db +DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} +#DATABASE_URL=sqlite+aiosqlite:// +DB_POOL_SIZE=10 +DB_POOL_MAX_OVERFLOW=20 +DB_POOL_TIMEOUT=30 +DB_POOL_RECYCLE=1800 +DB_POOL_ECHO=True diff --git a/robyn/scaffold/structured/sqlalchemy/devops/Dockerfile b/robyn/scaffold/structured/sqlalchemy/devops/Dockerfile new file mode 100644 index 000000000..903705ca0 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/devops/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-bookworm AS builder + +WORKDIR /workspace + +COPY . . +RUN pip install --no-cache-dir --upgrade -r requirements.txt --target=/workspace/deps + +FROM gcr.io/distroless/python3-debian11 + +WORKDIR /workspace +COPY --from=builder /workspace /workspace +ENV PYTHONPATH=/workspace/deps + +CMD ["server.py", "--log-level=DEBUG"] diff --git a/robyn/scaffold/structured/sqlalchemy/devops/Dockerfile.src b/robyn/scaffold/structured/sqlalchemy/devops/Dockerfile.src new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/devops/docker-compose.yaml b/robyn/scaffold/structured/sqlalchemy/devops/docker-compose.yaml new file mode 100644 index 000000000..66ebcc9e2 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/devops/docker-compose.yaml @@ -0,0 +1,11 @@ +version: '3' +services: + + service: + image: service # build an image with this tag + container_name: "service" + env_file: + - ../config.env + network_mode: host + + diff --git a/robyn/scaffold/structured/sqlalchemy/migrations/README.md b/robyn/scaffold/structured/sqlalchemy/migrations/README.md new file mode 100644 index 000000000..3f89ec979 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/migrations/README.md @@ -0,0 +1,62 @@ + +# Alembic Migrations + +This directory contains database migrations managed by Alembic, a lightweight migration tool for SQLAlchemy applications. + +## Configuration + +Update the `sqlalchemy.url` in `alembic.ini` with your database connection string: + +``` +sqlalchemy.url = postgresql+psycopg://user:password@localhost:5432/yourdb +``` + +### Example Connection Strings: +- **PostgreSQL**: `postgresql+psycopg://user:password@localhost:5432/yourdb` +- **MySQL**: `mysql+pymysql://user:password@localhost:3306/yourdb` +- **SQLite**: `sqlite:///./test.db` + +## Migrations Workflow + +### Create a New Migration +Generate a migration script based on model changes: + +``` +alembic revision --autogenerate -m "describe your changes" +``` + +### Run Migrations +Apply all pending migrations: + +``` +alembic upgrade head +``` + +### Downgrade Migrations +Rollback the most recent migration: + +``` +alembic downgrade -1 +``` + +Or downgrade to a specific revision: + +``` +alembic downgrade +``` + +### Common Commands + +Other useful commands include: + +- View the current migration status: + + ``` + alembic current + ``` + +- View the migration history: + + ``` + alembic history + ``` diff --git a/robyn/scaffold/structured/sqlalchemy/migrations/env.py b/robyn/scaffold/structured/sqlalchemy/migrations/env.py new file mode 100644 index 000000000..081d4c80f --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/migrations/env.py @@ -0,0 +1,73 @@ +from logging.config import fileConfig + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from adaptors import models +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +target_metadata = models.metadata +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + engine = create_engine(config.get_main_option("sqlalchemy.url")) + Session = sessionmaker(bind=engine) + + with Session() as session: + context.configure(connection=session.connection(), target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/robyn/scaffold/structured/sqlalchemy/migrations/script.py.mako b/robyn/scaffold/structured/sqlalchemy/migrations/script.py.mako new file mode 100644 index 000000000..fbc4b07dc --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/robyn/scaffold/structured/sqlalchemy/migrations/versions/__init__.py b/robyn/scaffold/structured/sqlalchemy/migrations/versions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/requirements.txt b/robyn/scaffold/structured/sqlalchemy/requirements.txt new file mode 100644 index 000000000..e5c19dfd7 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/requirements.txt @@ -0,0 +1,11 @@ +robyn +#DB +SQLAlchemy~=2.0.35 +alembic~=1.13.3 +#drivers +asyncpg~=0.29.0 +psycopg2-binary~=2.9.9 +aiosqlite~=0.20.0 +#schema +pydantic~=2.7.4 +pydantic-settings~=2.2.1 diff --git a/robyn/scaffold/structured/sqlalchemy/server.py b/robyn/scaffold/structured/sqlalchemy/server.py new file mode 100644 index 000000000..b70876b9d --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/server.py @@ -0,0 +1,14 @@ +from conf import settings +from utils.db import get_database_connection_pool + +from robyn import Robyn +from robyn.helpers import discover_routes + +app: Robyn = discover_routes("api.handlers") +# note: if you prefer to manuall refine routes, use your build_routes function instead + +app.inject_global(db_connection_pool=get_database_connection_pool()) + + +if __name__ == "__main__": + app.start(host="0.0.0.0", port=settings.service_port) diff --git a/robyn/scaffold/structured/sqlalchemy/utils/__init__.py b/robyn/scaffold/structured/sqlalchemy/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/robyn/scaffold/structured/sqlalchemy/utils/db.py b/robyn/scaffold/structured/sqlalchemy/utils/db.py new file mode 100644 index 000000000..14f80c9b1 --- /dev/null +++ b/robyn/scaffold/structured/sqlalchemy/utils/db.py @@ -0,0 +1,22 @@ +from contextlib import asynccontextmanager + +from conf import settings +from sqlalchemy.ext.asyncio import create_async_engine + + +def get_database_connection_pool(): + return create_async_engine( + settings.database_url, + pool_size=settings.db_pool_size, + max_overflow=settings.db_pool_max_overflow, + pool_timeout=settings.db_pool_timeout, + pool_recycle=settings.db_pool_recycle, + echo=settings.db_pool_echo, + query_cache_size=0, + ) + + +@asynccontextmanager +async def get_db_connection(engine): + async with engine.connect() as conn: + yield conn