diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa24c44..f3ba8a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,12 +150,35 @@ All notable changes to this project are documented in this file. The format is b *complete* verdict now keeps answering while one refresh runs. ### Security +- `smpy new` no longer writes generated secrets into the committed + `.env.example` (#441). `SM_SECRET_KEY` and the users token secrets go to a + gitignored `.env` (created if absent; an existing one only gains missing + keys), and `.env.example` keeps `# KEY=` placeholders. Apps scaffolded + earlier should rotate those secrets if `.env.example` was committed. +- The scaffold's `docker-compose.yml` no longer publishes Postgres on + `0.0.0.0:5432` with `postgres`/`postgres` while the app runs in production + mode (#444). The password comes from `POSTGRES_PASSWORD` (generated into + `.env`; compose refuses to start without it), and Postgres and Redis bind + `127.0.0.1` only. - The tenant header (`tenant_header`) is no longer honoured for an authenticated user without a tenant of their own: such a user could name any tenant. On the legacy path it applies to anonymous requests only; with the `tenants` resolver it selects among the user's own memberships. ### Fixed +- `render_item` renders sqlmodel>=0.0.48's `UTCDateTime` as + `sa.DateTime(timezone=True)` and adds `import sqlmodel` for any other + sqlmodel type, so an initial autogenerate no longer emits a migration that + dies with `NameError: name 'sqlmodel' is not defined` (#440). +- `smpy add ` for a module inside the host's uv workspace writes + `{ workspace = true }` and an unpinned dependency instead of a path source uv + rejects (#442). A relative path missing under `--path` falls back to the cwd, + and `smpy create-module` inside a workspace prints the `smpy add` command. +- Scaffold hygiene (#443): templates name the `smpy` CLI (not `sm`), `uv.lock` + is committed rather than gitignored, and the Postgres database name is + snake_case (`demo_app`, not `demo-app`). +- Scaffolded dev groups floor `playwright>=1.45`; without it uv resolved + playwright 1.18 and every `pytest` run failed at plugin load (#445). - `/admin/background-tasks` returned 500 on Postgres (#413): `TaskExecution`'s timestamps were declared naive although the columns are `timestamptz` since `e5f2a8c1d7b3`. This was already fixed on `main` by #406 (unreleased since diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 9fc7f608..e32702be 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -76,7 +76,7 @@ Hit `http://localhost:8000`. You should see the landing page. ## Database choice -`smpy new` writes a `.env.example`. The default is SQLite (zero setup): +`smpy new` writes a committed `.env.example` (placeholders only) and a gitignored `.env` holding the generated secrets. The default is SQLite (zero setup): ```bash SM_DATABASE_URL=sqlite+aiosqlite:///./app.db @@ -85,10 +85,13 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db For Postgres, scaffold with `--db postgres` (or pick Postgres in the wizard) and `smpy new` writes a matching `SM_DATABASE_URL`: ```bash -SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/myapp +POSTGRES_PASSWORD= +SM_DATABASE_URL=postgresql+asyncpg://postgres:@localhost:5432/my_app ``` -Every scaffold ships a `docker-compose.yml` that matches your `--db` choice (a migration history is dialect-frozen at autogenerate time, so containers must run the same database the migrations were generated against). With `--db postgres` it brings up `postgres` (db named after your app, user `postgres`/`postgres`) on `:5432` and the `app` service; the default SQLite scaffold runs the `app` container alone with the database on a named volume. Including the `background_tasks` module adds `redis` on `:6379` plus `worker`/`beat` services that reuse the app image. With `--db postgres`, the container also doubles as a local dev database: +The database name is the snake_case app name. `POSTGRES_PASSWORD` is shared with the compose `postgres` service below. + +Every scaffold ships a `docker-compose.yml` that matches your `--db` choice (a migration history is dialect-frozen at autogenerate time, so containers must run the same database the migrations were generated against). With `--db postgres` it brings up `postgres` (db named after your app, user `postgres`, password from `POSTGRES_PASSWORD` in `.env`) on `127.0.0.1:5432` and the `app` service; the default SQLite scaffold runs the `app` container alone with the database on a named volume. Including the `background_tasks` module adds `redis` on `127.0.0.1:6379` plus `worker`/`beat` services that reuse the app image. With `--db postgres`, the container also doubles as a local dev database: ```bash docker compose up -d postgres diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index f00f832d..4481ae2a 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -6,7 +6,8 @@ myapp/ ├── pyproject.toml # uv workspace root (members: host, modules/*) — not itself installed ├── package.json # npm workspace root (host/client_app, modules/*) -├── .env.example # default env vars; copy to .env +├── .env.example # committed template, no secrets +├── .env # generated secrets (gitignored) ├── Makefile # thin wrapper: install / dev / migrate / build / gen-pages ├── README.md │ diff --git a/framework/cli/simple_module_cli/_env.py b/framework/cli/simple_module_cli/_env.py index 58cf7548..16e7e11b 100644 --- a/framework/cli/simple_module_cli/_env.py +++ b/framework/cli/simple_module_cli/_env.py @@ -2,7 +2,7 @@ from __future__ import annotations -__all__ = ["set_env_key"] +__all__ = ["env_values", "set_env_key"] def set_env_key(text: str, key: str, value: str) -> str: @@ -10,3 +10,9 @@ def set_env_key(text: str, key: str, value: str) -> str: lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] lines.append(f"{key}={value}") return "\n".join(lines) + "\n" + + +def env_values(text: str) -> dict[str, str]: + """Parse the ``KEY=VALUE`` lines of an env-style file body (comments skipped).""" + lines = text.splitlines() + return dict(ln.split("=", 1) for ln in lines if "=" in ln and not ln.startswith("#")) diff --git a/framework/cli/simple_module_cli/_local_env.py b/framework/cli/simple_module_cli/_local_env.py new file mode 100644 index 00000000..a083a809 --- /dev/null +++ b/framework/cli/simple_module_cli/_local_env.py @@ -0,0 +1,72 @@ +"""Split a new app's env file into a committed template and a local ``.env``. + +``.env.example`` is committed, so it only ever carries placeholders. The +secrets ``smpy new`` generates go to ``.env``, which every scaffold's +``.gitignore`` excludes (GH #441). An existing ``.env`` is never overwritten: +only keys it does not define yet are added. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Iterable +from pathlib import Path + +from simple_module_cli._env import env_values, set_env_key + +__all__ = ["comment_out_env_key", "write_local_env"] + +_SECRET_NOTE = ( + "# Secrets — `smpy new` generated these into .env (gitignored); keep them\n" + "# out of this committed file. Set real values wherever you deploy." +) + + +def comment_out_env_key(text: str, key: str) -> str: + """Turn ``KEY=...`` into a bare ``# KEY=`` placeholder, in place if present.""" + lines = text.splitlines() + placeholder = f"# {key}=" + hits = [i for i, ln in enumerate(lines) if ln.startswith((f"{key}=", placeholder))] + if hits: + lines[hits[0]] = placeholder + lines = [ln for i, ln in enumerate(lines) if i not in hits[1:]] + else: + if _SECRET_NOTE.splitlines()[0] not in lines: + lines += ["", *_SECRET_NOTE.splitlines()] + lines.append(placeholder) + return "\n".join(lines) + "\n" + + +def write_local_env( + target: Path, *, secret_keys: Iterable[str], overrides: dict[str, str] | None = None +) -> None: + """Write ``target/.env`` from ``.env.example`` plus generated secrets. + + ``secret_keys`` get a fresh random value each; ``overrides`` are written + verbatim (e.g. a database URL carrying a generated password). Overrides + belong together, so an existing ``.env`` defining any of them gets none: + a fresh URL must not disagree with a kept ``POSTGRES_PASSWORD``. + """ + env_path = target / ".env" + existing = env_path.exists() + source = env_path if existing else target / ".env.example" + text = source.read_text(encoding="utf-8") if source.exists() else "" + defined = env_values(text).keys() + overrides = overrides or {} + if existing and defined & overrides.keys(): + overrides = {} + values = {key: secrets.token_urlsafe(32) for key in secret_keys} | overrides + for key, value in values.items(): + if existing and key in defined: + continue + placeholder = f"# {key}=" + lines = text.splitlines() + if placeholder in lines: + # Line-based, so a placeholder on the last line (no trailing + # newline) is handled too. + lines[lines.index(placeholder)] = f"{key}={value}" + text = "\n".join(lines) + "\n" + else: + text = set_env_key(text, key, value) + env_path.write_text(text, encoding="utf-8") + env_path.chmod(0o600) # holds generated secrets diff --git a/framework/cli/simple_module_cli/add_cmd.py b/framework/cli/simple_module_cli/add_cmd.py index f94d4d45..a656a2fd 100644 --- a/framework/cli/simple_module_cli/add_cmd.py +++ b/framework/cli/simple_module_cli/add_cmd.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os import subprocess import tempfile from collections.abc import Callable @@ -35,7 +36,9 @@ write_dependency, write_git_source, write_path_source, + write_workspace_source, ) +from simple_module_cli.uv_workspace import is_workspace_member __all__ = ["ExecRunner", "add_module", "post_install", "run_add"] @@ -137,18 +140,46 @@ def _add_pypi(doc, pyproject: Path, raw: str, no_sync: bool, exec_runner: ExecRu return [name] +def _resolve_local_path(project_dir: Path, spec_path: Path) -> tuple[Path, str]: + """Resolve a local-path spec to ``(directory, text written to pyproject)``. + + A relative path is resolved against the ``--path`` project (the pyproject + being edited, which is what ``[tool.uv.sources]`` paths are relative to). + If nothing exists there but the path exists relative to the cwd, the cwd + one is used and the written path is re-expressed relative to the project. + """ + if spec_path.is_absolute(): + return spec_path, str(spec_path) + root = project_dir / spec_path + if not root.exists() and (Path.cwd() / spec_path).exists(): + root = Path.cwd() / spec_path + return root, os.path.relpath(root.resolve(), project_dir.resolve()) + return root, str(spec_path) + + def _add_path( doc, pyproject: Path, spec_path: Path, no_sync: bool, exec_runner: ExecRunner ) -> list[str]: - root = spec_path if spec_path.is_absolute() else pyproject.parent / spec_path + root, written = _resolve_local_path(pyproject.parent, spec_path) found = [m for m in scan_modules(root) if m.subdirectory is None] if not found: - raise _fail(f"{root} has no [project.entry-points.simple_module] entry point at its root") + raise _fail( + f"{root} has no [project.entry-points.simple_module] entry point at its root " + f"(relative paths resolve against the --path project, {pyproject.parent}, " + "falling back to the current directory)" + ) mod = found[0] - write_dependency(doc, mod.dist_name, derive_range(mod.version)) - write_path_source(doc, mod.dist_name, path=str(spec_path)) + if is_workspace_member(pyproject.parent, root): + # uv refuses a path source for a workspace member; the scaffold's own + # sample module is wired the same way (unpinned + workspace = true). + write_dependency(doc, mod.dist_name, "") + write_workspace_source(doc, mod.dist_name) + typer.echo(f"Added {mod.dist_name} (uv workspace member {written}).") + else: + write_dependency(doc, mod.dist_name, derive_range(mod.version)) + write_path_source(doc, mod.dist_name, path=written) + typer.echo(f"Added {mod.dist_name} (editable path {written}).") save_pyproject(pyproject, doc) - typer.echo(f"Added {mod.dist_name} (editable path {spec_path}).") if not no_sync: post_install( pyproject.parent, diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 94f9f1aa..8a64e3fa 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -20,7 +20,8 @@ from typing import Any from simple_module_cli._env import set_env_key -from simple_module_cli.case import to_kebab_case, to_pascal_case +from simple_module_cli._local_env import comment_out_env_key, write_local_env +from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_cli.docker_assets import scaffold_docker_assets from simple_module_cli.recipes import RECIPES, ScaffoldCtx @@ -52,6 +53,8 @@ "pytest>=8.0", "pytest-asyncio>=0.24", "pytest-playwright>=0.7.2", + # Floor: unpinned, uv backtracks playwright to 1.18 (GH #445). + "playwright>=1.45", "ruff>=0.8", "ty>=0.0.29", ] @@ -150,19 +153,33 @@ def create_app_project( py_deps.append(_SAMPLE_MODULE_PKG) workspace_sources.append(_SAMPLE_MODULE_PKG) - env_path = target / ".env.example" - env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" - env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32)) + # Generated secrets go to the gitignored .env, never the committed + # .env.example (GH #441) — see write_local_env below. + secret_keys = ["SM_SECRET_KEY"] if "users" in resolved: # UsersSettings refuses its placeholder token secrets when # SM_ENVIRONMENT=production — the mode the compose containers run - # in — so generate real ones, same as SM_SECRET_KEY above. - for key in ( + # in — so generate real ones, same as SM_SECRET_KEY. + secret_keys += [ "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", "SM_USERS_VERIFICATION_TOKEN_SECRET", - ): - env_text = set_env_key(env_text, key, _secrets.token_urlsafe(32)) - env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat)) + ] + db_name = to_snake_case(name) + env_path = target / ".env.example" + env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + for key in secret_keys: + env_text = comment_out_env_key(env_text, key) + local_overrides: dict[str, str] = {} + if db == "postgres": + # The compose `postgres` service takes its password from .env, and + # the dev SM_DATABASE_URL must match it (GH #444). + env_text = set_env_key(env_text, "POSTGRES_PASSWORD", "") + password = _secrets.token_urlsafe(24) + local_overrides = { + "POSTGRES_PASSWORD": password, + "SM_DATABASE_URL": _db_url(db, db_name, flat=flat, password=password), + } + env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, db_name, flat=flat)) env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") env_path.write_text(env_text, encoding="utf-8") @@ -198,6 +215,8 @@ def create_app_project( RECIPES[recipe_key].apply(target, ctx) scaffold_docker_assets(target, ctx, flat=flat) + # Last, so .env picks up every key the recipes added to .env.example. + write_local_env(target, secret_keys=secret_keys, overrides=local_overrides) return host_dir, preserved @@ -234,9 +253,9 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None: (static_dist / ".gitkeep").touch() -def _db_url(db: str, slug: str, *, flat: bool) -> str: +def _db_url(db: str, db_name: str, *, flat: bool, password: str = "") -> str: if db == "postgres": - return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" + return f"postgresql+asyncpg://postgres:{password}@localhost:5432/{db_name}" # Workspace mode keeps the SQLite file next to host/'s alembic.ini so # `cd host && uvicorn` and `cd host && alembic` resolve the same path. return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db" diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py index d571272e..6a4aabb8 100644 --- a/framework/cli/simple_module_cli/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import Annotated @@ -30,6 +31,7 @@ from simple_module_cli.scaffolding import is_inside_existing_repo, resolve_framework_version from simple_module_cli.skills_cmd import app as skills_app from simple_module_cli.update_cmd import update_modules +from simple_module_cli.uv_workspace import find_workspace_root, is_workspace_member app = typer.Typer( help="SimpleModule developer CLI.", @@ -103,6 +105,25 @@ def create_host( typer.echo(" python main.py") +def _echo_workspace_hint(target: Path) -> None: + """Tell the user how to wire a workspace-member module into the host (GH #442).""" + root = find_workspace_root(target.parent) + if root is None: + return + host = root / "host" + if not (host / "pyproject.toml").is_file() or not is_workspace_member(host, target): + return + rel = os.path.relpath(target.resolve(), host.resolve()) + host_arg = os.path.relpath(host.resolve(), Path.cwd()) + if host_arg.startswith(".."): + host_arg = str(host.resolve()) + typer.echo( + f"\nThis module is a uv workspace member. Wire it into the host with:\n" + f" smpy add {rel} --path {host_arg}" + " # adds it as { workspace = true }" + ) + + @app.command("create-module") def create_module( name: Annotated[str, typer.Argument(help="Module name (any case).")], @@ -149,6 +170,7 @@ def create_module( "Skipped .github/ workflows: this module is inside an existing repo, " "where nested workflows never run. Use --standalone to emit them." ) + _echo_workspace_hint(target) typer.echo("\nNext steps:") typer.echo(f" cd {target}") typer.echo(" uv sync --extra dev") diff --git a/framework/cli/simple_module_cli/docker_assets.py b/framework/cli/simple_module_cli/docker_assets.py index 72459859..7db22c20 100644 --- a/framework/cli/simple_module_cli/docker_assets.py +++ b/framework/cli/simple_module_cli/docker_assets.py @@ -19,7 +19,7 @@ import shutil from pathlib import Path -from simple_module_cli.case import to_kebab_case +from simple_module_cli.case import to_snake_case from simple_module_cli.recipes import ScaffoldCtx __all__ = ["scaffold_docker_assets"] @@ -70,7 +70,8 @@ def scaffold_docker_assets(target: Path, ctx: ScaffoldCtx, *, flat: bool = False # compiles to DEFAULT 0 on SQLite, which Postgres rejects), so pointing # a sqlite-scaffolded app at a Postgres container fails on first boot. db = "postgres" if ctx.db == "postgres" else "sqlite" - pg_db = to_kebab_case(ctx.name) + # snake_case: a kebab name needs quoting in every psql call (GH #443). + pg_db = to_snake_case(ctx.name) with_tasks = "background_tasks" in ctx.selected compose = ( _read(templates / f"compose-base-{db}.yml.tpl") diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index d2cb5443..8df2b384 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -170,6 +170,7 @@ def new_project( for path in preserved: rel = path.relative_to(target) if path.is_relative_to(target) else path typer.echo(f" {rel}") + typer.echo("\nGenerated secrets were written to .env (gitignored), not .env.example.") typer.echo("\nNext steps:") typer.echo(f" cd {target}") if no_install: diff --git a/framework/cli/simple_module_cli/pyproject_edit.py b/framework/cli/simple_module_cli/pyproject_edit.py index a9323c22..427187b9 100644 --- a/framework/cli/simple_module_cli/pyproject_edit.py +++ b/framework/cli/simple_module_cli/pyproject_edit.py @@ -24,6 +24,7 @@ "write_dependency", "write_git_source", "write_path_source", + "write_workspace_source", ] @@ -90,6 +91,15 @@ def _uv_sources(doc: tomlkit.TOMLDocument) -> Table: return uv.setdefault("sources", tomlkit.table()) +def _set_source(doc: tomlkit.TOMLDocument, dist_name: str, src: Any) -> bool: + """Write ``src`` under ``[tool.uv.sources]``; False when already identical.""" + sources = _uv_sources(doc) + if dist_name in sources and dict(sources[dist_name]) == dict(src): + return False + sources[dist_name] = src + return True + + def write_git_source( doc: tomlkit.TOMLDocument, dist_name: str, @@ -104,22 +114,21 @@ def write_git_source( src[ref_info.kind] = ref_info.value if subdirectory: src["subdirectory"] = subdirectory - sources = _uv_sources(doc) - if dist_name in sources and dict(sources[dist_name]) == dict(src): - return False - sources[dist_name] = src - return True + return _set_source(doc, dist_name, src) def write_path_source(doc: tomlkit.TOMLDocument, dist_name: str, *, path: str) -> bool: src = tomlkit.inline_table() src["path"] = path src["editable"] = True - sources = _uv_sources(doc) - if dist_name in sources and dict(sources[dist_name]) == dict(src): - return False - sources[dist_name] = src - return True + return _set_source(doc, dist_name, src) + + +def write_workspace_source(doc: tomlkit.TOMLDocument, dist_name: str) -> bool: + """``dist = { workspace = true }`` — required for uv workspace members.""" + src = tomlkit.inline_table() + src["workspace"] = True + return _set_source(doc, dist_name, src) def _sources_dict(doc: tomlkit.TOMLDocument) -> dict[str, Any]: diff --git a/framework/cli/simple_module_cli/templates/docker/Makefile.snippet b/framework/cli/simple_module_cli/templates/docker/Makefile.snippet index 27c86dd7..6a8330bc 100644 --- a/framework/cli/simple_module_cli/templates/docker/Makefile.snippet +++ b/framework/cli/simple_module_cli/templates/docker/Makefile.snippet @@ -5,7 +5,9 @@ docker-build: ## Build the app image docker compose build docker-up: ## Run the full stack in containers - @test -f .env || cp .env.example .env + @test -f .env || { cp .env.example .env; \ + echo "Created .env from .env.example: set POSTGRES_PASSWORD (Postgres apps) and the"; \ + echo "commented SM_* secrets in .env; production containers refuse to boot without them."; } docker compose up --build docker-down: ## Stop the compose stack diff --git a/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl index 32025c8f..7cd43b11 100644 --- a/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +++ b/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl @@ -4,9 +4,11 @@ services: environment: POSTGRES_DB: "{{PG_DB}}" POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}" ports: - - "5432:5432" + # Loopback only: reachable as a local dev database, never from the + # network. The app reaches it over the compose network (GH #444). + - "127.0.0.1:5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -26,7 +28,7 @@ services: # Containers serve the built bundle; development mode would emit # asset tags pointing at the (absent) Vite dev server. SM_ENVIRONMENT: production - SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} + SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}" {{APP_EXTRA_ENV}} ports: - "8000:8000" depends_on: diff --git a/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl index 546f80d0..7246c9e1 100644 --- a/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +++ b/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl @@ -2,7 +2,7 @@ redis: image: redis:7-alpine ports: - - "6379:6379" + - "127.0.0.1:6379:6379" volumes: - redisdata:/data healthcheck: @@ -21,7 +21,7 @@ required: false environment: SM_ENVIRONMENT: production - SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} + SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}" SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 working_dir: /app @@ -48,7 +48,7 @@ required: false environment: SM_ENVIRONMENT: production - SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} + SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}" SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 working_dir: /app diff --git a/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl index 738d8770..2a456b0b 100644 --- a/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +++ b/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl @@ -2,7 +2,7 @@ redis: image: redis:7-alpine ports: - - "6379:6379" + - "127.0.0.1:6379:6379" volumes: - redisdata:/data healthcheck: diff --git a/framework/cli/simple_module_cli/templates/host/.env.example b/framework/cli/simple_module_cli/templates/host/.env.example index 0f78a40f..e42b7c5a 100644 --- a/framework/cli/simple_module_cli/templates/host/.env.example +++ b/framework/cli/simple_module_cli/templates/host/.env.example @@ -5,8 +5,9 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db # Environment: development | production SM_ENVIRONMENT=development -# Secret key for session middleware — change before deploying. -SM_SECRET_KEY=change-me-in-production +# Secret key for session middleware. Leave unset to have one generated and +# stored at first boot; `smpy new` writes a generated one into .env. +# SM_SECRET_KEY= # Vite dev server URL (only used in development). SM_VITE_DEV_URL=http://localhost:5050 diff --git a/framework/cli/simple_module_cli/templates/host/.gitignore b/framework/cli/simple_module_cli/templates/host/.gitignore index 7aaabc7a..3b107036 100644 --- a/framework/cli/simple_module_cli/templates/host/.gitignore +++ b/framework/cli/simple_module_cli/templates/host/.gitignore @@ -3,8 +3,6 @@ __pycache__/ *.egg-info/ .venv/ -uv.lock - node_modules/ .env @@ -14,6 +12,6 @@ node_modules/ static/dist/ -# Auto-generated by the host at boot / `sm gen-pages`. +# Auto-generated by the host at boot / `make gen-pages`. client_app/modules.manifest.json client_app/modules.generated.ts diff --git a/framework/cli/simple_module_cli/templates/host/README.md.tpl b/framework/cli/simple_module_cli/templates/host/README.md.tpl index 14e7aaed..71922d00 100644 --- a/framework/cli/simple_module_cli/templates/host/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/host/README.md.tpl @@ -1,6 +1,6 @@ # {{HOST_NAME}} -A SimpleModule host application, generated by `sm create-host`. +A SimpleModule host application, generated by `smpy create-host`. ## Quick start diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl index 59e73e0c..044b9d88 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl @@ -1,4 +1,4 @@ -"""{{MODULE_NAME}} module — generated by `sm create-module`.""" +"""{{MODULE_NAME}} module — generated by `smpy create-module`.""" from __future__ import annotations diff --git a/framework/cli/simple_module_cli/templates/workspace/.env.example b/framework/cli/simple_module_cli/templates/workspace/.env.example index 9ec3db13..a1225ca0 100644 --- a/framework/cli/simple_module_cli/templates/workspace/.env.example +++ b/framework/cli/simple_module_cli/templates/workspace/.env.example @@ -5,8 +5,9 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./host/app.db # Environment: development | production SM_ENVIRONMENT=development -# Secret key for session middleware — change before deploying. -SM_SECRET_KEY=change-me-in-production +# Secret key for session middleware. Leave unset to have one generated and +# stored at first boot; `smpy new` writes a generated one into .env. +# SM_SECRET_KEY= # Vite dev server URL (only used in development). SM_VITE_DEV_URL=http://localhost:5050 diff --git a/framework/cli/simple_module_cli/templates/workspace/.gitignore b/framework/cli/simple_module_cli/templates/workspace/.gitignore index 35c7b039..f34c824f 100644 --- a/framework/cli/simple_module_cli/templates/workspace/.gitignore +++ b/framework/cli/simple_module_cli/templates/workspace/.gitignore @@ -3,8 +3,6 @@ __pycache__/ *.egg-info/ .venv/ -uv.lock - node_modules/ .env @@ -14,7 +12,7 @@ node_modules/ host/static/dist/ -# Auto-generated by the host at boot / `sm host gen-pages`. +# Auto-generated by the host at boot / `make gen-pages`. host/client_app/modules.manifest.json host/client_app/modules.generated.ts host/client_app/modules.generated.css diff --git a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl index c9d0aac8..6ca4f289 100644 --- a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl @@ -1,6 +1,6 @@ # {{HOST_NAME}} -A SimpleModule application, scaffolded by `sm new`. +A SimpleModule application, scaffolded by `smpy new`. ## Layout @@ -21,8 +21,8 @@ A SimpleModule application, scaffolded by `sm new`. # Install Python + JS deps (uv workspace + npm workspace) make install -# Copy and customize env -cp .env.example .env +# `smpy new` wrote .env (gitignored) with generated secrets; adjust it as +# needed. .env.example is the committed template and holds no secrets. # Apply DB migrations # (`smpy new` already generated the initial migration for you; if you started @@ -56,13 +56,12 @@ container alone (DB on a named volume), `--db postgres` apps also get a ```bash # Workspace mode — drops a new module under modules// -uv run sm create-module my-feature --dest modules/my-feature +uv run smpy create-module my-feature --dest modules/my-feature -# Wire it into the host -# 1) Add `simple_module_my_feature` to host/pyproject.toml dependencies -# 2) Add `simple_module_my_feature = { workspace = true }` to host's -# [tool.uv.sources] (so uv resolves it from the workspace, not PyPI) -# 3) make install && make migration msg="add my-feature" && make migrate +# Wire it into the host: adds the dependency and a `{ workspace = true }` +# source to host/pyproject.toml (the path is relative to host/) +uv run smpy add ../modules/my-feature --path host +make migration msg="add my-feature" && make migrate ``` ## How the workspace fits together @@ -78,4 +77,4 @@ uv run sm create-module my-feature --dest modules/my-feature ## Falling back to a flat layout If you only consume published modules (no in-repo authoring), regenerate -with `sm new --flat` to skip the `modules/` tree entirely. +with `smpy new --flat` to skip the `modules/` tree entirely. diff --git a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl index 30174eb7..022d700d 100644 --- a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl @@ -24,6 +24,9 @@ dev = [ "pytest>=8.0", "pytest-asyncio>=0.24", "pytest-playwright>=0.7.2", + # Without a floor uv backtracks playwright to 1.18 (2022) to dodge a pyee + # pin, which pytest-playwright cannot load (GH #445). + "playwright>=1.45", "ruff>=0.8", "ty>=0.0.29", # Provides the build_test_app / fake_event_bus fixtures (pytest11 plugin). diff --git a/framework/cli/simple_module_cli/uv_workspace.py b/framework/cli/simple_module_cli/uv_workspace.py new file mode 100644 index 00000000..01333112 --- /dev/null +++ b/framework/cli/simple_module_cli/uv_workspace.py @@ -0,0 +1,57 @@ +"""Detect membership in an enclosing uv workspace. + +uv rejects a ``{path = ...}`` source for a package that is a workspace member +(it must be ``{ workspace = true }``), so ``smpy add`` needs to know whether a +local module lives inside the workspace its host belongs to. See GH #442. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +__all__ = ["find_workspace_root", "is_workspace_member"] + + +def _workspace_table(pyproject: Path) -> dict | None: + try: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return None + table = data.get("tool", {}).get("uv", {}).get("workspace") + return table if isinstance(table, dict) else None + + +def find_workspace_root(start: Path) -> Path | None: + """Nearest ancestor of ``start`` (inclusive) whose pyproject has ``[tool.uv.workspace]``.""" + current = start.resolve() + for candidate in (current, *current.parents): + pyproject = candidate / "pyproject.toml" + if pyproject.is_file() and _workspace_table(pyproject) is not None: + return candidate + return None + + +def _expand(root: Path, patterns: object) -> set[Path]: + if not isinstance(patterns, list): + return set() + found: set[Path] = set() + for pattern in patterns: + if not isinstance(pattern, str): + continue + for match in root.glob(pattern): + if match.is_dir(): + found.add(match.resolve()) + return found + + +def is_workspace_member(project_dir: Path, module_dir: Path) -> bool: + """True if ``module_dir`` is a member of the uv workspace enclosing ``project_dir``.""" + root = find_workspace_root(project_dir) + if root is None: + return False + table = _workspace_table(root / "pyproject.toml") or {} + members = _expand(root, table.get("members")) + excluded = _expand(root, table.get("exclude")) + target = module_dir.resolve() + return target in members and target not in excluded diff --git a/framework/cli/tests/test_cli_add_workspace_member.py b/framework/cli/tests/test_cli_add_workspace_member.py new file mode 100644 index 00000000..c6df0b17 --- /dev/null +++ b/framework/cli/tests/test_cli_add_workspace_member.py @@ -0,0 +1,89 @@ +"""`smpy add` of a local module inside the host's uv workspace (GH #442).""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest +import typer +from simple_module_cli.add_cmd import run_add +from simple_module_cli.uv_workspace import is_workspace_member + +HOST = '[project]\nname = "myhost"\nversion = "0.1.0"\ndependencies = []\n' +WORKSPACE = '[tool.uv.workspace]\nmembers = ["host", "modules/*"]\nexclude = ["modules/skipped"]\n' +DIST = "simple_module_forest_plots" +DEP = "simple-module-forest-plots" + + +def _noop(cmd: list[str], cwd: Path) -> int: + return 0 + + +def _write_module(root: Path) -> None: + (root / DIST).mkdir(parents=True) + (root / "pyproject.toml").write_text( + f'[project]\nname = "{DEP}"\nversion = "0.1.0"\n' + f'[project.entry-points.simple_module]\n{DIST} = "{DIST}.module:M"\n', + encoding="utf-8", + ) + + +def _host_data(workspace: Path) -> dict: + return tomllib.loads((workspace / "host" / "pyproject.toml").read_text(encoding="utf-8")) + + +def _add(workspace: Path, spec: str) -> None: + run_add(spec, pyproject=workspace / "host" / "pyproject.toml", no_sync=True, exec_runner=_noop) + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + (tmp_path / "pyproject.toml").write_text(WORKSPACE, encoding="utf-8") + (tmp_path / "host").mkdir() + (tmp_path / "host" / "pyproject.toml").write_text(HOST, encoding="utf-8") + return tmp_path + + +def test_workspace_member_gets_workspace_source_and_unpinned_dep(workspace: Path) -> None: + _write_module(workspace / "modules" / "forest_plots") + _add(workspace, "../modules/forest_plots") + data = _host_data(workspace) + assert data["project"]["dependencies"] == [DEP] + assert data["tool"]["uv"]["sources"][DEP] == {"workspace": True} + + +def test_excluded_workspace_dir_keeps_path_source(workspace: Path) -> None: + _write_module(workspace / "modules" / "skipped") + _add(workspace, "../modules/skipped") + data = _host_data(workspace) + assert data["tool"]["uv"]["sources"][DEP] == {"path": "../modules/skipped", "editable": True} + assert data["project"]["dependencies"] == [f"{DEP}>=0.1.0,<1.0"] + + +def test_no_enclosing_workspace_is_not_a_member(tmp_path: Path) -> None: + (tmp_path / "host").mkdir() + (tmp_path / "mod").mkdir() + assert not is_workspace_member(tmp_path / "host", tmp_path / "mod") + + +def test_cwd_relative_path_falls_back_and_is_rewritten( + workspace: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_module(workspace / "modules" / "forest_plots") + monkeypatch.chdir(workspace) + _add(workspace, "./modules/forest_plots") + assert _host_data(workspace)["tool"]["uv"]["sources"][DEP] == {"workspace": True} + + +def test_missing_path_error_names_the_resolution_base( + workspace: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(typer.Exit): + run_add( + "./nope", + pyproject=workspace / "host" / "pyproject.toml", + no_sync=True, + exec_runner=_noop, + ) + assert "--path project" in capsys.readouterr().err diff --git a/framework/cli/tests/test_cli_docker_assets.py b/framework/cli/tests/test_cli_docker_assets.py index fe4adbe9..f5cefde2 100644 --- a/framework/cli/tests/test_cli_docker_assets.py +++ b/framework/cli/tests/test_cli_docker_assets.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from simple_module_cli._env import env_values from simple_module_cli.app_project import create_app_project @@ -53,12 +54,42 @@ def test_postgres_scaffold_compose_ships_postgres(tmp_path: Path) -> None: target = tmp_path / "demo" _create(target, db="postgres") compose = (target / "docker-compose.yml").read_text() - assert 'POSTGRES_DB: "demo-app"' in compose - assert "postgresql+asyncpg://postgres:postgres@postgres:5432/demo-app" in compose + # snake_case: a kebab DB name needs quoting in every psql call (GH #443). + assert 'POSTGRES_DB: "demo_app"' in compose + assert "@postgres:5432/demo_app" in compose assert "pgdata:" in compose assert "sqlite" not in compose +def test_postgres_compose_has_no_default_password_or_public_port(tmp_path: Path) -> None: + # GH #444: the containers run in production mode, so the DB must not be + # reachable from the network with a well-known superuser password. + target = tmp_path / "demo" + _create(target, db="postgres", selected=["users", "background_tasks"]) + compose = (target / "docker-compose.yml").read_text() + assert "postgres:postgres" not in compose + assert "POSTGRES_PASSWORD: postgres" not in compose + assert '"5432:5432"' not in compose + assert '"6379:6379"' not in compose + assert '"127.0.0.1:5432:5432"' in compose + assert compose.count("${POSTGRES_PASSWORD:?") == 4 # postgres, app, worker, beat + + +def test_postgres_scaffold_env_matches_compose_password(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target, db="postgres") + env = env_values((target / ".env").read_text()) + password = env["POSTGRES_PASSWORD"] + assert len(password) >= 20 + assert ( + env["SM_DATABASE_URL"] + == f"postgresql+asyncpg://postgres:{password}@localhost:5432/demo_app" + ) + example = (target / ".env.example").read_text() + assert password not in example + assert "POSTGRES_PASSWORD=\n" in example + + def test_compose_containers_run_production_env(tmp_path: Path) -> None: # Development mode emits asset tags pointing at the (absent) Vite dev # server, so containers must boot in production mode. diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 727e6e25..a919eb54 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -5,6 +5,8 @@ import json from pathlib import Path +from simple_module_cli._env import env_values +from simple_module_cli._local_env import write_local_env from simple_module_cli.cli import app from typer.testing import CliRunner @@ -57,33 +59,43 @@ def test_sm_new_pins_client_app_simple_module_deps_to_framework_version(tmp_path ) -def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - runner.invoke( - app, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - env_text = (target / ".env.example").read_text() - assert "SM_SECRET_KEY=" in env_text - assert "CHANGE-ME" not in env_text - secret_line = next(ln for ln in env_text.splitlines() if ln.startswith("SM_SECRET_KEY=")) - assert len(secret_line.split("=", 1)[1]) >= 20 +_SECRET_KEYS = ( + "SM_SECRET_KEY", + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", + "SM_USERS_VERIFICATION_TOKEN_SECRET", +) -def test_sm_new_writes_generated_users_token_secrets(tmp_path: Path) -> None: - # UsersSettings refuses its placeholder token secrets in production — - # the mode the scaffold's compose containers boot in. +def test_sm_new_writes_generated_secrets_to_gitignored_env(tmp_path: Path) -> None: + # GH #441: the committed .env.example must not carry real secrets. The + # users token secrets are needed because UsersSettings refuses its + # placeholders in production, the mode the compose containers boot in. runner = CliRunner() target = tmp_path / "my-app" runner.invoke( app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - env_text = (target / ".env.example").read_text() - for key in ("SM_USERS_RESET_PASSWORD_TOKEN_SECRET", "SM_USERS_VERIFICATION_TOKEN_SECRET"): - line = next(ln for ln in env_text.splitlines() if ln.startswith(f"{key}=")) - assert len(line.split("=", 1)[1]) >= 20 + env = env_values((target / ".env").read_text()) + example_text = (target / ".env.example").read_text() + example = env_values((target / ".env.example").read_text()) + for key in _SECRET_KEYS: + assert len(env[key]) >= 20 + assert env[key] not in example_text + assert key not in example + assert f"# {key}=" in example_text + assert ".env" in (target / ".gitignore").read_text().splitlines() + # Non-secret settings stay in step between the two files. + assert env["SM_DATABASE_URL"] == example["SM_DATABASE_URL"] + + +def test_sm_new_keeps_an_existing_env(tmp_path: Path) -> None: + (tmp_path / ".env.example").write_text("A=1\nSM_SECRET_KEY=\n") + (tmp_path / ".env").write_text("SM_SECRET_KEY=mine\n") + write_local_env(tmp_path, secret_keys=["SM_SECRET_KEY", "OTHER"]) + env = env_values((tmp_path / ".env").read_text()) + assert env["SM_SECRET_KEY"] == "mine" + assert len(env["OTHER"]) >= 20 def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: @@ -232,3 +244,22 @@ def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: ) assert result.exit_code != 0 assert "exists" in result.output.lower() or "exists" in (result.stderr or "").lower() + + +def test_write_local_env_fills_placeholder_on_last_line_without_newline(tmp_path: Path) -> None: + (tmp_path / ".env").write_text("A=1\n# SM_SECRET_KEY=") + write_local_env(tmp_path, secret_keys=["SM_SECRET_KEY"]) + env = env_values((tmp_path / ".env").read_text()) + assert len(env["SM_SECRET_KEY"]) >= 20 + + +def test_write_local_env_keeps_existing_postgres_password_and_url_together( + tmp_path: Path, +) -> None: + (tmp_path / ".env").write_text("POSTGRES_PASSWORD=kept\n") + write_local_env( + tmp_path, + secret_keys=[], + overrides={"POSTGRES_PASSWORD": "new", "SM_DATABASE_URL": "postgresql://postgres:new@x/db"}, + ) + assert env_values((tmp_path / ".env").read_text()) == {"POSTGRES_PASSWORD": "kept"} diff --git a/framework/db/simple_module_db/migrations.py b/framework/db/simple_module_db/migrations.py index dec46f6b..fbefe033 100644 --- a/framework/db/simple_module_db/migrations.py +++ b/framework/db/simple_module_db/migrations.py @@ -232,8 +232,10 @@ def _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes) -> N def render_item(type_, obj, autogen_context): """Alembic ``render_item`` callback for SQLModel + extension types. - * Collapses SQLModel's ``AutoString`` to ``sa.String`` so migrations don't - need to ``import sqlmodel``. + * Collapses SQLModel's ``AutoString`` to ``sa.String`` and ``UTCDateTime`` + to ``sa.DateTime(timezone=True)`` so migrations don't need to + ``import sqlmodel`` (GH #440). Any other ``sqlmodel`` type still gets + the import added, so the migration stays importable. * Renders Python ``StrEnum`` columns with ``values_callable`` so the Postgres enum labels match the lowercase ``StrEnum`` values rather than SQLAlchemy's default of using uppercase attribute names. This means raw @@ -257,6 +259,11 @@ def render_item(type_, obj, autogen_context): if cls_name == "AutoString": length = getattr(obj, "length", None) return f"sa.String(length={length})" if length is not None else "sa.String()" + if cls_name == "UTCDateTime" and cls_module.startswith("sqlmodel"): + return "sa.DateTime(timezone=True)" + + if imports is not None and cls_module.startswith("sqlmodel"): + imports.add("import sqlmodel") if imports is not None and cls_module.startswith("fastapi_users_db_sqlalchemy"): imports.add("import fastapi_users_db_sqlalchemy.generics") diff --git a/framework/db/tests/test_migration_portability.py b/framework/db/tests/test_migration_portability.py index 0b3878b8..a311587b 100644 --- a/framework/db/tests/test_migration_portability.py +++ b/framework/db/tests/test_migration_portability.py @@ -162,3 +162,32 @@ def test_downgrade_recreated_boolean_defaults_render_portably(): assert code.count("sa.false()") == 2 assert code.count("sa.true()") == 2 + + +def test_sqlmodel_utc_datetime_renders_without_sqlmodel(): + """GH #440: sqlmodel>=0.0.48 maps ``datetime`` fields to ``UTCDateTime``.""" + from sqlmodel.sql.sqltypes import UTCDateTime + + md = sa.MetaData() + sa.Table( + "stamps", + md, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("created_at", UTCDateTime(), nullable=False), + ) + code, _, _ = _autogenerate(md) + + assert "sa.Column('created_at', sa.DateTime(timezone=True), nullable=False)" in code + assert "sqlmodel" not in code + + +def test_other_sqlmodel_types_add_the_import(): + class FakeType(sa.types.TypeDecorator): + impl = sa.Integer + cache_ok = True + + FakeType.__module__ = "sqlmodel.sql.sqltypes" + autogen_context = SimpleNamespace(imports=set()) + + assert render_item("type", FakeType(), autogen_context) is False + assert autogen_context.imports == {"import sqlmodel"}