diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c856aa --- /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@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + 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) 도구입니다. ``` 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/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/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/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/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/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_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 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 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 = [