diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30c473d..70ceaed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 25.1.0 + rev: 26.1.0 hooks: - id: black diff --git a/docs/BaseFlow_ko.md b/docs/BaseFlow_ko.md index 479e5bc..f48dcfe 100644 --- a/docs/BaseFlow_ko.md +++ b/docs/BaseFlow_ko.md @@ -1,6 +1,6 @@ # BaseFlow -`BaseFlow`는 Lang2SQL에서 **define-by-run(순수 파이썬 제어)** 철학을 구현하기 위한 “플로우의 최소 추상화(minimal abstraction)”입니다. +`BaseFlow`는 Lang2SQL에서 **define-by-run(순수 파이썬 제어)** 철학을 구현하기 위한 "플로우의 최소 추상화(minimal abstraction)"입니다. * 파이프라인의 **제어권(control-flow)** 을 프레임워크 DSL이 아니라 **사용자 코드(Python)** 가 갖습니다. * LangGraph 같은 그래프 엔진을 강제하지 않습니다. @@ -10,7 +10,7 @@ ## 왜 필요한가? -### 1) “제어는 파이썬으로”를 지키기 위해 +### 1) "제어는 파이썬으로"를 지키기 위해 Text2SQL은 현실적으로 다음 제어가 자주 필요합니다. @@ -19,11 +19,11 @@ Text2SQL은 현실적으로 다음 제어가 자주 필요합니다. * 부분 파이프라인(서브플로우) 호출 * 정책(policy) 기반 행동 결정 -`BaseFlow`는 이런 제어를 **사용자가 Python으로 직접 작성**하게 두고, 라이브러리는 “실행 컨테이너 + 관측성”만 제공합니다. +`BaseFlow`는 이런 제어를 **사용자가 Python으로 직접 작성**하게 두고, 라이브러리는 "실행 컨테이너 + 관측성"만 제공합니다. ### 2) 요청 단위 관측성(Flow-level tracing) -운영/디버깅에서는 “이 요청 전체가 언제 시작했고, 어디서 실패했고, 얼마나 걸렸는지”가 먼저 중요합니다. +운영/디버깅에서는 "이 요청 전체가 언제 시작했고, 어디서 실패했고, 얼마나 걸렸는지"가 먼저 중요합니다. `BaseFlow`는 다음 이벤트를 발행합니다. @@ -32,15 +32,6 @@ Text2SQL은 현실적으로 다음 제어가 자주 필요합니다. → 요청 1건을 **Flow 단위로 빠르게 파악**할 수 있습니다. -### 3) 공통 엔트리포인트(run_query) 제공 - -Text2SQL은 대부분 “문장(query)”이 시작점입니다. - -`run_query("...")`를 제공하면: - -* 초급 사용자는 `RunContext`를 몰라도 “바로 실행” 가능 -* 고급 사용자는 `run(RunContext)`로 제어를 확장 가능 - --- ## BaseFlow가 제공하는 API @@ -49,84 +40,57 @@ Text2SQL은 대부분 “문장(query)”이 시작점입니다. ```python class MyFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: + def run(self, value): ... - return run + return result ``` * Flow의 본체 로직은 여기에 작성합니다. * 제어는 Python으로 직접 작성합니다. (`if/for/while`) +* 입출력 타입은 자유롭게 정의합니다. 공유 상태 백(RunContext)을 강제하지 않습니다. ### 2) 호출: `__call__` ```python -out = flow(run) +out = flow(value) ``` * 내부적으로 `flow.run(...)`을 호출합니다. * hook 이벤트를 `start/end/error`로 기록합니다. -### 3) 편의 엔트리포인트: `run_query()` - -```python -out = flow.run_query("지난달 매출") -``` - -* 내부에서 `RunContext(query=...)`를 만들고 `run()`을 호출합니다. -* Quickstart / demo / 초급 UX용 엔트리포인트입니다. - -> 권장: **BaseFlow에 run_query를 둬서 “모든 Flow는 run_query가 된다”는 직관을 유지**합니다. - ---- - -## run(runcontext) vs run_query(query) - -둘은 기능적으로 **같은 동작**을 하도록 설계합니다. - -```python -out1 = flow.run_query("지난달 매출") -out2 = flow.run(RunContext(query="지난달 매출")) -``` - -* `run_query(query)` : 문자열 query에서 시작하는 편의 API -* `run(runcontext)` : 고급 사용자를 위한 명시적 API - --- ## 사용 패턴 -### 1) 초급: SequentialFlow로 구성하고 run_query로 실행 +### 1) 초급: SequentialFlow로 구성하고 run으로 실행 -초급 사용자는 보통 “구성만 하고 실행”하면 됩니다. +초급 사용자는 보통 "구성만 하고 실행"하면 됩니다. ```python flow = SequentialFlow(steps=[retriever, builder, generator, validator]) -out = flow.run_query("지난달 매출") +result = flow.run("지난달 매출") ``` +각 step은 이전 step의 출력을 입력으로 받습니다. + ### 2) 고급: CustomFlow로 제어(while/if/policy) 정책/루프/재시도 같은 제어가 들어오면 `BaseFlow`를 직접 상속해 작성하는 것이 가장 깔끔합니다. ```python class RetryFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: - while True: - run = retriever(run) - metrics = eval_retrieval(run) # 순수 함수 가능 - action = policy(metrics) # 순수 함수 가능 - if action == "retry": - continue - break - - run = generator(run) - run = validator(run) - return run + def run(self, query: str) -> str: + for _ in range(3): + schemas = retriever(query) + sql = generator(query, schemas) + if validator(sql): + return sql + return sql ``` ### 3) Sequential을 유지하면서 동적 파라미터가 필요하면 closure/partial -이건 “필수”가 아니라, **steps 배열을 유지하고 싶은 사람을 위한 옵션**입니다. +이건 "필수"가 아니라, **steps 배열을 유지하고 싶은 사람을 위한 옵션**입니다. --- @@ -140,7 +104,7 @@ from lang2sql.core.hooks import MemoryHook hook = MemoryHook() flow = SequentialFlow(steps=[...], hook=hook) -out = flow.run_query("지난달 매출") +result = flow.run("지난달 매출") for e in hook.events: print(e.name, e.phase, e.component, e.duration_ms, e.error) @@ -153,8 +117,8 @@ for e in hook.events: ## (관련 개념) BaseFlow와 BaseComponent의 관계 -* `BaseFlow`는 “어떻게 실행할지(제어/조립)”를 담당합니다. -* `BaseComponent`는 “한 단계에서 무엇을 할지(작업 단위)”를 담당합니다. +* `BaseFlow`는 "어떻게 실행할지(제어/조립)"를 담당합니다. +* `BaseComponent`는 "한 단계에서 무엇을 할지(작업 단위)"를 담당합니다. 일반적으로: @@ -176,8 +140,5 @@ A. Flow라는 개념은 사실상 필요하지만, **모든 사용자가 BaseFlo ### Q. Flow의 반환 타입은? -A. `run()`은 **반드시 `RunContext`를 반환**하는 것을 권장합니다. -(합성/디버깅/타입 안정성 측면에서 이득이 큽니다.) - ---- - +A. `run()`의 입출력 타입은 자유롭습니다. 컴포넌트끼리 합의한 타입을 그대로 사용하면 됩니다. +`SequentialFlow`는 각 step의 출력을 다음 step의 입력으로 전달하는 파이프입니다. diff --git a/docs/RunContext_ko.md b/docs/RunContext_ko.md index 3317216..1b57472 100644 --- a/docs/RunContext_ko.md +++ b/docs/RunContext_ko.md @@ -1,7 +1,11 @@ +> **레거시 유틸리티**: `RunContext`는 현재 레거시 유틸리티로 유지됩니다. +> 새 코드에서는 명시적 Python 인자를 사용하는 것을 권장합니다. +> 컴포넌트 I/O는 `RunContext` 대신 구체적인 타입(str, list 등)으로 표현하세요. + ## RunContext -`RunContext`는 define-by-run 파이프라인에서 **상태(state)를 운반하는 최소 State Carrier**입니다. -컴포넌트는 기본적으로 `RunContext -> RunContext` 계약을 따르며, 필요한 값을 읽고/쓰면서 파이프라인을 구성합니다. +`RunContext`는 define-by-run 파이프라인에서 **상태(state)를 운반하는 State Carrier**입니다. +레거시 파이프라인이나 직접 상태를 조합할 때 유용합니다. ### 설계 원칙 @@ -13,7 +17,7 @@ ## 데이터 구조 트리 -아래는 `RunContext`가 담는 데이터 구조를 “트리 형태”로 나타낸 것입니다. +아래는 `RunContext`가 담는 데이터 구조를 "트리 형태"로 나타낸 것입니다. ``` RunContext @@ -93,21 +97,36 @@ RunContext --- -## 파이프라인 예시 (Text2SQL) +## 파이프라인 예시 (Text2SQL) — 새 API + +새 API에서는 각 컴포넌트가 명시적 인자를 주고받습니다. -개념: +```python +query = "지난달 매출" -* retriever: `(query, catalog) -> selected` -* builder: `(query, selected) -> context` -* generator: `(query, context) -> sql` -* validator: `(sql) -> validation` +schemas = retriever(query) # str → list[CatalogEntry] +context = builder(query, schemas) # str, list → str +sql = generator(query, context) # str, str → str +result = validator(sql) # str → ValidationResult +``` -RunContext에서의 읽기/쓰기: +또는 `SequentialFlow`로 조합: -* retriever: `run.query`, `run.schema_catalog` 읽고 → `run.schema_selected` 작성 -* builder: `run.query`, `run.schema_selected` 읽고 → `run.schema_context` 작성 -* generator: `run.query`, `run.schema_context` 읽고 → `run.sql` 작성 -* validator: `run.sql` 읽고 → `run.validation` 작성 +```python +flow = SequentialFlow(steps=[retriever, builder, generator, validator]) +result = flow.run(query) +``` --- +## RunContext 직접 사용 (레거시) + +기존 코드나 직접 상태를 조합할 때만 사용합니다. + +```python +from lang2sql.core.context import RunContext + +run = RunContext(query="지난달 매출") +# run을 직접 조작하거나 레거시 컴포넌트에 전달 +run.metadata["session_id"] = "abc123" +``` diff --git a/interface/app_pages/chatbot.py b/interface/app_pages/chatbot.py index 9879147..8c460fd 100644 --- a/interface/app_pages/chatbot.py +++ b/interface/app_pages/chatbot.py @@ -82,15 +82,13 @@ def initialize_session_state(): # 페이지 제목 st.title("🤖 AI ChatBot") -st.markdown( - """ +st.markdown(""" LangGraph 기반 AI ChatBot과 대화를 나눌 수 있습니다. - 데이터베이스 테이블 정보 검색 - 용어집 조회 - 쿼리 예제 조회 - 대화를 통해 질문 구체화 - """ -) + """) # 설정 로드 config = load_config() diff --git a/interface/app_pages/home.py b/interface/app_pages/home.py index 856ee34..ef7644b 100644 --- a/interface/app_pages/home.py +++ b/interface/app_pages/home.py @@ -8,8 +8,7 @@ st.title("🏠 홈") -st.markdown( - """ +st.markdown(""" ### Lang2SQL 데이터 분석 도구에 오신 것을 환영합니다 🎉 이 도구는 자연어로 작성한 질문을 SQL 쿼리로 변환하고, @@ -21,7 +20,6 @@ 2. **🔍 Lang2SQL**: 자연어 → SQL 변환 및 결과 분석 3. **📊 그래프 빌더**: LangGraph 실행 순서를 프리셋/커스텀으로 구성하고 세션에 적용 4. **⚙️ 설정**: 데이터 소스, LLM, DB 연결 등 환경 설정 - """ -) + """) st.info("왼쪽 메뉴에서 기능 페이지를 선택해 시작하세요 🚀") diff --git a/interface/app_pages/lang2sql.py b/interface/app_pages/lang2sql.py index 10d3466..22f49aa 100644 --- a/interface/app_pages/lang2sql.py +++ b/interface/app_pages/lang2sql.py @@ -28,7 +28,6 @@ render_sidebar_db_selector, ) - TITLE = "Lang2SQL" DEFAULT_QUERY = "고객 데이터를 기반으로 유니크한 유저 수를 카운트하는 쿼리" SIDEBAR_OPTIONS = { diff --git a/interface/app_pages/settings.py b/interface/app_pages/settings.py index 21e693e..72d05c7 100644 --- a/interface/app_pages/settings.py +++ b/interface/app_pages/settings.py @@ -11,7 +11,6 @@ from interface.app_pages.settings_sections.llm_section import render_llm_section from interface.app_pages.settings_sections.db_section import render_db_section - st.title("⚙️ 설정") config = load_config() diff --git a/interface/app_pages/settings_sections/llm_section.py b/interface/app_pages/settings_sections/llm_section.py index f5fe59f..2473012 100644 --- a/interface/app_pages/settings_sections/llm_section.py +++ b/interface/app_pages/settings_sections/llm_section.py @@ -12,7 +12,6 @@ get_embedding_registry, ) - LLM_PROVIDERS = [ "openai", "azure", diff --git a/interface/core/result_renderer.py b/interface/core/result_renderer.py index 51886b8..69e3950 100644 --- a/interface/core/result_renderer.py +++ b/interface/core/result_renderer.py @@ -76,13 +76,11 @@ def should_show(_key: str) -> bool: st.markdown("---") token_summary = TokenUtils.get_token_usage_summary(data=res["messages"]) st.write("**토큰 사용량:**") - st.markdown( - f""" + st.markdown(f""" - Input tokens: `{token_summary['input_tokens']}` - Output tokens: `{token_summary['output_tokens']}` - Total tokens: `{token_summary['total_tokens']}` - """ - ) + """) if show_sql_section: st.markdown("---") diff --git a/pyproject.toml b/pyproject.toml index 809924c..62dd941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ "pgvector==0.3.6", "langchain-postgres==0.0.15", "trino>=0.329.0,<1.0.0", + "anthropic>=0.20.0", + "sqlalchemy>=2.0", ] [project.scripts] diff --git a/quickstart.md b/quickstart.md new file mode 100644 index 0000000..c1ff450 --- /dev/null +++ b/quickstart.md @@ -0,0 +1,938 @@ +# lang2sql Quickstart — 처음 사용자를 위한 튜토리얼 + +이 문서를 위에서 아래로 따라가면 **설치 → API 키 → 샘플 DB → 기본 파이프라인 → Hook 트레이싱 → 커스터마이징**까지 모두 체험할 수 있습니다. + +--- + +## 목차 + +1. [설치](#1-설치) +2. [API 키 설정](#2-api-키-설정) +3. [샘플 DB 세팅](#3-샘플-db-세팅) +4. [SQLAlchemyDB 연결 설정](#4-sqlalchemydb-연결-설정) +5. [예제 카탈로그](#5-예제-카탈로그) +6. [기본 사용 — BaselineNL2SQL](#6-기본-사용--baselinenl2sql) + - 6-A. Anthropic Claude + SQLite + - 6-B. OpenAI GPT + SQLite + - 6-C. PostgreSQL 연결 + - 6-D. 지원 DB 방언(dialect) 목록 +7. [Hook으로 실행 추적하기](#7-hook으로-실행-추적하기) +8. [고급 사용 — 컴포넌트 직접 조합](#8-고급-사용--컴포넌트-직접-조합) +9. [커스터마이징](#9-커스터마이징) + - 9-A. 시스템 프롬프트 교체 + - 9-B. 나만의 LLM 연결 + - 9-C. 나만의 DB 연결 + - 9-D. 커스텀 컴포넌트 만들기 + - 9-E. 커스텀 플로우 만들기 +10. [에러 처리](#10-에러-처리) +11. [전체 기능 체크리스트](#11-전체-기능-체크리스트) + +--- + +## 1. 설치 + +```bash +pip install lang2sql +``` + +> 개발 환경에서 uv를 사용하는 경우: +> ```bash +> uv sync +> ``` + +`anthropic`, `sqlalchemy`는 기본 의존성에 포함되어 있어 별도 설치가 필요 없습니다. + +> **패키지 업데이트 후** `pyproject.toml`이 변경되었다면 반드시 `uv sync`를 다시 실행하세요. + +--- + +## 2. API 키 설정 + +OpenAI, Anthropic SDK는 **환경변수를 자동으로 읽습니다.** +`api_key`를 코드에 직접 쓰지 않아도 됩니다. + +### 방법 A — 환경변수 (권장) + +```bash +# Anthropic +export ANTHROPIC_API_KEY="sk-ant-..." + +# OpenAI +export OPENAI_API_KEY="sk-..." +``` + +### 방법 B — `.env` 파일 + +프로젝트 루트에 `.env` 파일을 만들고: + +```env +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +``` + +Python 코드에서 로드: + +```python +from dotenv import load_dotenv +load_dotenv() +``` + +### 방법 C — 키 이름이 다를 때 + +환경변수 이름이 다른 경우(예: `OPEN_AI_KEY`) `api_key`로 직접 전달하거나 표준 이름으로 복사합니다: + +```python +import os + +# 방법 1 — api_key로 직접 전달 +llm = OpenAILLM(model="gpt-4o", api_key=os.environ["OPEN_AI_KEY"]) + +# 방법 2 — SDK가 읽는 표준 이름으로 복사 (이후 api_key 생략 가능) +os.environ["OPENAI_API_KEY"] = os.environ["OPEN_AI_KEY"] +llm = OpenAILLM(model="gpt-4o") +``` + +--- + +## 3. 샘플 DB 세팅 + +튜토리얼 코드를 실제 DB로 바로 실행해볼 수 있도록 샘플 데이터를 제공합니다. +고객 10명, 상품 12개, 주문 44건, 주문항목 83개가 포함됩니다. + +### SQLite (서버 불필요 — 바로 시작 가능) + +```bash +# 프로젝트 루트에서 실행 — sample.db 파일이 현재 디렉토리에 생성됩니다 +python scripts/setup_sample_db.py +``` + +``` +연결 중: sqlite:///sample.db +테이블 생성 완료: customers, products, orders, order_items + 고객: 10명 + 상품: 12개 + 주문: 44건 + 주문 항목: 83개 + +─── 검증 쿼리 결과 ─────────────────────────────── + 전체 주문 수: 44건 + gold 등급 고객 수: 3명 + 재고 10개 미만 상품: 5개 + - 무선 마우스: 3개 + - 후드 집업: 4개 + ... +───────────────────────────────────────────────── +완료! 아래 URL로 quickstart.md를 따라해 보세요: + sqlite:///sample.db +``` + +> `sample.db`는 스크립트를 실행한 디렉토리에 생성됩니다. +> Python 코드도 같은 디렉토리에서 실행해야 `sqlite:///sample.db`로 연결됩니다. + +### PostgreSQL (Docker 사용) + +```bash +# 1. 컨테이너 기동 (처음 한 번만) +docker compose -f docker/docker-compose-postgres.yml up -d + +# 2. 샘플 데이터 삽입 +python scripts/setup_sample_db.py --db postgres +``` + +> **커스텀 URL**을 사용하려면: +> ```bash +> python scripts/setup_sample_db.py --url "postgresql://myuser:mypass@myhost:5432/mydb" +> ``` + +--- + +## 4. SQLAlchemyDB 연결 설정 + +`SQLAlchemyDB`는 lang2sql이 SQL을 실제 DB에서 실행할 때 사용하는 DB 어댑터입니다. +SQLAlchemy URL만 넘기면 연결이 완료됩니다. + +```python +from lang2sql.integrations.db import SQLAlchemyDB + +# SQLite — 3번에서 생성한 sample.db에 바로 연결 +db = SQLAlchemyDB("sqlite:///sample.db") + +# PostgreSQL +db = SQLAlchemyDB("postgresql://postgres:postgres@localhost:5432/postgres") + +# MySQL +db = SQLAlchemyDB("mysql+mysqlconnector://user:pass@localhost:3306/dbname") + +# DuckDB (인메모리) +db = SQLAlchemyDB("duckdb:///:memory:") +``` + +### 연결 확인 + +```python +# execute()를 직접 호출해서 확인 +rows = db.execute("SELECT name, grade FROM customers WHERE grade = 'gold'") +print(rows) +# [{'name': '김철수', 'grade': 'gold'}, {'name': '박영희', 'grade': 'gold'}, ...] + +# 테이블 목록 확인 (SQLite) +print(db.execute("SELECT name FROM sqlite_master WHERE type='table'")) +# [{'name': 'customers'}, {'name': 'products'}, {'name': 'orders'}, {'name': 'order_items'}] +``` + +--- + +## 5. 예제 카탈로그 + +카탈로그는 **어떤 테이블이 있는지** 알려주는 메타데이터 목록입니다. +`KeywordRetriever`가 이 카탈로그를 BM25로 검색해 질문과 관련된 테이블을 찾습니다. + +### 현재 방식 — 직접 정의 + +`setup_sample_db.py`로 만든 DB와 컬럼 구조가 일치하도록 아래와 같이 정의합니다. + +```python +from lang2sql import CatalogEntry + +CATALOG: list[CatalogEntry] = [ + { + "name": "orders", + "description": "고객 주문 정보 테이블. 주문 건수, 금액, 날짜 조회에 사용.", + "columns": { + "order_id": "주문 고유 ID (PK)", + "customer_id": "주문한 고객 ID (FK → customers)", + "order_date": "주문 일시 (TIMESTAMP)", + "amount": "주문 금액 (DECIMAL)", + "status": "주문 상태: pending / confirmed / shipped / cancelled", + }, + }, + { + "name": "customers", + "description": "고객 마스터 데이터. 고객 이름, 가입일, 등급 조회에 사용.", + "columns": { + "customer_id": "고객 고유 ID (PK)", + "name": "고객 이름", + "email": "이메일 주소", + "joined_at": "가입 일시 (TIMESTAMP)", + "grade": "고객 등급: bronze / silver / gold", + }, + }, + { + "name": "products", + "description": "상품 정보 테이블. 상품명, 카테고리, 가격 조회에 사용.", + "columns": { + "product_id": "상품 고유 ID (PK)", + "name": "상품명", + "category": "카테고리: electronics / clothing / food", + "price": "판매 가격 (DECIMAL)", + "stock": "현재 재고 수량 (INTEGER)", + }, + }, + { + "name": "order_items", + "description": "주문별 상품 구성 테이블. 주문에 포함된 상품과 수량 조회에 사용.", + "columns": { + "item_id": "항목 고유 ID (PK)", + "order_id": "주문 ID (FK → orders)", + "product_id": "상품 ID (FK → products)", + "quantity": "주문 수량 (INTEGER)", + "unit_price": "주문 당시 단가 (DECIMAL)", + }, + }, +] +``` + +### 향후 방향 — 카탈로그 자동 생성 (미구현, 제안) + +**방법 A — SQLAlchemy inspect로 DB 스키마 읽기** +```python +# (현재 미구현, 아이디어 예시) +from sqlalchemy import create_engine, inspect + +engine = create_engine("sqlite:///sample.db") +insp = inspect(engine) + +catalog = [] +for table_name in insp.get_table_names(): + columns = { + col["name"]: str(col["type"]) + for col in insp.get_columns(table_name) + } + catalog.append({"name": table_name, "columns": columns}) +``` + +**방법 B — DataHub 등 메타데이터 플랫폼 연동** +- 이미 `utils/data/` 아래에 DataHub 연동 코드가 있습니다. +- 향후 `CatalogEntry` 형식으로 변환하는 어댑터를 추가할 예정입니다. + +--- + +## 6. 기본 사용 — BaselineNL2SQL + +가장 빠른 사용법입니다. LLM과 DB만 연결하면 자연어 → SQL → 실행 결과를 얻습니다. + +> **`db_dialect` 파라미터를 반드시 지정하세요.** +> SQLite는 `MONTH()`, `YEAR()` 같은 MySQL/PostgreSQL 함수를 지원하지 않습니다. +> `db_dialect`를 지정하면 해당 DB에 맞는 SQL 함수를 사용하는 프롬프트가 자동으로 적용됩니다. + +### 6-A. Anthropic Claude + SQLite + +```python +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.llm import AnthropicLLM +from lang2sql.integrations.db import SQLAlchemyDB + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", # ← DB 방언 지정 +) + +result = pipeline.run("이번달에 10만원 이상 주문한 고객 이름과 주문 금액을 알려줘") +print(result) +# 예시 출력: [{'name': '김철수', 'amount': Decimal('320000')}, ...] + +result2 = pipeline.run("gold 등급 고객 목록을 이름 순으로 보여줘") +print(result2) +# 예시 출력: [{'name': '김철수'}, {'name': '박영희'}, {'name': '이민준'}] +``` + +### 6-B. OpenAI GPT + SQLite + +```python +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.llm import OpenAILLM +from lang2sql.integrations.db import SQLAlchemyDB + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=OpenAILLM(model="gpt-4o"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", +) + +result = pipeline.run("재고가 10개 미만인 상품 목록") +print(result) +# 예시 출력: [{'name': '무선 마우스', 'stock': 3}, {'name': '후드 집업', 'stock': 4}, ...] +``` + +### 6-C. PostgreSQL 연결 + +```python +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("postgresql://postgres:postgres@localhost:5432/postgres"), + db_dialect="postgresql", +) + +result = pipeline.run("gold 등급 고객의 총 주문 금액") +``` + +### 6-D. 지원 DB 방언(dialect) 목록 + +| `db_dialect` | 대상 DB | 적용 내용 | +|---|---|---| +| `"sqlite"` | SQLite | `strftime()` 사용, `MONTH()`/`YEAR()` 사용 안 함 | +| `"postgresql"` | PostgreSQL | `DATE_TRUNC`, `EXTRACT`, `INTERVAL` | +| `"mysql"` | MySQL | `MONTH()`, `YEAR()`, `DATE_FORMAT()` | +| `"bigquery"` | Google BigQuery | `DATE_TRUNC`, `EXTRACT`, `FORMAT_DATE` | +| `"duckdb"` | DuckDB | `DATE_TRUNC`, `EXTRACT`, `INTERVAL` | +| `None` / 생략 | 방언 무관 | 기본 프롬프트 (날짜 함수 미지정) | + +--- + +## 7. Hook으로 실행 추적하기 + +`MemoryHook`을 달면 각 컴포넌트가 **언제, 얼마나 걸렸는지, 무엇을 받고 반환했는지** 전부 기록됩니다. + +```python +from lang2sql import BaselineNL2SQL, MemoryHook +from lang2sql.integrations.llm import AnthropicLLM +from lang2sql.integrations.db import SQLAlchemyDB + +hook = MemoryHook() + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", + hook=hook, +) + +result = pipeline.run("주문 건수") + +for event in hook.snapshot(): + dur = f" {event.duration_ms:.1f}ms" if event.duration_ms else "" + print(f"[{event.name}] {event.component:20s} phase={event.phase}{dur}") +``` + +**예상 출력:** + +``` +[flow.run] BaselineNL2SQL phase=start +[component.run] KeywordRetriever phase=start +[component.run] KeywordRetriever phase=end 1.2ms +[component.run] SQLGenerator phase=start +[component.run] SQLGenerator phase=end 843.5ms +[component.run] SQLExecutor phase=start +[component.run] SQLExecutor phase=end 12.3ms +[flow.run] BaselineNL2SQL phase=end 857.0ms +``` + +### 이벤트 상세 정보 + +```python +events = hook.snapshot() + +gen_events = [e for e in events if e.component == "SQLGenerator"] +for e in gen_events: + print(f" phase : {e.phase}") + print(f" duration_ms : {e.duration_ms}") + print(f" input : {e.input_summary}") + print(f" output : {e.output_summary}") +``` + +### 에러 추적 + +```python +hook.clear() + +try: + pipeline.run("...") +except Exception: + pass + +error_events = [e for e in hook.snapshot() if e.phase == "error"] +for e in error_events: + print(f"컴포넌트: {e.component}") + print(f"에러: {e.error}") +``` + +### 커스텀 Hook 만들기 + +`on_event(event)` 하나만 구현하면 됩니다. + +```python +class PrintHook: + def on_event(self, event): + if event.phase == "start": + print(f"▶ {event.component} 시작") + elif event.phase == "end": + print(f"✓ {event.component} 완료 ({event.duration_ms:.0f}ms)") + elif event.phase == "error": + print(f"✗ {event.component} 오류: {event.error}") + + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", + hook=PrintHook(), +) + +result = pipeline.run("재고 부족 상품 목록") +``` + +**예상 출력:** + +``` +▶ KeywordRetriever 시작 +✓ KeywordRetriever 완료 (1ms) +▶ SQLGenerator 시작 +✓ SQLGenerator 완료 (921ms) +▶ SQLExecutor 시작 +✓ SQLExecutor 완료 (8ms) +``` + +--- + +## 8. 고급 사용 — 컴포넌트 직접 조합 + +파이프라인을 쓰지 않고 컴포넌트를 직접 사용할 수 있습니다. +각 단계 결과를 중간에 확인하거나 조건 분기를 넣고 싶을 때 유용합니다. + +```python +from lang2sql import KeywordRetriever, SQLGenerator, SQLExecutor +from lang2sql.integrations.llm import AnthropicLLM +from lang2sql.integrations.db import SQLAlchemyDB + +retriever = KeywordRetriever(catalog=CATALOG, top_n=3) +generator = SQLGenerator( + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db_dialect="sqlite", +) +executor = SQLExecutor(db=SQLAlchemyDB("sqlite:///sample.db")) + +query = "gold 등급 고객의 이번 달 주문 총액" + +# Step 1 — 관련 스키마 검색 +schemas = retriever.run(query) +print("검색된 스키마:") +for s in schemas: + print(f" - {s['name']}: {s.get('description', '')}") + +# Step 2 — SQL 생성 +sql = generator.run(query, schemas) +print(f"\n생성된 SQL:\n{sql}") + +# Step 3 — 실행 +rows = executor.run(sql) +print(f"\n결과: {rows}") +``` + +--- + +## 9. 커스터마이징 + +### 9-A. 시스템 프롬프트 교체 + +`db_dialect`에 없는 DB(Snowflake, Trino 등)나 특별한 SQL 스타일이 필요할 때 +`system_prompt`로 직접 지정합니다. `system_prompt`는 `db_dialect`보다 우선합니다. + +```python +from lang2sql import SQLGenerator +from lang2sql.integrations.llm import AnthropicLLM + +MY_PROMPT = """ +You are a Snowflake SQL expert. +- Use DATEADD, DATEDIFF for date arithmetic +- Use TO_DATE() for date casting +- Use CURRENT_DATE() for today +- Return ONLY the SQL inside a ```sql ... ``` block +""" + +generator = SQLGenerator( + llm=AnthropicLLM(model="claude-sonnet-4-6"), + system_prompt=MY_PROMPT, # db_dialect 대신 직접 지정 +) + +sql = generator.run("이번 달 주문 건수", schemas) +``` + +프롬프트 파일은 `src/lang2sql/components/generation/prompts/` 아래에 있습니다. +새로운 dialect를 추가하려면 해당 경로에 `{dialect}.md` 파일을 만들면 됩니다. + +### 9-B. 나만의 LLM 연결 + +`invoke(messages) -> str` 하나만 구현하면 어떤 LLM이든 연결됩니다. + +```python +# 예: LangChain 모델 그대로 사용 +from langchain_openai import ChatOpenAI + +class LangChainLLM: + def __init__(self, model: str): + self._llm = ChatOpenAI(model=model) + + def invoke(self, messages: list[dict]) -> str: + from langchain_core.messages import HumanMessage, SystemMessage + lc_msgs = [] + for m in messages: + if m["role"] == "system": + lc_msgs.append(SystemMessage(content=m["content"])) + else: + lc_msgs.append(HumanMessage(content=m["content"])) + return self._llm.invoke(lc_msgs).content + + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=LangChainLLM("gpt-4o"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", +) +``` + +### 9-C. 나만의 DB 연결 + +`execute(sql) -> list[dict]` 하나만 구현하면 됩니다. + +```python +# 예: pandas DataFrame을 DuckDB로 쿼리 +class PandasDB: + def __init__(self, dataframes: dict): + import duckdb + self._conn = duckdb.connect() + for name, df in dataframes.items(): + self._conn.register(name, df) + + def execute(self, sql: str) -> list[dict]: + result = self._conn.execute(sql).fetchdf() + return result.to_dict(orient="records") + + +import pandas as pd + +pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=PandasDB({"orders": pd.read_csv("orders.csv")}), + db_dialect="duckdb", +) +``` + +### 9-D. 커스텀 컴포넌트 만들기 + +`BaseComponent`를 상속하고 `_run()`만 구현합니다. +Hook, 에러 처리, 타이밍은 자동으로 처리됩니다. + +```python +from lang2sql.core.base import BaseComponent +from lang2sql.core.exceptions import ComponentError + + +class SQLValidator(BaseComponent): + """생성된 SQL에 위험한 키워드가 있으면 ComponentError를 발생시킵니다.""" + + FORBIDDEN = {"DROP", "DELETE", "TRUNCATE", "UPDATE", "INSERT", "ALTER"} + + def _run(self, sql: str) -> str: + tokens = set(sql.upper().split()) + bad = tokens & self.FORBIDDEN + if bad: + raise ComponentError(self.name, f"위험한 키워드 감지: {bad}") + return sql + + +validator = SQLValidator() +safe_sql = validator.run("SELECT COUNT(*) FROM orders") # OK +# validator.run("DROP TABLE orders") # ComponentError 발생 +``` + +### 9-E. 커스텀 플로우 만들기 + +`BaseFlow`를 상속하고 `_run()`에서 **순수 Python 제어흐름**으로 컴포넌트를 조합합니다. + +```python +from lang2sql.core.base import BaseFlow +from lang2sql.core.exceptions import ComponentError + + +class SafeNL2SQL(BaseFlow): + """검증 단계를 추가한 파이프라인.""" + + def __init__(self, *, catalog, llm, db, hook=None): + super().__init__(name="SafeNL2SQL", hook=hook) + self._retriever = KeywordRetriever(catalog=catalog, hook=hook) + self._generator = SQLGenerator(llm=llm, db_dialect="sqlite", hook=hook) + self._validator = SQLValidator(hook=hook) + self._executor = SQLExecutor(db=db, hook=hook) + + def _run(self, query: str): + schemas = self._retriever.run(query) + sql = self._generator.run(query, schemas) + sql = self._validator.run(sql) # 검증 통과 시에만 실행 + return self._executor.run(sql) + + +pipeline = SafeNL2SQL( + catalog=CATALOG, + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("sqlite:///sample.db"), +) +result = pipeline.run("주문 건수") +``` + +#### 재시도 로직 예시 + +```python +class RetryNL2SQL(BaseFlow): + """SQL 생성 실패 시 최대 3번 재시도합니다.""" + + def __init__(self, *, catalog, llm, db, hook=None): + super().__init__(name="RetryNL2SQL", hook=hook) + self._retriever = KeywordRetriever(catalog=catalog, hook=hook) + self._generator = SQLGenerator(llm=llm, db_dialect="sqlite", hook=hook) + self._executor = SQLExecutor(db=db, hook=hook) + + def _run(self, query: str): + schemas = self._retriever.run(query) + + last_error = None + for attempt in range(3): + try: + sql = self._generator.run(query, schemas) + return self._executor.run(sql) + except ComponentError as e: + last_error = e + print(f" 시도 {attempt + 1} 실패: {e}") + + raise last_error +``` + +--- + +## 10. 에러 처리 + +```python +from lang2sql import ComponentError, IntegrationMissingError, Lang2SQLError + +try: + result = pipeline.run("주문 건수") +except ComponentError as e: + # 특정 컴포넌트에서 발생한 에러 + print(f"컴포넌트 오류: {e.component}") + print(f"메시지: {e}") + if e.cause: + print(f"원인: {e.cause}") + +except IntegrationMissingError as e: + # 패키지 미설치 (anthropic, sqlalchemy 등) + print(f"패키지 미설치: {e}") + # → uv sync 또는 pip install lang2sql 재실행 + +except Lang2SQLError as e: + # 그 외 lang2sql 도메인 에러 + print(f"도메인 오류: {e}") +``` + +**에러 계층:** + +``` +Lang2SQLError +├── ComponentError — 컴포넌트 실행 실패 (component, cause 속성) +├── IntegrationMissingError — 선택적 패키지 미설치 +└── ValidationError — 검증 실패 +``` + +**자주 발생하는 에러:** + +| 에러 | 원인 | 해결 | +|------|------|------| +| `IntegrationMissingError: anthropic` | anthropic 미설치 | `uv sync` | +| `OpenAIError: api_key must be set` | 환경변수 미설정 | `export OPENAI_API_KEY=...` | +| `no such table: products` | DB URL 오타 또는 sample.db 미생성 | `python scripts/setup_sample_db.py` 실행 | +| `no such function: MONTH` | SQLite에서 MySQL 함수 사용 | `db_dialect="sqlite"` 지정 | + +--- + +## 11. 전체 기능 체크리스트 + +실제 API 키나 DB 없이도 FakeLLM/FakeDB로 전체 흐름을 확인할 수 있습니다. + +```python +""" +lang2sql 전체 기능 체크리스트 +아래 코드를 순서대로 실행하면 모든 기능을 테스트할 수 있습니다. +""" + +# ── 0. 의존성 없는 Fake 구현 ────────────────────────────────────────────────── + +class FakeLLM: + """실제 API 키 없이 테스트할 수 있는 가짜 LLM.""" + def invoke(self, messages): + user_msg = next(m["content"] for m in reversed(messages) if m["role"] == "user") + if "주문" in user_msg: + return "```sql\nSELECT COUNT(*) AS cnt FROM orders\n```" + if "고객" in user_msg: + return "```sql\nSELECT name FROM customers ORDER BY name\n```" + return "```sql\nSELECT 1\n```" + +class FakeDB: + """실제 DB 없이 테스트할 수 있는 가짜 DB.""" + _data = { + "SELECT COUNT(*) AS cnt FROM orders": [{"cnt": 44}], + "SELECT name FROM customers ORDER BY name": [{"name": "김철수"}, {"name": "박영희"}], + } + def execute(self, sql): + return self._data.get(sql, [{"result": "ok"}]) + + +# ── 1. 카탈로그 정의 ────────────────────────────────────────────────────────── + +from lang2sql import CatalogEntry + +catalog: list[CatalogEntry] = [ + { + "name": "orders", + "description": "주문 정보 테이블", + "columns": {"order_id": "PK", "customer_id": "FK", "amount": "금액"}, + }, + { + "name": "customers", + "description": "고객 마스터 데이터", + "columns": {"customer_id": "PK", "name": "이름", "grade": "등급"}, + }, +] + + +# ── 2. KeywordRetriever 단독 테스트 ─────────────────────────────────────────── + +from lang2sql import KeywordRetriever + +retriever = KeywordRetriever(catalog=catalog, top_n=2) +schemas = retriever.run("주문 건수 조회") +print("✓ KeywordRetriever") +print(f" 검색 결과: {[s['name'] for s in schemas]}") +# 예상: ['orders'] + + +# ── 3. SQLGenerator 단독 테스트 ─────────────────────────────────────────────── + +from lang2sql import SQLGenerator + +generator = SQLGenerator(llm=FakeLLM()) +sql = generator.run("주문 건수", schemas) +print("\n✓ SQLGenerator") +print(f" 생성 SQL: {sql}") +# 예상: SELECT COUNT(*) AS cnt FROM orders + + +# ── 4. SQLExecutor 단독 테스트 ──────────────────────────────────────────────── + +from lang2sql import SQLExecutor + +executor = SQLExecutor(db=FakeDB()) +rows = executor.run(sql) +print("\n✓ SQLExecutor") +print(f" 결과: {rows}") +# 예상: [{'cnt': 44}] + + +# ── 5. BaselineNL2SQL (기본 파이프라인) 테스트 ──────────────────────────────── + +from lang2sql import BaselineNL2SQL + +pipeline = BaselineNL2SQL(catalog=catalog, llm=FakeLLM(), db=FakeDB()) +result = pipeline.run("주문 건수") +print("\n✓ BaselineNL2SQL") +print(f" 결과: {result}") +# 예상: [{'cnt': 44}] + + +# ── 6. db_dialect 테스트 ────────────────────────────────────────────────────── + +pipeline_sqlite = BaselineNL2SQL( + catalog=catalog, llm=FakeLLM(), db=FakeDB(), db_dialect="sqlite" +) +result = pipeline_sqlite.run("주문 건수") +print("\n✓ db_dialect='sqlite'") +print(f" 결과: {result}") + + +# ── 7. MemoryHook 트레이싱 테스트 ───────────────────────────────────────────── + +from lang2sql import MemoryHook + +hook = MemoryHook() +pipeline_traced = BaselineNL2SQL(catalog=catalog, llm=FakeLLM(), db=FakeDB(), hook=hook) +pipeline_traced.run("주문 건수") + +events = hook.snapshot() +print("\n✓ MemoryHook 이벤트") +for e in events: + dur = f" {e.duration_ms:.1f}ms" if e.duration_ms else "" + print(f" [{e.name}] {e.component:20s} phase={e.phase}{dur}") + +component_starts = [e for e in events if e.name == "component.run" and e.phase == "start"] +print(f"\n component start 이벤트 수: {len(component_starts)} (예상: 3)") + + +# ── 8. 에러 처리 테스트 ──────────────────────────────────────────────────────── + +from lang2sql import ComponentError + +class BrokenLLM: + def invoke(self, messages): + return "SQL 없이 일반 텍스트만 반환" # 코드블록 없음 + +try: + bad_pipeline = BaselineNL2SQL(catalog=catalog, llm=BrokenLLM(), db=FakeDB()) + bad_pipeline.run("주문") +except ComponentError as e: + print(f"\n✓ ComponentError 정상 발생") + print(f" 컴포넌트: {e.component}") + print(f" 메시지: {e}") + + +# ── 9. 커스텀 Hook 테스트 ───────────────────────────────────────────────────── + +class PrintHook: + def on_event(self, event): + if event.phase == "start": + print(f" ▶ {event.component}") + elif event.phase == "end": + print(f" ✓ {event.component} ({event.duration_ms:.0f}ms)") + +print("\n✓ 커스텀 PrintHook") +BaselineNL2SQL(catalog=catalog, llm=FakeLLM(), db=FakeDB(), hook=PrintHook()).run("고객 목록") + + +# ── 10. 커스텀 컴포넌트 테스트 ───────────────────────────────────────────────── + +from lang2sql.core.base import BaseComponent + +class UpperCaseSQL(BaseComponent): + """SQL을 대문자로 변환하는 후처리 컴포넌트.""" + def _run(self, sql: str) -> str: + return sql.upper() + +upper = UpperCaseSQL() +print(f"\n✓ 커스텀 BaseComponent") +print(f" 결과: {upper.run('select 1')}") +# 예상: SELECT 1 + + +# ── 11. public API import 확인 ──────────────────────────────────────────────── + +from lang2sql import ( + CatalogEntry, LLMPort, DBPort, + KeywordRetriever, SQLGenerator, SQLExecutor, + BaselineNL2SQL, + TraceHook, MemoryHook, NullHook, + Lang2SQLError, ComponentError, IntegrationMissingError, +) +print("\n✓ 모든 public import 성공") + +print("\n" + "="*50) +print("모든 체크리스트 통과! lang2sql 준비 완료.") +print("="*50) +``` + +--- + +## 참고: 아키텍처 한눈에 보기 + +``` +BaselineNL2SQL.run("자연어 질문") +│ +├── KeywordRetriever.run(query) +│ └── BM25 키워드 검색 → list[CatalogEntry] +│ +├── SQLGenerator.run(query, schemas) +│ ├── _load_prompt(db_dialect) → prompts/{dialect}.md 로드 +│ ├── _build_context(schemas) → 스키마 텍스트 구성 +│ ├── llm.invoke(messages) → LLM 호출 +│ └── _extract_sql(response) → ```sql...``` 파싱 +│ +└── SQLExecutor.run(sql) + └── db.execute(sql) → list[dict] + +모든 단계에서 Hook이 start / end / error 이벤트를 기록합니다. +``` + +**컴포넌트 확장 포인트:** + +| 인터페이스 | 구현할 메서드 | 용도 | +|-----------|-------------|------| +| `LLMPort` | `invoke(messages) -> str` | LLM 백엔드 교체 | +| `DBPort` | `execute(sql) -> list[dict]` | DB 백엔드 교체 | +| `BaseComponent` | `_run(*args) -> Any` | 새 컴포넌트 추가 | +| `BaseFlow` | `_run(*args) -> Any` | 새 파이프라인 조합 | +| `TraceHook` | `on_event(event) -> None` | 커스텀 모니터링 | + +**프롬프트 파일 위치:** + +``` +src/lang2sql/components/generation/prompts/ +├── default.md ← db_dialect 미지정 시 +├── sqlite.md +├── postgresql.md +├── mysql.md +├── bigquery.md +└── duckdb.md +``` diff --git a/scripts/setup_sample_db.py b/scripts/setup_sample_db.py new file mode 100644 index 0000000..65c9b4a --- /dev/null +++ b/scripts/setup_sample_db.py @@ -0,0 +1,267 @@ +""" +샘플 데이터베이스 세팅 스크립트 +================================ +quickstart.md 튜토리얼용 샘플 데이터를 SQLite 또는 PostgreSQL에 생성합니다. + +사용법 +------ +# SQLite (기본, 별도 서버 불필요) +python scripts/setup_sample_db.py + +# PostgreSQL (Docker 컨테이너가 먼저 실행 중이어야 함) +python scripts/setup_sample_db.py --db postgres +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta +import random + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + create_engine, + text, +) +from sqlalchemy.orm import DeclarativeBase, Session + +# ── 모델 정의 ──────────────────────────────────────────────────────────────── + + +class Base(DeclarativeBase): + pass + + +class Customer(Base): + __tablename__ = "customers" + customer_id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + email = Column(String(200), nullable=False, unique=True) + joined_at = Column(DateTime, nullable=False) + grade = Column(String(20), nullable=False) # bronze / silver / gold + + +class Product(Base): + __tablename__ = "products" + product_id = Column(Integer, primary_key=True) + name = Column(String(200), nullable=False) + category = Column(String(100), nullable=False) # electronics / clothing / food + price = Column(Numeric(10, 2), nullable=False) + stock = Column(Integer, nullable=False) + + +class Order(Base): + __tablename__ = "orders" + order_id = Column(Integer, primary_key=True) + customer_id = Column(Integer, ForeignKey("customers.customer_id"), nullable=False) + order_date = Column(DateTime, nullable=False) + amount = Column(Numeric(10, 2), nullable=False) + status = Column( + String(20), nullable=False + ) # pending / confirmed / shipped / cancelled + + +class OrderItem(Base): + __tablename__ = "order_items" + item_id = Column(Integer, primary_key=True) + order_id = Column(Integer, ForeignKey("orders.order_id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.product_id"), nullable=False) + quantity = Column(Integer, nullable=False) + unit_price = Column(Numeric(10, 2), nullable=False) + + +# ── 샘플 데이터 ────────────────────────────────────────────────────────────── + +CUSTOMERS = [ + (1, "김철수", "chulsoo.kim@example.com", "2022-03-15", "gold"), + (2, "박영희", "younghee.park@example.com", "2022-07-22", "gold"), + (3, "이민준", "minjun.lee@example.com", "2023-01-10", "gold"), + (4, "최수연", "sooyeon.choi@example.com", "2023-04-05", "silver"), + (5, "정우진", "woojin.jung@example.com", "2023-06-18", "silver"), + (6, "강지훈", "jihoon.kang@example.com", "2023-08-30", "silver"), + (7, "윤서현", "seohyun.yoon@example.com", "2023-10-12", "bronze"), + (8, "임도현", "dohyun.lim@example.com", "2024-01-25", "bronze"), + (9, "한소희", "sohee.han@example.com", "2024-03-08", "bronze"), + (10, "오준혁", "junhyuk.oh@example.com", "2024-05-17", "bronze"), +] + +PRODUCTS = [ + (1, "무선 마우스", "electronics", 29900, 3), + (2, "기계식 키보드", "electronics", 89000, 15), + (3, "27인치 모니터", "electronics", 320000, 8), + (4, "USB-C 허브", "electronics", 45000, 7), + (5, "노이즈캔슬링 이어폰", "electronics", 159000, 22), + (6, "면 반팔 티셔츠", "clothing", 19900, 50), + (7, "청바지", "clothing", 59900, 30), + (8, "운동화", "clothing", 89000, 9), + (9, "후드 집업", "clothing", 69900, 4), + (10, "유기농 아몬드", "food", 18900, 100), + (11, "그래놀라", "food", 12500, 60), + (12, "프로틴 바 (12개)", "food", 24900, 45), +] + + +def _make_orders_and_items(): + """지난 3개월간 주문 데이터를 생성합니다.""" + today = datetime(2026, 2, 24) + orders, items = [], [] + item_id = 1 + order_id = 1 + + random.seed(42) + + for cid, _, _, _, grade in CUSTOMERS: + # 등급별 주문 빈도 + n_orders = {"gold": 8, "silver": 4, "bronze": 2}[grade] + + for _ in range(n_orders): + days_ago = random.randint(1, 90) + order_date = today - timedelta(days=days_ago) + status = random.choice( + ["confirmed", "confirmed", "shipped", "pending", "cancelled"] + ) + + # 주문에 1~3개 상품 + n_items = random.randint(1, 3) + selected = random.sample(PRODUCTS, n_items) + total = 0 + + for pid, _, _, price, _ in selected: + qty = random.randint(1, 3) + unit_price = price + total += unit_price * qty + items.append((item_id, order_id, pid, qty, unit_price)) + item_id += 1 + + orders.append((order_id, cid, order_date, total, status)) + order_id += 1 + + return orders, items + + +# ── 세팅 함수 ──────────────────────────────────────────────────────────────── + + +def setup(db_url: str) -> None: + print(f"연결 중: {db_url}") + engine = create_engine(db_url, echo=False) + + # 기존 테이블 삭제 후 재생성 + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + print("테이블 생성 완료: customers, products, orders, order_items") + + orders, items = _make_orders_and_items() + + with Session(engine) as session: + # customers + session.add_all( + [ + Customer( + customer_id=cid, + name=name, + email=email, + joined_at=datetime.fromisoformat(joined_at), + grade=grade, + ) + for cid, name, email, joined_at, grade in CUSTOMERS + ] + ) + + # products + session.add_all( + [ + Product( + product_id=pid, name=name, category=cat, price=price, stock=stock + ) + for pid, name, cat, price, stock in PRODUCTS + ] + ) + + # orders + session.add_all( + [ + Order( + order_id=oid, + customer_id=cid, + order_date=odate, + amount=amount, + status=status, + ) + for oid, cid, odate, amount, status in orders + ] + ) + + # order_items + session.add_all( + [ + OrderItem( + item_id=iid, + order_id=oid, + product_id=pid, + quantity=qty, + unit_price=up, + ) + for iid, oid, pid, qty, up in items + ] + ) + + session.commit() + + print(f" 고객: {len(CUSTOMERS)}명") + print(f" 상품: {len(PRODUCTS)}개") + print(f" 주문: {len(orders)}건") + print(f" 주문 항목: {len(items)}개") + print() + + # 간단 검증 쿼리 + with engine.connect() as conn: + cnt = conn.execute(text("SELECT COUNT(*) FROM orders")).scalar() + gold = conn.execute( + text("SELECT COUNT(*) FROM customers WHERE grade = 'gold'") + ).scalar() + low_stock = conn.execute( + text("SELECT name, stock FROM products WHERE stock < 10 ORDER BY stock") + ).fetchall() + + print("─── 검증 쿼리 결과 ───────────────────────────────") + print(f" 전체 주문 수: {cnt}건") + print(f" gold 등급 고객 수: {gold}명") + print(f" 재고 10개 미만 상품: {len(low_stock)}개") + for name, stock in low_stock: + print(f" - {name}: {stock}개") + print("─────────────────────────────────────────────────") + print() + print("완료! 아래 URL로 quickstart.md를 따라해 보세요:") + print(f" {db_url}") + + +# ── CLI ────────────────────────────────────────────────────────────────────── + +DB_URLS = { + "sqlite": "sqlite:///sample.db", + "postgres": "postgresql://postgres:postgres@localhost:5432/postgres", +} + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="quickstart.md 샘플 DB 세팅") + parser.add_argument( + "--db", + choices=["sqlite", "postgres"], + default="sqlite", + help="대상 DB (기본값: sqlite)", + ) + parser.add_argument( + "--url", + default=None, + help="직접 SQLAlchemy URL 지정 (--db 보다 우선)", + ) + args = parser.parse_args() + + url = args.url or DB_URLS[args.db] + setup(url) diff --git a/src/lang2sql/__init__.py b/src/lang2sql/__init__.py index e69de29..cef1f74 100644 --- a/src/lang2sql/__init__.py +++ b/src/lang2sql/__init__.py @@ -0,0 +1,30 @@ +from .components.execution.sql_executor import SQLExecutor +from .components.generation.sql_generator import SQLGenerator +from .components.retrieval.keyword import KeywordRetriever +from .core.catalog import CatalogEntry +from .core.exceptions import ComponentError, IntegrationMissingError, Lang2SQLError +from .core.hooks import MemoryHook, NullHook, TraceHook +from .core.ports import DBPort, LLMPort +from .flows.nl2sql import BaselineNL2SQL + +__all__ = [ + # Data types + "CatalogEntry", + # Ports (protocols) + "LLMPort", + "DBPort", + # Components + "KeywordRetriever", + "SQLGenerator", + "SQLExecutor", + # Flows + "BaselineNL2SQL", + # Hooks + "TraceHook", + "MemoryHook", + "NullHook", + # Exceptions + "Lang2SQLError", + "ComponentError", + "IntegrationMissingError", +] diff --git a/src/lang2sql/components/execution/__init__.py b/src/lang2sql/components/execution/__init__.py new file mode 100644 index 0000000..a6f15b9 --- /dev/null +++ b/src/lang2sql/components/execution/__init__.py @@ -0,0 +1,3 @@ +from .sql_executor import SQLExecutor + +__all__ = ["SQLExecutor"] diff --git a/src/lang2sql/components/execution/sql_executor.py b/src/lang2sql/components/execution/sql_executor.py new file mode 100644 index 0000000..f7c9a28 --- /dev/null +++ b/src/lang2sql/components/execution/sql_executor.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any, Optional + +from ...core.base import BaseComponent +from ...core.exceptions import ComponentError +from ...core.hooks import TraceHook +from ...core.ports import DBPort + + +class SQLExecutor(BaseComponent): + """Executes a SQL string and returns rows as a list of dicts.""" + + def __init__( + self, + *, + db: DBPort, + name: Optional[str] = None, + hook: Optional[TraceHook] = None, + ) -> None: + super().__init__(name=name or "SQLExecutor", hook=hook) + self._db = db + + def _run(self, sql: str) -> list[dict[str, Any]]: + if not sql or not sql.strip(): + raise ComponentError(self.name, "sql must not be empty.") + return self._db.execute(sql) diff --git a/src/lang2sql/components/generation/__init__.py b/src/lang2sql/components/generation/__init__.py new file mode 100644 index 0000000..c2a86fe --- /dev/null +++ b/src/lang2sql/components/generation/__init__.py @@ -0,0 +1,3 @@ +from .sql_generator import SQLGenerator + +__all__ = ["SQLGenerator"] diff --git a/src/lang2sql/components/generation/prompts/bigquery.md b/src/lang2sql/components/generation/prompts/bigquery.md new file mode 100644 index 0000000..0e86d58 --- /dev/null +++ b/src/lang2sql/components/generation/prompts/bigquery.md @@ -0,0 +1,16 @@ +You are a Google BigQuery SQL expert. Generate a SQL query that runs correctly on BigQuery. + +BigQuery rules: +- Use DATE_TRUNC(col, MONTH) for month truncation +- Use EXTRACT(MONTH FROM col) for month extraction +- Use CURRENT_DATE() for today, CURRENT_TIMESTAMP() for now +- Use DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH) for last month +- Use FORMAT_DATE('%Y-%m', col) for date formatting +- Use TIMESTAMP_TRUNC for timestamp operations +- Qualify table names with project and dataset when applicable: `project.dataset.table` + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/prompts/default.md b/src/lang2sql/components/generation/prompts/default.md new file mode 100644 index 0000000..a1a17df --- /dev/null +++ b/src/lang2sql/components/generation/prompts/default.md @@ -0,0 +1,7 @@ +You are a SQL expert. Given a natural language question and relevant table schemas, write a single SQL query that answers the question. + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/prompts/duckdb.md b/src/lang2sql/components/generation/prompts/duckdb.md new file mode 100644 index 0000000..ac22f88 --- /dev/null +++ b/src/lang2sql/components/generation/prompts/duckdb.md @@ -0,0 +1,14 @@ +You are a DuckDB SQL expert. Generate a SQL query that runs correctly on DuckDB. + +DuckDB date/time rules: +- Use DATE_TRUNC('month', col) for month truncation +- Use EXTRACT(MONTH FROM col) for month extraction +- Use NOW() or CURRENT_DATE for current datetime/date +- Use NOW() - INTERVAL '1 month' for last month +- Use strftime(col, '%Y-%m') for date formatting (DuckDB: column first, format second) + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/prompts/mysql.md b/src/lang2sql/components/generation/prompts/mysql.md new file mode 100644 index 0000000..dc4024a --- /dev/null +++ b/src/lang2sql/components/generation/prompts/mysql.md @@ -0,0 +1,14 @@ +You are a MySQL SQL expert. Generate a SQL query that runs correctly on MySQL. + +MySQL date/time rules: +- Use MONTH(col), YEAR(col), DAY(col) for date part extraction +- Use NOW() or CURDATE() for current datetime/date +- Use DATE_FORMAT(col, '%Y-%m') for date formatting +- Use DATE_SUB(NOW(), INTERVAL 1 MONTH) for last month +- Use DATEDIFF(date1, date2) for date differences + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/prompts/postgresql.md b/src/lang2sql/components/generation/prompts/postgresql.md new file mode 100644 index 0000000..e99e27e --- /dev/null +++ b/src/lang2sql/components/generation/prompts/postgresql.md @@ -0,0 +1,14 @@ +You are a PostgreSQL SQL expert. Generate a SQL query that runs correctly on PostgreSQL. + +PostgreSQL date/time rules: +- Use DATE_TRUNC('month', col) to truncate to month +- Use EXTRACT(MONTH FROM col) or DATE_PART('month', col) for month extraction +- Use NOW() or CURRENT_TIMESTAMP for current datetime, CURRENT_DATE for today +- Use NOW() - INTERVAL '1 month' for last month +- Use TO_CHAR(col, 'YYYY-MM') for date formatting + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/prompts/sqlite.md b/src/lang2sql/components/generation/prompts/sqlite.md new file mode 100644 index 0000000..8899b84 --- /dev/null +++ b/src/lang2sql/components/generation/prompts/sqlite.md @@ -0,0 +1,14 @@ +You are a SQLite SQL expert. Generate a SQL query that runs correctly on SQLite. + +SQLite date/time rules (CRITICAL — SQLite does NOT support MySQL/PostgreSQL date functions): +- Use strftime('%Y-%m', col) = strftime('%Y-%m', 'now') for current month comparison +- Use strftime('%Y', col) = strftime('%Y', 'now') for current year comparison +- Use DATE('now') for today, DATE('now', '-1 month') for last month +- Do NOT use MONTH(), YEAR(), DAY(), DATE_FORMAT(), NOW(), CURDATE(), DATEDIFF() +- For date arithmetic use DATE(col, '+N days') or DATE(col, '-N months') + +Rules: +- Return ONLY the SQL query inside a ```sql ... ``` code block +- Do not include any explanation +- Use only the tables and columns provided in the schemas +- Generate SELECT queries only (no INSERT, UPDATE, DELETE, DROP, ALTER) diff --git a/src/lang2sql/components/generation/sql_generator.py b/src/lang2sql/components/generation/sql_generator.py new file mode 100644 index 0000000..eba4841 --- /dev/null +++ b/src/lang2sql/components/generation/sql_generator.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional + +from ...core.base import BaseComponent +from ...core.catalog import CatalogEntry +from ...core.exceptions import ComponentError +from ...core.hooks import TraceHook +from ...core.ports import LLMPort + +_PROMPT_DIR = Path(__file__).parent / "prompts" + +_SUPPORTED_DIALECTS = {"default", "sqlite", "postgresql", "mysql", "bigquery", "duckdb"} + + +def _load_prompt(dialect: str) -> str: + path = _PROMPT_DIR / f"{dialect}.md" + if not path.exists(): + raise ValueError( + f"Unsupported dialect: {dialect!r}. " + f"Available: {sorted(_SUPPORTED_DIALECTS)}" + ) + return path.read_text(encoding="utf-8").strip() + + +class SQLGenerator(BaseComponent): + """Generates a SQL string from a natural language query and schema context. + + System prompt priority (highest to lowest): + 1. ``system_prompt`` — explicit string passed by the caller + 2. ``db_dialect`` — loads the matching ``prompts/{dialect}.md`` + 3. default — loads ``prompts/default.md`` + """ + + def __init__( + self, + *, + llm: LLMPort, + db_dialect: Optional[str] = None, + system_prompt: Optional[str] = None, + name: Optional[str] = None, + hook: Optional[TraceHook] = None, + ) -> None: + super().__init__(name=name or "SQLGenerator", hook=hook) + self._llm = llm + + if system_prompt is not None: + self._system_prompt = system_prompt + elif db_dialect is not None: + self._system_prompt = _load_prompt(db_dialect) + else: + self._system_prompt = _load_prompt("default") + + def _run(self, query: str, schemas: list[CatalogEntry]) -> str: + context = self._build_context(schemas) + messages = [ + {"role": "system", "content": self._system_prompt}, + {"role": "user", "content": f"Schemas:\n{context}\n\nQuestion: {query}"}, + ] + response = self._llm.invoke(messages) + sql = self._extract_sql(response) + if not sql: + raise ComponentError( + self.name, + "LLM response did not contain a ```sql ... ``` code block.", + ) + return sql + + def _build_context(self, schemas: list[CatalogEntry]) -> str: + parts: list[str] = [] + for entry in schemas: + name = entry.get("name", "(unnamed)") + description = entry.get("description", "") + columns = entry.get("columns", {}) + + lines = [f"Table: {name}"] + if description: + lines.append(f" Description: {description}") + if columns: + lines.append(" Columns:") + for col, col_desc in columns.items(): + lines.append(f" - {col}: {col_desc}") + parts.append("\n".join(lines)) + return "\n\n".join(parts) + + @staticmethod + def _extract_sql(text: str) -> str: + match = re.search(r"```sql\s*(.*?)```", text, re.DOTALL | re.IGNORECASE) + if not match: + return "" + sql = match.group(1).strip() + sql = sql.rstrip(";").rstrip() + return sql diff --git a/src/lang2sql/components/retrieval/__init__.py b/src/lang2sql/components/retrieval/__init__.py index 3d9f791..d912694 100644 --- a/src/lang2sql/components/retrieval/__init__.py +++ b/src/lang2sql/components/retrieval/__init__.py @@ -1,3 +1,4 @@ from .keyword import KeywordRetriever +from ...core.catalog import CatalogEntry -__all__ = ["KeywordRetriever"] +__all__ = ["KeywordRetriever", "CatalogEntry"] diff --git a/src/lang2sql/components/retrieval/keyword.py b/src/lang2sql/components/retrieval/keyword.py index 1444086..bf29166 100644 --- a/src/lang2sql/components/retrieval/keyword.py +++ b/src/lang2sql/components/retrieval/keyword.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Optional from ...core.base import BaseComponent -from ...core.context import RunContext +from ...core.catalog import CatalogEntry from ...core.hooks import TraceHook from ._bm25 import _BM25Index @@ -15,8 +15,8 @@ class KeywordRetriever(BaseComponent): BM25-based keyword retriever over a table catalog. Indexes catalog entries at init time (in-memory). - On each call, reads ``run.query`` and writes top-N matches - into ``run.schema_selected``. + On each call, scores entries against the given query and returns + the top-N matches as a ranked list. Args: catalog: List of table dicts. Each dict should have at minimum @@ -33,14 +33,14 @@ class KeywordRetriever(BaseComponent): retriever = KeywordRetriever(catalog=[ {"name": "orders", "description": "주문 정보 테이블"}, ]) - run = retriever(RunContext(query="주문 조회")) - print(run.schema_selected) # [{"name": "orders", ...}] + results = retriever("주문 조회") + print(results) # [{"name": "orders", ...}] """ def __init__( self, *, - catalog: list[dict[str, Any]], + catalog: list[dict], top_n: int = 5, index_fields: Optional[list[str]] = None, name: Optional[str] = None, @@ -54,22 +54,21 @@ def __init__( ) self._index = _BM25Index(catalog, self._index_fields) - def run(self, run: RunContext) -> RunContext: + def _run(self, query: str) -> list[CatalogEntry]: """ - Search the catalog with BM25 and store results in ``run.schema_selected``. + Search the catalog with BM25 and return top-N matching entries. Args: - run: Current RunContext. Reads ``run.query``. + query: Natural language search query. Returns: - The same RunContext with ``run.schema_selected`` set to a - ranked list[dict] (BM25 score descending). Empty list if no match. + Ranked list of matching catalog entries (BM25 score descending). + Empty list if no match or catalog is empty. """ if not self._catalog: - run.schema_selected = [] - return run + return [] - scores = self._index.score(run.query) + scores = self._index.score(query) # Pair each catalog entry with its score, sort descending ranked = sorted( @@ -79,7 +78,4 @@ def run(self, run: RunContext) -> RunContext: ) # Return up to top_n entries that have a positive score - results = [entry for score, entry in ranked[: self._top_n] if score > 0.0] - - run.schema_selected = results - return run + return [entry for score, entry in ranked[: self._top_n] if score > 0.0] diff --git a/src/lang2sql/core/base.py b/src/lang2sql/core/base.py index 34e6de0..7a3c0d8 100644 --- a/src/lang2sql/core/base.py +++ b/src/lang2sql/core/base.py @@ -3,8 +3,6 @@ from abc import ABC, abstractmethod from typing import Any, Optional -from .exceptions import ContractError -from .context import RunContext from .exceptions import ComponentError, Lang2SQLError from .hooks import Event, NullHook, TraceHook, ms, now, summarize @@ -17,6 +15,10 @@ class BaseComponent(ABC): - Components are plain callables (define-by-run friendly). - No enforced global state schema. - Hooks provide observability without requiring a graph engine. + + Public entry point is ``run()``. ``__call__`` is a convenience alias so + components can be used as plain callables (e.g. in SequentialFlow steps). + Subclasses implement ``_run()``. """ def __init__( @@ -26,6 +28,9 @@ def __init__( self.hook: TraceHook = hook or NullHook() def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self.run(*args, **kwargs) + + def run(self, *args: Any, **kwargs: Any) -> Any: t0 = now() self.hook.on_event( Event( @@ -38,17 +43,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: ) try: - out = self.run(*args, **kwargs) - - if ( - args - and isinstance(args[0], RunContext) - and not isinstance(out, RunContext) - ): - got = "None" if out is None else type(out).__name__ - raise ContractError( - f"{self.name} must return RunContext (got {got}). Did you forget `return run`?" - ) + out = self._run(*args, **kwargs) t1 = now() self.hook.on_event( @@ -98,7 +93,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: ) from e @abstractmethod - def run(self, *args: Any, **kwargs: Any) -> Any: + def _run(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError @@ -109,6 +104,9 @@ class BaseFlow(ABC): Define-by-run: - Users write control-flow in pure Python (if/for/while). - We provide parts + presets, not a graph engine. + + Public entry point is ``run()``. ``__call__`` is a convenience alias. + Subclasses implement ``_run()``. """ def __init__( @@ -118,13 +116,16 @@ def __init__( self.hook: TraceHook = hook or NullHook() def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self.run(*args, **kwargs) + + def run(self, *args: Any, **kwargs: Any) -> Any: t0 = now() self.hook.on_event( Event(name="flow.run", component=self.name, phase="start", ts=t0) ) try: - out = self.run(*args, **kwargs) + out = self._run(*args, **kwargs) t1 = now() self.hook.on_event( Event( @@ -166,26 +167,5 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: raise @abstractmethod - def run(self, *args: Any, **kwargs: Any) -> Any: + def _run(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError - - def run_query(self, query: str) -> RunContext: - """ - Convenience entrypoint. - - Creates a RunContext(query=...) and runs the flow. - Intended for demos / quickstart. - - Args: - query: Natural language question. - - Returns: - RunContext after running this flow. - """ - out = self.run(RunContext(query=query)) - if not isinstance(out, RunContext): - got = "None" if out is None else type(out).__name__ - raise TypeError( - f"{self.name}.run(run: RunContext) must return RunContext, got {got}" - ) - return out diff --git a/src/lang2sql/core/catalog.py b/src/lang2sql/core/catalog.py new file mode 100644 index 0000000..469a250 --- /dev/null +++ b/src/lang2sql/core/catalog.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import TypedDict + + +class CatalogEntry(TypedDict, total=False): + name: str + description: str + columns: dict[str, str] diff --git a/src/lang2sql/core/ports.py b/src/lang2sql/core/ports.py index 7452d4c..fa6b3e4 100644 --- a/src/lang2sql/core/ports.py +++ b/src/lang2sql/core/ports.py @@ -1,6 +1,18 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol + + +class LLMPort(Protocol): + """Abstracts LLM backends (Anthropic, OpenAI, etc.).""" + + def invoke(self, messages: list[dict[str, str]]) -> str: ... + + +class DBPort(Protocol): + """Abstracts database backends (SQLAlchemy, etc.).""" + + def execute(self, sql: str) -> list[dict[str, Any]]: ... class EmbeddingPort(Protocol): diff --git a/src/lang2sql/flows/__init__.py b/src/lang2sql/flows/__init__.py new file mode 100644 index 0000000..f50eb0e --- /dev/null +++ b/src/lang2sql/flows/__init__.py @@ -0,0 +1,3 @@ +from .nl2sql import BaselineNL2SQL + +__all__ = ["BaselineNL2SQL"] diff --git a/src/lang2sql/flows/baseline.py b/src/lang2sql/flows/baseline.py index b088e7d..43b7dc1 100644 --- a/src/lang2sql/flows/baseline.py +++ b/src/lang2sql/flows/baseline.py @@ -1,53 +1,31 @@ from __future__ import annotations -from typing import Iterable, Protocol, Sequence +from typing import Any, Callable, Sequence from ..core.base import BaseFlow -from ..core.context import RunContext -from ..core.exceptions import ContractError - - -class RunComponent(Protocol): - """ - Protocol for a pipeline component. - - A component is a callable that takes a RunContext and must return a RunContext. - This enforces a strict "RunContext in -> RunContext out" contract. - - Args: - run: The current RunContext. - - Returns: - A RunContext instance (usually the same object, mutated in-place). - """ - - def __call__(self, run: RunContext) -> RunContext: ... class SequentialFlow(BaseFlow): """ A minimal sequential pipeline runner (define-by-run style). - This flow runs `steps` in order. Each step must follow the contract: - RunContext -> RunContext - - Notes: - - Steps may mutate `run` in-place and still must return `run`. - - Returning None or a non-RunContext value is treated as a contract bug and fails fast. + This flow runs `steps` in order, passing the output of each step + as the input to the next. Each step is a plain callable — no shared + state bag is required. Args: - steps: Ordered sequence of pipeline components. + steps: Ordered sequence of callables. name: Optional name override for tracing/logging. hook: Optional TraceHook. If not provided, a NullHook is used by BaseFlow. Returns: - The final RunContext after running all steps. + The final value after running all steps. """ def __init__( self, *, - steps: Sequence[RunComponent], + steps: Sequence[Callable[..., Any]], name: str | None = None, hook=None, ) -> None: @@ -55,7 +33,7 @@ def __init__( Initialize the flow with an ordered list of steps. Args: - steps: Ordered sequence of pipeline components. Must be non-empty. + steps: Ordered sequence of callables. Must be non-empty. name: Optional name override. hook: Optional TraceHook used by BaseFlow for flow-level events. @@ -65,77 +43,35 @@ def __init__( super().__init__(name=name or "SequentialFlow", hook=hook) if not steps: raise ValueError("SequentialFlow requires at least one step.") - self.steps: list[RunComponent] = list(steps) + self.steps: list[Callable[..., Any]] = list(steps) - @staticmethod - def _apply(step: RunComponent, run: RunContext) -> RunContext: + def _run(self, value: Any) -> Any: """ - Apply a single step with strict contract validation. + Execute the flow by passing `value` through each step in order. Args: - step: A pipeline component (callable). - run: The current RunContext. + value: The initial input value. Returns: - The RunContext returned by the step. - - Raises: - ContractError: If the step returns None or a non-RunContext value. + The final value after running all configured steps. """ - out = step(run) + for step in self.steps: + value = step(value) + return value - if isinstance(out, RunContext): - return out - - got = "None" if out is None else type(out).__name__ - raise ContractError( - f"Component must return RunContext (got {got}). Did you forget `return run`?" - ) - - def _run_steps( - self, run: RunContext, steps: Iterable[RunComponent] | None = None - ) -> RunContext: - """ - Run an iterable of steps sequentially. - - Args: - run: The initial RunContext. - steps: Optional override iterable of steps. If None, uses `self.steps`. - - Returns: - The final RunContext after applying all steps. - """ - it = self.steps if steps is None else steps - for step in it: - run = self._apply(step, run) - return run - - def run(self, run: RunContext) -> RunContext: - """ - Execute the flow on the given RunContext. - - Args: - run: The initial RunContext. - - Returns: - The final RunContext after running all configured steps. - """ - return self._run_steps(run) - - def run_query(self, query: str) -> RunContext: - """ - Beginner-friendly sugar API. - - Args: - query: Natural language question / user query. - - Returns: - A RunContext initialized with `query` and processed by the flow. - """ - return super().run_query(query) +def BaselineFlow(*args, **kwargs): + """ + Deprecated alias for SequentialFlow. -# Backward-compatible alias (optional). -# Keeping this alias means existing imports `from lang2sql.flows... import BaselineFlow` -# continue to work without changes. -BaselineFlow = SequentialFlow + .. deprecated:: + Use :class:`SequentialFlow` instead. + """ + import warnings + + warnings.warn( + "BaselineFlow is deprecated. Use SequentialFlow instead.", + DeprecationWarning, + stacklevel=2, + ) + return SequentialFlow(*args, **kwargs) diff --git a/src/lang2sql/flows/nl2sql.py b/src/lang2sql/flows/nl2sql.py new file mode 100644 index 0000000..26b480c --- /dev/null +++ b/src/lang2sql/flows/nl2sql.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any, Optional + +from ..components.execution.sql_executor import SQLExecutor +from ..components.generation.sql_generator import SQLGenerator +from ..components.retrieval.keyword import KeywordRetriever +from ..core.base import BaseFlow +from ..core.hooks import TraceHook +from ..core.ports import DBPort, LLMPort + + +class BaselineNL2SQL(BaseFlow): + """ + End-to-end NL→SQL pipeline. + + Pipeline: KeywordRetriever → SQLGenerator → SQLExecutor + + Usage:: + + pipeline = BaselineNL2SQL( + catalog=[{"name": "orders", "description": "...", "columns": {...}}], + llm=AnthropicLLM(model="claude-sonnet-4-6"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", + ) + rows = pipeline.run("지난달 주문 건수") + + Supported ``db_dialect`` values: ``"sqlite"``, ``"postgresql"``, ``"mysql"``, + ``"bigquery"``, ``"duckdb"``, ``"default"`` (or ``None`` for default). + """ + + def __init__( + self, + *, + catalog: list[dict], + llm: LLMPort, + db: DBPort, + db_dialect: Optional[str] = None, + hook: Optional[TraceHook] = None, + ) -> None: + super().__init__(name="BaselineNL2SQL", hook=hook) + self._retriever = KeywordRetriever(catalog=catalog, hook=hook) + self._generator = SQLGenerator(llm=llm, db_dialect=db_dialect, hook=hook) + self._executor = SQLExecutor(db=db, hook=hook) + + def _run(self, query: str) -> list[dict[str, Any]]: + schemas = self._retriever(query) + sql = self._generator(query, schemas) + return self._executor(sql) diff --git a/src/lang2sql/integrations/__init__.py b/src/lang2sql/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lang2sql/integrations/db/__init__.py b/src/lang2sql/integrations/db/__init__.py new file mode 100644 index 0000000..4096452 --- /dev/null +++ b/src/lang2sql/integrations/db/__init__.py @@ -0,0 +1,3 @@ +from .sqlalchemy_ import SQLAlchemyDB + +__all__ = ["SQLAlchemyDB"] diff --git a/src/lang2sql/integrations/db/sqlalchemy_.py b/src/lang2sql/integrations/db/sqlalchemy_.py new file mode 100644 index 0000000..faa252f --- /dev/null +++ b/src/lang2sql/integrations/db/sqlalchemy_.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any + +from ...core.exceptions import IntegrationMissingError + +try: + from sqlalchemy import create_engine, text as sa_text + from sqlalchemy.engine import Engine +except ImportError: + create_engine = None # type: ignore[assignment] + sa_text = None # type: ignore[assignment] + Engine = None # type: ignore[assignment,misc] + + +class SQLAlchemyDB: + """DBPort implementation backed by SQLAlchemy 2.x.""" + + def __init__(self, url: str) -> None: + if create_engine is None: + raise IntegrationMissingError( + "sqlalchemy", extra="sqlalchemy", hint="pip install sqlalchemy" + ) + self._engine: Engine = create_engine(url) + + def execute(self, sql: str) -> list[dict[str, Any]]: + with self._engine.connect() as conn: + result = conn.execute(sa_text(sql)) + return [dict(row._mapping) for row in result] diff --git a/src/lang2sql/integrations/llm/__init__.py b/src/lang2sql/integrations/llm/__init__.py new file mode 100644 index 0000000..528072c --- /dev/null +++ b/src/lang2sql/integrations/llm/__init__.py @@ -0,0 +1,4 @@ +from .anthropic_ import AnthropicLLM +from .openai_ import OpenAILLM + +__all__ = ["AnthropicLLM", "OpenAILLM"] diff --git a/src/lang2sql/integrations/llm/anthropic_.py b/src/lang2sql/integrations/llm/anthropic_.py new file mode 100644 index 0000000..5d5d803 --- /dev/null +++ b/src/lang2sql/integrations/llm/anthropic_.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from ...core.exceptions import IntegrationMissingError + +try: + import anthropic as _anthropic +except ImportError: + _anthropic = None # type: ignore[assignment] + + +class AnthropicLLM: + """LLMPort implementation backed by the Anthropic Messages API.""" + + def __init__(self, *, model: str, api_key: str | None = None) -> None: + if _anthropic is None: + raise IntegrationMissingError( + "anthropic", hint="pip install anthropic # or: uv sync" + ) + self._client = _anthropic.Anthropic(api_key=api_key) + self._model = model + + def invoke(self, messages: list[dict[str, str]]) -> str: + system = next((m["content"] for m in messages if m["role"] == "system"), None) + user_msgs = [m for m in messages if m["role"] != "system"] + resp = self._client.messages.create( + model=self._model, + max_tokens=1024, + system=system or "", + messages=user_msgs, + ) + return resp.content[0].text diff --git a/src/lang2sql/integrations/llm/openai_.py b/src/lang2sql/integrations/llm/openai_.py new file mode 100644 index 0000000..bb1e6ea --- /dev/null +++ b/src/lang2sql/integrations/llm/openai_.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ...core.exceptions import IntegrationMissingError + +try: + import openai as _openai +except ImportError: + _openai = None # type: ignore[assignment] + + +class OpenAILLM: + """LLMPort implementation backed by the OpenAI Chat Completions API.""" + + def __init__(self, *, model: str, api_key: str | None = None) -> None: + if _openai is None: + raise IntegrationMissingError( + "openai", hint="pip install openai # or: uv sync" + ) + self._client = _openai.OpenAI(api_key=api_key) + self._model = model + + def invoke(self, messages: list[dict[str, str]]) -> str: + resp = self._client.chat.completions.create( + model=self._model, + messages=messages, # type: ignore[arg-type] + ) + return resp.choices[0].message.content or "" diff --git a/tests/test_components_keyword_retriever.py b/tests/test_components_keyword_retriever.py index 27b9d5c..2af2f3c 100644 --- a/tests/test_components_keyword_retriever.py +++ b/tests/test_components_keyword_retriever.py @@ -8,7 +8,6 @@ import pytest from lang2sql.components.retrieval import KeywordRetriever -from lang2sql.core.context import RunContext from lang2sql.core.hooks import MemoryHook from lang2sql.flows.baseline import SequentialFlow @@ -47,59 +46,51 @@ def test_basic_search_returns_relevant_table(): """'주문' 질문 → order_table이 top 위치.""" retriever = KeywordRetriever(catalog=CATALOG) - run = retriever(RunContext(query="주문 정보 조회")) + results = retriever("주문 정보 조회") - assert run.schema_selected - assert run.schema_selected[0]["name"] == "order_table" + assert results + assert results[0]["name"] == "order_table" def test_top_n_limits_results(): """top_n=2 → 최대 2개 반환.""" retriever = KeywordRetriever(catalog=CATALOG, top_n=2) - run = retriever(RunContext(query="테이블")) + results = retriever("테이블") - assert len(run.schema_selected) <= 2 + assert len(results) <= 2 def test_top_n_larger_than_catalog(): """top_n=10, catalog 3개 → 최대 3개 반환.""" retriever = KeywordRetriever(catalog=CATALOG, top_n=10) - run = retriever(RunContext(query="테이블")) + results = retriever("테이블") - assert len(run.schema_selected) <= len(CATALOG) + assert len(results) <= len(CATALOG) def test_zero_results_returns_empty_list(): - """완전히 무관한 query → schema_selected == [].""" + """완전히 무관한 query → [].""" retriever = KeywordRetriever(catalog=CATALOG) - run = retriever(RunContext(query="xyzzy_no_match_token_12345")) + results = retriever("xyzzy_no_match_token_12345") - assert run.schema_selected == [] + assert results == [] -def test_schema_selected_is_list_of_dict(): +def test_returns_list_of_dict(): """결과가 list[dict]인지 확인.""" retriever = KeywordRetriever(catalog=CATALOG) - run = retriever(RunContext(query="주문")) + results = retriever("주문") - assert isinstance(run.schema_selected, list) - assert len(run.schema_selected) > 0 - assert isinstance(run.schema_selected[0], dict) - - -def test_returns_runcontext(): - """run 메서드가 RunContext를 반환하는지 확인.""" - retriever = KeywordRetriever(catalog=CATALOG) - result = retriever(RunContext(query="주문")) - - assert isinstance(result, RunContext) + assert isinstance(results, list) + assert len(results) > 0 + assert isinstance(results[0], dict) def test_hook_start_end_events(): """MemoryHook으로 start/end 이벤트 확인.""" hook = MemoryHook() retriever = KeywordRetriever(catalog=CATALOG, hook=hook) - retriever(RunContext(query="주문")) + retriever("주문") assert len(hook.events) == 2 assert hook.events[0].name == "component.run" @@ -111,21 +102,20 @@ def test_hook_start_end_events(): def test_empty_catalog(): - """catalog=[] → schema_selected == [].""" + """catalog=[] → [].""" retriever = KeywordRetriever(catalog=[]) - run = retriever(RunContext(query="주문")) + results = retriever("주문") - assert run.schema_selected == [] + assert results == [] def test_meta_preserved_in_results(): """meta 필드가 결과 dict에 그대로 포함되는지 확인.""" retriever = KeywordRetriever(catalog=CATALOG) - run = retriever(RunContext(query="주문")) + results = retriever("주문") - result = run.schema_selected[0] - assert "meta" in result - assert result["meta"]["primary_key"] == "order_id" + assert "meta" in results[0] + assert results[0]["meta"]["primary_key"] == "order_id" def test_index_fields_meta(): @@ -148,10 +138,10 @@ def test_index_fields_meta(): catalog=catalog, index_fields=["description", "meta"], ) - run = retriever(RunContext(query="finance")) + results = retriever("finance") - assert len(run.schema_selected) > 0 - assert run.schema_selected[0]["name"] == "alpha" + assert len(results) > 0 + assert results[0]["name"] == "alpha" def test_result_order_by_relevance(): @@ -168,10 +158,10 @@ def test_result_order_by_relevance(): ] retriever = KeywordRetriever(catalog=catalog) - run = retriever(RunContext(query="주문")) + results = retriever("주문") - assert len(run.schema_selected) >= 2 - assert run.schema_selected[0]["name"] == "order_summary" + assert len(results) >= 2 + assert results[0]["name"] == "order_summary" def test_columns_text_indexed(): @@ -190,10 +180,10 @@ def test_columns_text_indexed(): ] retriever = KeywordRetriever(catalog=catalog) - run = retriever(RunContext(query="매출액")) + results = retriever("매출액") - assert len(run.schema_selected) > 0 - assert run.schema_selected[0]["name"] == "sales" + assert len(results) > 0 + assert results[0]["name"] == "sales" def test_missing_optional_fields_no_error(): @@ -209,19 +199,17 @@ def test_missing_optional_fields_no_error(): ] retriever = KeywordRetriever(catalog=catalog) - # 예외가 발생하지 않으면 테스트 통과 - run = retriever(RunContext(query="테이블")) - assert isinstance(run.schema_selected, list) + results = retriever("테이블") + assert isinstance(results, list) def test_end_to_end_in_sequential_flow(): - """SequentialFlow(steps=[retriever]).run_query('...') 가 동작하는지 확인.""" + """SequentialFlow(steps=[retriever]).run('...') 가 동작하는지 확인.""" retriever = KeywordRetriever(catalog=CATALOG) flow = SequentialFlow(steps=[retriever]) - run = flow.run_query("주문 내역 확인") + results = flow.run("주문 내역 확인") - assert isinstance(run, RunContext) - assert isinstance(run.schema_selected, list) - assert len(run.schema_selected) > 0 - assert run.schema_selected[0]["name"] == "order_table" + assert isinstance(results, list) + assert len(results) > 0 + assert results[0]["name"] == "order_table" diff --git a/tests/test_components_sql_executor.py b/tests/test_components_sql_executor.py new file mode 100644 index 0000000..1b97fb8 --- /dev/null +++ b/tests/test_components_sql_executor.py @@ -0,0 +1,77 @@ +"""Tests for SQLExecutor.""" + +from __future__ import annotations + +import pytest + +from lang2sql.components.execution.sql_executor import SQLExecutor +from lang2sql.core.exceptions import ComponentError +from lang2sql.core.hooks import MemoryHook + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeDB: + def __init__(self, rows: list[dict] | None = None): + self._rows = rows if rows is not None else [{"count": 1}] + + def execute(self, sql: str) -> list[dict]: + return self._rows + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_sql_executor_returns_rows(): + rows = [{"order_id": 1, "amount": 100}] + executor = SQLExecutor(db=FakeDB(rows)) + result = executor.run("SELECT * FROM orders") + assert result == rows + + +def test_sql_executor_raises_on_empty_sql(): + executor = SQLExecutor(db=FakeDB()) + with pytest.raises(ComponentError): + executor.run("") + + +def test_sql_executor_raises_on_whitespace_sql(): + executor = SQLExecutor(db=FakeDB()) + with pytest.raises(ComponentError): + executor.run(" ") + + +def test_sql_executor_returns_empty_list(): + executor = SQLExecutor(db=FakeDB([])) + result = executor.run("SELECT * FROM empty_table") + assert result == [] + + +def test_sql_executor_emits_hook_events(): + hook = MemoryHook() + executor = SQLExecutor(db=FakeDB(), hook=hook) + executor.run("SELECT 1") + + events = hook.snapshot() + phases = [e.phase for e in events] + assert "start" in phases + assert "end" in phases + + +def test_sql_executor_error_event_on_db_failure(): + class BrokenDB: + def execute(self, sql: str): + raise RuntimeError("connection refused") + + hook = MemoryHook() + executor = SQLExecutor(db=BrokenDB(), hook=hook) + + with pytest.raises(ComponentError): + executor.run("SELECT 1") + + error_events = [e for e in hook.snapshot() if e.phase == "error"] + assert error_events, "expected an error event" diff --git a/tests/test_components_sql_generator.py b/tests/test_components_sql_generator.py new file mode 100644 index 0000000..84990c0 --- /dev/null +++ b/tests/test_components_sql_generator.py @@ -0,0 +1,169 @@ +"""Tests for SQLGenerator.""" + +from __future__ import annotations + +import pytest + +from lang2sql.components.generation.sql_generator import SQLGenerator +from lang2sql.core.catalog import CatalogEntry +from lang2sql.core.exceptions import ComponentError +from lang2sql.core.hooks import MemoryHook + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeLLM: + def __init__(self, response: str = "```sql\nSELECT 1\n```"): + self._response = response + + def invoke(self, messages: list[dict]) -> str: + return self._response + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _catalog() -> list[CatalogEntry]: + return [ + { + "name": "orders", + "description": "Monthly order records", + "columns": {"order_id": "primary key", "amount": "order amount"}, + } + ] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_sql_generator_returns_sql_string(): + gen = SQLGenerator(llm=FakeLLM("```sql\nSELECT COUNT(*) FROM orders\n```")) + result = gen.run("주문 건수", _catalog()) + assert result == "SELECT COUNT(*) FROM orders" + + +def test_sql_generator_strips_trailing_semicolon(): + gen = SQLGenerator(llm=FakeLLM("```sql\nSELECT 1;\n```")) + result = gen.run("test", _catalog()) + assert not result.endswith(";") + + +def test_sql_generator_raises_on_no_code_block(): + gen = SQLGenerator(llm=FakeLLM("Here is your answer: SELECT 1")) + with pytest.raises(ComponentError): + gen.run("test", _catalog()) + + +def test_sql_generator_passes_query_and_schema_to_llm(): + received: list[list[dict]] = [] + + class CaptureLLM: + def invoke(self, messages): + received.append(messages) + return "```sql\nSELECT 1\n```" + + gen = SQLGenerator(llm=CaptureLLM()) + gen.run("주문 건수", _catalog()) + + assert received, "invoke was not called" + msgs = received[0] + user_content = next(m["content"] for m in msgs if m["role"] == "user") + assert "orders" in user_content + assert "주문 건수" in user_content + + +def test_sql_generator_custom_system_prompt(): + received: list[list[dict]] = [] + + class CaptureLLM: + def invoke(self, messages): + received.append(messages) + return "```sql\nSELECT 1\n```" + + custom_prompt = "You are a DBA." + gen = SQLGenerator(llm=CaptureLLM(), system_prompt=custom_prompt) + gen.run("test", _catalog()) + + system_content = next(m["content"] for m in received[0] if m["role"] == "system") + assert system_content == custom_prompt + + +def test_sql_generator_emits_hook_events(): + hook = MemoryHook() + gen = SQLGenerator(llm=FakeLLM(), hook=hook) + gen.run("test", _catalog()) + + events = hook.snapshot() + phases = [e.phase for e in events] + assert "start" in phases + assert "end" in phases + + +def test_sql_generator_empty_schemas(): + gen = SQLGenerator(llm=FakeLLM("```sql\nSELECT 1\n```")) + result = gen.run("test", []) + assert result == "SELECT 1" + + +# --------------------------------------------------------------------------- +# db_dialect tests +# --------------------------------------------------------------------------- + + +def test_sql_generator_db_dialect_loads_sqlite_prompt(): + received: list[list[dict]] = [] + + class CaptureLLM: + def invoke(self, messages): + received.append(messages) + return "```sql\nSELECT 1\n```" + + gen = SQLGenerator(llm=CaptureLLM(), db_dialect="sqlite") + gen.run("test", _catalog()) + + system_content = next(m["content"] for m in received[0] if m["role"] == "system") + assert "SQLite" in system_content + assert "strftime" in system_content + + +def test_sql_generator_db_dialect_loads_postgresql_prompt(): + received: list[list[dict]] = [] + + class CaptureLLM: + def invoke(self, messages): + received.append(messages) + return "```sql\nSELECT 1\n```" + + gen = SQLGenerator(llm=CaptureLLM(), db_dialect="postgresql") + gen.run("test", _catalog()) + + system_content = next(m["content"] for m in received[0] if m["role"] == "system") + assert "PostgreSQL" in system_content + assert "DATE_TRUNC" in system_content + + +def test_sql_generator_unsupported_dialect_raises_value_error(): + with pytest.raises(ValueError, match="Unsupported dialect"): + SQLGenerator(llm=FakeLLM(), db_dialect="oracle") + + +def test_sql_generator_system_prompt_overrides_db_dialect(): + received: list[list[dict]] = [] + + class CaptureLLM: + def invoke(self, messages): + received.append(messages) + return "```sql\nSELECT 1\n```" + + custom = "You are a Snowflake expert." + gen = SQLGenerator(llm=CaptureLLM(), db_dialect="sqlite", system_prompt=custom) + gen.run("test", _catalog()) + + system_content = next(m["content"] for m in received[0] if m["role"] == "system") + assert system_content == custom diff --git a/tests/test_core_base.py b/tests/test_core_base.py index cba775a..524fe25 100644 --- a/tests/test_core_base.py +++ b/tests/test_core_base.py @@ -6,47 +6,45 @@ ComponentError, ValidationError, IntegrationMissingError, - ContractError, ) - # ------------------------- # Fixtures: tiny components/flows # ------------------------- class AddOne(BaseComponent): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: return x + 1 class BoomValueError(BaseComponent): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: raise ValueError("boom") class BoomDomainError(BaseComponent): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: raise ValidationError("bad sql") class BoomIntegrationMissing(BaseComponent): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: raise IntegrationMissingError("faiss", extra="faiss") class FlowOk(BaseFlow): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: return x * 2 class FlowBoomDomain(BaseFlow): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: raise ValidationError("flow bad") class FlowBoomUnknown(BaseFlow): - def run(self, x: int) -> int: + def _run(self, x: int) -> int: raise RuntimeError("flow boom") @@ -126,51 +124,6 @@ def test_base_component_preserves_domain_error_integration_missing(): assert "faiss" in (hook.events[1].error or "") -def test_runcontext_contract_none_return_raises_contract_error(): - from lang2sql.core.context import RunContext - - class BadNone(BaseComponent): - def run(self, run: RunContext): - run.metadata["x"] = 1 - return None # forgot return run - - hook = MemoryHook() - c = BadNone(hook=hook) - - with pytest.raises(ContractError) as ei: - c(RunContext(query="q")) - - assert "must return RunContext" in str(ei.value) - assert "None" in str(ei.value) - - assert len(hook.events) == 2 - assert hook.events[0].phase == "start" - assert hook.events[1].phase == "error" - assert "ContractError" in (hook.events[1].error or "") - - -def test_runcontext_contract_wrong_type_return_raises_contract_error(): - from lang2sql.core.context import RunContext - - class BadType(BaseComponent): - def run(self, run: RunContext): - return 123 # wrong - - hook = MemoryHook() - c = BadType(hook=hook) - - with pytest.raises(ContractError) as ei: - c(RunContext(query="q")) - - assert "must return RunContext" in str(ei.value) - assert "int" in str(ei.value) - - assert len(hook.events) == 2 - assert hook.events[0].phase == "start" - assert hook.events[1].phase == "error" - assert "ContractError" in (hook.events[1].error or "") - - # ------------------------- # BaseFlow tests # ------------------------- diff --git a/tests/test_flows_baseline.py b/tests/test_flows_baseline.py index 72804fc..93df60f 100644 --- a/tests/test_flows_baseline.py +++ b/tests/test_flows_baseline.py @@ -1,256 +1,128 @@ import pytest -from lang2sql.core.context import RunContext from lang2sql.core.base import BaseFlow -from lang2sql.core.exceptions import ContractError -from lang2sql.flows.baseline import BaselineFlow +from lang2sql.flows.baseline import BaselineFlow, SequentialFlow def test_requires_at_least_one_step(): with pytest.raises(ValueError): - BaselineFlow(steps=[]) + SequentialFlow(steps=[]) -def test_run_query_sets_inputs_query(): - def step(run: RunContext) -> RunContext: - run.sql = "SELECT 1;" - return run +def test_baseline_flow_emits_deprecation_warning(): + with pytest.warns(DeprecationWarning, match="BaselineFlow is deprecated"): + flow = BaselineFlow(steps=[lambda x: x]) + assert isinstance(flow, SequentialFlow) - flow = BaselineFlow(steps=[step]) - out = flow.run_query("지난달 매출") - assert out.inputs["query"] == "지난달 매출" - assert out.query == "지난달 매출" - assert out.sql == "SELECT 1;" +def test_run_passes_value_through_single_step(): + flow = SequentialFlow(steps=[lambda x: x + 1]) + assert flow.run(1) == 2 -def test_ctx_mutate_style_step_mutates_and_returns_same_context(): - def step(run: RunContext) -> RunContext: - run.metadata["x"] = 1 - return run - - flow = BaselineFlow(steps=[step]) - run = RunContext(query="q") - out = flow.run(run) - - assert out is run - assert out.metadata["x"] == 1 - - -def test_functional_style_step_can_return_new_context(): - def step(run: RunContext) -> RunContext: - new = RunContext(query=run.query) - new.sql = "SELECT 2;" - return new - - flow = BaselineFlow(steps=[step]) - run = RunContext(query="q") - out = flow.run(run) - - assert out is not run - assert out.query == "q" - assert out.sql == "SELECT 2;" - - -def test_invalid_step_return_type_raises_contract_error(): - def bad_step(run: RunContext): - return 123 # invalid - - flow = BaselineFlow(steps=[bad_step]) - with pytest.raises(ContractError): - flow.run(RunContext(query="q")) +def test_run_chains_multiple_steps(): + flow = SequentialFlow(steps=[lambda x: x * 2, lambda x: x + 10]) + assert flow.run(5) == 20 # 5 * 2 = 10, 10 + 10 = 20 def test_step_order_is_preserved(): - def s1(run: RunContext) -> RunContext: - run.push_meta("order", "s1") - return run - - def s2(run: RunContext) -> RunContext: - run.push_meta("order", "s2") - return run - - def s3(run: RunContext) -> RunContext: - run.push_meta("order", "s3") - return run + trace = [] - flow = BaselineFlow(steps=[s1, s2, s3]) - out = flow.run(RunContext(query="q")) + def s1(x): + trace.append("s1") + return x - assert out.get_meta_list("order") == ["s1", "s2", "s3"] + def s2(x): + trace.append("s2") + return x + def s3(x): + trace.append("s3") + return x -def test_user_can_override_pipeline_by_composing_flows_without_private_api(): - def default_step(run: RunContext) -> RunContext: - run.push_meta("order", "default") - return run + SequentialFlow(steps=[s1, s2, s3]).run("input") + assert trace == ["s1", "s2", "s3"] - def override_step(run: RunContext) -> RunContext: - run.push_meta("order", "override") - return run - flow_default = BaselineFlow(steps=[default_step]) - flow_override = BaselineFlow(steps=[override_step]) +def test_user_can_compose_flows_with_python_control_flow(): + default_flow = SequentialFlow(steps=[lambda x: x + "_default"]) + override_flow = SequentialFlow(steps=[lambda x: x + "_override"]) - out_default = flow_default(RunContext(query="q")) - assert out_default.get_meta_list("order") == ["default"] + assert default_flow("q") == "q_default" class CustomFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: - # Explicitly choose override pipeline - return flow_override(run) + def _run(self, value): + return override_flow(value) - out = CustomFlow().run(RunContext(query="q")) - assert out.get_meta_list("order") == ["override"] + assert CustomFlow().run("q") == "q_override" # ------------------------- -# 1) Advanced: retry patterns (NO private API) +# Advanced: retry patterns # ------------------------- -def test_custom_flow_fallback_then_revalidate_makes_validation_ok(): - def gen_bad(run: RunContext) -> RunContext: - run.sql = "DROP TABLE users;" - return run +def test_custom_flow_fallback_then_revalidate(): + def gen_bad(_query): + return "DROP TABLE users;" - class _V: - def __init__(self, ok: bool): - self.ok = ok - - def validate(run: RunContext) -> RunContext: - ok = "drop " not in run.sql.lower() - run.validation = _V(ok) - return run - - pipeline = BaselineFlow(steps=[gen_bad, validate]) # gen -> validate + def validate(sql): + return "drop " not in sql.lower() class FixThenRevalidateFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: - pipeline(run) - if run.validation.ok: - return run - - run.sql = "SELECT 1;" - validate(run) # explicit re-validate - return run - - out = FixThenRevalidateFlow().run(RunContext(query="q")) - assert out.sql == "SELECT 1;" - assert out.validation.ok is True - - -def test_custom_flow_retry_regenerates_sql_until_valid(): - def gen_with_attempt(run: RunContext) -> RunContext: - attempt = int(run.metadata.get("attempt", 0)) - run.metadata["attempt"] = attempt + 1 - - if attempt == 0: - run.sql = "DROP TABLE users;" - else: - run.sql = "SELECT 1;" - return run - - class _V: - def __init__(self, ok: bool): - self.ok = ok - - def validate(run: RunContext) -> RunContext: - ok = "drop " not in run.sql.lower() - run.validation = _V(ok) - return run - - class RegenerateRetryFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: - for _ in range(3): - gen_with_attempt(run) - validate(run) - if run.validation.ok: - return run - return run + def _run(self, query): + sql = gen_bad(query) + if not validate(sql): + sql = "SELECT 1;" + return sql - out = RegenerateRetryFlow().run(RunContext(query="q")) - assert out.sql == "SELECT 1;" - assert out.validation.ok is True - assert out.metadata["attempt"] >= 2 + assert FixThenRevalidateFlow().run("q") == "SELECT 1;" -# ------------------------- -# 2) Composition: flow as a step (subflow) -# ------------------------- +def test_custom_flow_retry_until_valid(): + attempts = {"count": 0} + def generate(_query): + attempts["count"] += 1 + return "DROP TABLE users;" if attempts["count"] == 1 else "SELECT 1;" -def test_subflow_can_be_used_as_a_step_and_mutates_same_context(): - def a1(run: RunContext) -> RunContext: - run.push_meta("trace", "a1") - return run + def validate(sql): + return "drop " not in sql.lower() - def a2(run: RunContext) -> RunContext: - run.sql = "SELECT 42;" - run.push_meta("trace", "a2") - return run + class RetryFlow(BaseFlow): + def _run(self, query): + for _ in range(3): + sql = generate(query) + if validate(sql): + return sql + return sql - flow_a = BaselineFlow(steps=[a1, a2]) + result = RetryFlow().run("q") + assert result == "SELECT 1;" + assert attempts["count"] >= 2 - def b1(run: RunContext) -> RunContext: - run.push_meta("trace", "b1") - return run - def b2(run: RunContext) -> RunContext: - run.push_meta("trace", "b2") - return run +# ------------------------- +# Composition: flow as step +# ------------------------- - flow_b = BaselineFlow(steps=[b1, flow_a, b2]) - run = RunContext(query="q") - out = flow_b.run(run) +def test_subflow_can_be_used_as_a_step(): + inner = SequentialFlow(steps=[lambda x: x + 1]) + outer = SequentialFlow(steps=[lambda x: x * 2, inner]) - assert out is run - assert out.get_meta_list("trace") == ["b1", "a1", "a2", "b2"] - assert out.sql == "SELECT 42;" + # 3 * 2 = 6, 6 + 1 = 7 + assert outer.run(3) == 7 def test_subflow_can_be_conditionally_invoked_in_custom_flow(): - def a1(run: RunContext) -> RunContext: - run.push_meta("trace", "a1") - return run - - def a2(run: RunContext) -> RunContext: - run.push_meta("trace", "a2") - return run - - flow_a = BaselineFlow(steps=[a1, a2]) - - def b1(run: RunContext) -> RunContext: - run.push_meta("trace", "b1") - return run - - def b2(run: RunContext) -> RunContext: - run.push_meta("trace", "b2") - return run + flow_a = SequentialFlow(steps=[lambda x: x + "_a"]) class ConditionalFlow(BaseFlow): - def run(self, run: RunContext) -> RunContext: - b1(run) - if "use_a" in run.query: - flow_a(run) - b2(run) - return run - - out1 = ConditionalFlow().run(RunContext(query="nope")) - assert out1.get_meta_list("trace") == ["b1", "b2"] - - out2 = ConditionalFlow().run(RunContext(query="please use_a")) - assert out2.get_meta_list("trace") == ["b1", "a1", "a2", "b2"] - - -def test_none_return_raises_contract_error(): - def bad_none(run: RunContext): - run.sql = "SELECT 1;" - return None # forgot return run - - flow = BaselineFlow(steps=[bad_none]) - with pytest.raises(ContractError) as ei: - flow.run(RunContext(query="q")) + def _run(self, value): + if "use_a" in value: + return flow_a(value) + return value - assert "Did you forget" in str(ei.value) or "return run" in str(ei.value) + assert ConditionalFlow().run("nope") == "nope" + assert ConditionalFlow().run("please use_a") == "please use_a_a" diff --git a/tests/test_flows_nl2sql.py b/tests/test_flows_nl2sql.py new file mode 100644 index 0000000..2573dfa --- /dev/null +++ b/tests/test_flows_nl2sql.py @@ -0,0 +1,124 @@ +"""End-to-end tests for BaselineNL2SQL.""" + +from __future__ import annotations + +import pytest + +from lang2sql.core.exceptions import ComponentError +from lang2sql.core.hooks import MemoryHook +from lang2sql.flows.nl2sql import BaselineNL2SQL + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeLLM: + def __init__(self, response: str = "```sql\nSELECT COUNT(*) FROM orders\n```"): + self._response = response + + def invoke(self, messages: list[dict]) -> str: + return self._response + + +class FakeDB: + def __init__(self, rows: list[dict] | None = None): + self._rows = rows if rows is not None else [{"count": 42}] + + def execute(self, sql: str) -> list[dict]: + return self._rows + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +CATALOG = [ + { + "name": "orders", + "description": "Monthly order records", + "columns": {"order_id": "primary key", "amount": "order amount"}, + }, + { + "name": "customers", + "description": "Customer master data", + "columns": {"customer_id": "primary key", "name": "customer name"}, + }, +] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_pipeline_e2e_returns_rows(): + pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=FakeLLM(), + db=FakeDB([{"count": 42}]), + ) + result = pipeline.run("지난달 주문 건수") + assert result == [{"count": 42}] + + +def test_pipeline_emits_3_component_start_events(): + hook = MemoryHook() + pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=FakeLLM(), + db=FakeDB(), + hook=hook, + ) + pipeline.run("주문 건수") + + component_starts = [ + e for e in hook.snapshot() if e.name == "component.run" and e.phase == "start" + ] + assert len(component_starts) == 3 + + +def test_pipeline_propagates_component_error_on_bad_llm_response(): + pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=FakeLLM("No SQL here, just text."), + db=FakeDB(), + ) + with pytest.raises(ComponentError): + pipeline.run("주문 건수") + + +def test_pipeline_hook_records_all_phases(): + hook = MemoryHook() + pipeline = BaselineNL2SQL( + catalog=CATALOG, + llm=FakeLLM(), + db=FakeDB(), + hook=hook, + ) + pipeline.run("test") + + events = hook.snapshot() + component_events = [e for e in events if e.name == "component.run"] + flow_events = [e for e in events if e.name == "flow.run"] + + assert len(component_events) == 6 # 3 components × (start + end) + assert len(flow_events) == 2 # flow start + flow end + + +def test_pipeline_advanced_usage_manual_composition(): + """고급 사용자 — 직접 컴포넌트 조합.""" + from lang2sql.components.execution.sql_executor import SQLExecutor + from lang2sql.components.generation.sql_generator import SQLGenerator + from lang2sql.components.retrieval.keyword import KeywordRetriever + + retriever = KeywordRetriever(catalog=CATALOG) + generator = SQLGenerator(llm=FakeLLM()) + executor = SQLExecutor(db=FakeDB([{"total": 99}])) + + query = "주문 건수" + schemas = retriever(query) + sql = generator(query, schemas) + result = executor(sql) + + assert result == [{"total": 99}] diff --git a/utils/llm/chatbot.py b/utils/llm/chatbot.py index 51bcab0..d3b30b8 100644 --- a/utils/llm/chatbot.py +++ b/utils/llm/chatbot.py @@ -104,8 +104,7 @@ def call_model(state: ChatBotState): dict: LLM 응답이 포함된 상태 업데이트 """ # 질문 구체화 전문 어시스턴트 시스템 메시지 - sys_msg = SystemMessage( - content="""# 역할 + sys_msg = SystemMessage(content="""# 역할 당신은 사용자의 모호한 질문을 명확하고 구체적인 질문으로 만드는 전문 AI 어시스턴트입니다. # 주요 임무 @@ -141,8 +140,7 @@ def call_model(state: ChatBotState): - 불확실한 정보가 있다면 추측하지 말고 도구를 사용하여 확인한 후 답변하세요 --- -다음은 사용자와의 대화입니다:""" - ) +다음은 사용자와의 대화입니다:""") # 시스템 메시지를 대화의 맨 앞에 추가 messages = [sys_msg] + state["messages"] response = self.llm.invoke(messages) diff --git a/uv.lock b/uv.lock index 32b59b5..f793c6a 100644 --- a/uv.lock +++ b/uv.lock @@ -201,6 +201,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -1105,6 +1124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "duckdb" version = "1.3.2" @@ -1195,7 +1223,6 @@ dependencies = [ { name = "numpy" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/1b/6fe5dbe5be0240cfd82b52bd7c186655c578d935c0ce2e713c100e6f8cce/faiss_cpu-1.10.0.tar.gz", hash = "sha256:5bdca555f24bc036f4d67f8a5a4d6cc91b8d2126d4e78de496ca23ccd46e479d", size = 69159, upload-time = "2025-01-31T07:45:49.305Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/56/87eb506d8634f08fc7c63d1ca5631aeec7d6b9afbfabedf2cb7a2a804b13/faiss_cpu-1.10.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:6693474be296a7142ade1051ea18e7d85cedbfdee4b7eac9c52f83fed0467855", size = 7693034, upload-time = "2025-01-31T07:44:31.908Z" }, { url = "https://files.pythonhosted.org/packages/51/46/f4d9de34ed1b06300b1a75b824d4857963216f5826de33f291af78088e39/faiss_cpu-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:70ebe60a560414dc8dd6cfe8fed105c8f002c0d11f765f5adfe8d63d42c0467f", size = 3234656, upload-time = "2025-01-31T07:44:34.418Z" }, @@ -1622,6 +1649,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, @@ -1631,6 +1660,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -1640,6 +1671,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -1649,6 +1682,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -1656,6 +1691,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, { url = "https://files.pythonhosted.org/packages/f7/c0/93885c4106d2626bf51fdec377d6aef740dfa5c4877461889a7cf8e565cc/greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", size = 269859, upload-time = "2025-08-07T13:16:16.003Z" }, { url = "https://files.pythonhosted.org/packages/4d/f5/33f05dc3ba10a02dedb1485870cf81c109227d3d3aa280f0e48486cac248/greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", size = 627610, upload-time = "2025-08-07T13:43:01.345Z" }, @@ -1665,6 +1702,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/4c/f3de2a8de0e840ecb0253ad0dc7e2bb3747348e798ec7e397d783a3cb380/greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", size = 582817, upload-time = "2025-08-07T13:18:35.48Z" }, { url = "https://files.pythonhosted.org/packages/89/80/7332915adc766035c8980b161c2e5d50b2f941f453af232c164cff5e0aeb/greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", size = 1111985, upload-time = "2025-08-07T13:42:42.425Z" }, { url = "https://files.pythonhosted.org/packages/66/71/1928e2c80197353bcb9b50aa19c4d8e26ee6d7a900c564907665cf4b9a41/greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98", size = 1136137, upload-time = "2025-08-07T13:18:26.168Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bf/7bd33643e48ed45dcc0e22572f650767832bd4e1287f97434943cc402148/greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10", size = 1542941, upload-time = "2025-11-04T12:42:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/9b/74/4bc433f91d0d09a1c22954a371f9df928cb85e72640870158853a83415e5/greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be", size = 1609685, upload-time = "2025-11-04T12:42:29.242Z" }, { url = "https://files.pythonhosted.org/packages/89/48/a5dc74dde38aeb2b15d418cec76ed50e1dd3d620ccda84d8199703248968/greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b", size = 281400, upload-time = "2025-08-07T14:02:20.263Z" }, { url = "https://files.pythonhosted.org/packages/e5/44/342c4591db50db1076b8bda86ed0ad59240e3e1da17806a4cf10a6d0e447/greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb", size = 298533, upload-time = "2025-08-07T13:56:34.168Z" }, ] @@ -2486,6 +2525,7 @@ wheels = [ name = "lang2sql" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "clickhouse-driver" }, { name = "crate" }, { name = "databricks-sql-connector" }, @@ -2514,13 +2554,16 @@ dependencies = [ { name = "oracledb" }, { name = "pgvector" }, { name = "plotly" }, + { name = "psycopg", extra = ["binary"] }, { name = "psycopg2-binary" }, { name = "pyhive" }, { name = "pyodbc" }, { name = "python-dotenv" }, { name = "snowflake-connector-python" }, + { name = "sqlalchemy" }, { name = "streamlit" }, { name = "transformers" }, + { name = "trino" }, ] [package.dev-dependencies] @@ -2531,6 +2574,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.20.0" }, { name = "clickhouse-driver", specifier = ">=0.2.9,<0.3.0" }, { name = "crate", specifier = ">=0.29.0,<1.0.0" }, { name = "databricks-sql-connector", specifier = ">=4.0.3,<5.0.0" }, @@ -2556,13 +2600,16 @@ requires-dist = [ { name = "oracledb", specifier = ">=3.1.0,<4.0.0" }, { name = "pgvector", specifier = "==0.3.6" }, { name = "plotly" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4.0" }, { name = "psycopg2-binary", specifier = ">=2.9.10,<3.0.0" }, { name = "pyhive", specifier = ">=0.6.6,<1.0.0" }, { name = "pyodbc", specifier = ">=5.1.0,<6.0.0" }, { name = "python-dotenv", specifier = "==1.0.1" }, { name = "snowflake-connector-python", specifier = ">=3.15.0,<4.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0" }, { name = "streamlit", specifier = "==1.41.1" }, { name = "transformers", specifier = "==4.51.2" }, + { name = "trino", specifier = ">=0.329.0,<1.0.0" }, ] [package.metadata.requires-dev] @@ -4213,6 +4260,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" }, ] +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ce/d677bc51f9b180986e5515268603519cee682eb6b5e765ae46cdb8526579/psycopg_binary-3.2.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093", size = 4033081, upload-time = "2025-05-13T16:06:29.666Z" }, + { url = "https://files.pythonhosted.org/packages/de/f4/b56263eb20dc36d71d7188622872098400536928edf86895736e28546b3c/psycopg_binary-3.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8", size = 4082141, upload-time = "2025-05-13T16:06:33.81Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/5316c3b0a2b1ff5f1d440a27638250569994534874a2ce88bf24f5c51c0f/psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2", size = 4678993, upload-time = "2025-05-13T16:06:36.309Z" }, + { url = "https://files.pythonhosted.org/packages/53/24/b2c667b59f07fd7d7805c0c2074351bf2b98a336c5030d961db316512ffb/psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7", size = 4500117, upload-time = "2025-05-13T16:06:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/a08f8878b0fe0b34b083c149df950bce168bc1b18b2fe849fa42bf4378d4/psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695", size = 4766985, upload-time = "2025-05-13T16:06:42.502Z" }, + { url = "https://files.pythonhosted.org/packages/10/be/3a45d5b7d8f4c4332fd42465f2170b5aef4d28a7c79e79ac7e5e1dac74d7/psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894", size = 4461990, upload-time = "2025-05-13T16:06:45.971Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/20682b9a4fc270d8dc644a0b16c1978732146c6ff0abbc48fbab2f4a70aa/psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1", size = 3777947, upload-time = "2025-05-13T16:06:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/07/5c/f6d486e00bcd8709908ccdd436b2a190d390dfd61e318de4060bc6ee2a1e/psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee", size = 3337502, upload-time = "2025-05-13T16:06:51.378Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a1/086508e929c0123a7f532840bb0a0c8a1ebd7e06aef3ee7fa44a3589bcdf/psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb", size = 3440809, upload-time = "2025-05-13T16:06:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/3a347a0f894355a6b173fca2202eca279b6197727b24e4896cf83f4263ee/psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7", size = 3497231, upload-time = "2025-05-13T16:06:58.858Z" }, + { url = "https://files.pythonhosted.org/packages/18/31/0845a385eb6f4521b398793293b5f746a101e80d5c43792990442d26bc2e/psycopg_binary-3.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8", size = 2936845, upload-time = "2025-05-13T16:07:02.712Z" }, + { url = "https://files.pythonhosted.org/packages/b6/84/259ea58aca48e03c3c793b4ccfe39ed63db7b8081ef784d039330d9eed96/psycopg_binary-3.2.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4", size = 4040785, upload-time = "2025-05-13T16:07:07.569Z" }, + { url = "https://files.pythonhosted.org/packages/25/22/ce58ffda2b7e36e45042b4d67f1bbd4dd2ccf4cfd2649696685c61046475/psycopg_binary-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b", size = 4087601, upload-time = "2025-05-13T16:07:11.75Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/b043e85268650c245025e80039b79663d8986f857bc3d3a72b1de67f3550/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a", size = 4676524, upload-time = "2025-05-13T16:07:17.038Z" }, + { url = "https://files.pythonhosted.org/packages/da/29/7afbfbd3740ea52fda488db190ef2ef2a9ff7379b85501a2142fb9f7dd56/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc", size = 4495671, upload-time = "2025-05-13T16:07:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/ea/eb/df69112d18a938cbb74efa1573082248437fa663ba66baf2cdba8a95a2d0/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b", size = 4768132, upload-time = "2025-05-13T16:07:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/76/fe/4803b20220c04f508f50afee9169268553f46d6eed99640a08c8c1e76409/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95", size = 4458394, upload-time = "2025-05-13T16:07:29.148Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0f/5ecc64607ef6f62b04e610b7837b1a802ca6f7cb7211339f5d166d55f1dd/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891", size = 3776879, upload-time = "2025-05-13T16:07:32.503Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d8/1c3d6e99b7db67946d0eac2cd15d10a79aa7b1e3222ce4aa8e7df72027f5/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186", size = 3333329, upload-time = "2025-05-13T16:07:35.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/a4e82099816559f558ccaf2b6945097973624dc58d5d1c91eb1e54e5a8e9/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d", size = 3435683, upload-time = "2025-05-13T16:07:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f27055290d58e8818bed8a297162a096ef7f8ecdf01d98772d4b02af46c4/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a", size = 3497124, upload-time = "2025-05-13T16:07:40.567Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/17ed07579625529534605eeaeba34f0536754a5667dbf20ea2624fc80614/psycopg_binary-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a", size = 2939520, upload-time = "2025-05-13T16:07:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/29/6f/ec9957e37a606cd7564412e03f41f1b3c3637a5be018d0849914cb06e674/psycopg_binary-3.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e", size = 4022205, upload-time = "2025-05-13T16:07:48.195Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/497b8bea72b20a862ac95a94386967b745a472d9ddc88bc3f32d5d5f0d43/psycopg_binary-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0", size = 4083795, upload-time = "2025-05-13T16:07:50.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/07/af9503e8e8bdad3911fd88e10e6a29240f9feaa99f57d6fac4a18b16f5a0/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff", size = 4655043, upload-time = "2025-05-13T16:07:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/aff8c9850df1648cc6a5cc7a381f11ee78d98a6b807edd4a5ae276ad60ad/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724", size = 4477972, upload-time = "2025-05-13T16:07:57.925Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/8e9d1b77ec1a632818fe2f457c3a65af83c68710c4c162d6866947d08cc5/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d", size = 4737516, upload-time = "2025-05-13T16:08:01.616Z" }, + { url = "https://files.pythonhosted.org/packages/46/ec/222238f774cd5a0881f3f3b18fb86daceae89cc410f91ef6a9fb4556f236/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6", size = 4436160, upload-time = "2025-05-13T16:08:04.278Z" }, + { url = "https://files.pythonhosted.org/packages/37/78/af5af2a1b296eeca54ea7592cd19284739a844974c9747e516707e7b3b39/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40", size = 3753518, upload-time = "2025-05-13T16:08:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ac/8a3ed39ea069402e9e6e6a2f79d81a71879708b31cc3454283314994b1ae/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795", size = 3313598, upload-time = "2025-05-13T16:08:09.999Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/26549af068347c808fbfe5f07d2fa8cef747cfff7c695136172991d2378b/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a", size = 3407289, upload-time = "2025-05-13T16:08:12.66Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/ea8d227c77df8e8aec880ded398316735add8fda5eb4ff5cc96fac11e964/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4", size = 3472493, upload-time = "2025-05-13T16:08:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/6ff2a5bc53c3cd653d281666728e29121149179c73fddefb1e437024c192/psycopg_binary-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21", size = 2927400, upload-time = "2025-05-13T16:08:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" }, + { url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" }, + { url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4a/e095884dd016b2bde2796043c61cd383b79e5d2a820c33e2c47293707ca8/psycopg_binary-3.2.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe", size = 4034274, upload-time = "2025-05-13T16:09:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/11/e9/ab3fad6033de260a620f6481e66092417ce31fa194dbf9ac292ab8cb9fd0/psycopg_binary-3.2.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786", size = 4083015, upload-time = "2025-05-13T16:09:54.896Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/6cd54a349d0b62b080761eb7bda43190003ecbbf17920d57254d5c780e11/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07", size = 4679369, upload-time = "2025-05-13T16:10:00.545Z" }, + { url = "https://files.pythonhosted.org/packages/51/34/35c65ac413c485e9340d62f14adcb34420acae44425f77aee591d49e6647/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4", size = 4500889, upload-time = "2025-05-13T16:10:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/77/a9/f691b8037b0bcef481b09ae4283beedbf048f79b6fe9bda1445dbb14ed18/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04", size = 4769218, upload-time = "2025-05-13T16:10:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/25afc811c1dfb664b31d66d6f5c070326a1f89f768f1b673273a3abe6912/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734", size = 4462834, upload-time = "2025-05-13T16:10:30.442Z" }, + { url = "https://files.pythonhosted.org/packages/df/e2/eb4a8230e13f691d6e386e22b16d4b90f454839b78ac547be3f399562ee4/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2", size = 3779527, upload-time = "2025-05-13T16:10:42.705Z" }, + { url = "https://files.pythonhosted.org/packages/26/39/0f79c7d42f0c5711861ce9db55c65e14e7f1e52bd40304b4d6e7cd505e61/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78", size = 3337958, upload-time = "2025-05-13T16:10:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/11/ce/28b1d98aed9337a721b271778d07c5ac7f85730d96f0185cc6d22684536d/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074", size = 3440567, upload-time = "2025-05-13T16:10:57.821Z" }, + { url = "https://files.pythonhosted.org/packages/24/54/40a3a8175566f8c1268af0bacf5d7b26371697b6cefa87352c1df4b435e1/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd", size = 3498637, upload-time = "2025-05-13T16:11:02.854Z" }, + { url = "https://files.pythonhosted.org/packages/63/ee/51748bc8af0ba08e7415fcbbd00b7d069c068f8c08509e8dd0dd0a066394/psycopg_binary-3.2.9-cp39-cp39-win_amd64.whl", hash = "sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523", size = 2938614, upload-time = "2025-05-13T16:11:13.299Z" }, +] + [[package]] name = "psycopg-pool" version = "3.2.6" @@ -6069,6 +6183,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/af/a3eb4449c8fdde24413555a66e9c100b669f4428fc829bad4ceb73472f4f/transformers-4.51.2-py3-none-any.whl", hash = "sha256:5cb8259098b75ff4b5dd04533a318f7c4750d5307d9617e6d0593526432c404d", size = 10366692, upload-time = "2025-04-10T16:00:10.287Z" }, ] +[[package]] +name = "trino" +version = "0.336.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lz4" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "tzlocal" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/bc/1559f7eaf8501e5205be18f18acb13c2e91604c5eae5a9299963b24e62df/trino-0.336.0.tar.gz", hash = "sha256:389150841446949119c3c2c13c1a51bb4be1a27818e40ae40dd3701f36c02550", size = 55505, upload-time = "2025-08-14T11:09:16.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/5b/ce0c2740cb73d11e0fe47aff292599db0edfa8c86bbb274cc72b31a5b76c/trino-0.336.0-py3-none-any.whl", hash = "sha256:e82339e9fffe5c6c51de3bfdf28f083e3ae5945a4502739ab2094a0d08d68070", size = 57983, upload-time = "2025-08-14T11:09:15.359Z" }, +] + [[package]] name = "triton" version = "3.4.0" @@ -6423,3 +6555,109 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50e wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/d0a405dad6ab6f9f759c26d866cca66cb209bff6f8db656074d662a953dd/zstandard-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0", size = 795263, upload-time = "2025-09-14T22:18:21.683Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/ceb8d79cbad6dabd4cb1178ca853f6a4374d791c5e0241a0988173e2a341/zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2", size = 640560, upload-time = "2025-09-14T22:18:22.867Z" }, + { url = "https://files.pythonhosted.org/packages/88/cd/2cf6d476131b509cc122d25d3416a2d0aa17687ddbada7599149f9da620e/zstandard-0.25.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df", size = 5344244, upload-time = "2025-09-14T22:18:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/e14820b61a1c137966b7667b400b72fa4a45c836257e443f3d77607db268/zstandard-0.25.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53", size = 5054550, upload-time = "2025-09-14T22:18:26.445Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ce/26dc5a6fa956be41d0e984909224ed196ee6f91d607f0b3fd84577741a77/zstandard-0.25.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3", size = 5401150, upload-time = "2025-09-14T22:18:28.745Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1b/402cab5edcfe867465daf869d5ac2a94930931c0989633bc01d6a7d8bd68/zstandard-0.25.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362", size = 5448595, upload-time = "2025-09-14T22:18:30.475Z" }, + { url = "https://files.pythonhosted.org/packages/86/b2/fc50c58271a1ead0e5a0a0e6311f4b221f35954dce438ce62751b3af9b68/zstandard-0.25.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530", size = 5555290, upload-time = "2025-09-14T22:18:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/d2/20/5f72d6ba970690df90fdd37195c5caa992e70cb6f203f74cc2bcc0b8cf30/zstandard-0.25.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb", size = 5043898, upload-time = "2025-09-14T22:18:34.215Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f1/131a0382b8b8d11e84690574645f528f5c5b9343e06cefd77f5fd730cd2b/zstandard-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751", size = 5571173, upload-time = "2025-09-14T22:18:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/2a37931023f737fd849c5c28def57442bbafadb626da60cf9ed58461fe24/zstandard-0.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577", size = 4958261, upload-time = "2025-09-14T22:18:38.098Z" }, + { url = "https://files.pythonhosted.org/packages/b5/52/ca76ed6dbfd8845a5563d3af4e972da3b9da8a9308ca6b56b0b929d93e23/zstandard-0.25.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7", size = 5265680, upload-time = "2025-09-14T22:18:39.834Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/edd117dedb97a768578b49fb2f1156defb839d1aa5b06200a62be943667f/zstandard-0.25.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936", size = 5439747, upload-time = "2025-09-14T22:18:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/75/71/c2e9234643dcfbd6c5e975e9a2b0050e1b2afffda6c3a959e1b87997bc80/zstandard-0.25.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388", size = 5818805, upload-time = "2025-09-14T22:18:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/f5/93/8ebc19f0a31c44ea0e7348f9b0d4b326ed413b6575a3c6ff4ed50222abb6/zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27", size = 5362280, upload-time = "2025-09-14T22:18:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/29cc59d4a9d51b3fd8b477d858d0bd7ab627f700908bf1517f46ddd470ae/zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649", size = 436460, upload-time = "2025-09-14T22:18:49.077Z" }, + { url = "https://files.pythonhosted.org/packages/41/b5/bc7a92c116e2ef32dc8061c209d71e97ff6df37487d7d39adb51a343ee89/zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860", size = 506097, upload-time = "2025-09-14T22:18:47.342Z" }, +]