From 97cb85425c6938a118a429b34ae5b504f766413d Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Sun, 18 Jan 2026 13:04:49 +0100 Subject: [PATCH 01/12] Fix readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d8a0f1d..89b5baa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ iron_sql keeps SQL close to Python call sites while giving you typed, async quer - Safe-by-default: helper methods enforce expected row counts instead of returning silent `None`. ## Quick start -1. Install `iron_sql`, `psycopg`, `psycopg-pool`, `orjson`, and `pydantic`. +1. Install `iron_sql`, `psycopg`, `psycopg-pool`, and `pydantic`. 2. Install [`sqlc` v2](https://docs.sqlc.dev/en/latest/overview/install.html) and ensure `/usr/local/bin/sqlc` is in PATH. 3. Add a Postgres schema dump, for example `db/adept_schema.sql`. 4. Call `generate_sql_package(schema_path=..., package_full_name=..., dsn_import=...)` from a small script or task. The generator scans your code, runs `sqlc`, and writes a module such as `adept/db/adept.py`. From 7ea421b59ac12d717291ff9be3751367fc87f341 Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Sun, 18 Jan 2026 18:00:10 +0100 Subject: [PATCH 02/12] Minor typo fix --- src/iron_sql/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iron_sql/__init__.py b/src/iron_sql/__init__.py index 09f266f..45c399c 100644 --- a/src/iron_sql/__init__.py +++ b/src/iron_sql/__init__.py @@ -1,4 +1,4 @@ -"""iron_gql: Typed GraphQL client generator for Python.""" +"""iron_sql: Typed SQL client generator for Python.""" from iron_sql.generator import generate_sql_package From 528ba6e76a0cac0e29f3620425fc444e9944f3df Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Sun, 18 Jan 2026 20:21:02 +0100 Subject: [PATCH 03/12] Make sqlc path configurable --- README.md | 3 ++- src/iron_sql/generator.py | 2 ++ src/iron_sql/sqlc.py | 13 ++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 89b5baa..c889976 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ iron_sql keeps SQL close to Python call sites while giving you typed, async quer ## Quick start 1. Install `iron_sql`, `psycopg`, `psycopg-pool`, and `pydantic`. -2. Install [`sqlc` v2](https://docs.sqlc.dev/en/latest/overview/install.html) and ensure `/usr/local/bin/sqlc` is in PATH. +2. Install [`sqlc` v2](https://docs.sqlc.dev/en/latest/overview/install.html) and ensure it is available in your PATH. 3. Add a Postgres schema dump, for example `db/adept_schema.sql`. 4. Call `generate_sql_package(schema_path=..., package_full_name=..., dsn_import=...)` from a small script or task. The generator scans your code, runs `sqlc`, and writes a module such as `adept/db/adept.py`. @@ -34,4 +34,5 @@ Provide the schema file and DSN import string, then call `generate_sql_package() - `schema_path`: path to the schema SQL file. - `package_full_name`: target module, e.g. `adept.db.analytics`. - `dsn_import`: import path to a DSN string, e.g. `adept.config:CONFIG.analytics_db_url.value`. +- `sqlc_path`: optional path to the sqlc binary if not in PATH (e.g., `Path("/custom/bin/sqlc")`). - Optional `application_name`, `debug_path`, and `to_pascal_fn` if you need naming overrides or want to keep `sqlc` inputs for inspection. diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index 7723a58..335d0eb 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -40,6 +40,7 @@ def generate_sql_package( # noqa: PLR0914 to_pascal_fn=alias_generators.to_pascal, debug_path: Path | None = None, src_path: Path, + sqlc_path: Path | None = None, ) -> bool: dsn_import_package, dsn_import_path = dsn_import.split(":") @@ -58,6 +59,7 @@ def generate_sql_package( # noqa: PLR0914 [(q.name, q.stmt) for q in queries], dsn=dsn, debug_path=debug_path, + sqlc_path=sqlc_path, ) if sqlc_res.error: diff --git a/src/iron_sql/sqlc.py b/src/iron_sql/sqlc.py index 04838d4..ca465e9 100644 --- a/src/iron_sql/sqlc.py +++ b/src/iron_sql/sqlc.py @@ -123,6 +123,7 @@ def run_sqlc( *, dsn: str | None, debug_path: Path | None = None, + sqlc_path: Path | None = None, ) -> SQLCResult: if not schema_path.exists(): msg = f"Schema file not found: {schema_path}" @@ -136,6 +137,16 @@ def run_sqlc( queries = list({q[0]: q for q in queries}.values()) + if sqlc_path is None: + discovered_path = shutil.which("sqlc") + if discovered_path is None: + msg = "sqlc not found in PATH" + raise FileNotFoundError(msg) + sqlc_path = Path(discovered_path) + if not sqlc_path.exists(): + msg = f"sqlc not found at {sqlc_path}" + raise FileNotFoundError(msg) + with tempfile.TemporaryDirectory() as tempdir: queries_path = Path(tempdir) / "queries.sql" queries_path.write_text( @@ -164,7 +175,7 @@ def run_sqlc( config_path.write_text(json.dumps(sqlc_config, indent=2), encoding="utf-8") sqlc_run_result = subprocess.run( # noqa: S603 - ["/usr/local/bin/sqlc", "generate", "--file", str(config_path)], + [str(sqlc_path), "generate", "--file", str(config_path)], capture_output=True, check=False, ) From ce79ab68317e1c3f47fb06808131834eddcc05f6 Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Sun, 18 Jan 2026 21:00:42 +0100 Subject: [PATCH 04/12] Update docs --- README.md | 13 +++++++------ src/iron_sql/generator.py | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c889976..3394dcd 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ iron_sql keeps SQL close to Python call sites while giving you typed, async quer ## Quick start 1. Install `iron_sql`, `psycopg`, `psycopg-pool`, and `pydantic`. 2. Install [`sqlc` v2](https://docs.sqlc.dev/en/latest/overview/install.html) and ensure it is available in your PATH. -3. Add a Postgres schema dump, for example `db/adept_schema.sql`. -4. Call `generate_sql_package(schema_path=..., package_full_name=..., dsn_import=...)` from a small script or task. The generator scans your code, runs `sqlc`, and writes a module such as `adept/db/adept.py`. +3. Add a Postgres schema dump, for example `db/mydatabase_schema.sql`. +4. Call `generate_sql_package(schema_path=..., package_full_name=..., dsn_import=...)` from a small script or task. The generator scans your code (defaults to current directory), runs `sqlc`, and writes a module such as `myapp/db/mydatabase.py`. ## Authoring queries -- Use the package helper for your DB, e.g. `adept_sql("select ...")`. The SQL string must be a literal so the generator can find it. +- Use the package helper for your DB, e.g. `mydatabase_sql("select ...")`. The SQL string must be a literal so the generator can find it. - Named parameters: - Required: `@param` - Optional: `@param?` (expands to `sqlc.narg('param')`) @@ -31,8 +31,9 @@ iron_sql keeps SQL close to Python call sites while giving you typed, async quer ## Adding another database package Provide the schema file and DSN import string, then call `generate_sql_package()` with: -- `schema_path`: path to the schema SQL file. -- `package_full_name`: target module, e.g. `adept.db.analytics`. -- `dsn_import`: import path to a DSN string, e.g. `adept.config:CONFIG.analytics_db_url.value`. +- `schema_path`: path to the schema SQL file (relative to `src_path`). +- `package_full_name`: target module, e.g. `myapp.db`. +- `dsn_import`: import path to a DSN string, e.g. `myapp.config:CONFIG.db_url.value`. +- `src_path`: optional base source path for scanning queries (defaults current directory). - `sqlc_path`: optional path to the sqlc binary if not in PATH (e.g., `Path("/custom/bin/sqlc")`). - Optional `application_name`, `debug_path`, and `to_pascal_fn` if you need naming overrides or want to keep `sqlc` inputs for inspection. diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index 335d0eb..dc2e7e1 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -39,9 +39,26 @@ def generate_sql_package( # noqa: PLR0914 application_name: str | None = None, to_pascal_fn=alias_generators.to_pascal, debug_path: Path | None = None, - src_path: Path, + src_path: Path = Path(), sqlc_path: Path | None = None, ) -> bool: + """Generate a typed SQL package from schema and queries. + + Args: + schema_path: Path to the Postgres schema SQL file (relative to src_path) + package_full_name: Target module name (e.g., "myapp.mydatabase") + dsn_import: Import path to DSN string (e.g., + "myapp.config:CONFIG.db_url") + application_name: Optional application name for connection pool + to_pascal_fn: Function to convert names to PascalCase (default: + pydantic's to_pascal) + debug_path: Optional path to save sqlc inputs for inspection + src_path: Base source path for scanning queries (default: Path()) + sqlc_path: Optional path to sqlc binary if not in PATH + + Returns: + True if the package was generated or modified, False otherwise + """ dsn_import_package, dsn_import_path = dsn_import.split(":") package_name = package_full_name.split(".")[-1] # noqa: PLC0207 From 2f08b9533a3449379ac918f213195367df304b5b Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 09:36:07 +0100 Subject: [PATCH 05/12] Add tests --- .github/workflows/main.yml | 4 + pyproject.toml | 7 +- src/iron_sql/conftest.py | 151 ++++++++++++++++++++++++ src/iron_sql/integration_test.py | 109 ++++++++++++++++- uv.lock | 194 +++++++++++++++++++++++++++++++ 5 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 src/iron_sql/conftest.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1ec7a62..99f1ddb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,6 +15,10 @@ jobs: uses: extractions/setup-just@v3 - name: Install uv uses: astral-sh/setup-uv@v6 + - name: Install sqlc + run: | + curl -sSL https://github.com/sqlc-dev/sqlc/releases/download/v1.28.0/sqlc_1.28.0_linux_amd64.tar.gz | tar xz + sudo mv sqlc /usr/local/bin/ - name: Run lint run: just lint - name: Run test diff --git a/pyproject.toml b/pyproject.toml index 05a9ba1..31e89c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dev = [ "pytest-asyncio>=1.2.0", "pytest-cov>=7.0.0", "ruff>=0.14.1", + "testcontainers>=4", ] [tool.pyright] @@ -100,11 +101,15 @@ max-args = 10 ban-relative-imports = "all" [tool.pytest.ini_options] -filterwarnings = ["error"] +filterwarnings = [ + "error", + "ignore:The @wait_container_is_ready decorator is deprecated:DeprecationWarning", +] addopts = ["--no-cov-on-fail", "--cov-report=term-missing:skip-covered"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" + [tool.coverage.run] branch = true omit = ["*_test.py"] diff --git a/src/iron_sql/conftest.py b/src/iron_sql/conftest.py new file mode 100644 index 0000000..c13ee25 --- /dev/null +++ b/src/iron_sql/conftest.py @@ -0,0 +1,151 @@ +import importlib +import shutil +import sys +from collections.abc import AsyncIterator +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import psycopg +import pytest +from testcontainers.postgres import PostgresContainer + +from iron_sql import generate_sql_package + +SCHEMA_SQL = """ + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL, + email TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB + ); + + CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + content TEXT, + published BOOLEAN NOT NULL DEFAULT false + ); +""" + + +@pytest.fixture(scope="session") +def pg_container() -> Iterator[PostgresContainer]: + postgres = PostgresContainer("postgres:17-alpine") + postgres.start() + try: + yield postgres + finally: + postgres.stop() + + +@pytest.fixture(scope="session") +def pg_dsn(pg_container: PostgresContainer) -> str: + url = pg_container.get_connection_url(driver="psycopg") + return url.replace("+psycopg", "") + + +@pytest.fixture(scope="session") +def schema_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + temp_dir = tmp_path_factory.mktemp("data") + path = temp_dir / "schema.sql" + path.write_text(SCHEMA_SQL, encoding="utf-8") + return path + + +def _reset_db(dsn: str) -> None: + with psycopg.connect(dsn) as conn: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute("DROP SCHEMA IF EXISTS public CASCADE") + cur.execute("CREATE SCHEMA public") + cur.execute("GRANT ALL ON SCHEMA public TO public") + cur.execute(SCHEMA_SQL) + + +@pytest.fixture(scope="session") +def _apply_schema_once(pg_dsn: str) -> None: # pyright: ignore[reportUnusedFunction] + _reset_db(pg_dsn) + + +async def close_generated_pools(module: Any) -> None: + for name in dir(module): + if name.endswith("_POOL"): + pool = getattr(module, name) + if hasattr(pool, "close"): + await pool.close() + + +class ProjectBuilder: + def __init__(self, root: Path, dsn: str, test_name: str, schema_path: Path): + self.root = root + self.dsn = dsn + self.test_name = test_name + self.schema_path = schema_path + self.pkg_name = f"testapp_{test_name}.testdb" + self.src_path = root / "src" + self.app_pkg = f"testapp_{test_name}" + self.app_dir = self.src_path / self.app_pkg + self.queries: list[tuple[str, str]] = [] + self.generated_modules: list[Any] = [] + + self.app_dir.mkdir(parents=True, exist_ok=True) + (self.app_dir / "__init__.py").touch() + + schema_src = self.schema_path.absolute() + schema_dest = self.src_path / "schema.sql" + schema_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(schema_src, schema_dest) + + def add_query(self, name: str, sql: str) -> None: + self.queries.append((name, sql)) + + def generate(self) -> Any: + (self.app_dir / "config.py").write_text( + f'DSN = "{self.dsn}"\n', encoding="utf-8" + ) + + lines = ["from typing import Any"] + lines.extend(["def testdb_sql(q: str, **kwargs: Any) -> Any: ...", ""]) + + for name, sql in self.queries: + if name: + lines.append(f'{name} = testdb_sql("""{sql}""")') + else: + lines.append(f'testdb_sql("""{sql}""")') + + (self.app_dir / "queries.py").write_text("\n".join(lines), encoding="utf-8") + + if str(self.src_path) not in sys.path: + sys.path.insert(0, str(self.src_path)) + importlib.invalidate_caches() + + generate_sql_package( + schema_path=Path("schema.sql"), + package_full_name=self.pkg_name, + dsn_import=f"{self.app_pkg}.config:DSN", + src_path=self.src_path, + ) + + mod = importlib.import_module(self.pkg_name) + self.generated_modules.append(mod) + return mod + + +@pytest.fixture +async def test_project( + tmp_path: Path, + request: pytest.FixtureRequest, + pg_dsn: str, + schema_path: Path, + _apply_schema_once: None, # noqa: PT019 +) -> AsyncIterator[ProjectBuilder]: + clean_name = request.node.name.replace("[", "_").replace("]", "_").replace("-", "_") + builder = ProjectBuilder(tmp_path, pg_dsn, clean_name, schema_path) + yield builder + for mod in builder.generated_modules: + await close_generated_pools(mod) + _reset_db(pg_dsn) diff --git a/src/iron_sql/integration_test.py b/src/iron_sql/integration_test.py index a3e981b..f65a105 100644 --- a/src/iron_sql/integration_test.py +++ b/src/iron_sql/integration_test.py @@ -1,2 +1,107 @@ -def test_nothing(): - pass +import uuid + +import pytest + +from iron_sql.conftest import ProjectBuilder +from iron_sql.runtime import NoRowsError +from iron_sql.runtime import TooManyRowsError + + +async def test_codegen_e2e(test_project: ProjectBuilder): + insert_sql = "INSERT INTO users (id, username, is_active) VALUES ($1, $2, $3)" + select_sql = "SELECT id, username, is_active FROM users WHERE id = $1" + + test_project.add_query("ins", insert_sql) + test_project.add_query("sel", select_sql) + + mod = test_project.generate() + + uid = uuid.uuid4() + + await mod.testdb_sql(insert_sql).execute(uid, "testuser", True) + + row = await mod.testdb_sql(select_sql).query_single_row(uid) + + assert row.id == uid + assert row.username == "testuser" + assert row.is_active is True + + +async def test_runtime_errors(test_project: ProjectBuilder): + select_sql = "SELECT * FROM users WHERE username = $1" + insert_sql = "INSERT INTO users (id, username) VALUES ($1, $2)" + + test_project.add_query("sel", select_sql) + test_project.add_query("ins", insert_sql) + + mod = test_project.generate() + uid1 = uuid.uuid4() + uid2 = uuid.uuid4() + + with pytest.raises(NoRowsError): + await mod.testdb_sql(select_sql).query_single_row("missing") + + res = await mod.testdb_sql(select_sql).query_optional_row("missing") + assert res is None + + await mod.testdb_sql(insert_sql).execute(uid1, "duplicate") + await mod.testdb_sql(insert_sql).execute(uid2, "duplicate") + + with pytest.raises(TooManyRowsError): + await mod.testdb_sql(select_sql).query_single_row("duplicate") + + with pytest.raises(TooManyRowsError): + await mod.testdb_sql(select_sql).query_optional_row("duplicate") + + +async def test_jsonb_roundtrip(test_project: ProjectBuilder): + sql = ( + "INSERT INTO users (id, username, metadata) " + "VALUES ($1, $2, $3) RETURNING metadata" + ) + test_project.add_query("q", sql) + + mod = test_project.generate() + uid = uuid.uuid4() + data = {"key": "value", "list": [1, 2], "nested": {"a": 1}} + + res = await mod.testdb_sql(sql).query_single_row(uid, "json_user", data) + assert res == data + + +async def test_transaction_commit(test_project: ProjectBuilder): + insert = "INSERT INTO users (id, username) VALUES ($1, 'tx_user')" + select = "SELECT count(*) as cnt FROM users WHERE username = 'tx_user'" + + test_project.add_query("i", insert) + test_project.add_query("s", select) + + mod = test_project.generate() + uid = uuid.uuid4() + + async with mod.testdb_transaction(): + await mod.testdb_sql(insert).execute(uid) + + row = await mod.testdb_sql(select).query_single_row() + assert row == 1 + + +async def test_transaction_rollback(test_project: ProjectBuilder): + insert = "INSERT INTO users (id, username) VALUES ($1, 'rollback_user')" + select = "SELECT count(*) as cnt FROM users WHERE username = 'rollback_user'" + + test_project.add_query("i", insert) + test_project.add_query("s", select) + + mod = test_project.generate() + uid = uuid.uuid4() + + try: + async with mod.testdb_transaction(): + await mod.testdb_sql(insert).execute(uid) + raise RuntimeError # noqa: TRY301 + except RuntimeError: + pass + + row = await mod.testdb_sql(select).query_single_row() + assert row == 0 diff --git a/uv.lock b/uv.lock index 7598e82..ad89b68 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/90/ce01ad2d0afdc1b82b8b5aaba27e60d2e138e39d887e71c35c55d8f1bfcd/basedpyright-1.31.7-py3-none-any.whl", hash = "sha256:7c54beb7828c9ed0028630aaa6904f395c27e5a9f5a313aa9e91fc1d11170831", size = 11817571, upload-time = "2025-10-11T05:12:45.432Z" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -93,6 +143,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "inflection" version = "0.5.1" @@ -129,6 +202,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "testcontainers" }, ] [package.metadata] @@ -146,6 +220,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.14.1" }, + { name = "testcontainers", specifier = ">=4" }, ] [[package]] @@ -325,6 +400,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "ruff" version = "0.14.1" @@ -351,6 +463,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/5a/d24f5c7ef787fc152b1e4e4cfb84ef9364dbf165b3c7f7817e2f2583f749/testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7", size = 79885, upload-time = "2026-01-07T23:35:22.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/c4/53efc88d890d7dd38337424a83bbff32007d9d3390a79a4b53bfddaa64e8/testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59", size = 125385, upload-time = "2026-01-07T23:35:21.343Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -380,3 +508,69 @@ sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be76 wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, + { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, + { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] From 08ea54060938aeb74eecaebbbaf51b32f2942cec Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 11:22:36 +0100 Subject: [PATCH 06/12] Containerize sqlc --- .github/workflows/main.yml | 4 - README.md | 1 + pyproject.toml | 1 + src/iron_sql/conftest.py | 36 +++-- src/iron_sql/generator.py | 3 + src/iron_sql/sqlc.py | 9 +- src/iron_sql/testing/__init__.py | 3 + src/iron_sql/testing/sqlc_testcontainers.py | 148 ++++++++++++++++++++ 8 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 src/iron_sql/testing/__init__.py create mode 100644 src/iron_sql/testing/sqlc_testcontainers.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 99f1ddb..1ec7a62 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,10 +15,6 @@ jobs: uses: extractions/setup-just@v3 - name: Install uv uses: astral-sh/setup-uv@v6 - - name: Install sqlc - run: | - curl -sSL https://github.com/sqlc-dev/sqlc/releases/download/v1.28.0/sqlc_1.28.0_linux_amd64.tar.gz | tar xz - sudo mv sqlc /usr/local/bin/ - name: Run lint run: just lint - name: Run test diff --git a/README.md b/README.md index 3394dcd..b70564a 100644 --- a/README.md +++ b/README.md @@ -36,4 +36,5 @@ Provide the schema file and DSN import string, then call `generate_sql_package() - `dsn_import`: import path to a DSN string, e.g. `myapp.config:CONFIG.db_url.value`. - `src_path`: optional base source path for scanning queries (defaults current directory). - `sqlc_path`: optional path to the sqlc binary if not in PATH (e.g., `Path("/custom/bin/sqlc")`). +- `tempdir_path`: optional path for temporary file generation (useful for Docker mounts). - Optional `application_name`, `debug_path`, and `to_pascal_fn` if you need naming overrides or want to keep `sqlc` inputs for inspection. diff --git a/pyproject.toml b/pyproject.toml index 31e89c2..3185742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ ignore = [ "RUF003", "TC006", "TD", + "TRY300", ] [tool.ruff.lint.per-file-ignores] diff --git a/src/iron_sql/conftest.py b/src/iron_sql/conftest.py index c13ee25..fda9290 100644 --- a/src/iron_sql/conftest.py +++ b/src/iron_sql/conftest.py @@ -11,6 +11,7 @@ from testcontainers.postgres import PostgresContainer from iron_sql import generate_sql_package +from iron_sql.testing import SqlcShim SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS users ( @@ -71,6 +72,11 @@ def _apply_schema_once(pg_dsn: str) -> None: # pyright: ignore[reportUnusedFunc _reset_db(pg_dsn) +@pytest.fixture(scope="session") +def containerized_sqlc(tmp_path_factory: pytest.TempPathFactory) -> SqlcShim: + return SqlcShim(tmp_path_factory.mktemp("sqlc_bin")) + + async def close_generated_pools(module: Any) -> None: for name in dir(module): if name.endswith("_POOL"): @@ -80,11 +86,19 @@ async def close_generated_pools(module: Any) -> None: class ProjectBuilder: - def __init__(self, root: Path, dsn: str, test_name: str, schema_path: Path): + def __init__( + self, + root: Path, + dsn: str, + test_name: str, + schema_path: Path, + sqlc: SqlcShim, + ): self.root = root self.dsn = dsn self.test_name = test_name self.schema_path = schema_path + self._sqlc = sqlc self.pkg_name = f"testapp_{test_name}.testdb" self.src_path = root / "src" self.app_pkg = f"testapp_{test_name}" @@ -123,12 +137,15 @@ def generate(self) -> Any: sys.path.insert(0, str(self.src_path)) importlib.invalidate_caches() - generate_sql_package( - schema_path=Path("schema.sql"), - package_full_name=self.pkg_name, - dsn_import=f"{self.app_pkg}.config:DSN", - src_path=self.src_path, - ) + with self._sqlc.env_context(self.src_path): + generate_sql_package( + schema_path=Path("schema.sql"), + package_full_name=self.pkg_name, + dsn_import=f"{self.app_pkg}.config:DSN", + src_path=self.src_path, + sqlc_path=self._sqlc.path, + tempdir_path=self.src_path, + ) mod = importlib.import_module(self.pkg_name) self.generated_modules.append(mod) @@ -141,10 +158,13 @@ async def test_project( request: pytest.FixtureRequest, pg_dsn: str, schema_path: Path, + containerized_sqlc: SqlcShim, _apply_schema_once: None, # noqa: PT019 ) -> AsyncIterator[ProjectBuilder]: clean_name = request.node.name.replace("[", "_").replace("]", "_").replace("-", "_") - builder = ProjectBuilder(tmp_path, pg_dsn, clean_name, schema_path) + builder = ProjectBuilder( + tmp_path, pg_dsn, clean_name, schema_path, containerized_sqlc + ) yield builder for mod in builder.generated_modules: await close_generated_pools(mod) diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index dc2e7e1..2fa0681 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -41,6 +41,7 @@ def generate_sql_package( # noqa: PLR0914 debug_path: Path | None = None, src_path: Path = Path(), sqlc_path: Path | None = None, + tempdir_path: Path | None = None, ) -> bool: """Generate a typed SQL package from schema and queries. @@ -55,6 +56,7 @@ def generate_sql_package( # noqa: PLR0914 debug_path: Optional path to save sqlc inputs for inspection src_path: Base source path for scanning queries (default: Path()) sqlc_path: Optional path to sqlc binary if not in PATH + tempdir_path: Optional path for temporary file generation Returns: True if the package was generated or modified, False otherwise @@ -77,6 +79,7 @@ def generate_sql_package( # noqa: PLR0914 dsn=dsn, debug_path=debug_path, sqlc_path=sqlc_path, + tempdir_path=tempdir_path, ) if sqlc_res.error: diff --git a/src/iron_sql/sqlc.py b/src/iron_sql/sqlc.py index ca465e9..046f5bc 100644 --- a/src/iron_sql/sqlc.py +++ b/src/iron_sql/sqlc.py @@ -124,6 +124,7 @@ def run_sqlc( dsn: str | None, debug_path: Path | None = None, sqlc_path: Path | None = None, + tempdir_path: Path | None = None, ) -> SQLCResult: if not schema_path.exists(): msg = f"Schema file not found: {schema_path}" @@ -147,7 +148,9 @@ def run_sqlc( msg = f"sqlc not found at {sqlc_path}" raise FileNotFoundError(msg) - with tempfile.TemporaryDirectory() as tempdir: + with tempfile.TemporaryDirectory( + dir=str(tempdir_path) if tempdir_path else None + ) as tempdir: queries_path = Path(tempdir) / "queries.sql" queries_path.write_text( "\n\n".join( @@ -174,8 +177,10 @@ def run_sqlc( } config_path.write_text(json.dumps(sqlc_config, indent=2), encoding="utf-8") + cmd = [str(sqlc_path), "generate", "--file", str(config_path.resolve())] + sqlc_run_result = subprocess.run( # noqa: S603 - [str(sqlc_path), "generate", "--file", str(config_path)], + cmd, capture_output=True, check=False, ) diff --git a/src/iron_sql/testing/__init__.py b/src/iron_sql/testing/__init__.py new file mode 100644 index 0000000..8fa2c09 --- /dev/null +++ b/src/iron_sql/testing/__init__.py @@ -0,0 +1,3 @@ +from iron_sql.testing.sqlc_testcontainers import SqlcShim + +__all__ = ["SqlcShim"] diff --git a/src/iron_sql/testing/sqlc_testcontainers.py b/src/iron_sql/testing/sqlc_testcontainers.py new file mode 100644 index 0000000..8b80ff8 --- /dev/null +++ b/src/iron_sql/testing/sqlc_testcontainers.py @@ -0,0 +1,148 @@ +import os +import stat +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from testcontainers.core.container import DockerContainer + +ENV_SQLC_IMAGE = "IRON_SQL_SQLC_IMAGE" +ENV_SQLC_MOUNT = "IRON_SQL_SQLC_MOUNT" +ENV_SQLC_TIMEOUT_SECONDS = "IRON_SQL_SQLC_TIMEOUT_SECONDS" +ENV_SQLC_ADD_HOST_GATEWAY = "IRON_SQL_SQLC_ADD_HOST_GATEWAY" + +DEFAULT_SQLC_IMAGE = "sqlc/sqlc:1.29.0" +DEFAULT_TIMEOUT_SECONDS = 120 + + +@dataclass(frozen=True, slots=True) +class SqlcRunResult: + exit_code: int + stdout: bytes + stderr: bytes + + +@contextmanager +def _temporary_env(values: dict[str, str]) -> Iterator[None]: + old: dict[str, str | None] = {k: os.environ.get(k) for k in values} + try: + os.environ.update(values) + yield + finally: + for k, prev in old.items(): + if prev is None: + os.environ.pop(k, None) + else: + os.environ[k] = prev + + +def _is_subpath(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def run_sqlc_in_container( + *, argv: list[str], mount: Path, image: str, timeout_s: int +) -> SqlcRunResult: + mount = mount.resolve() + + workdir = Path.cwd().resolve() + if not _is_subpath(workdir, mount): + # Если вызывают из cwd вне маунта — не ломаемся, просто уходим в mount. + workdir = mount + + container = ( + DockerContainer(image) + .with_volume_mapping(str(mount), str(mount), mode="rw") + .with_kwargs(working_dir=str(workdir)) + .with_command(argv) + ) + + add_host_gateway = os.environ.get(ENV_SQLC_ADD_HOST_GATEWAY, "1") != "0" + if add_host_gateway: + # Это эквивалентно: --add-host=localhost:host-gateway + container = container.with_kwargs(extra_hosts={"localhost": "host-gateway"}) + + container.start() + try: + wrapped = container.get_wrapped_container() + + wait_res = wrapped.wait(timeout=timeout_s) + exit_code = int(wait_res.get("StatusCode", 1)) + + # docker-py позволяет просить stdout/stderr отдельно + stdout = wrapped.logs(stdout=True, stderr=False) or b"" + stderr = wrapped.logs(stdout=False, stderr=True) or b"" + + return SqlcRunResult(exit_code=exit_code, stdout=stdout, stderr=stderr) + finally: + container.stop() + + +def shim_main() -> int: + mount_raw = os.environ.get(ENV_SQLC_MOUNT) + if not mount_raw: + msg = f"{ENV_SQLC_MOUNT} is required" + raise RuntimeError(msg) + + image = os.environ.get(ENV_SQLC_IMAGE, DEFAULT_SQLC_IMAGE) + timeout_s = int( + os.environ.get(ENV_SQLC_TIMEOUT_SECONDS, str(DEFAULT_TIMEOUT_SECONDS)) + ) + + res = run_sqlc_in_container( + argv=sys.argv[1:], + mount=Path(mount_raw), + image=image, + timeout_s=timeout_s, + ) + + # Важно: generator читает stderr при ошибке. + if res.stdout: + sys.stdout.buffer.write(res.stdout) + if res.stderr: + sys.stderr.buffer.write(res.stderr) + + return res.exit_code + + +@dataclass(frozen=True, slots=True) +class SqlcShim: + path: Path + image: str = DEFAULT_SQLC_IMAGE + + def __init__(self, script_dir: Path, *, image: str = DEFAULT_SQLC_IMAGE) -> None: + script_dir = script_dir.resolve() + script_dir.mkdir(parents=True, exist_ok=True) + + shim_path = script_dir / "sqlc" + shim_path.write_text( + "#!/usr/bin/env python3\n" + "from iron_sql.testing.sqlc_testcontainers import shim_main\n" + "raise SystemExit(shim_main())\n", + encoding="utf-8", + ) + shim_path.chmod( + shim_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + + object.__setattr__(self, "path", shim_path) + object.__setattr__(self, "image", image) + + @contextmanager + def env_context(self, mount_path: Path) -> Iterator[None]: + mount = str(mount_path.resolve()) + # TMPDIR is important: + # run_sqlc uses TemporaryDirectory and symlink_to(absolute_path), + # and we need the tempdir to reside inside the mount. + with _temporary_env({ + "TMPDIR": mount, + ENV_SQLC_MOUNT: mount, + ENV_SQLC_IMAGE: self.image, + }): + yield From 7c2767e15fb903f5e91fdc7d514b65f08600d02d Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 13:50:14 +0100 Subject: [PATCH 07/12] Minor tests tweak --- src/iron_sql/conftest.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/iron_sql/conftest.py b/src/iron_sql/conftest.py index fda9290..2862886 100644 --- a/src/iron_sql/conftest.py +++ b/src/iron_sql/conftest.py @@ -1,3 +1,4 @@ +import asyncio import importlib import shutil import sys @@ -57,7 +58,7 @@ def schema_path(tmp_path_factory: pytest.TempPathFactory) -> Path: return path -def _reset_db(dsn: str) -> None: +def _apply_schema(dsn: str) -> None: with psycopg.connect(dsn) as conn: conn.autocommit = True with conn.cursor() as cur: @@ -67,9 +68,16 @@ def _reset_db(dsn: str) -> None: cur.execute(SCHEMA_SQL) +def _cleanup_data(dsn: str) -> None: + with psycopg.connect(dsn) as conn: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE posts, users RESTART IDENTITY CASCADE") + + @pytest.fixture(scope="session") def _apply_schema_once(pg_dsn: str) -> None: # pyright: ignore[reportUnusedFunction] - _reset_db(pg_dsn) + _apply_schema(pg_dsn) @pytest.fixture(scope="session") @@ -168,4 +176,5 @@ async def test_project( yield builder for mod in builder.generated_modules: await close_generated_pools(mod) - _reset_db(pg_dsn) + sys.modules.pop(builder.pkg_name, None) + await asyncio.to_thread(_cleanup_data, pg_dsn) From 58ec2197575fd594a8e9c5e6127399e1dc189c2e Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 15:55:41 +0100 Subject: [PATCH 08/12] Rework test containerization --- pyproject.toml | 1 + src/iron_sql/generator.py | 3 + src/iron_sql/sqlc.py | 40 ++++-- src/iron_sql/testing/__init__.py | 3 - src/iron_sql/testing/sqlc_testcontainers.py | 148 -------------------- tests/__init__.py | 1 + {src/iron_sql => tests}/conftest.py | 34 +++-- {src/iron_sql => tests}/integration_test.py | 2 +- tests/sqlc_testcontainers.py | 101 +++++++++++++ 9 files changed, 156 insertions(+), 177 deletions(-) delete mode 100644 src/iron_sql/testing/__init__.py delete mode 100644 src/iron_sql/testing/sqlc_testcontainers.py create mode 100644 tests/__init__.py rename {src/iron_sql => tests}/conftest.py (88%) rename {src/iron_sql => tests}/integration_test.py (98%) create mode 100644 tests/sqlc_testcontainers.py diff --git a/pyproject.toml b/pyproject.toml index 3185742..aad1579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ max-args = 10 ban-relative-imports = "all" [tool.pytest.ini_options] +testpaths = ["tests"] filterwarnings = [ "error", "ignore:The @wait_container_is_ready decorator is deprecated:DeprecationWarning", diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index 2fa0681..b43b4a3 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -42,6 +42,7 @@ def generate_sql_package( # noqa: PLR0914 src_path: Path = Path(), sqlc_path: Path | None = None, tempdir_path: Path | None = None, + sqlc_command: list[str] | None = None, ) -> bool: """Generate a typed SQL package from schema and queries. @@ -57,6 +58,7 @@ def generate_sql_package( # noqa: PLR0914 src_path: Base source path for scanning queries (default: Path()) sqlc_path: Optional path to sqlc binary if not in PATH tempdir_path: Optional path for temporary file generation + sqlc_command: Optional command prefix to run sqlc Returns: True if the package was generated or modified, False otherwise @@ -80,6 +82,7 @@ def generate_sql_package( # noqa: PLR0914 debug_path=debug_path, sqlc_path=sqlc_path, tempdir_path=tempdir_path, + sqlc_command=sqlc_command, ) if sqlc_res.error: diff --git a/src/iron_sql/sqlc.py b/src/iron_sql/sqlc.py index 046f5bc..d81bab2 100644 --- a/src/iron_sql/sqlc.py +++ b/src/iron_sql/sqlc.py @@ -117,6 +117,32 @@ def used_schemas(self) -> list[str]: return list(result) +def _resolve_sqlc_command( + sqlc_path: Path | None, + sqlc_command: list[str] | None, +) -> list[str]: + if sqlc_command is not None: + if sqlc_path is not None: + msg = "sqlc_command and sqlc_path are mutually exclusive" + raise ValueError(msg) + if not sqlc_command: + msg = "sqlc_command must not be empty" + raise ValueError(msg) + return sqlc_command + + if sqlc_path is None: + discovered_path = shutil.which("sqlc") + if discovered_path is None: + msg = "sqlc not found in PATH" + raise FileNotFoundError(msg) + sqlc_path = Path(discovered_path) + if not sqlc_path.exists(): + msg = f"sqlc not found at {sqlc_path}" + raise FileNotFoundError(msg) + + return [str(sqlc_path)] + + def run_sqlc( schema_path: Path, queries: list[tuple[str, str]], @@ -125,6 +151,7 @@ def run_sqlc( debug_path: Path | None = None, sqlc_path: Path | None = None, tempdir_path: Path | None = None, + sqlc_command: list[str] | None = None, ) -> SQLCResult: if not schema_path.exists(): msg = f"Schema file not found: {schema_path}" @@ -137,16 +164,7 @@ def run_sqlc( ) queries = list({q[0]: q for q in queries}.values()) - - if sqlc_path is None: - discovered_path = shutil.which("sqlc") - if discovered_path is None: - msg = "sqlc not found in PATH" - raise FileNotFoundError(msg) - sqlc_path = Path(discovered_path) - if not sqlc_path.exists(): - msg = f"sqlc not found at {sqlc_path}" - raise FileNotFoundError(msg) + cmd_prefix = _resolve_sqlc_command(sqlc_path, sqlc_command) with tempfile.TemporaryDirectory( dir=str(tempdir_path) if tempdir_path else None @@ -177,7 +195,7 @@ def run_sqlc( } config_path.write_text(json.dumps(sqlc_config, indent=2), encoding="utf-8") - cmd = [str(sqlc_path), "generate", "--file", str(config_path.resolve())] + cmd = [*cmd_prefix, "generate", "--file", str(config_path.resolve())] sqlc_run_result = subprocess.run( # noqa: S603 cmd, diff --git a/src/iron_sql/testing/__init__.py b/src/iron_sql/testing/__init__.py deleted file mode 100644 index 8fa2c09..0000000 --- a/src/iron_sql/testing/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from iron_sql.testing.sqlc_testcontainers import SqlcShim - -__all__ = ["SqlcShim"] diff --git a/src/iron_sql/testing/sqlc_testcontainers.py b/src/iron_sql/testing/sqlc_testcontainers.py deleted file mode 100644 index 8b80ff8..0000000 --- a/src/iron_sql/testing/sqlc_testcontainers.py +++ /dev/null @@ -1,148 +0,0 @@ -import os -import stat -import sys -from collections.abc import Iterator -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path - -from testcontainers.core.container import DockerContainer - -ENV_SQLC_IMAGE = "IRON_SQL_SQLC_IMAGE" -ENV_SQLC_MOUNT = "IRON_SQL_SQLC_MOUNT" -ENV_SQLC_TIMEOUT_SECONDS = "IRON_SQL_SQLC_TIMEOUT_SECONDS" -ENV_SQLC_ADD_HOST_GATEWAY = "IRON_SQL_SQLC_ADD_HOST_GATEWAY" - -DEFAULT_SQLC_IMAGE = "sqlc/sqlc:1.29.0" -DEFAULT_TIMEOUT_SECONDS = 120 - - -@dataclass(frozen=True, slots=True) -class SqlcRunResult: - exit_code: int - stdout: bytes - stderr: bytes - - -@contextmanager -def _temporary_env(values: dict[str, str]) -> Iterator[None]: - old: dict[str, str | None] = {k: os.environ.get(k) for k in values} - try: - os.environ.update(values) - yield - finally: - for k, prev in old.items(): - if prev is None: - os.environ.pop(k, None) - else: - os.environ[k] = prev - - -def _is_subpath(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - return True - except ValueError: - return False - - -def run_sqlc_in_container( - *, argv: list[str], mount: Path, image: str, timeout_s: int -) -> SqlcRunResult: - mount = mount.resolve() - - workdir = Path.cwd().resolve() - if not _is_subpath(workdir, mount): - # Если вызывают из cwd вне маунта — не ломаемся, просто уходим в mount. - workdir = mount - - container = ( - DockerContainer(image) - .with_volume_mapping(str(mount), str(mount), mode="rw") - .with_kwargs(working_dir=str(workdir)) - .with_command(argv) - ) - - add_host_gateway = os.environ.get(ENV_SQLC_ADD_HOST_GATEWAY, "1") != "0" - if add_host_gateway: - # Это эквивалентно: --add-host=localhost:host-gateway - container = container.with_kwargs(extra_hosts={"localhost": "host-gateway"}) - - container.start() - try: - wrapped = container.get_wrapped_container() - - wait_res = wrapped.wait(timeout=timeout_s) - exit_code = int(wait_res.get("StatusCode", 1)) - - # docker-py позволяет просить stdout/stderr отдельно - stdout = wrapped.logs(stdout=True, stderr=False) or b"" - stderr = wrapped.logs(stdout=False, stderr=True) or b"" - - return SqlcRunResult(exit_code=exit_code, stdout=stdout, stderr=stderr) - finally: - container.stop() - - -def shim_main() -> int: - mount_raw = os.environ.get(ENV_SQLC_MOUNT) - if not mount_raw: - msg = f"{ENV_SQLC_MOUNT} is required" - raise RuntimeError(msg) - - image = os.environ.get(ENV_SQLC_IMAGE, DEFAULT_SQLC_IMAGE) - timeout_s = int( - os.environ.get(ENV_SQLC_TIMEOUT_SECONDS, str(DEFAULT_TIMEOUT_SECONDS)) - ) - - res = run_sqlc_in_container( - argv=sys.argv[1:], - mount=Path(mount_raw), - image=image, - timeout_s=timeout_s, - ) - - # Важно: generator читает stderr при ошибке. - if res.stdout: - sys.stdout.buffer.write(res.stdout) - if res.stderr: - sys.stderr.buffer.write(res.stderr) - - return res.exit_code - - -@dataclass(frozen=True, slots=True) -class SqlcShim: - path: Path - image: str = DEFAULT_SQLC_IMAGE - - def __init__(self, script_dir: Path, *, image: str = DEFAULT_SQLC_IMAGE) -> None: - script_dir = script_dir.resolve() - script_dir.mkdir(parents=True, exist_ok=True) - - shim_path = script_dir / "sqlc" - shim_path.write_text( - "#!/usr/bin/env python3\n" - "from iron_sql.testing.sqlc_testcontainers import shim_main\n" - "raise SystemExit(shim_main())\n", - encoding="utf-8", - ) - shim_path.chmod( - shim_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH - ) - - object.__setattr__(self, "path", shim_path) - object.__setattr__(self, "image", image) - - @contextmanager - def env_context(self, mount_path: Path) -> Iterator[None]: - mount = str(mount_path.resolve()) - # TMPDIR is important: - # run_sqlc uses TemporaryDirectory and symlink_to(absolute_path), - # and we need the tempdir to reside inside the mount. - with _temporary_env({ - "TMPDIR": mount, - ENV_SQLC_MOUNT: mount, - ENV_SQLC_IMAGE: self.image, - }): - yield diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/iron_sql/conftest.py b/tests/conftest.py similarity index 88% rename from src/iron_sql/conftest.py rename to tests/conftest.py index 2862886..a705357 100644 --- a/src/iron_sql/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from testcontainers.postgres import PostgresContainer from iron_sql import generate_sql_package -from iron_sql.testing import SqlcShim +from tests.sqlc_testcontainers import SqlcContainer SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS users ( @@ -81,8 +81,15 @@ def _apply_schema_once(pg_dsn: str) -> None: # pyright: ignore[reportUnusedFunc @pytest.fixture(scope="session") -def containerized_sqlc(tmp_path_factory: pytest.TempPathFactory) -> SqlcShim: - return SqlcShim(tmp_path_factory.mktemp("sqlc_bin")) +def containerized_sqlc( + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[SqlcContainer]: + sqlc = SqlcContainer() + sqlc.start(tmp_path_factory.getbasetemp()) + try: + yield sqlc + finally: + sqlc.stop() async def close_generated_pools(module: Any) -> None: @@ -100,7 +107,7 @@ def __init__( dsn: str, test_name: str, schema_path: Path, - sqlc: SqlcShim, + sqlc: SqlcContainer, ): self.root = root self.dsn = dsn @@ -145,15 +152,14 @@ def generate(self) -> Any: sys.path.insert(0, str(self.src_path)) importlib.invalidate_caches() - with self._sqlc.env_context(self.src_path): - generate_sql_package( - schema_path=Path("schema.sql"), - package_full_name=self.pkg_name, - dsn_import=f"{self.app_pkg}.config:DSN", - src_path=self.src_path, - sqlc_path=self._sqlc.path, - tempdir_path=self.src_path, - ) + generate_sql_package( + schema_path=Path("schema.sql"), + package_full_name=self.pkg_name, + dsn_import=f"{self.app_pkg}.config:DSN", + src_path=self.src_path, + tempdir_path=self.src_path, + sqlc_command=self._sqlc.sqlc_command(), + ) mod = importlib.import_module(self.pkg_name) self.generated_modules.append(mod) @@ -166,7 +172,7 @@ async def test_project( request: pytest.FixtureRequest, pg_dsn: str, schema_path: Path, - containerized_sqlc: SqlcShim, + containerized_sqlc: SqlcContainer, _apply_schema_once: None, # noqa: PT019 ) -> AsyncIterator[ProjectBuilder]: clean_name = request.node.name.replace("[", "_").replace("]", "_").replace("-", "_") diff --git a/src/iron_sql/integration_test.py b/tests/integration_test.py similarity index 98% rename from src/iron_sql/integration_test.py rename to tests/integration_test.py index f65a105..9552d9e 100644 --- a/src/iron_sql/integration_test.py +++ b/tests/integration_test.py @@ -2,9 +2,9 @@ import pytest -from iron_sql.conftest import ProjectBuilder from iron_sql.runtime import NoRowsError from iron_sql.runtime import TooManyRowsError +from tests.conftest import ProjectBuilder async def test_codegen_e2e(test_project: ProjectBuilder): diff --git a/tests/sqlc_testcontainers.py b/tests/sqlc_testcontainers.py new file mode 100644 index 0000000..df1d6c9 --- /dev/null +++ b/tests/sqlc_testcontainers.py @@ -0,0 +1,101 @@ +import io +import tarfile +from dataclasses import dataclass +from pathlib import Path + +import docker +from testcontainers.core.container import DockerContainer + +DEFAULT_SQLC_IMAGE = "sqlc/sqlc:1.30.0" +DEFAULT_HELPER_IMAGE = "alpine:3.20" +DEFAULT_TIMEOUT_SECONDS = 120 + + +def _fetch_sqlc_binary(image: str) -> bytes: + client = docker.from_env() + container = client.containers.create(image) + try: + stream, _ = container.get_archive("/workspace/sqlc") + data = b"".join(stream) + finally: + container.remove() + client.close() + + with tarfile.open(fileobj=io.BytesIO(data)) as tar: + member = next( + (m for m in tar.getmembers() if Path(m.name).name == "sqlc"), + None, + ) + if member is None: + msg = "sqlc binary not found in archive" + raise RuntimeError(msg) + fileobj = tar.extractfile(member) + if fileobj is None: + msg = "sqlc binary could not be extracted" + raise RuntimeError(msg) + return fileobj.read() + + +def _build_sqlc_archive(sqlc_binary: bytes) -> bytes: + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w") as tar: + info = tarfile.TarInfo(name="sqlc") + info.size = len(sqlc_binary) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(sqlc_binary)) + return out.getvalue() + + +@dataclass(slots=True) +class SqlcContainer: + image: str = DEFAULT_SQLC_IMAGE + helper_image: str = DEFAULT_HELPER_IMAGE + timeout_s: int = DEFAULT_TIMEOUT_SECONDS + add_host_gateway: bool = True + _container_id: str | None = None + _mount: Path | None = None + _container: DockerContainer | None = None + + def start(self, mount: Path) -> None: + if self._container is not None: + msg = "SqlcContainer already started" + raise RuntimeError(msg) + + mount = mount.resolve() + sqlc_archive = _build_sqlc_archive(_fetch_sqlc_binary(self.image)) + container = DockerContainer(self.helper_image).with_volume_mapping( + str(mount), + str(mount), + mode="rw", + ) + container = container.with_kwargs( + working_dir=str(mount), + ).with_command(["/bin/sh", "-c", "sleep infinity"]) + if self.add_host_gateway: + container = container.with_kwargs(extra_hosts={"localhost": "host-gateway"}) + + container.start() + wrapped = container.get_wrapped_container() + if not wrapped.put_archive("/usr/local/bin", sqlc_archive): + container.stop() + msg = "failed to install sqlc in helper container" + raise RuntimeError(msg) + + self._container = container + self._container_id = wrapped.id + self._mount = mount + + def stop(self) -> None: + if self._container is None: + return + self._container.stop() + self._container = None + self._container_id = None + self._mount = None + + def sqlc_command(self) -> list[str]: + if self._container_id is None or self._mount is None: + msg = "SqlcContainer is not started" + raise RuntimeError(msg) + + return ["docker", "exec", "-w", str(self._mount), self._container_id, "sqlc"] From a9ac37b1d820fa8a46a348933a7ee10bd1cb7ca5 Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 16:41:31 +0100 Subject: [PATCH 09/12] Minor polishing --- src/iron_sql/generator.py | 32 +++++++++++++++++--------------- src/iron_sql/sqlc.py | 5 ----- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index b43b4a3..ceed845 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -122,7 +122,7 @@ def generate_sql_package( # noqa: PLR0914 render_query_overload(sql_fn_name, q.name, q.stmt, q.row_type) for q in queries ] - query_cases = [render_query_case(q.name, q.stmt) for q in queries] + query_dict_entries = [render_query_dict_entry(q.name, q.stmt) for q in queries] new_content = render_package( dsn_import_package, @@ -132,7 +132,7 @@ def generate_sql_package( # noqa: PLR0914 sorted(entities), sorted(query_classes), sorted(query_overloads), - sorted(query_cases), + sorted(query_dict_entries), application_name, ) changed = write_if_changed(target_package_path, new_content + "\n") @@ -149,7 +149,7 @@ def render_package( entities: list[str], query_classes: list[str], query_overloads: list[str], - query_cases: list[str], + query_dict_entries: list[str], application_name: str | None = None, ): return f""" @@ -195,7 +195,7 @@ def render_package( {package_name.upper()}_POOL = runtime.ConnectionPool( {dsn_import_path}, name="{package_name}", - application_name="{application_name}", + application_name={application_name!r}, ) _{package_name}_connection = ContextVar[psycopg.AsyncConnection | None]( @@ -228,14 +228,21 @@ class Query: {"\n\n\n".join(query_classes)} +_QUERIES: dict[str, type[Query]] = {{ + {("," + chr(10) + " ").join(query_dict_entries)}, +}} + + {"\n".join(query_overloads)} @overload def {sql_fn_name}(stmt: str) -> Query: ... def {sql_fn_name}(stmt: str, row_type: str | None = None) -> Query: - {indent_block("\n".join(query_cases), " ")} - return Query() + if stmt in _QUERIES: + return _QUERIES[stmt]() + msg = f"Unknown statement: {{stmt!r}}" + raise KeyError(msg) """.strip() @@ -333,11 +340,11 @@ async def query_all_rows({", ".join(query_fn_params)}) -> list[{result}]: async def query_single_row({", ".join(query_fn_params)}) -> {result}: async with self._execute({params_arg}) as cur: - return runtime.get_one_row(await cur.fetchall()) + return runtime.get_one_row(await cur.fetchmany(2)) async def query_optional_row({", ".join(query_fn_params)}) -> {base_result} | None: async with self._execute({params_arg}) as cur: - return runtime.get_one_row_or_none(await cur.fetchall()) + return runtime.get_one_row_or_none(await cur.fetchmany(2)) """.strip() else: @@ -382,13 +389,8 @@ def {sql_fn_name}(stmt: Literal[{stmt!r}]{result_arg}) -> {query_name}: ... """.strip() -def render_query_case(query_name: str, stmt: str) -> str: - return f""" - -if stmt == {stmt!r}: - return {query_name}() - - """.strip() +def render_query_dict_entry(query_name: str, stmt: str) -> str: + return f"{stmt!r}: {query_name}" @dataclass(kw_only=True) diff --git a/src/iron_sql/sqlc.py b/src/iron_sql/sqlc.py index d81bab2..016e7dd 100644 --- a/src/iron_sql/sqlc.py +++ b/src/iron_sql/sqlc.py @@ -8,11 +8,6 @@ import pydantic -SQLC_QUERY_TPL = """ - -- name: ${name} :exec - ${stmt}; -""" - class CatalogReference(pydantic.BaseModel): catalog: str From 4e494d8c1bd189a341d3dc852fce37045a26742c Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 17:02:15 +0100 Subject: [PATCH 10/12] Pull sqlc image --- tests/sqlc_testcontainers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/sqlc_testcontainers.py b/tests/sqlc_testcontainers.py index df1d6c9..9553c23 100644 --- a/tests/sqlc_testcontainers.py +++ b/tests/sqlc_testcontainers.py @@ -13,6 +13,8 @@ def _fetch_sqlc_binary(image: str) -> bytes: client = docker.from_env() + if not client.images.list(filters={"reference": image}): + client.images.pull(image) container = client.containers.create(image) try: stream, _ = container.get_archive("/workspace/sqlc") From 75596aa74f9399788cb4f9df0fe7302f0c0a5c80 Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 17:48:04 +0100 Subject: [PATCH 11/12] Add more tests --- src/iron_sql/generator.py | 11 ++- tests/conftest.py | 60 ++++++++++---- tests/test_parameters.py | 82 +++++++++++++++++++ tests/test_results.py | 78 ++++++++++++++++++ .../{integration_test.py => test_runtime.py} | 44 ++++------ tests/test_validation.py | 79 ++++++++++++++++++ 6 files changed, 313 insertions(+), 41 deletions(-) create mode 100644 tests/test_parameters.py create mode 100644 tests/test_results.py rename tests/{integration_test.py => test_runtime.py} (70%) create mode 100644 tests/test_validation.py diff --git a/src/iron_sql/generator.py b/src/iron_sql/generator.py index ceed845..128f6dd 100644 --- a/src/iron_sql/generator.py +++ b/src/iron_sql/generator.py @@ -510,7 +510,16 @@ def column_py_spec( # noqa: C901, PLR0912 match db_type: case "bool" | "boolean": py_type = "bool" - case "int2" | "int4" | "int8" | "smallint" | "integer" | "bigint": + case ( + "int2" + | "int4" + | "int8" + | "smallint" + | "integer" + | "bigint" + | "serial" + | "bigserial" + ): py_type = "int" case "float4" | "float8": py_type = "float" diff --git a/tests/conftest.py b/tests/conftest.py index a705357..01bfe1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import importlib import shutil import sys +import textwrap from collections.abc import AsyncIterator from collections.abc import Iterator from pathlib import Path @@ -15,6 +16,8 @@ from tests.sqlc_testcontainers import SqlcContainer SCHEMA_SQL = """ + CREATE TYPE user_status AS ENUM ('active', 'inactive'); + CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, username TEXT NOT NULL, @@ -31,6 +34,16 @@ content TEXT, published BOOLEAN NOT NULL DEFAULT false ); + + CREATE TABLE IF NOT EXISTS json_payloads ( + id SERIAL PRIMARY KEY, + payload JSON NOT NULL + ); + + CREATE TABLE IF NOT EXISTS jsonb_arrays ( + id SERIAL PRIMARY KEY, + payloads JSONB[] NOT NULL + ); """ @@ -72,7 +85,10 @@ def _cleanup_data(dsn: str) -> None: with psycopg.connect(dsn) as conn: conn.autocommit = True with conn.cursor() as cur: - cur.execute("TRUNCATE TABLE posts, users RESTART IDENTITY CASCADE") + cur.execute( + "TRUNCATE TABLE posts, users, json_payloads, jsonb_arrays " + "RESTART IDENTITY CASCADE" + ) @pytest.fixture(scope="session") @@ -118,8 +134,9 @@ def __init__( self.src_path = root / "src" self.app_pkg = f"testapp_{test_name}" self.app_dir = self.src_path / self.app_pkg - self.queries: list[tuple[str, str]] = [] + self.queries: list[tuple[str, str, dict[str, Any]]] = [] self.generated_modules: list[Any] = [] + self.queries_source: str | None = None self.app_dir.mkdir(parents=True, exist_ok=True) (self.app_dir / "__init__.py").touch() @@ -129,30 +146,43 @@ def __init__( schema_dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy(schema_src, schema_dest) - def add_query(self, name: str, sql: str) -> None: - self.queries.append((name, sql)) + def set_queries_source(self, source: str) -> None: + self.queries_source = textwrap.dedent(source) - def generate(self) -> Any: + def add_query(self, name: str, sql: str, **kwargs: Any) -> None: + self.queries.append((name, sql, kwargs)) + + def generate_no_import(self) -> bool: (self.app_dir / "config.py").write_text( f'DSN = "{self.dsn}"\n', encoding="utf-8" ) - lines = ["from typing import Any"] - lines.extend(["def testdb_sql(q: str, **kwargs: Any) -> Any: ...", ""]) + if self.queries_source is not None: + (self.app_dir / "queries.py").write_text( + self.queries_source, encoding="utf-8" + ) + else: + lines = ["from typing import Any"] + lines.extend(["def testdb_sql(q: str, **kwargs: Any) -> Any: ...", ""]) - for name, sql in self.queries: - if name: - lines.append(f'{name} = testdb_sql("""{sql}""")') - else: - lines.append(f'testdb_sql("""{sql}""")') + for name, sql, kwargs in self.queries: + args = ", ".join(f"{k}={v!r}" for k, v in kwargs.items()) + call_args = f'"""{sql}"""' + if args: + call_args += f", {args}" - (self.app_dir / "queries.py").write_text("\n".join(lines), encoding="utf-8") + if name: + lines.append(f"{name} = testdb_sql({call_args})") + else: + lines.append(f"testdb_sql({call_args})") + + (self.app_dir / "queries.py").write_text("\n".join(lines), encoding="utf-8") if str(self.src_path) not in sys.path: sys.path.insert(0, str(self.src_path)) importlib.invalidate_caches() - generate_sql_package( + return generate_sql_package( schema_path=Path("schema.sql"), package_full_name=self.pkg_name, dsn_import=f"{self.app_pkg}.config:DSN", @@ -161,6 +191,8 @@ def generate(self) -> Any: sqlc_command=self._sqlc.sqlc_command(), ) + def generate(self) -> Any: + self.generate_no_import() mod = importlib.import_module(self.pkg_name) self.generated_modules.append(mod) return mod diff --git a/tests/test_parameters.py b/tests/test_parameters.py new file mode 100644 index 0000000..86e9144 --- /dev/null +++ b/tests/test_parameters.py @@ -0,0 +1,82 @@ +import inspect +import uuid + +from tests.conftest import ProjectBuilder + + +async def test_parameters_named(test_project: ProjectBuilder) -> None: + insert_sql = ( + "INSERT INTO users (id, username, is_active) VALUES (@id, @username, @active)" + ) + test_project.add_query("insert_user", insert_sql) + mod = test_project.generate() + + uid = uuid.uuid4() + await mod.testdb_sql(insert_sql).execute(id=uid, username="e1_user", active=True) + + sig = inspect.signature(mod.testdb_sql(insert_sql).__class__.execute) + params = list(sig.parameters.values()) + assert params[1].kind == inspect.Parameter.KEYWORD_ONLY + + +async def test_parameters_mixed(test_project: ProjectBuilder) -> None: + select_mixed_sql = "SELECT id FROM users WHERE id = $1 AND username = @username" + test_project.add_query("select_mixed", select_mixed_sql) + mod = test_project.generate() + + uid = uuid.uuid4() + async with mod.testdb_connection() as conn: + await conn.execute( + "INSERT INTO users (id, username) VALUES (%s, 'e1_user')", (uid,) + ) + + row = await mod.testdb_sql(select_mixed_sql).query_single_row( + uid, username="e1_user" + ) + assert row == uid + + sig_mixed = inspect.signature( + mod.testdb_sql(select_mixed_sql).__class__.query_single_row + ) + params_mixed = list(sig_mixed.parameters.values()) + # 0=self, 1=param_1 (POSITIONAL_OR_KEYWORD), 2=username (KEYWORD_ONLY) + assert params_mixed[1].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + assert params_mixed[2].kind == inspect.Parameter.KEYWORD_ONLY + + +async def test_parameters_optional(test_project: ProjectBuilder) -> None: + select_opt_sql = "SELECT count(*) FROM users WHERE username = @u?" + test_project.add_query("select_opt", select_opt_sql) + mod = test_project.generate() + + uid = uuid.uuid4() + async with mod.testdb_connection() as conn: + await conn.execute( + "INSERT INTO users (id, username) VALUES (%s, 'e1_user')", (uid,) + ) + + c1 = await mod.testdb_sql(select_opt_sql).query_single_row(u=None) + assert c1 == 0 + + c2 = await mod.testdb_sql(select_opt_sql).query_single_row(u="e1_user") + assert c2 == 1 + + +async def test_parameters_dedup(test_project: ProjectBuilder) -> None: + select_dedup_sql = "SELECT count(*) FROM users WHERE id = $1 OR id = $2" + test_project.add_query("select_dedup", select_dedup_sql) + mod = test_project.generate() + + uid = uuid.uuid4() + async with mod.testdb_connection() as conn: + await conn.execute( + "INSERT INTO users (id, username) VALUES (%s, 'e1_user')", (uid,) + ) + + sig_dedup = inspect.signature( + mod.testdb_sql(select_dedup_sql).__class__.query_single_row + ) + param_names = list(sig_dedup.parameters.keys()) + assert "id" in param_names + c3 = await mod.testdb_sql(select_dedup_sql).query_single_row(uid, uid) + assert c3 == 1 diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..2a72e5e --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,78 @@ +import uuid + +from tests.conftest import ProjectBuilder + + +async def test_result_shapes(test_project: ProjectBuilder) -> None: + get_users_sql = "SELECT id FROM users ORDER BY created_at" + test_project.add_query("get_users", get_users_sql) + + get_user_full_sql = "SELECT * FROM users WHERE id = $1" + test_project.add_query("get_user_full", get_user_full_sql) + + get_user_mini_sql = "SELECT id, username FROM users WHERE id=$1" + test_project.add_query( + "get_user_mini", + get_user_mini_sql, + row_type="UserMini", + ) + + mod = test_project.generate() + + id1 = uuid.uuid4() + id2 = uuid.uuid4() + + async with mod.testdb_connection() as conn: + await conn.execute("INSERT INTO users (id, username) VALUES (%s, 'u1')", (id1,)) + await conn.execute("INSERT INTO users (id, username) VALUES (%s, 'u2')", (id2,)) + + rows = await mod.testdb_sql(get_users_sql).query_all_rows() + assert len(rows) == 2 + assert isinstance(rows[0], uuid.UUID) + + user = await mod.testdb_sql(get_user_full_sql).query_single_row(id1) + + assert type(user).__name__ == "TestdbUser" + assert user.id == id1 + + mini = await mod.testdb_sql( + get_user_mini_sql, row_type="UserMini" + ).query_single_row(id1) + assert type(mini).__name__ == "UserMini" + assert mini.id == id1 + assert mini.username == "u1" + + +async def test_basic_execution(test_project: ProjectBuilder): + insert_sql = "INSERT INTO users (id, username, is_active) VALUES ($1, $2, $3)" + select_sql = "SELECT id, username, is_active FROM users WHERE id = $1" + + test_project.add_query("ins", insert_sql) + test_project.add_query("sel", select_sql) + + mod = test_project.generate() + + uid = uuid.uuid4() + + await mod.testdb_sql(insert_sql).execute(uid, "testuser", True) + + row = await mod.testdb_sql(select_sql).query_single_row(uid) + + assert row.id == uid + assert row.username == "testuser" + assert row.is_active is True + + +async def test_jsonb_roundtrip(test_project: ProjectBuilder): + sql = ( + "INSERT INTO users (id, username, metadata) " + "VALUES ($1, $2, $3) RETURNING metadata" + ) + test_project.add_query("q", sql) + + mod = test_project.generate() + uid = uuid.uuid4() + data = {"key": "value", "list": [1, 2], "nested": {"a": 1}} + + res = await mod.testdb_sql(sql).query_single_row(uid, "json_user", data) + assert res == data diff --git a/tests/integration_test.py b/tests/test_runtime.py similarity index 70% rename from tests/integration_test.py rename to tests/test_runtime.py index 9552d9e..1277ee0 100644 --- a/tests/integration_test.py +++ b/tests/test_runtime.py @@ -7,24 +7,31 @@ from tests.conftest import ProjectBuilder -async def test_codegen_e2e(test_project: ProjectBuilder): - insert_sql = "INSERT INTO users (id, username, is_active) VALUES ($1, $2, $3)" - select_sql = "SELECT id, username, is_active FROM users WHERE id = $1" +def test_unknown_statement_dispatch(test_project: ProjectBuilder) -> None: + test_project.add_query("q1", "SELECT 1") + mod = test_project.generate() + with pytest.raises(KeyError, match="Unknown statement"): + mod.testdb_sql("SELECT 42") - test_project.add_query("ins", insert_sql) - test_project.add_query("sel", select_sql) +async def test_runtime_context_pool(test_project: ProjectBuilder) -> None: + test_project.add_query("q", "SELECT 1") mod = test_project.generate() - uid = uuid.uuid4() + # Nested connection reuse + async with mod.testdb_connection() as c1, mod.testdb_connection() as c2: + assert c1 is c2 + + await mod.testdb_sql("SELECT 1").query_single_row() - await mod.testdb_sql(insert_sql).execute(uid, "testuser", True) + pool = mod.TESTDB_POOL + old_inner = pool.psycopg_pool + await pool.close() - row = await mod.testdb_sql(select_sql).query_single_row(uid) + await mod.testdb_sql("SELECT 1").query_single_row() + assert pool.psycopg_pool is not old_inner - assert row.id == uid - assert row.username == "testuser" - assert row.is_active is True + pool.psycopg_pool.get_stats() async def test_runtime_errors(test_project: ProjectBuilder): @@ -54,21 +61,6 @@ async def test_runtime_errors(test_project: ProjectBuilder): await mod.testdb_sql(select_sql).query_optional_row("duplicate") -async def test_jsonb_roundtrip(test_project: ProjectBuilder): - sql = ( - "INSERT INTO users (id, username, metadata) " - "VALUES ($1, $2, $3) RETURNING metadata" - ) - test_project.add_query("q", sql) - - mod = test_project.generate() - uid = uuid.uuid4() - data = {"key": "value", "list": [1, 2], "nested": {"a": 1}} - - res = await mod.testdb_sql(sql).query_single_row(uid, "json_user", data) - assert res == data - - async def test_transaction_commit(test_project: ProjectBuilder): insert = "INSERT INTO users (id, username) VALUES ($1, 'tx_user')" select = "SELECT count(*) as cnt FROM users WHERE username = 'tx_user'" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..9ed47a7 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,79 @@ +import pytest + +from tests.conftest import ProjectBuilder + + +def test_scanner_rejects_non_literal_sql(test_project: ProjectBuilder) -> None: + test_project.set_queries_source( + """ + from typing import Any + def testdb_sql(q: str, **kwargs: Any) -> Any: ... + + SQL = "SELECT 1" + q = testdb_sql(SQL) + """ + ) + with pytest.raises(TypeError, match="expected a single string literal"): + test_project.generate_no_import() + + +def test_scanner_rejects_non_literal_row_type(test_project: ProjectBuilder) -> None: + test_project.set_queries_source( + """ + from typing import Any + def testdb_sql(q: str, **kwargs: Any) -> Any: ... + + RT = "UserMini" + q = testdb_sql("SELECT 1", row_type=RT) + """ + ) + with pytest.raises(TypeError, match="expected a string literal"): + test_project.generate_no_import() + + +def test_scanner_rejects_wrong_call_shape(test_project: ProjectBuilder) -> None: + test_project.set_queries_source( + """ + from typing import Any + def testdb_sql(q: str, **kwargs: Any) -> Any: ... + + testdb_sql("SELECT 1", "extra") + """ + ) + with pytest.raises(TypeError, match="expected a single string literal"): + test_project.generate_no_import() + + +def test_sqlc_failure_returns_false(test_project: ProjectBuilder) -> None: + test_project.add_query("bad_query", "SELEC FROM users") + assert test_project.generate_no_import() is False + + +def test_result_shapes_validation_error_zero_cols(test_project: ProjectBuilder) -> None: + test_project.add_query( + "insert_bad", "INSERT INTO users (id, username) VALUES ($1, $2)", row_type="Bad" + ) + with pytest.raises(ValueError, match="Query has row_type=Bad but no result"): + test_project.generate_no_import() + + +def test_result_shapes_validation_error_one_col(test_project: ProjectBuilder) -> None: + test_project.add_query("select_bad", "SELECT id FROM users", row_type="Bad2") + with pytest.raises(ValueError, match="Query has row_type=Bad2 but only one column"): + test_project.generate_no_import() + + +def test_unsupported_param_types_json(test_project: ProjectBuilder) -> None: + test_project.add_query( + "bad_json", "INSERT INTO json_payloads (payload) VALUES ($1)" + ) + with pytest.raises(TypeError, match="Unsupported column type: json"): + test_project.generate_no_import() + + +def test_unsupported_param_types_array(test_project: ProjectBuilder) -> None: + test_project.add_query( + "bad_jsonb_array", "INSERT INTO jsonb_arrays (payloads) VALUES ($1)" + ) + with pytest.raises(TypeError, match=r"Unsupported column type: jsonb\[\]"): + test_project.generate_no_import() From bea442e1a37d94b78c334aee4095a7457a626f27 Mon Sep 17 00:00:00 2001 From: Ilia Ablamonov Date: Mon, 19 Jan 2026 18:06:11 +0100 Subject: [PATCH 12/12] Update linter ignores for tests --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aad1579..c98bbf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"{*_test.py,conftest.py}" = ["A002", "PLR2004", "S", "FBT"] +"test_*.py" = ["A002", "PLR2004", "S", "FBT"] [tool.ruff.lint.isort] force-single-line = true