From 43d65533d57bcc69e27e605290ac35ffdde1a4bc Mon Sep 17 00:00:00 2001 From: Jacob Date: Tue, 8 Sep 2026 22:48:52 +0900 Subject: [PATCH 1/5] fix: accept the dialect names this repo itself uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everywhere but sqlglot, MSSQL is called mssql here — read/mssql.py, the --mssql flag, the note in dialect_for_live. sqlglot only knows tsql, so --dialect mssql, the most natural thing to type, died inside the library with "Unknown dialect 'mssql'. Did you mean mysql?". normalize_dialect maps the aliases and rejects an unknown name as a domain error that lists what is available. It runs in expand() and in the engine's constructor rather than at the call sites, since the engine parses on its own path too and normalising in only one of them would let the two drift onto different dialects. --- src/tablefold/rewrite/__init__.py | 9 ++++-- src/tablefold/rewrite/expand.py | 42 +++++++++++++++++++++++++++ src/tablefold/t2sql/engine.py | 12 +++++++- tests/test_expand.py | 47 ++++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/tablefold/rewrite/__init__.py b/src/tablefold/rewrite/__init__.py index 9da7d3a..e3d5b61 100644 --- a/src/tablefold/rewrite/__init__.py +++ b/src/tablefold/rewrite/__init__.py @@ -4,6 +4,11 @@ 않는다. """ -from tablefold.rewrite.expand import ExpansionError, ExpansionResult, expand +from tablefold.rewrite.expand import ( + ExpansionError, + ExpansionResult, + expand, + normalize_dialect, +) -__all__ = ["ExpansionError", "ExpansionResult", "expand"] +__all__ = ["ExpansionError", "ExpansionResult", "expand", "normalize_dialect"] diff --git a/src/tablefold/rewrite/expand.py b/src/tablefold/rewrite/expand.py index cd725ea..a3abb44 100644 --- a/src/tablefold/rewrite/expand.py +++ b/src/tablefold/rewrite/expand.py @@ -189,6 +189,47 @@ def joins_pruned(self) -> int: return self.joins_available - self.joins_emitted +# 사람이 부르는 이름 → sqlglot 이 아는 이름. +# +# 이 저장소는 다른 데서 전부 "MSSQL" 이라고 부른다 — ``read/mssql.py``, ``--mssql`` +# 플래그, :func:`~tablefold.t2sql.prepare.dialect_for_live` 의 주석까지. 그런데 +# sqlglot 이 아는 이름은 ``tsql`` 뿐이라, ``--dialect mssql`` 이라는 **가장 +# 자연스러운 입력**이 라이브러리 깊은 곳에서 터졌다: +# +# ValueError: Unknown dialect 'mssql'. Did you mean mysql? +# +# 별칭을 받아 주고, 모르는 이름은 여기서 도메인 오류로 세운다. +_DIALECT_ALIASES = { + "mssql": "tsql", + "sqlserver": "tsql", + "sql_server": "tsql", + "postgresql": "postgres", + "pg": "postgres", +} + + +def normalize_dialect(dialect: str) -> str: + """방언 이름을 sqlglot 이 받는 형태로 바꾼다. 모르는 이름은 거부한다. + + 빈 문자열은 sqlglot 의 기본 방언을 뜻하므로 그대로 통과시킨다 — 호출부에서 + "안 정했다"는 뜻으로 쓰인다(:data:`~tablefold.t2sql.prepare.GENERIC_DIALECTS`). + """ + from sqlglot.dialects.dialect import Dialect, Dialects + + name = (dialect or "").strip().lower() + if not name: + return name + name = _DIALECT_ALIASES.get(name, name) + try: + Dialect.get_or_raise(name) + except Exception as exc: # noqa: BLE001 — 도메인 오류로 올린다 + known = ", ".join(sorted(d.value for d in Dialects if d.value)) + raise ExpansionError( + f"unknown SQL dialect {dialect!r}; known dialects: {known}" + ) from exc + return name + + def expand( sql: str, layer: LogicalLayer, @@ -200,6 +241,7 @@ def expand( """논리 모델을 참조하는 *sql*을 실제 물리 테이블 대상 구문으로 다시 재작성(Expand)합니다. """ + dialect = normalize_dialect(dialect) try: statement = sqlglot.parse_one(sql, read=dialect) except Exception as exc: # noqa: BLE001 - surfaced as a domain error diff --git a/src/tablefold/t2sql/engine.py b/src/tablefold/t2sql/engine.py index f0abbd2..61f2907 100644 --- a/src/tablefold/t2sql/engine.py +++ b/src/tablefold/t2sql/engine.py @@ -52,7 +52,12 @@ from tablefold.fold import FoldResult from tablefold.ir import LogicalLayer, LogicalModel from tablefold.relate.graph import SchemaGraph -from tablefold.rewrite.expand import ExpansionError, FilterOnlyMisuse, expand +from tablefold.rewrite.expand import ( + ExpansionError, + FilterOnlyMisuse, + expand, + normalize_dialect, +) from tablefold.t2sql.parse import SQLNotFound, extract_sql from tablefold.t2sql.prompt import ( Example, @@ -201,6 +206,11 @@ def __init__( if max_attempts < 1: raise ValueError(f"max_attempts must be at least 1, got {max_attempts}") + # 여기서 한 번 세워 둔다. 엔진은 :func:`expand` 와 별개로 직접 파싱도 + # 하므로(:meth:`_parse`), 확장 단계에서만 정규화하면 두 경로가 서로 다른 + # 방언을 쓰게 된다. + dialect = normalize_dialect(dialect) + self.layer: LogicalLayer = fold_result.layer self.graph: SchemaGraph = fold_result.graph self.dialect = dialect diff --git a/tests/test_expand.py b/tests/test_expand.py index f621f02..7c5b3cc 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -8,7 +8,7 @@ from tablefold.choose.classify import profile_tables from tablefold.choose.cluster import SelectionPolicy, cluster from tablefold.ir import FieldKind -from tablefold.rewrite.expand import ExpansionError, expand +from tablefold.rewrite.expand import ExpansionError, expand, normalize_dialect @pytest.fixture @@ -360,3 +360,48 @@ def test_a_parenthesized_or_is_still_rejected(filterable_layer, retail_graph): filterable_layer, retail_graph, ) + + +# ── 방언 이름 ──────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("mssql", "tsql"), + ("MSSQL", "tsql"), + ("SQLServer", "tsql"), + ("sql_server", "tsql"), + ("postgresql", "postgres"), + ("pg", "postgres"), + ("tsql", "tsql"), + ("oracle", "oracle"), + (" postgres ", "postgres"), + ("", ""), + ], +) +def test_normalize_dialect_accepts_the_names_people_actually_type(given, expected): + assert normalize_dialect(given) == expected + + +def test_normalize_dialect_rejects_an_unknown_name_with_the_list(): + with pytest.raises(ExpansionError) as excinfo: + normalize_dialect("mssqll") + message = str(excinfo.value) + assert "mssqll" in message + assert "tsql" in message # 무엇을 쓸 수 있는지 함께 말해 준다 + + +def test_expand_accepts_mssql_as_a_dialect_name(tiny_layer, tiny_graph): + """``--dialect mssql`` 은 가장 자연스러운 입력이면서 예전엔 터졌다. + + sqlglot 이 아는 이름은 ``tsql`` 뿐인데, 이 저장소는 다른 데서 전부 MSSQL + 이라고 부른다. 라이브러리의 ``ValueError`` 가 그대로 올라오지 않아야 한다. + """ + result = expand( + "SELECT id, total FROM orders", + tiny_layer, + tiny_graph, + dialect="mssql", + ) + assert result.sql From 30e6d56748647b21b7517ecedb08927f4b43bd2c Mon Sep 17 00:00:00 2001 From: Jacob Date: Tue, 8 Sep 2026 22:49:49 +0900 Subject: [PATCH 2/5] ci: run lint, format and the suite on 3.11 and 3.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were 388 tests and nothing that ran them on a push. requires-python says >=3.11, so the matrix covers the floor as well as the version in .python-version — a floor nobody runs is a guess. Verified both rows locally with uv run --isolated before wiring them up. --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 40 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..848d8f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # requires-python 은 >=3.11 이다. 하한을 실제로 돌려 봐야 하한이다. + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install + run: uv sync --locked --all-extras + + - name: Lint + run: uv run ruff check src tests + + - name: Format check + run: uv run ruff format --check src tests + + - name: Test + run: uv run pytest diff --git a/README.md b/README.md index 8afed2d..94bcaa2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # tablefold +[![CI](https://github.com/Jacob-9909/tablefold/actions/workflows/ci.yml/badge.svg)](https://github.com/Jacob-9909/tablefold/actions/workflows/ci.yml) + > 방대한 물리 데이터베이스 스키마를 LLM이 한눈에 이해할 수 있는 소수의 **와이드 논리 모델(Wide Logical Models)**로 접고(Fold), 작성된 쿼리를 실제 실행 가능한 물리 SQL로 다시 펼쳐주는(Expand) 도구입니다. ``` From d2e0bdda1b9163d61706443765cd7c2eec12f11b Mon Sep 17 00:00:00 2001 From: Jacob Date: Tue, 8 Sep 2026 22:52:27 +0900 Subject: [PATCH 3/5] feat: read Oracle catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read layer had DDL, SQL Server and Postgres, so an Oracle source could only arrive as a dumped DDL file. Adds an introspector on the same contract as the other two — catalog rows in, one PhysicalSchema out, no inference. Two Oracle facts shape the assembly. The owner is the schema and it is stored upper case, so a lower-case name returns zero rows and the database looks empty rather than misconfigured; the introspector upper-cases and falls back to CURRENT_SCHEMA when none is given, since Oracle has no fixed default the way Postgres has public. And num_rows is a statistics value that is NULL until someone gathers stats, which stays None instead of becoming 0 — a 0 would tell classify the table is empty and cost it the size weight it should have won. --dsn dispatches on an oracle:// prefix rather than gaining a sibling option, because six commands take --dsn and a second option would need the both-given check repeated in all six. --- pyproject.toml | 1 + src/tablefold/cli.py | 32 ++++- src/tablefold/read/oracle.py | 234 ++++++++++++++++++++++++++++++++ tests/test_introspect_oracle.py | 116 ++++++++++++++++ uv.lock | 213 ++++++++++++++++++++++++++++- 5 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 src/tablefold/read/oracle.py create mode 100644 tests/test_introspect_oracle.py diff --git a/pyproject.toml b/pyproject.toml index 0fdebf4..06f810f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.optional-dependencies] postgres = ["psycopg[binary]>=3.1"] +oracle = ["oracledb>=2.0"] llm = ["anthropic>=0.40"] [project.scripts] diff --git a/src/tablefold/cli.py b/src/tablefold/cli.py index 8678cfe..e92494c 100644 --- a/src/tablefold/cli.py +++ b/src/tablefold/cli.py @@ -36,7 +36,11 @@ DsnOption = Annotated[ str | None, typer.Option( - "--dsn", help="PostgreSQL connection string (needs the postgres extra)." + "--dsn", + help=( + "Database connection string. PostgreSQL by default (needs the " + "postgres extra); prefix with oracle:// for Oracle (oracle extra)." + ), ), ] SchemaOption = Annotated[str, typer.Option("--schema", help="Database schema name.")] @@ -593,9 +597,33 @@ def _load_schema( raise typer.Exit(code=2) return DDLIntrospector.from_path(ddl).introspect() + assert dsn is not None + return _introspect_dsn(dsn, schema) + + +ORACLE_SCHEMES = ("oracle://", "oracle+oracledb://") +"""``--dsn`` 이 Oracle 을 뜻한다는 표시. + +명령마다 ``--oracle-dsn`` 을 하나씩 더 다는 대신 스킴으로 가른다. ``--dsn`` 을 +받는 명령이 여섯 개라 옵션을 늘리면 여섯 군데가 늘어나고, 늘어난 뒤에는 둘 다 +준 경우를 여섯 군데에서 막아야 한다. +""" + + +def _introspect_dsn(dsn: str, schema: str) -> PhysicalSchema: + for scheme in ORACLE_SCHEMES: + if dsn.startswith(scheme): + from tablefold.read.oracle import OracleIntrospector + + # ``--schema`` 의 기본값은 Postgres 의 ``public`` 이라 "안 정했다"는 + # 뜻으로 흘러 들어온다. Oracle 에서 ``PUBLIC`` 은 실재하는 특수 + # 스키마라 그대로 넘기면 빈 결과가 나온다 — 접속은 됐는데 표가 + # 하나도 없는 것처럼 보인다. 안 정했으면 접속 계정 스키마를 쓴다. + owner = "" if schema == "public" else schema + return OracleIntrospector(dsn[len(scheme) :], schema=owner).introspect() + from tablefold.read.postgres import PostgresIntrospector - assert dsn is not None return PostgresIntrospector(dsn, schema=schema).introspect() diff --git a/src/tablefold/read/oracle.py b/src/tablefold/read/oracle.py new file mode 100644 index 0000000..d28b7f0 --- /dev/null +++ b/src/tablefold/read/oracle.py @@ -0,0 +1,234 @@ +"""Oracle 카탈로그에서 물리 스키마를 읽는다. + +Postgres·SQL Server 탐색기와 같은 계약을 지킨다 — 테이블·컬럼·기본키·외래키를 +읽어 :class:`PhysicalSchema` 하나로 조립할 뿐, 추론이나 폴딩은 하지 않는다. + +Oracle 특유의 두 가지가 조립에 영향을 준다. + +* **소유자(owner)가 곧 스키마다.** 그리고 대문자로 저장된다. 소문자로 넘기면 + 카탈로그가 조용히 0행을 돌려주므로 — 접속은 됐는데 스키마가 비어 보인다 — + :class:`OracleIntrospector` 가 올려서 조회한다. +* **행 수는 통계값이다.** ``all_tables.num_rows`` 는 마지막 통계 수집 시점의 + 값이라 오래됐을 수 있고, 통계를 한 번도 돌리지 않았으면 ``NULL`` 이다. + Postgres 의 ``reltuples`` 와 같은 성격이며 :mod:`tablefold.choose.classify` + 의 크기 가중치로만 쓰이므로 그 정도면 충분하다. +""" + +from __future__ import annotations + +from tablefold.ir import ( + ForeignKey, + PhysicalColumn, + PhysicalSchema, + PhysicalTable, +) + +DIALECT = "oracle" +"""이 소스가 말하는 sqlglot 방언. 읽는 쪽과 쓰는 쪽이 따로 적지 않도록 한 벌만 둔다.""" + +# 뷰까지 포함한다. Postgres 탐색기가 ``relkind IN ('r','p','v','m')`` 로 뷰와 +# 구체화 뷰를 넣으므로, 여기서 테이블만 읽으면 같은 스키마를 두 드라이버로 읽었을 +# 때 결과가 달라진다. ``all_tab_comments`` 가 둘 다 들고 있어 이걸 기준으로 삼고 +# 행 수만 ``all_tables`` 에서 붙인다(뷰에는 없다). +_TABLES_SQL = """ +SELECT + tc.table_name, + tc.comments, + t.num_rows +FROM all_tab_comments tc +LEFT JOIN all_tables t + ON t.owner = tc.owner + AND t.table_name = tc.table_name +WHERE tc.owner = :owner + AND tc.table_type IN ('TABLE', 'VIEW') +ORDER BY tc.table_name +""" + +_COLUMNS_SQL = """ +SELECT + c.table_name, + c.column_name, + c.data_type, + c.data_length, + c.data_precision, + c.data_scale, + CASE WHEN c.nullable = 'Y' THEN 1 ELSE 0 END AS is_nullable, + cc.comments, + c.column_id +FROM all_tab_columns c +LEFT JOIN all_col_comments cc + ON cc.owner = c.owner + AND cc.table_name = c.table_name + AND cc.column_name = c.column_name +WHERE c.owner = :owner +ORDER BY c.table_name, c.column_id +""" + +_PRIMARY_KEYS_SQL = """ +SELECT + cols.table_name, + cols.column_name, + cols.position +FROM all_constraints cons +JOIN all_cons_columns cols + ON cols.owner = cons.owner + AND cols.constraint_name = cons.constraint_name +WHERE cons.constraint_type = 'P' + AND cons.owner = :owner +ORDER BY cols.table_name, cols.position +""" + +# 참조 쪽 컬럼은 ``r_constraint_name`` 이 가리키는 제약(대상 테이블의 PK/UNIQUE) +# 에서 가져온다. 두 쪽을 ``position`` 으로 맞춰야 복합키의 짝이 어긋나지 않는다. +_FOREIGN_KEYS_SQL = """ +SELECT + cons.constraint_name, + src.table_name AS from_table, + src.column_name AS from_column, + tgt.table_name AS to_table, + tgt.column_name AS to_column, + src.position +FROM all_constraints cons +JOIN all_cons_columns src + ON src.owner = cons.owner + AND src.constraint_name = cons.constraint_name +JOIN all_cons_columns tgt + ON tgt.owner = cons.r_owner + AND tgt.constraint_name = cons.r_constraint_name + AND tgt.position = src.position +WHERE cons.constraint_type = 'R' + AND cons.owner = :owner +ORDER BY cons.constraint_name, src.position +""" + + +class OracleIntrospector: + def __init__(self, dsn: str, *, schema: str = "") -> None: + """*dsn* 은 python-oracledb 접속 문자열, *schema* 는 소유자다. + + *schema* 를 비우면 접속 계정 자신의 스키마를 읽는다 — Oracle 에서 + 기본 스키마는 로그인 사용자이지, Postgres 의 ``public`` 처럼 고정된 + 이름이 아니다. + """ + self._dsn = dsn + self._schema = schema + + def introspect(self) -> PhysicalSchema: + try: + import oracledb + except ImportError as exc: # pragma: no cover - import guard + raise RuntimeError( + "Oracle introspection needs the 'oracle' extra: " + "pip install 'tablefold[oracle]'" + ) from exc + + with oracledb.connect(self._dsn) as conn, conn.cursor() as cur: + owner = (self._schema or _current_schema(cur)).upper() + params = {"owner": owner} + + cur.execute(_TABLES_SQL, params) + table_rows = cur.fetchall() + cur.execute(_COLUMNS_SQL, params) + column_rows = cur.fetchall() + cur.execute(_PRIMARY_KEYS_SQL, params) + pk_rows = cur.fetchall() + cur.execute(_FOREIGN_KEYS_SQL, params) + fk_rows = cur.fetchall() + + return assemble(owner, table_rows, column_rows, pk_rows, fk_rows) + + +def _current_schema(cur) -> str: + cur.execute("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM dual") + row = cur.fetchone() + return row[0] if row and row[0] else "" + + +def render_type( + name: str, + length: int | None, + precision: int | None, + scale: int | None, +) -> str: + """카탈로그의 조각난 타입 정보를 선언문 형태로 되돌린다. + + ``ir._norm_type`` 이 ``VARCHAR2(50)`` 같은 선언형을 파싱하도록 되어 있어서, + 카탈로그가 나눠 주는 길이·정밀도를 다시 붙여 주는 편이 IR 전체와 일관된다. + + ``NUMBER`` 는 정밀도가 ``NULL`` 일 수 있고(부동 소수점 형태), 그때 ``(None)`` + 을 찍으면 파서가 깨지므로 이름만 남긴다. + """ + upper = name.upper() + if upper in {"VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR", "RAW"}: + return f"{name}({length})" if length else name + if upper == "NUMBER": + if precision is None: + return name + return f"{name}({precision},{scale or 0})" if scale else f"{name}({precision})" + if upper in {"FLOAT"} and precision is not None: + return f"{name}({precision})" + return name + + +def assemble( + schema_name: str, + table_rows: list[tuple], + column_rows: list[tuple], + pk_rows: list[tuple], + fk_rows: list[tuple], +) -> PhysicalSchema: + """카탈로그 행들을 :class:`PhysicalSchema` 하나로 조립한다.""" + columns: dict[str, list[PhysicalColumn]] = {} + for tbl, col, ty, length, prec, scale, nullable, comment, _ in column_rows: + columns.setdefault(tbl, []).append( + PhysicalColumn( + name=col, + type=render_type(ty, length, prec, scale), + nullable=bool(nullable), + comment=comment, + ) + ) + + primary_keys: dict[str, list[str]] = {} + for tbl, col, _ in pk_rows: + primary_keys.setdefault(tbl, []).append(col) + + tables = tuple( + PhysicalTable( + name=tbl, + columns=tuple(columns.get(tbl, ())), + primary_key=tuple(primary_keys.get(tbl, ())), + schema=schema_name, + comment=comment, + row_estimate=int(rows) if rows is not None else None, + ) + for tbl, comment, rows in table_rows + if columns.get(tbl) + ) + + grouped: dict[str, dict] = {} + for name, from_table, from_column, to_table, to_column, _ in fk_rows: + entry = grouped.setdefault( + name, + { + "from_table": from_table, + "to_table": to_table, + "from_columns": [], + "to_columns": [], + }, + ) + entry["from_columns"].append(from_column) + entry["to_columns"].append(to_column) + + foreign_keys = tuple( + ForeignKey( + name=name, + from_table=e["from_table"], + from_columns=tuple(e["from_columns"]), + to_table=e["to_table"], + to_columns=tuple(e["to_columns"]), + ) + for name, e in grouped.items() + ) + + return PhysicalSchema(tables=tables, foreign_keys=foreign_keys) diff --git a/tests/test_introspect_oracle.py b/tests/test_introspect_oracle.py new file mode 100644 index 0000000..26243f1 --- /dev/null +++ b/tests/test_introspect_oracle.py @@ -0,0 +1,116 @@ +"""Oracle 카탈로그 행 → :class:`PhysicalSchema` 조립. + +라이브 DB 없이 돈다. 드라이버가 돌려주는 행 모양을 그대로 흉내 내고, 조립 +로직만 검사한다 — 접속은 :class:`OracleIntrospector` 의 몫이고 여기서 볼 것이 +아니다. +""" + +from __future__ import annotations + +import pytest + +from tablefold.read.oracle import DIALECT, assemble, render_type + + +def test_dialect_is_what_sqlglot_knows(): + import sqlglot + + sqlglot.parse_one("SELECT 1", read=DIALECT) + + +@pytest.mark.parametrize( + ("args", "expected"), + [ + (("VARCHAR2", 50, None, None), "VARCHAR2(50)"), + (("NVARCHAR2", 20, None, None), "NVARCHAR2(20)"), + (("CHAR", 2, None, None), "CHAR(2)"), + (("NUMBER", 22, 10, 2), "NUMBER(10,2)"), + (("NUMBER", 22, 10, 0), "NUMBER(10)"), + (("NUMBER", 22, 10, None), "NUMBER(10)"), + # 정밀도가 없는 NUMBER 는 이름만 남아야 한다 — NUMBER(None) 은 안 된다. + (("NUMBER", 22, None, None), "NUMBER"), + (("DATE", 7, None, None), "DATE"), + (("CLOB", 4000, None, None), "CLOB"), + ], +) +def test_render_type_rebuilds_the_declaration(args, expected): + assert render_type(*args) == expected + + +@pytest.fixture +def rows(): + table_rows = [ + ("ORDERS", "주문", 1200), + ("CUSTOMERS", None, None), # 통계 미수집 → num_rows 가 NULL + ("EMPTY_TAB", None, 0), # 컬럼이 없으면 조립에서 빠진다 + ] + column_rows = [ + ("ORDERS", "ID", "NUMBER", 22, 10, 0, 0, "주문번호", 1), + ("ORDERS", "CUSTOMER_ID", "NUMBER", 22, 10, 0, 1, None, 2), + ("ORDERS", "TOTAL", "NUMBER", 22, 12, 2, 1, None, 3), + ("CUSTOMERS", "ID", "NUMBER", 22, 10, 0, 0, None, 1), + ("CUSTOMERS", "EMAIL", "VARCHAR2", 320, None, None, 1, None, 2), + ] + pk_rows = [("ORDERS", "ID", 1), ("CUSTOMERS", "ID", 1)] + fk_rows = [("FK_ORDERS_CUSTOMER", "ORDERS", "CUSTOMER_ID", "CUSTOMERS", "ID", 1)] + return table_rows, column_rows, pk_rows, fk_rows + + +def test_assemble_builds_tables_columns_and_keys(rows): + schema = assemble("SALES", *rows) + + assert {t.name for t in schema.tables} == {"ORDERS", "CUSTOMERS"} + orders = schema.table("ORDERS") + assert orders.schema == "SALES" + assert orders.comment == "주문" + assert orders.row_estimate == 1200 + assert orders.primary_key == ("ID",) + assert orders.column_names == ("ID", "CUSTOMER_ID", "TOTAL") + assert orders.columns[0].nullable is False + assert orders.columns[1].nullable is True + assert orders.columns[2].type.upper().startswith("NUMBER(12,2)") + + +def test_assemble_drops_a_table_with_no_columns(rows): + schema = assemble("SALES", *rows) + assert schema.table("EMPTY_TAB") is None + + +def test_assemble_keeps_a_missing_row_estimate_as_none(rows): + """통계를 안 돌린 테이블은 0 이 아니라 '모른다' 여야 한다. + + 0 으로 채우면 :mod:`tablefold.choose.classify` 가 빈 테이블로 보고 크기 + 가중치를 깎는다 — 실제로는 클 수도 있는 표가 앵커 경쟁에서 밀린다. + """ + schema = assemble("SALES", *rows) + assert schema.table("CUSTOMERS").row_estimate is None + + +def test_assemble_groups_a_composite_foreign_key(): + """복합키는 제약 이름 하나로 묶이고 position 순서를 지켜야 한다.""" + fk_rows = [ + ("FK_TWO", "CHILD", "A_ID", "PARENT", "PA", 1), + ("FK_TWO", "CHILD", "B_ID", "PARENT", "PB", 2), + ] + column_rows = [ + ("CHILD", "A_ID", "NUMBER", 22, 10, 0, 0, None, 1), + ("CHILD", "B_ID", "NUMBER", 22, 10, 0, 0, None, 2), + ("PARENT", "PA", "NUMBER", 22, 10, 0, 0, None, 1), + ("PARENT", "PB", "NUMBER", 22, 10, 0, 0, None, 2), + ] + schema = assemble( + "SALES", [("CHILD", None, 1), ("PARENT", None, 1)], column_rows, [], fk_rows + ) + + assert len(schema.foreign_keys) == 1 + fk = schema.foreign_keys[0] + assert fk.from_columns == ("A_ID", "B_ID") + assert fk.to_columns == ("PA", "PB") + + +def test_assembled_schema_folds(rows): + """조립 결과가 실제로 파이프라인에 들어간다 — 계약이 맞는지 끝까지 확인.""" + from tablefold.fold import fold + + result = fold(assemble("SALES", *rows)) + assert result.layer.models diff --git a/uv.lock b/uv.lock index 5aae450..f455dd5 100644 --- a/uv.lock +++ b/uv.lock @@ -61,6 +61,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -171,6 +269,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -407,6 +561,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "oracledb" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/ae/4576e5df7b8eadec51bb7d981a2bcc0d8387d7d6998a51d146c0886a523e/oracledb-4.0.2.tar.gz", hash = "sha256:0a380ab72853487ea2764c5df772f35026b4219868fc3eba68e193c9aea230ac", size = 881658, upload-time = "2026-07-14T17:21:28.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/dd/101f815a178ecb6a2d73d9d70c74e4263cbdfe67815cd420e9f7e78e29e7/oracledb-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f4a4847024f42e8b3be3c7d2597d682ef24f9342b5ebf1695a1bef2aa5be96", size = 4369262, upload-time = "2026-07-14T17:21:45.542Z" }, + { url = "https://files.pythonhosted.org/packages/ae/65/92a00e394ffc19eb02aa97742c4baa86bdadbe570c5a4e3a71e6577c0ebf/oracledb-4.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3bde3d507d8f50faa9480e99222fe5fa04dc0db2cb8b57746092022ac69ae93d", size = 2495499, upload-time = "2026-07-14T17:21:47.191Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/f6e15a8d5d6a3442cfa00b12619eb6f2438fddbb93da0de6fae0020ecca7/oracledb-4.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33bcb765bf97056764c38481dc1dd959e4b61f3a240baacc645d7f06038ce225", size = 2679015, upload-time = "2026-07-14T17:21:48.826Z" }, + { url = "https://files.pythonhosted.org/packages/9d/25/aba1ef7c69e729295c6625e11c67a8c66d77584291a5c6c17af360767d01/oracledb-4.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:567c093138d32f06512572de5b5d52459bea82b4e47098e9d668dae759965ccb", size = 2541856, upload-time = "2026-07-14T17:21:50.326Z" }, + { url = "https://files.pythonhosted.org/packages/44/4f/ff6f82cd92015692ec3a6b2236e6e7b5002f3092758e962ae29ee18a2244/oracledb-4.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff8a239de950acb600ab4fbe1b349fb085a75383bc9a163e50cab362293fc936", size = 2708525, upload-time = "2026-07-14T17:21:51.963Z" }, + { url = "https://files.pythonhosted.org/packages/e3/53/ada1003c61a4d95d049efbfc711ebcfa4e3bccd13afffd7b10c8b66a9d17/oracledb-4.0.2-cp311-cp311-win32.whl", hash = "sha256:0a0be8111db80bb697ec66a34688a6985589e3f72789afe28e3b162b2c4cdd2b", size = 1522211, upload-time = "2026-07-14T17:21:54.175Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/5f79d68e18c42f6f7cb4f73aeb12c996765ae965fcf941ee7807cda34db7/oracledb-4.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:d5a82a449d96fb89a93346d88128ad618526ffa290376d4b57fbcee1eedcd19f", size = 1931283, upload-time = "2026-07-14T17:21:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/65ca81d885d3c413bf871c279f4062938654f7b00cc063a3259159efed7b/oracledb-4.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:6d4effb098af3dbcc1fcf785b304523afee1b9a311a8bd9c62bd3b4669198970", size = 1589129, upload-time = "2026-07-14T17:21:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/a5/89/2568c2d32afb3c0a66cef2e74dac7769f69e117a14402301b106970475dd/oracledb-4.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cbe3a75b463a334da9b3d76026062bcfb24ec563b7df291f700c9f7eefcbeefe", size = 4366002, upload-time = "2026-07-14T17:21:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c8/1825d240aa68b255eb78867c62964d2a69c6ae6197ab3543fe220539bf69/oracledb-4.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d11723018b6aeae035f4e1feae4150b7cca1161023c2d68d957d7310fe0dd56", size = 2286174, upload-time = "2026-07-14T17:22:00.649Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/5b9d6fa28942ff38e2d76dcef3184e7569d4b0006d0425f92a3d4a764dc4/oracledb-4.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:579f2c568433523a990cde5bea73c980d144754dc54d3ab2cd37efd670dc31d6", size = 2490198, upload-time = "2026-07-14T17:22:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/28/4e/77b21ec50c786270a7471abd6f48ec3c2e629ce304ec792eeeb6f03ba4d0/oracledb-4.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82d3d83bdc6246e53f8ad8b598d2508e9f5d983e352ce2e8a71a020200ba2d6", size = 2330058, upload-time = "2026-07-14T17:23:04.213Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/834e07805b8b3aaab7e87b818ef44ab3cb5394a73149a580ecf73cd92d4d/oracledb-4.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4f2b51837248d4acfb323a64c48d89b62467b0f73d1211c99db9b80df0cdfbc", size = 2510815, upload-time = "2026-07-14T17:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/49/10/f05a3348a50ecab07b86317424751a4f63891e31c228a6d95f92b9538afd/oracledb-4.0.2-cp312-cp312-win32.whl", hash = "sha256:b5d203426f0d191842b4cfd87c223163ccc57f7e7875ec7ed073f163de2229af", size = 1488879, upload-time = "2026-07-14T17:23:07.53Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/99fe3fa29466533df10fdfed90faee133aa1e7147b9edfc13b839e06f738/oracledb-4.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:5fe6e07ed29f84a6656e0580b4e662109d3bb90fc13d8f98ae3a56a67c75b80e", size = 1865771, upload-time = "2026-07-14T17:23:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/87/cb/009980df826442900419a7bc317e35dc85a031bc2d3806256d469de7d670/oracledb-4.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:a1f46b01a089e0e0dd44c4ac4c5c79903760976346c74a698955c1fb76d68bf5", size = 1519969, upload-time = "2026-07-14T17:23:11.531Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cb/e9ad24c2fa20ff977ca52b5a68a2010a054f724d420681be75e7e3d92b57/oracledb-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af4121112f0b68e8d61ce46a086c6aec8256fa2ca32d2e6467195a6c575c207b", size = 4352202, upload-time = "2026-07-14T17:23:13.202Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ad/583d85906b5c4b344600be2bf30077f3a6c2bf04ee01b4fee9856d1da269/oracledb-4.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a814307ca8a6bf0649d8bf0a650a72f23030d9af052a2d4a481b94b6ab50d5a4", size = 2274847, upload-time = "2026-07-14T17:23:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/cf/53/badcc7e29ba9e9f2158f2e18214197d63c53d99c4720d436d8fc0b6b175f/oracledb-4.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b89c02670bafc9b1fcc0d15175f097bdc1968bc36ee4d66aa63aee09bfbbe30", size = 2474482, upload-time = "2026-07-14T17:23:16.605Z" }, + { url = "https://files.pythonhosted.org/packages/b9/76/0bc64bdfc7ca794f4576c757da89617922be402f9fcf434bf5de7e44235a/oracledb-4.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db0d76199b6dffd721bda48dad4eeea6603942ce2e246f3b9301d85af6bda0ee", size = 2327237, upload-time = "2026-07-14T17:23:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/15/f5/2fc4e30b24a60e40fa6419dbadd8f869878a3ac49d28b7d16bca59dd2519/oracledb-4.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f5ed684ab419603d53981e0f32b51da673e21a42d7dce1bf3f215a9301c7c108", size = 2504800, upload-time = "2026-07-14T17:23:20.025Z" }, + { url = "https://files.pythonhosted.org/packages/2e/03/0a15a43a88addfb7b4f8d8343f0c47df68173510a27e2196b6b7347efb06/oracledb-4.0.2-cp313-cp313-win32.whl", hash = "sha256:a45a13e33509db5bd3622a05cb2e4a6cb41d56d1bfd2a32caeacd26f4ce8b988", size = 1493052, upload-time = "2026-07-14T17:23:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4a/9895cb5a1fffa68f2d9fb1cfb43de88628c049d313734de7ecef17099460/oracledb-4.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:6444be4991f33754cd98f624cecef3eb98db3e87f8ac14e9b65bd3591c9dd252", size = 1864126, upload-time = "2026-07-14T17:23:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/0b/14/5c7c44a8f441783d461b9657994e03f8798e28bebe67395951c53c86c270/oracledb-4.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:087fdf5bc36b03dc3c55f7abc944a163ba8edc9c802bc8baf91698a52041ee13", size = 1519906, upload-time = "2026-07-14T17:23:24.338Z" }, + { url = "https://files.pythonhosted.org/packages/d8/96/30cdbc8399f8edd5cd8bc28bae89fba0eaa40c529168360e9aa84fa21e97/oracledb-4.0.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:58e71a3c1e294a99e39b086adf42074d48287548bf8edfa720c9507106186bbb", size = 4389290, upload-time = "2026-07-14T17:23:25.769Z" }, + { url = "https://files.pythonhosted.org/packages/1f/35/eec834f01d84f6974ff9b397b83cc80b67064410476049aaf9f88269590d/oracledb-4.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d4a1cc1482b5927a9962c7766aeeae616a85eedbe49af8b797148ac1c1ac32", size = 2315809, upload-time = "2026-07-14T17:23:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/fd/09/5185ea1270987fa3514ff6bf99540e23befefdb511759137f57848e1ab72/oracledb-4.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14124b5e8e4ba087f105ee389ee43e407840220a0ba2504de68eed903f51a711", size = 2490090, upload-time = "2026-07-14T17:23:28.995Z" }, + { url = "https://files.pythonhosted.org/packages/c2/63/2547b13274f95d2eb073af2c936a37d7da3025f910937dc2e411b7160784/oracledb-4.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:36e72ffaaf589041dac72ddf589d694c0a9ac4576662a04628966d4ea089d6ea", size = 2365819, upload-time = "2026-07-14T17:23:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/23/bd/f52b69a5d86d62cf9cb3992a7cafd1d46c418ae64eaa8a256a3d1eee9e51/oracledb-4.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0101b88c15ae628af556506840ae876e12bdb75506ef342e4990ac1758bad1eb", size = 2518689, upload-time = "2026-07-14T17:23:32.566Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6d15fedcbd67fabd6c9c345554e184ba6617e389bfea348358f8aacc0e5f/oracledb-4.0.2-cp314-cp314-win32.whl", hash = "sha256:b14d52c09b3c7a8273b3640ba06675acec5000dab0356ef288587c46c30bbbb4", size = 1514946, upload-time = "2026-07-14T17:23:33.981Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b9/d6e2c3ee3d9fb3585f04910ecf6ff91dbfd84e8dafc06084717f6a134bca/oracledb-4.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:5c1f13ba535d9f0fc61e1987720988612cf9424667d491fc344ba681f3336a17", size = 1915171, upload-time = "2026-07-14T17:23:35.574Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6b/47f5c76223798ebfb5b6d41e9c3a451d296d6d90123584d85159e4236ff5/oracledb-4.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:93b4d2cd17a93224711fadd28f77e956e5c6575cd923f24bfe0b1a2a581beb79", size = 1574652, upload-time = "2026-07-14T17:23:36.982Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -494,6 +692,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -848,6 +1055,9 @@ dependencies = [ llm = [ { name = "anthropic" }, ] +oracle = [ + { name = "oracledb" }, +] postgres = [ { name = "psycopg", extra = ["binary"] }, ] @@ -865,6 +1075,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.141.1" }, { name = "openai", specifier = ">=2.53.0" }, { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "oracledb", marker = "extra == 'oracle'", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.1" }, { name = "pymssql", specifier = ">=2.3.13" }, { name = "python-dotenv", specifier = ">=1.2.2" }, @@ -873,7 +1084,7 @@ requires-dist = [ { name = "typer", specifier = ">=0.12" }, { name = "uvicorn", specifier = ">=0.52.1" }, ] -provides-extras = ["postgres", "llm"] +provides-extras = ["postgres", "oracle", "llm"] [package.metadata.requires-dev] dev = [ From e369dee465d111a2d60fd35403b711e3a5e7e805 Mon Sep 17 00:00:00 2001 From: Jacob Date: Tue, 8 Sep 2026 22:57:12 +0900 Subject: [PATCH 4/5] feat: measure the fold against not folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything in report/ measures the fold itself — how much it compressed, what survived, what stays answerable — and the goldset script measures tablefold against ground truth. None of it answers the claim the project actually makes, that Text-to-SQL fails on schema context rather than on the model. That claim needs the same questions asked without the fold. report/baseline.py is that arm: raw DDL in the prompt, physical SQL out, no expansion. It shares the completer, the questions, the execution check and the retry cap with the folded arm, so the one thing that differs is the shape the schema arrives in — a folded-only self-correction loop would leave it unclear whether the fold or the retries won. Not an identity layer, though that would be far less code. _reject_multiple_models refuses a query that reads two models, which is the guard that protects wide models; under one-model-per-table every join question dies on it, and most of the goldset needs a join. That scores the control at zero by tying its hands rather than by folding well. schema_ddl also replaces demo's own renderer, which drew no foreign keys and no comments. A baseline reading that version cannot know the join paths, which is the same rigging in a quieter form. Running the comparison needs the live MSSQL and LLM credentials, so the numbers are not in this commit — the harness is. --- demo/live.py | 16 +-- scripts/run_baseline_comparison.py | 173 +++++++++++++++++++++++++++++ src/tablefold/report/baseline.py | 170 ++++++++++++++++++++++++++++ tests/test_baseline.py | 129 +++++++++++++++++++++ 4 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 scripts/run_baseline_comparison.py create mode 100644 src/tablefold/report/baseline.py create mode 100644 tests/test_baseline.py diff --git a/demo/live.py b/demo/live.py index 2166ee2..e1624b9 100644 --- a/demo/live.py +++ b/demo/live.py @@ -23,6 +23,7 @@ env_configured, ) from tablefold.relate.validate import DEFAULT_VIOLATION_TOLERANCE, recover_with_data +from tablefold.report.baseline import schema_ddl load_dotenv() @@ -61,13 +62,14 @@ def available() -> bool: def render_ddl(schema: PhysicalSchema) -> str: - blocks = [] - for table in schema.tables: - lines = [f" {c.name} {c.type}" for c in table.columns] - if table.primary_key: - lines.append(f" PRIMARY KEY ({', '.join(table.primary_key)})") - blocks.append(f"CREATE TABLE {table.name} (\n" + ",\n".join(lines) + "\n);") - return "\n\n".join(blocks) + """화면에 보여 줄 원본 스키마. + + :func:`tablefold.report.baseline.schema_ddl` 한 벌만 쓴다. 여기 있던 판은 + 외래 키와 주석을 빼고 그렸는데, 대조군이 그걸 쓰면 조인 경로를 모르는 채로 + 답하게 되어 접힌 쪽이 이긴 이유가 흐려진다. 화면과 대조군이 같은 것을 + 보게 두는 편이 낫다. + """ + return schema_ddl(schema) def load(schema_name: str = "dbo") -> tuple[PhysicalSchema, dict]: diff --git a/scripts/run_baseline_comparison.py b/scripts/run_baseline_comparison.py new file mode 100644 index 0000000..447238d --- /dev/null +++ b/scripts/run_baseline_comparison.py @@ -0,0 +1,173 @@ +"""접은 쪽과 접지 않은 쪽을, 같은 질문으로 나란히 돌린다. + +``run_goldset_value_match_test.py`` 는 tablefold 가 정답 SQL 과 얼마나 같은 +값을 내는지 잰다. 그것만으로는 **접기가 원인이었는지** 알 수 없다 — 이 프로젝트의 +주장이 "실패 원인은 모델이 아니라 스키마 컨텍스트"이므로, 접지 않았을 때의 같은 +숫자가 있어야 주장이 증명된다. + +두 팔은 다음을 공유한다. 달라지는 것은 **LLM 이 스키마를 어떤 모양으로 보는가** +하나다. + +* 같은 질문(``PRECISE_QUESTION_MAP``) +* 같은 라이브 데이터베이스와 같은 정답 SQL +* 같은 값 비교 규칙(``evaluate_strict_match``) +* 같은 재시도 상한 — 접힌 쪽만 자기수정을 가지면 이긴 원인이 갈리지 않는다 + + 접은 팔 : 원본 스키마 → fold → 논리 SQL → expand → 물리 SQL + 안 접은 팔 : 원본 DDL → 물리 SQL (tablefold 를 지나치지 않는다) + +실행에는 라이브 MSSQL 과 LLM 자격 증명이 필요하다. + + uv run python scripts/run_baseline_comparison.py +""" + +from __future__ import annotations + +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from run_goldset_value_match_test import ( # noqa: E402 + PRECISE_QUESTION_MAP, + evaluate_strict_match, + extract_gold_sqls, + sanitize_for_mssql, +) + +from demo import live # noqa: E402 +from tablefold.report.baseline import generate_without_fold # noqa: E402 +from tablefold.t2sql import ( # noqa: E402 + TextToSQLEngine, + load_goldset, + prepare_for_questions, +) +from tablefold.t2sql.provider import default_completer # noqa: E402 + +DIALECT = "tsql" +MAX_ATTEMPTS = 3 + +# 값이 맞은 것으로 세는 등급. 실행만 된 것은 정답이 아니다. +CORRECT = {"EXACT_VALUE_MATCH", "CLOSE_VALUE_MATCH"} + + +@dataclass +class ArmResult: + case_id: str + question: str + status: str + sql: str + ms: float + + +def _grade(gold_res: dict, sql: str) -> tuple[str, dict]: + """생성된 SQL 을 실행해 정답과 값으로 견준다.""" + if not sql: + return "FAIL", {} + gen_res = live.execute_query(sql) + status, _notes, _gold_sum, _gen_sum = evaluate_strict_match(gold_res, gen_res) + return status, gen_res + + +def run() -> None: + schema, meta = live.load() + db = meta.get("database", "NL2SQL") + print(f"[schema] {db} — 물리 테이블 {len(schema.tables)}개") + + prep = prepare_for_questions(schema) + engine = TextToSQLEngine( + fold_result=prep.result, dialect=DIALECT, max_attempts=MAX_ATTEMPTS + ) + print(f"[folded] 와이드 모델 {len(prep.result.layer.models)}개") + + completer = default_completer() + print("[baseline] 원본 DDL 을 그대로 프롬프트에 넣는다\n") + + cases = load_goldset("20251104_NL2SQL_메뉴별컨텐츠정리.xlsx") + gold_sqls = extract_gold_sqls("20251104_NL2SQL_메뉴별컨텐츠정리.xlsx") + + folded: list[ArmResult] = [] + unfolded: list[ArmResult] = [] + + for i, case in enumerate(cases, 1): + question = PRECISE_QUESTION_MAP.get( + case.case_id, case.concrete_question or case.question + ) + gold_sql = sanitize_for_mssql(gold_sqls.get(case.case_id, "")) + gold_res = live.execute_query(gold_sql) if gold_sql else {} + + print(f"[{i:02d}/{len(cases)}] {case.case_id}: {question}") + + t0 = time.time() + try: + out = engine.generate(question) + status, _ = _grade(gold_res, out.physical_sql.strip()) + sql = out.physical_sql.strip() + except Exception as exc: # noqa: BLE001 — 실패도 결과다 + status, sql = "FAIL", f"-- {exc}" + folded.append( + ArmResult(case.case_id, question, status, sql, (time.time() - t0) * 1000) + ) + print(f" folded {status}") + + t0 = time.time() + try: + base = generate_without_fold( + question, + schema, + completer=completer, + dialect=DIALECT, + max_attempts=MAX_ATTEMPTS, + executor=lambda s: live.execute_query(s), + ) + status, _ = _grade(gold_res, base.sql or "") + sql = base.sql or f"-- {base.error}" + except Exception as exc: # noqa: BLE001 — 실패도 결과다 + status, sql = "FAIL", f"-- {exc}" + unfolded.append( + ArmResult(case.case_id, question, status, sql, (time.time() - t0) * 1000) + ) + print(f" baseline {status}") + + _report(folded, unfolded) + + +def _rate(arm: list[ArmResult]) -> float: + return 100.0 * sum(1 for r in arm if r.status in CORRECT) / max(len(arm), 1) + + +def _report(folded: list[ArmResult], unfolded: list[ArmResult]) -> None: + print("\n" + "=" * 68) + print("값 일치율 (EXACT + CLOSE 를 정답으로 센다)") + print("=" * 68) + f, u = _rate(folded), _rate(unfolded) + f_hit = sum(1 for r in folded if r.status in CORRECT) + u_hit = sum(1 for r in unfolded if r.status in CORRECT) + print(f" 접은 쪽 {f:5.1f}% ({f_hit}/{len(folded)})") + print(f" 안 접은 쪽 {u:5.1f}% ({u_hit}/{len(unfolded)})") + print(f" 차이 {f - u:+5.1f}%p") + + print("\n등급 분포") + for name, arm in (("folded", folded), ("baseline", unfolded)): + counts: dict[str, int] = {} + for r in arm: + counts[r.status] = counts.get(r.status, 0) + 1 + rendered = " ".join(f"{k}={v}" for k, v in sorted(counts.items())) + print(f" {name:9s} {rendered}") + + flipped = [ + (a.case_id, a.status, b.status) + for a, b in zip(folded, unfolded, strict=True) + if (a.status in CORRECT) != (b.status in CORRECT) + ] + if flipped: + print("\n한쪽만 맞힌 문항 — 차이가 어디서 왔는지 여기를 읽는다") + for case_id, fs, us in flipped: + winner = "folded" if fs in CORRECT else "baseline" + print(f" {case_id:10s} folded={fs:18s} baseline={us:18s} → {winner}") + + +if __name__ == "__main__": + run() diff --git a/src/tablefold/report/baseline.py b/src/tablefold/report/baseline.py new file mode 100644 index 0000000..5df29f0 --- /dev/null +++ b/src/tablefold/report/baseline.py @@ -0,0 +1,170 @@ +"""대조군 — tablefold 없이 같은 질문에 답하게 한다. + +:mod:`tablefold.report` 의 나머지는 전부 **접기 자체**를 잰다. 얼마나 줄었는지 +(:mod:`~tablefold.report.compression`), 무엇이 남았는지 +(:mod:`~tablefold.report.fidelity`), 무엇을 답할 수 있는지 +(:mod:`~tablefold.report.answerable`). 전부 접은 결과의 성질이다. + +그런데 이 프로젝트의 주장은 그게 아니다. 주장은 **"Text-to-SQL 이 실패하는 원인은 +모델이 아니라 스키마 컨텍스트"** 이고, 그 주장은 접지 않았을 때와 견주어야만 +증명된다. 압축률은 접기가 무엇을 했는지 말하지, 그게 도움이 됐는지는 말하지 않는다. + +이 모듈이 그 반대편이다. 같은 LLM, 같은 질문, 같은 실행 검증에 **원본 DDL** 을 +주고 물리 SQL 을 직접 쓰게 한다. + +**왜 '항등 폴드'가 아닌가.** 표 하나를 모델 하나로 만든 레이어를 태우면 코드가 +훨씬 적어진다. 그런데 :func:`~tablefold.rewrite.expand._reject_multiple_models` +가 한 질의의 다중 모델 참조를 거부한다 — 와이드 모델을 지키려고 있는 가드다. +항등 레이어에서는 모든 조인 질문이 그 가드에 걸려 죽고, 골드셋의 대부분이 +조인을 필요로 하므로 대조군이 0점을 받는다. 접기가 이긴 게 아니라 대조군의 +손발을 묶은 것이다. 그래서 확장 단계를 통째로 지나친다. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +from tablefold.ir import PhysicalSchema +from tablefold.t2sql.prompt import Prompt +from tablefold.t2sql.provider import Completer + +DEFAULT_MAX_ATTEMPTS = 3 + + +@dataclass(frozen=True) +class BaselineResult: + question: str + sql: str | None + turns: int + error: str | None = None + prompt_chars: int = 0 + errors_seen: tuple[str, ...] = field(default_factory=tuple) + + @property + def ok(self) -> bool: + return self.sql is not None and self.error is None + + +def schema_ddl(schema: PhysicalSchema) -> str: + """물리 스키마를 ``CREATE TABLE`` 문으로 되돌린다. + + 대조군이 보는 화면이다. 접힌 레이어 대신 이걸 프롬프트에 넣는다. + + 주석을 함께 싣는다 — 빼면 대조군에게서 접힌 레이어는 가지고 있는 정보를 + 빼앗는 셈이라, 이긴 이유가 '접기' 인지 '설명을 줬는지' 인지 갈리지 않는다. + """ + blocks: list[str] = [] + for table in schema.tables: + lines = [f"CREATE TABLE {table.qualified_name} ("] + parts = [] + for column in table.columns: + piece = f" {column.name} {column.type}" + if not column.nullable: + piece += " NOT NULL" + if column.comment: + piece += f" -- {column.comment}" + parts.append(piece) + if table.primary_key: + parts.append(f" PRIMARY KEY ({', '.join(table.primary_key)})") + lines.append(",\n".join(parts)) + lines.append(");") + if table.comment: + lines.insert(0, f"-- {table.name}: {table.comment}") + blocks.append("\n".join(lines)) + + for fk in schema.foreign_keys: + blocks.append( + f"ALTER TABLE {fk.from_table} ADD FOREIGN KEY " + f"({', '.join(fk.from_columns)}) REFERENCES {fk.to_table} " + f"({', '.join(fk.to_columns)});" + ) + return "\n\n".join(blocks) + + +_CONTRACT = """\ +너는 {dialect} SQL 을 쓴다. 위 스키마의 표와 컬럼만 쓴다. + +1. 답은 실행 가능한 SELECT 문 하나다. 설명·주석·마크다운 없이 SQL 만 낸다. +2. 없는 표나 컬럼을 지어내지 않는다. +3. 1:N 관계를 조인한 뒤 부모 쪽 값을 SUM 하면 자식 행 수만큼 부풀어 오른다. + 그럴 때는 자식을 먼저 집계한 뒤 조인한다. +""" + + +def build_prompt(schema: PhysicalSchema, question: str, *, dialect: str) -> Prompt: + """접힌 레이어 대신 원본 DDL 을 넣은 프롬프트. + + :class:`~tablefold.t2sql.prompt.Prompt` 를 그대로 쓴다 — 캐시 경계가 같아야 + 두 팔의 토큰 비용을 나란히 놓고 볼 수 있고, 같은 + :data:`~tablefold.t2sql.provider.Completer` 를 태울 수 있다. + """ + cached = f"{schema_ddl(schema)}\n\n{_CONTRACT.format(dialect=dialect)}" + return Prompt(cached=cached, fresh=f"질문: {question}\nSQL:") + + +def generate_without_fold( + question: str, + schema: PhysicalSchema, + *, + completer: Completer, + dialect: str = "postgres", + max_attempts: int = DEFAULT_MAX_ATTEMPTS, + executor: Callable[[str], None] | None = None, + extract: Callable[[str, str], str] | None = None, +) -> BaselineResult: + """원본 DDL 만 주고 물리 SQL 을 쓰게 한다. 실패하면 오류를 물려 다시 묻는다. + + 재시도 상한을 엔진과 같은 값으로 두는 이유는, 접힌 쪽만 자기수정을 갖고 + 있으면 이긴 원인이 접기인지 재시도인지 갈리지 않기 때문이다. + """ + from tablefold.t2sql.parse import extract_sql + + take = extract or (lambda raw, d: extract_sql(raw, dialect=d)) + prompt = build_prompt(schema, question, dialect=dialect) + seen: list[str] = [] + + for turn in range(1, max_attempts + 1): + raw = completer(prompt) + try: + sql = take(raw, dialect) + except Exception as exc: # noqa: BLE001 — 파싱 실패도 한 번의 실패다 + error = str(exc) or type(exc).__name__ + else: + error = _run(executor, sql) + if error is None: + return BaselineResult( + question=question, + sql=sql, + turns=turn, + prompt_chars=len(prompt), + errors_seen=tuple(seen), + ) + seen.append(error) + prompt = Prompt( + cached=prompt.cached, + fresh=( + f"질문: {question}\n" + f"직전 답이 실패했다: {error}\n" + "원인을 고쳐 SQL 만 다시 낸다.\nSQL:" + ), + ) + + return BaselineResult( + question=question, + sql=None, + turns=max_attempts, + error=seen[-1] if seen else "no attempt produced SQL", + prompt_chars=len(prompt), + errors_seen=tuple(seen), + ) + + +def _run(executor: Callable[[str], None] | None, sql: str) -> str | None: + if executor is None: + return None + try: + executor(sql) + except Exception as exc: # noqa: BLE001 — 무슨 예외든 실행 실패다 + return str(exc) or type(exc).__name__ + return None diff --git a/tests/test_baseline.py b/tests/test_baseline.py new file mode 100644 index 0000000..20983f3 --- /dev/null +++ b/tests/test_baseline.py @@ -0,0 +1,129 @@ +"""대조군 — 접지 않은 팔. + +LLM 없이 돈다. :data:`~tablefold.t2sql.provider.Completer` 가 ``Prompt -> str`` +이라 고정 문자열을 돌려주는 함수를 넘기면 전 경로가 검사된다. +""" + +from __future__ import annotations + +import pytest + +from tablefold.report.baseline import ( + build_prompt, + generate_without_fold, + schema_ddl, +) + + +def test_schema_ddl_carries_columns_keys_and_relations(tiny_schema): + ddl = schema_ddl(tiny_schema) + assert "CREATE TABLE" in ddl + for table in tiny_schema.tables: + assert table.name in ddl + assert "PRIMARY KEY" in ddl + if tiny_schema.foreign_keys: + assert "FOREIGN KEY" in ddl + + +def test_schema_ddl_keeps_comments(tiny_schema): + """주석을 빼면 대조군이 접힌 레이어보다 적은 정보를 받는다. + + 그러면 접힌 쪽이 이겨도 원인이 '접기' 인지 '설명' 인지 갈리지 않는다. + """ + commented = [c for t in tiny_schema.tables for c in t.columns if c.comment] + if not commented: + pytest.skip("픽스처에 컬럼 주석이 없다") + ddl = schema_ddl(tiny_schema) + assert commented[0].comment in ddl + + +def test_prompt_prefix_is_stable_across_questions(tiny_schema): + """캐시 접두사가 질문마다 달라지면 두 팔의 비용 비교가 무의미해진다.""" + a = build_prompt(tiny_schema, "매출 알려줘", dialect="tsql") + b = build_prompt(tiny_schema, "주문 수 알려줘", dialect="tsql") + assert a.cached == b.cached + assert a.fresh != b.fresh + + +def test_first_answer_that_runs_is_taken(tiny_schema): + calls = [] + + def completer(prompt): + calls.append(prompt) + return "SELECT id FROM orders" + + result = generate_without_fold( + "주문 번호", tiny_schema, completer=completer, executor=lambda sql: None + ) + assert result.ok + assert result.turns == 1 + assert len(calls) == 1 + + +def test_execution_error_is_fed_back_and_retried(tiny_schema): + """접힌 쪽만 자기수정을 가지면 이긴 원인이 접기인지 재시도인지 갈리지 않는다.""" + answers = iter(["SELECT nope FROM orders", "SELECT id FROM orders"]) + seen_prompts = [] + + def completer(prompt): + seen_prompts.append(str(prompt)) + return next(answers) + + def executor(sql): + if "nope" in sql: + raise RuntimeError("Invalid column name 'nope'") + + result = generate_without_fold( + "주문 번호", + tiny_schema, + completer=completer, + executor=executor, + max_attempts=3, + ) + assert result.ok + assert result.turns == 2 + assert result.errors_seen == ("Invalid column name 'nope'",) + assert "Invalid column name 'nope'" in seen_prompts[1] + + +def test_gives_up_at_the_attempt_cap_and_says_why(tiny_schema): + def completer(prompt): + return "SELECT nope FROM orders" + + def executor(sql): + raise RuntimeError("Invalid column name 'nope'") + + result = generate_without_fold( + "주문 번호", + tiny_schema, + completer=completer, + executor=executor, + max_attempts=2, + ) + assert not result.ok + assert result.sql is None + assert result.turns == 2 + assert "nope" in result.error + assert len(result.errors_seen) == 2 + + +def test_unparseable_answer_counts_as_a_failed_turn(tiny_schema): + def completer(prompt): + return "미안, 모르겠다" + + result = generate_without_fold( + "주문 번호", tiny_schema, completer=completer, max_attempts=1 + ) + assert not result.ok + assert result.errors_seen # 조용히 통과하지 않는다 + + +def test_without_an_executor_the_first_parseable_answer_stands(tiny_schema): + """실행 검증이 없으면 예전 엔진과 같은 동작 — 확장/파싱 통과가 곧 답이다.""" + result = generate_without_fold( + "주문 번호", + tiny_schema, + completer=lambda p: "SELECT id FROM orders", + ) + assert result.ok + assert result.turns == 1 From e6365838128c09b65328207ebaca17454eb8ed90 Mon Sep 17 00:00:00 2001 From: Jacob Date: Tue, 8 Sep 2026 23:18:34 +0900 Subject: [PATCH 5/5] ci: move off actions pinned to the deprecated Node 20 runtime --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 848d8f3..1c856aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: python-version: ["3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true