diff --git a/CHANGELOG.md b/CHANGELOG.md index 8041402..c814e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Full-text search tool backed by the engine's BM25 index. `make_hotdata_tools` grows + `search_table`/`search_column`/`search_columns`/`search_k`/`search_tool_name` and appends a + `hotdata_search_text` tool when a table and column are given; `make_hotdata_search_tool` + builds one directly, so several searchable corpora can be registered side by side. The + corpus is pinned at construction rather than chosen by the model, because nothing in the + tool surface lets an agent discover which columns carry a BM25 index. +- `bm25_search_sql` and `bm25_search_json` for building and running a ranked search without + going through a tool. `bm25_search_json` returns the same `{"metadata", "rows"}` envelope as + `execute_sql_json`. +- `hotdata_describe_tables` tool (registered by default, `describe_tables=False` to omit) and + `describe_tables_json`/`make_hotdata_describe_tables_tool`. With no argument it lists the + scoped database's tables and their column counts; with a table name it returns that table's + columns and types, capped so one wide table cannot flood the model's context. Reads + `information_schema`, so it needs no extra permissions. Without it an agent guesses column + names off the shape of the data it has already seen. +- `demo/` — an end-to-end script that creates a managed database, loads the public SF Airbnb + fixture, builds a BM25 index, invokes the search tool, and then hands both the search and + SQL tools to a LangChain agent. + +### Changed +- Tool descriptions now state the engine's actual contract instead of a one-line summary. + `hotdata_execute_sql` names the dialect and the supported constructs, points at the search + tool for text relevance when one is registered, and warns that an aggregate query must + reference a column (`COUNT(*)` alone is rejected). The database tools steer callers towards + ids, since names are non-unique display labels. Every claim was verified against a live + engine and is pinned by `tests/test_descriptions.py`. Without this an agent reaches for + `to_tsvector` and the query fails; with it, the correct search-then-SQL path is taken with + no system-prompt guidance at all. +- Require `hotdata-framework>=0.9.0` / `hotdata>=0.8.0`. The pinned 0.4.1 uploaded through + `POST /v1/files`, which the API no longer serves, so `load_managed_table` failed with a bare + `Not Found`; 0.9.0 uses the session/finalize upload flow. + +### Fixed + +- README quickstart used `create_tool_calling_agent`/`AgentExecutor`, neither of which exists + in LangChain v1; replaced with `create_agent`. +- README and `examples/langchain_basic.py` ran SQL without a database scope, which the API + now rejects with `a database is required`. Both now scope their queries. ## [0.2.2] - 2026-06-27 diff --git a/README.md b/README.md index 524f3df..e1b1ff2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # hotdata-langchain -Give your [LangChain](https://python.langchain.com/) agents access to [Hotdata](https://hotdata.dev) — run SQL against your workspace connections and work with managed databases. +Give your [LangChain](https://python.langchain.com/) agents access to [Hotdata](https://hotdata.dev) — run SQL against your workspace connections, full-text search indexed columns, and work with managed databases. ## Install @@ -14,18 +14,27 @@ Set `HOTDATA_API_KEY` in your environment. Optionally set `HOTDATA_WORKSPACE` to ## Quickstart +The package itself depends only on `langchain-core`, and works with any tool-calling model. +Running an agent additionally needs the `langchain` package and the integration for whichever +model provider you use. + ```python -from langchain.agents import AgentExecutor, create_tool_calling_agent +from langchain.agents import create_agent import hotdata_langchain as hl client = hl.from_env() -tools = hl.make_hotdata_tools(client) +tools = hl.make_hotdata_tools(client, database="sales") -agent = create_tool_calling_agent(llm=your_llm, tools=tools, prompt=your_prompt) -executor = AgentExecutor(agent=agent, tools=tools) -result = executor.invoke({"input": "How many rows are in the orders table?"}) +agent = create_agent(model=your_model, tools=tools) +result = agent.invoke( + {"messages": [{"role": "user", "content": "Which product categories have the most orders?"}]} +) +print(result["messages"][-1].content) ``` +Queries run against a database scope, so pass `database=` (a managed database name or id). +`hl.from_env().list_managed_databases()` shows what is available in the workspace. + ## Tools `make_hotdata_tools(client)` returns a list of LangChain `StructuredTool` objects ready to pass to any agent: @@ -36,13 +45,33 @@ result = executor.invoke({"input": "How many rows are in the orders table?"}) | `hotdata_list_managed_databases` | List available managed databases | | `hotdata_create_managed_database` | Create a new managed database | | `hotdata_load_managed_table` | Load a parquet file into a managed table | +| `hotdata_describe_tables` | List tables, or one table's columns and types | +| `hotdata_search_text` | Full-text search an indexed column, ranked by relevance (opt-in — see below) | + +The descriptions carry the engine's contract — dialect, what SQL can and cannot do, and where +to look things up — so an agent does not need a system prompt explaining the query engine. + +## Letting the agent discover the schema + +`hotdata_describe_tables` is registered by default. Called with no arguments it lists every +table with its column count; called with a table name it returns that table's columns and +types. Without it an agent has to guess column names, and a guess that misses fails the query. + +```python +tools = hl.make_hotdata_tools(client, database="sales") # included +tools = hl.make_hotdata_tools(client, database="sales", describe_tables=False) # omitted +``` + +It reads `information_schema` in whichever database the tools are scoped to, so it needs no +extra permissions. With it turned off, the SQL tool's description tells the agent to query +`information_schema` directly instead. ## Calling tools directly You can also invoke tools outside of an agent loop: ```python -tools = {t.name: t for t in hl.make_hotdata_tools(client)} +tools = {t.name: t for t in hl.make_hotdata_tools(client, database="sales")} result = tools["hotdata_execute_sql"].invoke({"sql": "SELECT * FROM orders LIMIT 10"}) print(result) # JSON rows @@ -60,9 +89,64 @@ tools["hotdata_load_managed_table"].invoke({ }) ``` +## Full-text search + +Point the agent at a text column carrying a BM25 index and it gets a search tool alongside SQL: + +```python +tools = hl.make_hotdata_tools( + client, + database="sf_airbnb", + search_table="default.public.listings", # catalog.schema.table + search_column="description", # must have a BM25 index + search_columns=["id", "name", "price", "description"], # what each hit returns + search_k=5, +) + +hits = {t.name: t for t in tools}["hotdata_search_text"].invoke( + {"query": "cozy apartment with a view"} +) +``` + +Rows come back ranked, each with a `score`. The agent supplies only `query` and an optional +`k`; the table and column are fixed when you build the tool. That is deliberate — nothing in +the tool surface lets an agent discover which columns are indexed, and the engine errors +outright rather than falling back to a scan when a column has no BM25 index. + +Inside a managed database the built-in catalog is always `default`, so a managed table reads +as `default..` when `database=` scopes the query to it. + +For more than one searchable corpus, build the tools yourself and give each a distinct name +and description — the agent then routes on the descriptions: + +```python +tools = [ + # Configure the first corpus here, so the SQL tool's description still names a search + # tool to defer text matching to. Passing no search_table/search_column drops that, + # and the agent goes back to trying to match text in SQL. + *hl.make_hotdata_tools( + client, + database="sf_airbnb", + search_table="default.public.listings", + search_column="description", + search_tool_name="search_listings", + ), + hl.make_hotdata_search_tool( + client, table="default.public.reviews", column="comments", + name="search_reviews", database="sf_airbnb", + ), +] +``` + +Provisioning the index itself is not yet part of this package; create it through the Hotdata +API or CLI. `demo/` has a script that does the whole flow — managed database, data load, BM25 +index, then an agent that picks between search and SQL. + ## Scoping queries to a managed database -Pass `database=` so all SQL the agent runs resolves against a specific managed database: +`database=` resolves all SQL the agent runs against a specific managed database, by name or +id. The API requires a database scope, so queries fail with `a database is required` without +it: ```python tools = hl.make_hotdata_tools(client, database="sales") @@ -83,6 +167,9 @@ uv run python examples/langchain_basic.py uv run python examples/langchain_managed_db.py ``` +For a full end-to-end run against a real workspace — data load, BM25 index build, then an +agent choosing between search and SQL — see [`demo/`](demo/README.md). + ## Development ```bash diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..4770417 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,127 @@ +# BM25 search demo + +Takes a Hotdata workspace from empty to a LangChain agent that full-text searches real +data, in one script. Uses the public San Francisco Airbnb listings fixture (7,535 rows) +and builds a BM25 index over the free-text `description` column. + +## Run it + +Steps 1–5 need only a Hotdata key and exercise the tool directly, no model involved: + +```bash +uv run --group demo --env-file .env python demo/bm25_search_demo.py +``` + +The agent step (6) runs when you name a tool-calling model. Any provider works — install +its LangChain integration, set its key, and pass `:`: + +```bash +uv run --group demo --env-file .env python demo/bm25_search_demo.py \ + --model ':' +``` + +The script is safe to re-run: it reuses the managed database, skips the load when the +table already has rows, and reuses an existing index. + +```bash +# different search text, more hits +uv run --group demo --env-file .env python demo/bm25_search_demo.py \ + --query "quiet garden studio near the park" --k 10 + +# stop before the agent step even with a model set +uv run --group demo --env-file .env python demo/bm25_search_demo.py --skip-agent + +# tear down the managed database it created +uv run --group demo --env-file .env python demo/bm25_search_demo.py --cleanup + +# trace the run into a named LangSmith project +LANGSMITH_TRACING=true LANGSMITH_PROJECT=hotdata-langchain-bm25 \ + uv run --group demo --env-file .env python demo/bm25_search_demo.py +``` + +## Credentials + +| Variable | Needed for | Notes | +|---|---|---| +| `HOTDATA_API_KEY` | steps 1–5 | everything except the agent run | +| `HOTDATA_WORKSPACE` | optional | pins a workspace; first available otherwise | +| your model provider's key | step 6 | skipped without it, or without `--model` | +| `LANGSMITH_API_KEY` + `LANGSMITH_TRACING=true` | optional | traces the run to LangSmith | +| `LANGSMITH_PROJECT` | optional | project the traces land in (default: `default`) | + +BM25 needs no embedding provider — just a string column — so there is no extra +credential for the index itself, unlike a vector index. LangSmith is observability +only; the agent still needs a model provider key of its own. + +With tracing on, every tool call becomes a run in the LangSmith project — the search +tool shows up as a `tool` run named `hotdata_search_text` carrying its query and the +ranked JSON it returned, so the generated SQL and the agent's choice between search +and SQL are both inspectable after the fact. + +## What each step does + +1. **Managed database** — creates `langchain_bm25_demo` with `public.listings` declared + up front, so the load materialises into it directly. +2. **Listings data** — downloads the fixture parquet (cached in the system temp dir) and + loads it into the managed table. +3. **BM25 index** — creates a `bm25` index on `description` through `IndexesApi` and + polls until it reports ready. This step is a hard prerequisite: `bm25_search` has no + brute-force fallback and errors outright when no index exists. +4. **Tools** — reads the real table schema and narrows the projection to a handful of + useful columns. The listings table is 85 columns wide; returning all of them would + flood the agent's context. +5. **Direct tool invocation** — prints the generated SQL, then the ranked hits with + their BM25 scores. Proves the tool against the live engine without an LLM in the loop. +6. **Agent run** — gives a LangChain agent both `hotdata_search_text` and + `hotdata_execute_sql` and asks a question neither answers alone: find listings whose + descriptions mention a quiet garden studio, then report listing counts and average + review scores across *all* listings in those neighbourhoods. The aggregate spans the + whole dataset, not the handful search returned, so the agent has to use search to + identify the neighbourhoods and SQL to aggregate over them. The printed tool calls + show which pathway it picked for which part. + + Set the model with `--model` or `DEMO_MODEL`; any tool-calling model works, and the + step is skipped when its provider key is absent. + + **What this step demonstrates is the routing.** Across five runs of the current + configuration the agent called the search tool and the schema tool every time, and the + final table matched the whole-neighbourhood figures every time. Two of those runs also + recovered from a bad query after the error was handed back to them. + + Do not read five runs as a guarantee. Answering a compound question correctly is a + property of the model rather than of these tools, and an earlier configuration — which + shared one row budget between the search and SQL tools, silently truncating the + aggregate — got it right in only four of five. The printed tool calls are the reliable + part; treat the prose answer as illustrative. + +## What makes the agent run work + +**The tool descriptions, not the system prompt.** The demo's system prompt says only who +the agent is — it deliberately says nothing about which tool to use or how the engine +behaves. That guidance lives in the tool descriptions, so any application gets it without +having to teach the model about the query engine. + +It matters. An earlier version of this demo had a one-line SQL tool description and a +system prompt spelling out the rules; the model still reached for Postgres full-text +idioms (`to_tsvector`/`plainto_tsquery`), which the engine rejects with +`Invalid function 'to_tsvector'`. With the constraint stated in the SQL tool's own +description instead, the model uses the search tool and feeds its results into SQL — +even with no system-prompt guidance at all. + +**Tool errors have to reach the model.** The tools raise on failure, and an exception out +of a tool aborts the whole graph — so the demo wraps them (`with_error_feedback`) to +return the error as a message instead. The wrapper also digs the engine's real message +out of the exception chain: the framework raises `RuntimeError("Bad Request")` while the +useful text sits in the underlying API response body. Descriptions lower the failure +rate; readable errors are what let the model recover from what slips through. + +## Why the generated SQL looks the way it does + +Step 5 prints the query. Two details in it are load-bearing: + +- **`ORDER BY score DESC`** — the engine returns hits in rowid order, not ranked, so + ranking has to be asked for explicitly. +- **`k` appearing twice** — once as `bm25_search(...)`'s fourth argument and once as a + trailing `LIMIT`. The fourth argument is what actually bounds the search, because + `ORDER BY` blocks limit pushdown; relying on the trailing `LIMIT` alone would let the + scan fall back to the engine's much larger default bound. diff --git a/demo/bm25_search_demo.py b/demo/bm25_search_demo.py new file mode 100644 index 0000000..f12fe8c --- /dev/null +++ b/demo/bm25_search_demo.py @@ -0,0 +1,351 @@ +"""End-to-end demo of the Hotdata BM25 search tool, from empty workspace to agent. + +Stands up everything the tool needs, then exercises it twice — once by invoking the +tool directly, once through a LangChain agent that also has the SQL tool and has to +pick between them. + + uv run --group demo --env-file .env python demo/bm25_search_demo.py + +The agent step works with any tool-calling model — pass one with --model or DEMO_MODEL +— and is skipped when its provider key is not set; every step before it needs only +HOTDATA_API_KEY. Set LANGSMITH_API_KEY and LANGSMITH_TRACING=true to trace the run to +LangSmith. +""" + +from __future__ import annotations + +import argparse +import json +import os +import tempfile +import time +import urllib.request +from pathlib import Path +from typing import Any + +import hotdata + +import hotdata_langchain as hl + +PARQUET_URL = "https://www.hotdata.dev/data/sf-airbnb-listings.parquet" +USER_AGENT = "hotdata-langchain-demo" +DATABASE = "langchain_bm25_demo" +SCHEMA = "public" +TABLE = "listings" +SEARCH_COLUMN = "description" +INDEX_NAME = "listings_description_bm25" + +# The managed database is addressable as the `default` catalog inside its own scope, +# so the search tool's table reference is catalog-qualified against that. +TABLE_REF = f"default.{SCHEMA}.{TABLE}" + +# Narrow the columns each hit returns: the listings table is 85 columns wide and all +# of them would land in the agent's context. Filtered against the real schema below. +# `price` is excluded deliberately — it is NULL for every row in this fixture. +PREFERRED_COLUMNS = ["id", "name", "room_type", "neighbourhood_cleansed", "description"] + +DEFAULT_QUERY = "cozy apartment with a view" +# Env var holding the provider's key, looked up from the provider prefix of --model so the +# agent step can be skipped with a useful message rather than failing inside the provider. +# An unlisted provider simply skips the pre-check. +PROVIDER_KEY_VARS = { + "anthropic": "ANTHROPIC_API_KEY", + "google_genai": "GOOGLE_API_KEY", + "groq": "GROQ_API_KEY", + "mistralai": "MISTRAL_API_KEY", + "openai": "OPENAI_API_KEY", +} +INDEX_TIMEOUT_SECONDS = 600 +#: Row budget for the SQL tool, separate from the search tool's `k`. +SQL_MAX_ROWS = 100 + +# Deliberately not answerable from search results alone: the ratings and counts span +# every listing in the matched neighbourhoods, not just the handful search returned. +# That forces the agent to use search to identify the neighbourhoods and SQL to +# aggregate over them, which is the routing behaviour the demo exists to show. +AGENT_TASK = ( + "Find listings whose descriptions mention a quiet garden studio, and tell me which " + "neighbourhoods they are in. Then, across all listings in those neighbourhoods, " + "report the number of listings and the average review score for each one." +) + + +def step(message: str) -> None: + print(f"\n=== {message} ===") + + +def download_parquet() -> Path: + path = Path(tempfile.gettempdir()) / "sf-airbnb-listings.parquet" + if path.exists() and path.stat().st_size > 0: + print(f"Using cached fixture at {path} ({path.stat().st_size / 1_000_000:.1f} MB)") + return path + print(f"Downloading {PARQUET_URL}") + # The default urllib user-agent is rejected with a 403 by the asset host. + request = urllib.request.Request(PARQUET_URL, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=180) as response: + path.write_bytes(response.read()) + print(f"Saved {path} ({path.stat().st_size / 1_000_000:.1f} MB)") + return path + + +def find_database(client: hl.HotdataClient, name: str) -> Any | None: + for db in client.list_managed_databases(): + if db.description == name or db.id == name: + return db + return None + + +def ensure_database(client: hl.HotdataClient) -> Any: + existing = find_database(client, DATABASE) + if existing is not None: + print(f"Reusing managed database {DATABASE!r} (id={existing.id})") + return existing + db = client.create_managed_database(description=DATABASE, schema=SCHEMA, tables=[TABLE]) + print(f"Created managed database {DATABASE!r} (id={db.id}) with {SCHEMA}.{TABLE} declared") + return db + + +def load_listings(client: hl.HotdataClient, parquet: Path) -> None: + loaded = client.load_managed_table(DATABASE, TABLE, schema=SCHEMA, file=str(parquet)) + print(f"Loaded {loaded.row_count} rows into {loaded.full_name}") + + +def table_columns(client: hl.HotdataClient) -> list[str]: + """Return the table's column names, through the same tool the agent gets.""" + described = json.loads( + hl.describe_tables_json(client, table=f"{SCHEMA}.{TABLE}", database=DATABASE) + ) + return [column["name"] for column in described["columns"]] + + +def ensure_bm25_index(client: hl.HotdataClient, connection_id: str) -> None: + indexes_api = hotdata.IndexesApi(client.api) + existing = indexes_api.list_indexes(connection_id, SCHEMA, TABLE).indexes + match = next((idx for idx in existing if idx.index_name == INDEX_NAME), None) + + if match is None: + print(f"Creating BM25 index {INDEX_NAME!r} on {SCHEMA}.{TABLE}.{SEARCH_COLUMN}") + indexes_api.create_index( + connection_id, + SCHEMA, + TABLE, + hotdata.CreateIndexRequest( + index_name=INDEX_NAME, + columns=[SEARCH_COLUMN], + index_type="bm25", + var_async=True, + ), + ) + else: + print(f"Index {INDEX_NAME!r} already exists (status={match.status})") + + deadline = time.monotonic() + INDEX_TIMEOUT_SECONDS + while True: + indexes = indexes_api.list_indexes(connection_id, SCHEMA, TABLE).indexes + current = next((idx for idx in indexes if idx.index_name == INDEX_NAME), None) + if current is not None and current.status == hotdata.IndexStatus.READY: + print(f"Index ready (type={current.index_type}, columns={current.columns})") + return + if time.monotonic() > deadline: + raise TimeoutError( + f"index {INDEX_NAME!r} not ready after {INDEX_TIMEOUT_SECONDS}s " + f"(status={getattr(current, 'status', 'missing')})" + ) + status = getattr(current, "status", "missing") + print(f" waiting for index build (status={status})…") + time.sleep(5) + + +def print_hits(payload: str, *, query: str) -> None: + parsed = json.loads(payload) + rows = parsed["rows"] + print(f"Top {len(rows)} hits for {query!r} (took {parsed['metadata']['execution_time_ms']}ms)") + for rank, row in enumerate(rows, start=1): + description = str(row.get(SEARCH_COLUMN, "")) + snippet = description[:110].replace("\n", " ") + label = row.get("name") or row.get("id") or "—" + print(f" {rank}. score={row['score']:.3f} {label}") + print(f" {snippet}…") + + +def engine_error_message(exc: BaseException) -> str: + """Pull the engine's own message out of a failed call. + + The framework raises ``RuntimeError(e.reason)`` — "Bad Request" — while the useful + text ("Invalid function 'to_tsvector'. Did you mean 'to_char'?") stays in the + ApiException body further down the ``__cause__`` chain. + """ + node: BaseException | None = exc + while node is not None: + body = getattr(node, "body", None) + if body: + try: + return str(json.loads(body)["error"]["message"]) + except (ValueError, KeyError, TypeError): + return str(body)[:500] + node = node.__cause__ + return str(exc) + + +def with_error_feedback(tools: list[Any]) -> list[Any]: + """Return tools that hand failures back to the model instead of raising. + + An agent that cannot see why a call failed cannot correct it, and an exception + out of a tool aborts the whole graph. Arguably belongs in the package itself. + """ + + def wrap(tool: Any) -> Any: + inner = tool.func + + def safe(*args: Any, **kwargs: Any) -> str: + try: + return str(inner(*args, **kwargs)) + except Exception as e: + message = engine_error_message(e) + print(f" [tool error fed back to the model] {message[:120]}") + return json.dumps({"error": message}) + + return tool.model_copy(update={"func": safe}) + + return [wrap(t) for t in tools] + + +def run_agent(tools: list[Any], *, model: str) -> None: + from langchain.agents import create_agent + + tracing = os.environ.get("LANGSMITH_TRACING", "").lower() in {"1", "true", "yes"} + has_langsmith_key = bool(os.environ.get("LANGSMITH_API_KEY")) + project = os.environ.get("LANGSMITH_PROJECT", "default") + print(f"LangSmith tracing={tracing} (api key present={has_langsmith_key}, project={project!r})") + + agent = create_agent( + model=model, + tools=with_error_feedback(tools), + # Role only. Which tool to reach for, and the engine's constraints, come from + # the tool descriptions themselves — an app should not have to teach the model + # how the query engine behaves. + system_prompt=( + "You are a data analyst working with a dataset of San Francisco Airbnb " + "listings. Cite the concrete numbers you retrieve." + ), + ) + result = agent.invoke({"messages": [{"role": "user", "content": AGENT_TASK}]}) + + print("\n--- tool calls the agent made ---") + for message in result["messages"]: + for call in getattr(message, "tool_calls", None) or []: + print(f" {call['name']}({json.dumps(call['args'])[:160]})") + + print("\n--- final answer ---") + print(result["messages"][-1].content) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--query", default=DEFAULT_QUERY, help="search text to run") + parser.add_argument("--k", type=int, default=5, help="how many ranked hits to return") + parser.add_argument( + "--model", + default=os.environ.get("DEMO_MODEL"), + help="tool-calling model for the agent step, e.g. ':' " + "(or set DEMO_MODEL); the agent step is skipped without one", + ) + parser.add_argument("--skip-agent", action="store_true", help="stop after direct tool use") + parser.add_argument("--reload", action="store_true", help="reload the parquet even if loaded") + parser.add_argument( + "--cleanup", action="store_true", help="delete the demo managed database and exit" + ) + args = parser.parse_args() + + client = hl.from_env() + print(f"Connected to {client.host} (workspace={client.workspace_id})") + + if args.cleanup: + existing = find_database(client, DATABASE) + if existing is None: + print(f"No managed database named {DATABASE!r} to delete") + else: + client.delete_managed_database(existing.id) + print(f"Deleted managed database {DATABASE!r} (id={existing.id})") + client.close() + return + + try: + step("1. Managed database") + db = ensure_database(client) + + step("2. Listings data") + already_loaded = False + if not args.reload: + # Probing with a row read rather than COUNT(*): the engine rejects a + # projection that is aggregates only. + try: + probe = client.execute_sql(f"SELECT id FROM {TABLE_REF} LIMIT 1", database=DATABASE) + already_loaded = bool(probe.rows) + if already_loaded: + print(f"{TABLE_REF} already holds data; skipping load (--reload to force)") + except Exception as e: + print(f"Table not queryable yet ({type(e).__name__}); loading fixture") + if not already_loaded: + load_listings(client, download_parquet()) + + step("3. BM25 index") + ensure_bm25_index(client, db.default_connection_id) + + step("4. Tools") + available = table_columns(client) + columns = [c for c in PREFERRED_COLUMNS if c in available] + if SEARCH_COLUMN not in columns: + columns.append(SEARCH_COLUMN) + print(f"Table has {len(available)} columns; projecting {columns}") + + tools = hl.make_hotdata_tools( + client, + database=DATABASE, + # Not args.k: max_rows also caps the SQL tool, and the agent's aggregate in + # step 6 groups over whole neighbourhoods, which a search-sized budget would + # silently truncate. Search hits and SQL rows are different budgets. + max_rows=SQL_MAX_ROWS, + search_table=TABLE_REF, + search_column=SEARCH_COLUMN, + search_columns=columns, + search_k=args.k, + ) + by_name = {tool.name: tool for tool in tools} + print(f"Tools exposed to the agent: {sorted(by_name)}") + + step("5. Direct tool invocation") + search = by_name["hotdata_search_text"] + sql = hl.bm25_search_sql( + table=TABLE_REF, + column=SEARCH_COLUMN, + query=args.query, + k=args.k, + columns=columns, + ) + print(f"SQL the tool will run:\n {sql}") + print_hits(search.invoke({"query": args.query}), query=args.query) + + done = "Everything above is the tool working end to end against the real engine." + if args.skip_agent: + print("\n--skip-agent set; stopping before the agent run") + return + if not args.model: + print("\nNo model given — skipping the agent run.") + print("Pass --model ':' (or set DEMO_MODEL) to run it.") + print(done) + return + provider_key_var = PROVIDER_KEY_VARS.get(args.model.split(":", 1)[0]) + if provider_key_var and not os.environ.get(provider_key_var): + print(f"\n{provider_key_var} is not set — skipping the agent run.") + print(done) + return + + step("6. LangChain agent choosing between search and SQL") + run_agent(tools, model=args.model) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ff2814f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# docs/ + +Working notes for the AI-native query layer effort, checked in deliberately while the repo is +small so the reasoning stays with the code rather than in chat logs. Expect to prune these once +the roadmap is largely delivered. + +| Doc | What it is | +|---|---| +| [`engine-contract.md`](./engine-contract.md) | What the Hotdata SQL and search surface actually does, verified against a live workspace. The source of truth the tool descriptions encode — read this before asserting engine behaviour. | +| [`ai-native-layer-roadmap.md`](./ai-native-layer-roadmap.md) | Tiered plan for the layer, the routing decomposition, and the cross-repo work it surfaced (issues #36–#42). | +| [`vectorstore-plan.md`](./vectorstore-plan.md) | Design for `HotdataVectorStore` — schema, methods, SQL path, testing. Not yet implemented. | + +These are point-in-time notes, not specifications. Where one states engine behaviour it carries +the date it was verified; re-check against a live workspace before relying on it. diff --git a/docs/ai-native-layer-roadmap.md b/docs/ai-native-layer-roadmap.md new file mode 100644 index 0000000..444f080 --- /dev/null +++ b/docs/ai-native-layer-roadmap.md @@ -0,0 +1,136 @@ +# AI-native query layer — near-term roadmap + +## Context + +The team's shared vision for `hotdata-langchain` is a single `query_hotdata(...)` tool where +"the agent decides what to ask, not how to fetch it" — routing across four pathways (SQL, +full-text/BM25, vector/semantic, point lookups), merging/ranking results, with caching and +permissions underneath. Caching (`HotdataToolCache`) is built but still an unmerged draft +(PR #33, currently parked); `HotdataVectorStore` is planned (see +[`vectorstore-plan.md`](./vectorstore-plan.md)). + +A verification pass (2026-07-22) checked three previously-unverified assumptions directly +against the codebase, across `monopoly`, `runtimedb`, `datafusion-vector-search-ext`, +`hotdata-ibis`, and this repo: + +- **BM25/full-text is not a gap — it's already shipped and mature.** `runtimedb` has a real + Tantivy-backed full-text index (`src/bm25/`), a `bm25_search(...)` DataFusion table + function, its own `IndexType::Bm25` catalog entry, and a passing e2e test. `monopoly`'s + control plane is a thin proxy — RuntimeDB owns validation/dispatch. This repo just doesn't + expose it as a tool yet. +- **Point lookups are a scoped refactor, not a redesign.** The abstraction + (`PointLookupProvider::fetch_by_keys` in `datafusion-vector-search-ext`) is already + general-purpose; the coupling is in `runtimedb`'s vector-index build pipeline. Tracked as + [hotdata-langchain#34](https://github.com/hotdata-dev/hotdata-langchain/issues/34) + (implementation lands in `runtimedb`). +- **Routing/planning across pathways is genuinely greenfield** — nothing in the stack does + intent-based pathway selection today. `vector_search()`/`bm25_search()` are both explicit + SQL functions the caller must name; this repo's tools are fully independent with zero + shared dispatch. The one reusable precedent is `runtimedb`'s + `LazyTableProvider::select_best_index` (a narrow, single-heuristic strategy picker) and the + catalog's `IndexType` enum, which already models `Sorted`/`Bm25`/`Vector` side by side. + +## Routing, resolved into three separate problems (2026-07-27) + +The earlier "is routing one problem?" framing dissolved once the engine was read directly. +See [`engine-contract.md`](./engine-contract.md) for the verified specifics. + +- **Sorted index vs. table scan — already handled, by the planner.** Nobody decides. The + sorted index has no callable function; it is a transparent substitution inside + `hotdata_execute_sql`. A "search by number" tool would expose a choice that does not exist. +- **SQL vs. search — the model decides, and it needs telling.** This is an intent + difference no engine can recover from SQL. It is also *not* self-evident to the model: + text matching is expressible as computation (`LIKE`, `tsvector`), so an unguided agent + matches text in SQL and fails. Fixed by stating the constraint in the tool description, + which measurably works (see engine-contract.md's last section). +- **BM25 vs. vector — nobody should decide; fuse them.** Both take free text and return + ranked rows, and they fail differently: BM25 misses paraphrase, vector misses rare exact + tokens (ids, proper nouns). The established answer is `EnsembleRetriever`-style reciprocal + rank fusion. Fusion must operate on **ranks, not scores** — BM25 is unbounded (~8–11 + observed) and cosine is 0–2, so the scales are not comparable. + +The consequence for the tool surface: **one text-search capability that fuses underneath**, +not two tools the model picks between. `hotdata_search_text`'s description deliberately +names no mechanism (a test enforces that it never says "bm25"/"vector"/"hnsw"), so the +retrieval strategy can change without changing the contract the model was given. + +Fusion goes **client-side first**. Measured on a real 3-call agent run: 7,057 ms total, 65% +in model calls, 35% in tools — but each tool call was ~1,200 ms wall against 49–79 ms of +engine execution, so ~1,100 ms is round trip. A naive client-side hybrid therefore adds a +full extra round trip; issuing the two searches concurrently recovers most of it. Engine-side +`hybrid_search()` would remove it entirely and would benefit `hotdata-ibis` and dlt too, but +it is the optimization to do *after* the fusion parameters (RRF constant, dedup key, +tie-breaking) are settled empirically. + +## Checklist + +### Tier 1 — buildable now, zero blockers (this repo + `sdk-python`) + +- [x] **BM25 tool.** Shipped as `hotdata_langchain/search.py` — `hotdata_search_text`, with the + corpus pinned at construction (nothing lets an agent discover which columns are indexed, + and the engine errors outright when one is missing). +- [x] **Tool descriptions that carry the engine's contract.** The highest-leverage item found + so far, and not originally on this list. A 12-token SQL description produced a failed + run; the contract version produces the correct search-then-SQL path with no system + prompt at all. Pinned by `tests/test_descriptions.py`. +- [x] **Schema discovery.** `hotdata_describe_tables` over `information_schema`, registered by + default. Without it an agent guesses column names — and got away with it only because + the demo fixture is a famous public dataset. +- [ ] **`HotdataVectorStore` MVP.** Fully scoped in [`vectorstore-plan.md`](./vectorstore-plan.md) + — `add_texts`, `similarity_search(_by_vector)`, `get_by_ids`, `delete`, `from_texts`, + equality metadata filtering. No open design questions left, no code written yet. +- [ ] **`create_index` on `HotdataClient` (`sdk-python`).** Generalize the originally-scoped + `create_vector_index` into `create_index(..., index_type=...)` — `CreateIndexRequest` + already accepts `"bm25"` as well as `"vector"`, so one SDK method covers self-provisioning + for both instead of building it twice. + +### Tier 2 — needs scoped backend work, not exploratory + +- [ ] **Semantic search tool, then hybrid fusion.** Tracked as + [#39](https://github.com/hotdata-dev/hotdata-langchain/issues/39). Reciprocal-rank-fusion + merge over BM25 and vector, exposed as one tool rather than two the model chooses between. + No intent classification needed — the first real, demoable slice of the "routing" vision. +- [ ] **Point-lookup generalization in `runtimedb`.** Tracked as + [hotdata-langchain#34](https://github.com/hotdata-dev/hotdata-langchain/issues/34). + Decouple the lookup-sidecar build from the vector-index pipeline, loosen the registry, + add a `point_lookup(...)` UDTF. + +### Tier 3 — not ready to scope yet, needs a decision first + +- [ ] **Structured-intent routing** (deciding *when* to reach into SQL/point-lookup, à la + `SelfQueryRetriever`). Deferred until the hybrid retriever (Tier 2) shows whether a + classifier is actually needed. +- [ ] **Permissions.** Entirely untouched by shipped or planned work; no scoping done. +- [ ] **Cross-source joins.** Engine-level question, likely beyond what client-side LangChain + code alone can provide. `attach_database_catalog` may already be the supported route for + the cross-*database* case; unverified. + +## Cross-repo work this surfaced + +Everything below came out of building the BM25 tool and running it against production. The +per-item evidence lives in the linked issues; the verified engine behaviour behind them is in +[`engine-contract.md`](./engine-contract.md). + +Grouped by code surface: + +- **`hotdata-framework` client gaps** ([#36](https://github.com/hotdata-dev/hotdata-langchain/issues/36)) — errors discard the engine's message (raises + `e.reason`, "Bad Request", losing the actionable text); no `create_index`; + `resolve_managed_database` falls back to matching non-unique display names; `from_env()` + silently picks a workspace. The first is the one with demonstrated impact on agent behaviour. +- **`runtimedb` engine gaps** ([#37](https://github.com/hotdata-dev/hotdata-langchain/issues/37)) — ungrouped `COUNT(*)`/`COUNT(1)` rejected while + `COUNT()` works (probable bug, and the shape an agent writes first); no + hybrid/RRF primitive for the eventual server-side fusion. +- **id-first addressing in this repo** ([#38](https://github.com/hotdata-dev/hotdata-langchain/issues/38)) — breaking; mirrors the `hotdata-dlt-destination` + change (its PR #59) that removed by-name resolution entirely. Worth landing before the next + release rather than shipping two breaking versions. +- **Retrieval surface** ([#39](https://github.com/hotdata-dev/hotdata-langchain/issues/39)) — vector search tool, then client-side hybrid fusion over it and BM25. +- **Discovery surface** ([#40](https://github.com/hotdata-dev/hotdata-langchain/issues/40)) — report which columns are searchable in `hotdata_describe_tables`. + Newly unblocked: indexes are invisible to SQL but `IndexesApi.list_indexes` returns them, so + this needs no engine change. It is what would let the search corpus stop being pinned. +- **Tool-layer robustness** ([#41](https://github.com/hotdata-dev/hotdata-langchain/issues/41)) — fold the demo's `with_error_feedback` into the package (a raising + tool aborts the whole LangGraph run, and `handle_tool_error` only catches `ToolException`); + URL-based table loads, since the demo has to download its fixture by hand and an agent + cannot. + +Already tracked elsewhere and deliberately not duplicated: point-lookup generalization +(hotdata-langchain#34) and the sorted-index cost model (runtimedb#481). diff --git a/docs/engine-contract.md b/docs/engine-contract.md new file mode 100644 index 0000000..aeb854e --- /dev/null +++ b/docs/engine-contract.md @@ -0,0 +1,123 @@ +# Engine contract — what the SQL and search surface actually does + +Every claim here was checked against a live workspace (`api.hotdata.dev`, RuntimeDB behind it) +rather than read off a spec, because several of them contradict what the docs or the dialect +imply. They are the facts the tool descriptions in `hotdata_langchain/` encode, so if one of +them changes, a description somewhere is now lying to the model. + +Last verified 2026-07-27 against `hotdata-framework` 0.9.0 / `hotdata` 0.8.0. + +## SQL + +Postgres dialect, and the following are confirmed working: joins, CTEs, subqueries, `GROUP BY`, +window functions, `ORDER BY`/`LIMIT`, ordinary scalar functions, `LIKE` and `ILIKE`, and +schema-qualified as well as bare table names. + +Two constraints matter enough to state in a tool description: + +- **An aggregate query must reference at least one column.** `SELECT COUNT(*) FROM t` and + `SELECT COUNT(1) FROM t` are rejected with `must either specify a row count or at least one + column`; `SELECT COUNT(id) FROM t`, `SELECT MIN(id), MAX(id) FROM t` and + `SELECT room_type, COUNT(*) … GROUP BY room_type` all work. The failing shape is the one an + agent writes first, so the description gives the workaround. +- **There is no full-text matching in SQL.** No `to_tsvector`, no `plainto_tsquery`. The engine + answers with `Invalid function 'to_tsvector'. Did you mean 'to_char'?`. `LIKE`/`ILIKE` work as + substring tests but cannot rank. + +**A database scope is required.** An unscoped query fails with `a database is required: set the +X-Database-Id header or the database_id body field`. Inside a managed database the built-in +catalog is always `default`. + +## Full-text search + +```sql +bm25_search('catalog.schema.table', 'column', 'query text' [, limit]) +``` + +Returns the table's columns plus a trailing `score` (Float32). Three properties shape +`hotdata_langchain/search.py`: + +- **Results are not sorted.** Rows come back in rowid order, like SQLite FTS5. Verified: without + `ORDER BY` the scores came back `8.788, 8.092, 8.034, 8.254, 8.496`. Ranking must be asked for. +- **The fourth argument is the real bound.** BM25 is top-k, so tantivy needs the bound before + planning. A bare `LIMIT n` pushes down and drives it, but `ORDER BY score DESC LIMIT n` does + not — the sort blocks limit pushdown and the scan falls back to the engine's much larger + default. Correctness is unaffected (explicit-`k` and trailing-`LIMIT` returned identical + top-3), and at 7.5k rows the cost was not measurable (40 ms vs 38 ms median), so this is a + scan-bound difference rather than an observed slowdown. Passing `k` explicitly is free, so we do. +- **The index is a hard prerequisite.** No brute-force fallback: a column without a BM25 index + gives `No BM25 index found on column 'name' for .public.listings`. This differs from + vector search, where scalar distance UDFs still work without an index. + +Scores are comparable within one result set, not across queries. Observed BM25 range on real +data: roughly 8–11. Cosine distance is 0–2. **Never compare or average across the two** — this is +why fusion must work on ranks (RRF), not scores. + +## Index types and how each is reached + +RuntimeDB has three (`IndexType` in `src/catalog/manager.rs`: `Sorted`, `Bm25`, `Vector`), but +they are not three of the same kind of thing: + +| Index | Reached by | Named by the caller? | +|---|---|---| +| Sorted | the planner substitutes the sorted parquet when a pushed-down filter matches the index's **leading** sort column | no — transparent | +| BM25 | `bm25_search(...)` table function | yes | +| Vector | `vector_search(...)` table function | yes | + +So the sorted index needs no tool: it is already served through `hotdata_execute_sql`. There is +no callable function for it. + +**There is no cross-modality routing in the engine.** `LazyTableProvider::select_best_index` and +`IndexAwareManagedProvider::select_catalog_index` query the catalog with +`list_indexes(..., Some(IndexType::Sorted))` — they only ever see sorted indexes, and choose +index-scan versus table-scan. The code's own comment notes a proper cost model is still needed +(runtimedb#481). Nothing in the engine chooses between BM25, vector and sorted, and a grep for +hybrid/RRF/fusion across the engine finds nothing. + +## Schema and index discovery + +Working in SQL: `information_schema.tables`, `information_schema.columns` (with +`table_catalog`, `table_schema`, `table_name`, `column_name`, `ordinal_position`, `data_type`, +`is_nullable`), `SHOW TABLES`, and `DESCRIBE
`. `hotdata_langchain/schema.py` builds on +`information_schema.columns`, so it needs no extra permissions. + +**Indexes are not visible in SQL** — no `pg_indexes`, no `information_schema.indexes`. They are +only reachable through the control plane, `IndexesApi.list_indexes(connection_id, schema, table)`, +which returns index name, type, columns and status. This is why an agent cannot currently +discover which columns are searchable, and why the search tool pins its corpus. + +## Databases and workspaces + +- **One client can query many databases.** `execute_sql(sql, database=...)` takes the scope per + call; the same client read from two different managed databases in one session. +- **Cross-database references inside a single query fail** by default: + `SELECT id FROM f1_db.public.drivers` from within another database's scope gives + `table 'f1_db.public.drivers' not found`. `DatabasesApi` does expose + `attach_database_catalog`/`detach_database_catalog`, and `bm25_search`'s scope resolution + translates "an attachment alias or `default`", so attachment is presumably the supported route + — **not verified here**. +- **Database names are not unique.** `name` is a display label; `resolve_managed_database` tries + the id first and then scans `list_databases()` matching on name. Ids are the only safe handle. +- **`from_env()` picks a workspace silently** when `HOTDATA_WORKSPACE` is unset — first active, + else first overall, no warning. `HotdataClient(api_key, workspace_id)` takes it explicitly. + +## Error reporting + +The framework raises `RuntimeError(e.reason)`, which is the bare HTTP reason (`"Bad Request"`). +The engine's actual message survives only in the underlying `ApiException`'s `body`, further down +the `__cause__` chain. This is not cosmetic: an agent shown `"Bad Request"` cannot correct +itself, while the real text (`Invalid function 'to_tsvector'…`) is directly actionable. See the +cross-repo list in [`ai-native-layer-roadmap.md`](./ai-native-layer-roadmap.md). + +## What an agent does without guidance + +Both observed with a small tool-calling model and the tools from `make_hotdata_tools`: + +- **It matches text in SQL.** With a one-line SQL tool description — even *with* a system prompt + spelling out the rule — it wrote `to_tsvector`/`plainto_tsquery`, the query failed, and the + exception aborted the whole LangGraph run. With the constraint in the SQL tool's own + description it uses the search tool correctly, with no system-prompt guidance at all. +- **It guesses column names.** It produced `AVG(review_scores_rating)` for a column that was + never in any tool output — correct only because the SF Airbnb fixture is a well-known public + dataset. On proprietary data that guess fails. With `hotdata_describe_tables` registered it + calls the overview, drills into the table, and then writes the query. diff --git a/docs/vectorstore-plan.md b/docs/vectorstore-plan.md new file mode 100644 index 0000000..5dccbc0 --- /dev/null +++ b/docs/vectorstore-plan.md @@ -0,0 +1,268 @@ +# `HotdataVectorStore` — implementation plan + +Status: plan / not yet built. Not committed — decide later whether this belongs in the repo +long-term or stays a local reference doc (mirrors the convention used in +`hotdata-dlt-destination/docs/vector-search-exploration.md`). + +## Problem & positioning + +Hotdata is working with LangChain on deeper ecosystem integration. Phase 1 of that work — `HotdataToolCache`/`cached()`, a Hotdata-backed cache for +arbitrary LangChain tool calls — shipped as draft PR #33. The team has validated two +directions coming out of that: "Hotdata as a tool for LangChain" (the existing 4-tool +foundation in `make_hotdata_tools`) and "Tool caching" (Phase 1 itself). + +`VectorStore`/RAG is the next priority, chosen specifically because it converges with Rohan's +own recent vector-search engineering: + +- **`datafusion-vector-search-ext`** — the DataFusion extension that makes USearch HNSW ANN + search a first-class SQL operator (`ORDER BY (col, query) LIMIT k`, transparently + rewritten into an index lookup). PR #31 (merged 2026-07-21) fixed the optimizer rule to see + through `SubqueryAlias` nodes, which is what makes the fast path reachable from SQL generated + by anything that aliases tables (ibis, ORMs, BI tools). +- **`runtimedb`** — the deployed query engine; PR #953 bumped its pin to pick up #31. **Merged + and confirmed live in production** — the fast path is no longer pending. +- **`hotdata-ibis`** — gets its own read-side vector helper layer (a `semantic_search()` + + distance-UDF module), planned and owned separately by Rohan; this plan cross-references it + but doesn't depend on it. +- **`hotdata-dlt-destination`** — already flows `list` embedding columns through its + write path untouched; a differentiated auto-embed-on-ingest adapter is a separate, later + piece. + +Put together, these converge on one story: one fast, DataFusion-backed engine, addressable +from SQL, ibis, and now LangChain's own `VectorStore` primitive — not three disconnected +integrations. + +**Bigger-picture context (not yet planned, understanding-only as of 2026-07-22):** the team +has separately articulated a longer-term vision of Hotdata as an "AI-native query layer" for +LangChain — a single tool that routes across SQL, full-text, vector, and point-lookup +pathways with its own query planning and permissions, rather than several discrete tools the +agent picks between. `HotdataVectorStore` (this doc) and `HotdataToolCache` are partial +building blocks toward that vision (the SQL and caching pieces, plus this doc's vector +pathway) — not the vision itself. That larger design is intentionally not scoped here; it's +tracked separately until it moves from understanding to planning. + +**This document covers `HotdataVectorStore` only** — a new class in `hotdata_langchain`. It +does not cover the `hotdata-ibis` helper or the `hotdata-dlt-destination` adapter in +implementation detail; see "Cross-repo dependency tracking" below for how those relate. + +## `HotdataVectorStore` design + +### File and constructor + +New file: `hotdata_langchain/vectorstore.py` (sibling to `cache.py`, not folded into +`databases.py`). Constructor mirrors `HotdataToolCache`'s `database`/`database_id`/`table`/`schema` +pattern from `cache.py`: + +```python +HotdataVectorStore( + client: HotdataClient, + embedding: Embeddings, + *, + database: str = "langchain_vectorstore", + database_id: str | None = None, + table: str = "vectors", + schema: str = DEFAULT_SCHEMA, + distance: Literal["cosine", "l2", "dot"] = "cosine", + metadata_columns: Mapping[str, Literal["string", "int", "float", "bool"]] | None = None, +) +``` + +`embedding` is held on `self`, not passed per-call — the universal LangChain convention, and +what lets `similarity_search(self, query, k=4, **kwargs)` match the ABC's fixed signature. + +### Storage schema + +One managed table, key = `["id"]` (enables `mode="upsert"`/`"delete"` exactly like +`HotdataToolCache`'s `cache_key` pattern): + +| column | type | purpose | +|---|---|---| +| `id` | `string` | LangChain doc id / managed-table key | +| `content` | `string` | `page_content` | +| `metadata_json` | `string` | full metadata dict (`json.dumps(..., default=_json_default)`), always kept in full for read-back fidelity | +| `embedding` | `list` | confirmed to round-trip through `load_managed_table` via a live spike in a sibling repo | +| *(promoted metadata columns)* | typed per `metadata_columns` | denormalized copy of declared metadata keys, so `WHERE` can target a real typed column — see Filtering below | + +### Methods (verified against installed `langchain_core==1.4.0` source, not docs) + +Only `similarity_search` and `from_texts` are truly `@abstractmethod` on `VectorStore`. +Everything else has a default or raises `NotImplementedError` until overridden. + +- **`add_texts`** (implement; `add_documents` derives for free from it, confirmed via the base + class's own delegation check). `self._embedding.embed_documents(texts)` → one pyarrow table + (id/content/metadata_json/embedding/promoted columns) → temp parquet → + `client.load_managed_table(..., mode="upsert", key=["id"])`. Same shape as + `HotdataToolCache.set()`. Generate ids via `uuid.uuid4().hex` when omitted — never `None` (the + key column can't be null). +- **`similarity_search` / `similarity_search_by_vector` / `similarity_search_with_score(_by_vector)`** + — implement all explicitly rather than relying on ABC defaults. See "SQL-path decision" below + for the query shape. +- **`_select_relevance_score_fn`** — mapped off `self._distance`. Default constructor value is + `cosine` specifically because its score function (`1 - distance`) needs no scale assumption; + see the `l2` caveat below. +- **`get_by_ids(ids)`** — `WHERE id IN (...)`, no vector math involved; the simplest method, + built first. +- **`delete(ids=None, **kwargs)`** — **requires** `ids` (raises if omitted; no "delete + everything" in v1, mirroring `HotdataToolCache`'s stance of never exposing an unbounded + destructive operation). Backed by `load_managed_table(..., mode="delete", key=["id"])`. + Raises on backend failure — deletes do **not** fail open (unlike the cache's fail-open + policy: silently reporting a delete succeeded when it didn't is actively dangerous, a cache + miss is not). +- **`from_texts(cls, texts, embedding, metadatas=None, *, ids=None, **kwargs)`** — classmethod; + `client` threaded through `**kwargs` (the ABC's sanctioned per-implementation extension + point, same pattern every real integration uses for constructor args the ABC can't + standardize). Builds the store, calls `add_texts`, returns it. Index creation (if requested) + happens strictly after `add_texts` — see "Dimension binding" below. +- **MMR (`max_marginal_relevance_search_by_vector`)** — **not free.** The ABC raises + `NotImplementedError` by default (confirmed by reading `InMemoryVectorStore`, LangChain's own + reference implementation) — every real implementation fetches `fetch_k` candidates *with + their raw embedding vectors* and runs + `langchain_core.vectorstores.utils.maximal_marginal_relevance`. This needs its own query + branch that *does* select the `embedding` column, which breaks the "never surface the vector + column" rule the primary read path relies on for the engine's fast-path rewrite (engine issue + #508) — so this branch is always brute-force by design. Acceptable: `fetch_k` defaults small + and is caller-bounded, so a full scan over a bounded candidate set is cheap. Own phase, own + PR — a distinct correctness surface (raw-vector round-trip on read, which the primary path + never needs), not bundled with the MVP. +- Everything else (`add_documents`, async variants via thread-pool wrapping, `as_retriever()`, + `similarity_search_with_relevance_scores`) is free from the base class — verified by tests + that they delegate correctly, no new code required. + +**Internal plumbing**: reuse `HotdataToolCache`'s `_ensure_ready()`/`_resolve_and_declare()` +pattern verbatim — resolve-or-create the managed database, best-effort `add_managed_table` with +`key=["id"]`, swallow "already declared" failures at `logger.debug`. + +### SQL-path decision + +**Build every read query as a scalar-UDF `ORDER BY ... LIMIT`, not the `vector_search_vector(...)` +table function:** + +```sql +SELECT id, content, metadata_json, , + (embedding, ARRAY[...]) AS dist +FROM "default".""."
" +[WHERE = ] +ORDER BY dist ASC +LIMIT +``` + +using the engine's index-independent scalar distance UDFs (`cosine_distance`, `l2_distance`, +`negative_dot_product` — confirmed to work as plain row-by-row functions even with **no index +at all**, always correct, just a full-table brute-force scan without one). + +Why this over the table function: this shape is correct from row one with zero +preconditions, and it transparently upgrades to the HNSW fast path the moment a +matching-metric index exists on that column — one code path, no index-vs-no-index branching +to build or test. The `vector_search_vector(...)` table function, by contrast, errors loudly +("no loaded vector index") if the index doesn't exist yet, which would make a freshly +constructed `HotdataVectorStore` unusable out of the box — a bad default for a +partnership-facing integration. The raw `embedding` column is never selected in this path +(engine issue #508: a vector column in the output declines the fast-path rewrite). + +### Metadata filtering (v1 scope) + +Equality filters only, and only on keys explicitly declared via the constructor's +`metadata_columns` (which promotes them to real typed columns at write time). +`filter={"key": value}` on a key not in `metadata_columns` raises `ValueError` immediately — +fail loudly at call time, not silently-wrong at query time. Free-form/undeclared metadata keys +are simply not filterable in v1. + +Filter predicates always go in the *same* query, in `WHERE`, ahead of `ORDER BY`/`LIMIT` — +never as an outer query wrapping an already-computed top-k result, which would silently +return fewer than `k` rows (a filter applied after top-k selection can only shrink the result, +never re-fill it). Whether a `WHERE`-filtered query still triggers the HNSW fast path is +explicitly **unverified** (attribute-filtered ANN is a harder capability many engines don't +support natively) — brute-force-but-correct is an accepted v1 cost, not a blocker, consistent +with the engine's own "brute force is always correct, just not accelerated" design. + +Ids and filter literals are charset-validated before SQL interpolation (mirroring `cache.py`'s +`_KEY_PATTERN` philosophy — reject anything outside a conservative charset rather than +attempt general SQL escaping). The query vector itself is never user-controlled text; it's a +list of floats we format ourselves. + +### Dimension binding + +A vector's dimension is only knowable after the first `embed_documents` call, but +`create_index` needs `dimensions` up front. Sequencing inside `from_texts`: (1) construct the +store, (2) call `add_texts` (embeds and writes rows — dimension is now known from the vectors +just embedded), (3) only then, if `create_index=True` was requested, create the index with the +now-known dimension. Index creation never precedes the first write. + +### `l2` relevance-score caveat + +The engine's `l2_distance` is **squared** L2 (no `sqrt`), but `VectorStore`'s default +`_euclidean_relevance_score_fn` assumes true (unsquared) Euclidean distance on +unit-normalized embeddings — using `l2` as the configured metric would produce a +wrong-scale relevance score unless corrected, and correcting it properly requires knowing +embedding normalization we don't control. Constructor defaults to `cosine` for this reason +(`1 - distance` is exact, no scale assumption); `l2`/`dot` remain available but flagged in the +docstring rather than silently "fixed." + +## Testing strategy + +No live embedding-provider credentials are available in this repo's `.env` (Hotdata +credentials only). Unit tests mirror `tests/test_cache.py`'s fixture style exactly: a fake +`HotdataClient` (`MagicMock`) backed by an in-memory dict, where `load_managed_table` does a +*real* `pq.read_table(file).to_pylist()` (exercising the `list` round-trip `cache.py` +never needed) and `execute_sql` does real SQL-shape parsing rather than a bare mocked return +value. For embeddings, use `langchain_core.embeddings.DeterministicFakeEmbedding` (confirmed +present in the installed `langchain_core`, zero new dependency) rather than a bespoke fake. + +Coverage: schema/type correctness on write; exact SQL shape on read (distance aliased, +embedding column absent from `SELECT`, filter predicate inside the same query); `ValueError` +on an undeclared filter key or a malformed id; `delete` requiring `ids`; `from_texts` +round-tripping end to end; MMR selecting the embedding column and calling into +`maximal_marginal_relevance` with the right shapes. + +**Live verification** (once real credentials or a local cluster are available — not part of +this repo's CI): round-trip `add_texts`/`similarity_search` against a real workspace with a +real embedding; `EXPLAIN` the primary query before and after provisioning a matching-metric +index, confirming the plan shows the USearch-rewritten node. Since `runtimedb` PR #953 is now +merged and live in production, this is no longer blocked on a pending deploy — it's now +directly verifiable once `HotdataVectorStore` itself exists, rather than a claim asserted from +a sibling repo's spike. `EXPLAIN` a `WHERE`-filtered query to settle whether filtered queries +still hit the fast path. + +## Phasing + +1. **MVP** — `add_texts`, `similarity_search(_by_vector)`, + `similarity_search_with_score(_by_vector)`, `get_by_ids`, `delete`, `from_texts` (no + self-provisioned index yet), promoted-column equality filtering, full unit-test suite, + `examples/langchain_vectorstore.py`, README section, CHANGELOG entry. A complete, correct, + mergeable `VectorStore` on its own — `as_retriever()`, chains, and evals all work once this + lands, independent of anything below. +2. **MMR** — its own PR; isolated correctness surface (the raw-vector read path). +3. **Self-provisioning** — gated on the `sdk-python` dependency below. Adds + `from_texts(..., create_index=True)`. +4. Docs/examples are pulled into Phase 1 rather than deferred to the end — this is the piece + the LangChain conversation will exercise first. + +## Cross-repo dependency tracking + +- **`sdk-python` (`hotdata_framework.HotdataClient`) — in scope, ours to build.** A + `create_vector_index` addition. Confirmed via full grep: zero existing index-related code in + the package today. The raw generated `hotdata.api.indexes_api.IndexesApi.create_index` + + `CreateIndexRequest` already support everything needed (`columns`, `metric`, `dimensions`, + `embedding_provider_id`, `output_column`, an async job-polling path). Follows + `create_managed_database`'s exact shape: resolve `database` → `default_connection_id`, build + the request, call the raw API, wrap `ApiException` → `RuntimeError(api_error_message(e))`, + return a frozen dataclass built field-by-field from `IndexInfoResponse` (or poll + `SubmitJobResponse.status_url` for the async path, reusing the existing polling-loop style + already in `client.py`). This addition only blocks Phase 3 (self-provisioning) above — + Phases 1–2 work today regardless, against an existing index, a not-yet-existing index, or no + index ever, by construction of the SQL-path decision above. +- **`runtimedb` PR #953 — merged and live in production.** Pin-bump to pick up + `datafusion-vector-search-ext` PR #31, confirmed deployed. Our SQL was designed to be + correct either way (the scalar UDFs work with no index at all) — this just means the HNSW + fast path is now actually live for any query matching the contract above, not merely + pending. No longer a dependency to track. +- **`hotdata-ibis` vector helper layer — external, owned separately, tracked for consistency + only.** Not a dependency of this work. Cross-referenced so both surfaces target the same + engine contract (same distance-function names, same "never select the vector column" + constraint) rather than drifting apart. +- **`hotdata-dlt-destination` write-side embedding adapter — external, future, tracked.** + Still a design sketch (`hotdata_adapter(data, embed=[...])`). Once built, completes the full + pipeline this plan enables end to end: dlt ingests and auto-embeds on the way in → + `HotdataVectorStore` reads it out for a LangChain agent. Not a blocker for this work — this + plan's MVP works against precomputed embeddings loaded any way (including today's dlt + destination, unchanged). diff --git a/examples/langchain_basic.py b/examples/langchain_basic.py index a8e9427..27003d9 100644 --- a/examples/langchain_basic.py +++ b/examples/langchain_basic.py @@ -5,14 +5,20 @@ def main() -> None: client = hl.from_env() - tools = hl.make_hotdata_tools(client) - by_name = {tool.name: tool for tool in tools} - sql_tool = by_name["hotdata_execute_sql"] - print(sql_tool.invoke({"sql": "SELECT 1 AS ok"})) + tools = {tool.name: tool for tool in hl.make_hotdata_tools(client)} + print(tools["hotdata_list_managed_databases"].invoke({})) - list_tool = by_name["hotdata_list_managed_databases"] - print(list_tool.invoke({})) + # Queries need a database scope, so pick one from the workspace. + databases = client.list_managed_databases() + if not databases: + print("No managed databases in this workspace; create one to run a query.") + client.close() + return + + scoped = hl.make_hotdata_tools(client, database=databases[0].id) + by_name = {tool.name: tool for tool in scoped} + print(by_name["hotdata_execute_sql"].invoke({"sql": "SELECT 1 AS ok"})) client.close() diff --git a/hotdata_langchain/__init__.py b/hotdata_langchain/__init__.py index 1ec683c..818cbc7 100644 --- a/hotdata_langchain/__init__.py +++ b/hotdata_langchain/__init__.py @@ -16,6 +16,19 @@ load_result_summary, managed_database_summary, ) +from hotdata_langchain.schema import ( + DEFAULT_DESCRIBE_TOOL_NAME, + describe_tables_json, + make_hotdata_describe_tables_tool, +) +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + DEFAULT_SEARCH_TOOL_NAME, + SCORE_COLUMN, + bm25_search_json, + bm25_search_sql, + make_hotdata_search_tool, +) from hotdata_langchain.tools import ( execute_sql_json, make_hotdata_tools, @@ -23,15 +36,24 @@ ) __all__ = [ + "DEFAULT_DESCRIBE_TOOL_NAME", + "DEFAULT_SEARCH_LIMIT", + "DEFAULT_SEARCH_TOOL_NAME", + "SCORE_COLUMN", "HotdataClient", "QueryResult", "__version__", + "bm25_search_json", + "bm25_search_sql", "create_managed_database", + "describe_tables_json", "execute_sql_json", "from_env", "list_managed_databases_json", "load_managed_table", "load_result_summary", + "make_hotdata_describe_tables_tool", + "make_hotdata_search_tool", "make_hotdata_tools", "managed_database_summary", "result_rows_for_llm", diff --git a/hotdata_langchain/_sql.py b/hotdata_langchain/_sql.py new file mode 100644 index 0000000..ecc64a6 --- /dev/null +++ b/hotdata_langchain/_sql.py @@ -0,0 +1,25 @@ +"""Shared SQL identifier and literal handling. + +These sit between model-authored strings and a SQL literal, so they live in one place +rather than being reimplemented per module where the two copies could drift apart. +""" + +from __future__ import annotations + +import re + +IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def validate_identifier(value: str, *, label: str) -> str: + """Return ``value`` if it is a bare SQL identifier, else raise ``ValueError``.""" + if not IDENTIFIER_RE.fullmatch(value): + raise ValueError(f"{label} must be a bare SQL identifier, got {value!r}") + return value + + +def quote_literal(value: str) -> str: + """Return ``value`` as a single-quoted SQL string literal, with quotes doubled.""" + if "\x00" in value: + raise ValueError("SQL string literals may not contain null bytes") + return "'" + value.replace("'", "''") + "'" diff --git a/hotdata_langchain/schema.py b/hotdata_langchain/schema.py new file mode 100644 index 0000000..3302bd6 --- /dev/null +++ b/hotdata_langchain/schema.py @@ -0,0 +1,140 @@ +"""Schema introspection so an agent can learn what it is allowed to query.""" + +from __future__ import annotations + +import json + +from hotdata_framework import HotdataClient +from langchain_core.tools import StructuredTool + +from hotdata_langchain._sql import validate_identifier + +DEFAULT_DESCRIBE_TOOL_NAME = "hotdata_describe_tables" + +#: Cap on columns returned for a single table, so one wide table cannot flood the context. +DEFAULT_MAX_COLUMNS = 200 + + +def _split_table(table: str) -> tuple[str | None, str]: + """Split ``schema.table`` or a bare ``table`` into its parts, validating both.""" + parts = table.split(".") + if len(parts) > 2: + raise ValueError( + "table must be 'table' or 'schema.table' (the database is already scoped), " + f"got {table!r}" + ) + for part in parts: + validate_identifier(part, label="table") + return (parts[0], parts[1]) if len(parts) == 2 else (None, parts[0]) + + +def table_overview_sql() -> str: + """Return SQL listing every table in the scoped database with its column count.""" + return ( + "SELECT table_schema, table_name, COUNT(column_name) AS column_count " + "FROM information_schema.columns " + "GROUP BY table_schema, table_name " + "ORDER BY table_schema, table_name" + ) + + +def table_columns_sql(table: str, *, limit: int = DEFAULT_MAX_COLUMNS) -> str: + """Return SQL listing one table's columns and types, in declaration order.""" + schema, name = _split_table(table) + where = f"WHERE table_name = '{name}'" + if schema is not None: + where += f" AND table_schema = '{schema}'" + return ( + f"SELECT table_schema, table_name, column_name, data_type " + f"FROM information_schema.columns {where} " + f"ORDER BY table_schema, table_name, ordinal_position " + f"LIMIT {limit}" + ) + + +def describe_tables_json( + client: HotdataClient, + *, + table: str | None = None, + database: str | None = None, + max_columns: int = DEFAULT_MAX_COLUMNS, +) -> str: + """Describe the scoped database's tables, or one table's columns, as JSON. + + Without ``table`` this returns every table with its column count — a cheap map of + what exists. With ``table`` it returns that table's columns and types in + declaration order, capped at ``max_columns`` so a wide table cannot flood the + model's context; the payload says so when the cap truncated the list. + + Raises ``ValueError`` for a non-positive ``max_columns``. + """ + if max_columns < 1: + raise ValueError(f"max_columns must be >= 1, got {max_columns}") + if table is None: + result = client.execute_sql(table_overview_sql(), database=database) + tables = [ + { + "table": f"{row['table_schema']}.{row['table_name']}", + "column_count": row["column_count"], + } + for row in result.to_records() + ] + return json.dumps({"tables": tables}, indent=2) + + # One row past the cap, so a table with exactly `max_columns` columns is reported as + # complete rather than flagged as truncated — telling the model part of the schema is + # missing is the one thing likely to send it back to guessing. + result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database) + records = result.to_records() + if not records: + return json.dumps( + {"table": table, "columns": [], "error": f"no table named {table!r} in this database"}, + indent=2, + ) + truncated = len(records) > max_columns + records = records[:max_columns] + payload: dict[str, object] = { + "table": f"{records[0]['table_schema']}.{records[0]['table_name']}", + "columns": [{"name": r["column_name"], "type": r["data_type"]} for r in records], + } + if truncated: + payload["truncated_at"] = max_columns + return json.dumps(payload, indent=2) + + +def default_describe_description() -> str: + """Return the agent-facing description for the schema tool.""" + return ( + "Discover what data is available before writing a query. Called with no " + "arguments it lists every table with how many columns it has; called with a " + "table name ('listings' or 'public.listings') it returns that table's columns " + "and their types.\n" + "Use it whenever you are unsure a table or column exists — guessing a column " + "name that is not there makes the query fail." + ) + + +def make_hotdata_describe_tables_tool( + client: HotdataClient, + *, + database: str | None = None, + name: str = DEFAULT_DESCRIBE_TOOL_NAME, + description: str | None = None, + max_columns: int = DEFAULT_MAX_COLUMNS, +) -> StructuredTool: + """Return a LangChain tool that reports the scoped database's tables and columns. + + Fails fast on a non-positive ``max_columns`` rather than at first invocation. + """ + if max_columns < 1: + raise ValueError(f"max_columns must be >= 1, got {max_columns}") + + def hotdata_describe_tables(table: str | None = None) -> str: + """List the tables in the database, or one table's columns and types.""" + return describe_tables_json(client, table=table, database=database, max_columns=max_columns) + + return StructuredTool.from_function( + func=hotdata_describe_tables, + name=name, + description=description or default_describe_description(), + ) diff --git a/hotdata_langchain/search.py b/hotdata_langchain/search.py new file mode 100644 index 0000000..af798ed --- /dev/null +++ b/hotdata_langchain/search.py @@ -0,0 +1,188 @@ +"""Full-text (BM25) search helpers and tools for LangChain agents.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence + +from hotdata_framework import HotdataClient +from langchain_core.tools import StructuredTool + +from hotdata_langchain._sql import quote_literal, validate_identifier + +#: Column the engine appends to every ``bm25_search`` result, holding the BM25 relevance score. +SCORE_COLUMN = "score" + +#: Default number of ranked hits requested when a caller does not specify one. +DEFAULT_SEARCH_LIMIT = 5 + +DEFAULT_SEARCH_TOOL_NAME = "hotdata_search_text" + +_TABLE_REF_RE = re.compile( + r"[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*" +) + + +def _validate_table_ref(table: str) -> str: + """Return ``table`` if it is a bare ``catalog.schema.table`` reference, else raise.""" + if not _TABLE_REF_RE.fullmatch(table): + raise ValueError( + "table must be a fully qualified 'catalog.schema.table' reference " + f"of bare identifiers, got {table!r}" + ) + return table + + +def _projection(column: str, columns: Sequence[str] | None) -> list[str]: + selected = list(columns) if columns is not None else [column] + if not selected: + raise ValueError("columns must not be empty") + for name in selected: + validate_identifier(name, label="column") + return [*(name for name in selected if name != SCORE_COLUMN), SCORE_COLUMN] + + +def bm25_search_sql( + *, + table: str, + column: str, + query: str, + k: int = DEFAULT_SEARCH_LIMIT, + columns: Sequence[str] | None = None, +) -> str: + """Build the SQL for a ranked BM25 top-k search over an indexed text column. + + ``table`` is a fully qualified ``catalog.schema.table`` reference. Inside a managed + database the built-in catalog is always ``default``, so a managed table reads as + ``default.public.listings`` when the query is scoped to that database. + + ``column`` must be a column carrying a BM25 index; the engine has no brute-force + fallback and errors when no index exists. ``columns`` selects which table columns + come back (defaulting to the searched column alone); ``score`` is always appended + last and never duplicated. + + ``k`` is emitted twice, and both are load-bearing. It is passed as the ``bm25_search`` + fourth argument, which bounds the search tantivy runs, and again as a trailing + ``LIMIT``. The explicit argument is what actually caps the scan: ``ORDER BY`` blocks + limit pushdown, so a query relying on the trailing ``LIMIT`` alone falls back to the + engine's much larger default bound. + + Raises ``ValueError`` for identifiers that are not bare SQL identifiers, for a + non-positive ``k``, and for search text containing null bytes. + """ + _validate_table_ref(table) + validate_identifier(column, label="column") + if k < 1: + raise ValueError(f"k must be >= 1, got {k}") + + projection = _projection(column, columns) + return ( + f"SELECT {', '.join(projection)} " + f"FROM bm25_search(" + f"{quote_literal(table)}, {quote_literal(column)}, {quote_literal(query)}, {k}) " + f"ORDER BY {SCORE_COLUMN} DESC " + f"LIMIT {k}" + ) + + +def bm25_search_json( + client: HotdataClient, + *, + table: str, + column: str, + query: str, + k: int = DEFAULT_SEARCH_LIMIT, + columns: Sequence[str] | None = None, + max_rows: int = 100, + database: str | None = None, +) -> str: + """Run a BM25 search and return ``{"metadata": ..., "rows": [...]}`` as JSON. + + Mirrors the envelope :func:`hotdata_langchain.tools.execute_sql_json` returns, so an + agent sees one result shape across every Hotdata tool. Rows arrive ranked by + ``score`` descending. + """ + sql = bm25_search_sql(table=table, column=column, query=query, k=k, columns=columns) + result = client.execute_sql(sql, database=database) + payload = { + "metadata": result.metadata_dict(), + "rows": result.to_records(max_rows=max_rows), + } + return json.dumps(payload, indent=2) + + +def default_search_description(table: str, column: str) -> str: + """Return the agent-facing tool description used when no override is given. + + Describes the capability ("find rows whose text is relevant") rather than the index + behind it, so the contract the model is given survives the retrieval strategy + changing underneath it. + """ + return ( + f"Find rows of {table} whose '{column}' text is relevant to a natural-language " + "query. This is the only way to match on what the text says — SQL cannot rank " + "rows by textual relevance.\n" + "Returns the best-matching rows ordered by a 'score' column, highest first; " + "scores are comparable within one result set but not across queries. Ask for " + "more with 'k' when you need a wider net.\n" + "Use it to identify which rows are relevant, then take the values you need from " + "the results into the SQL tool for filters, joins and aggregates." + ) + + +def make_hotdata_search_tool( + client: HotdataClient, + *, + table: str, + column: str, + columns: Sequence[str] | None = None, + k: int = DEFAULT_SEARCH_LIMIT, + name: str = DEFAULT_SEARCH_TOOL_NAME, + description: str | None = None, + max_rows: int = 100, + database: str | None = None, +) -> StructuredTool: + """Return a LangChain tool that full-text searches one indexed column. + + The corpus is pinned here rather than chosen by the model: nothing in the tool + surface lets an agent discover which columns carry a BM25 index, and the engine + errors outright when one is missing. The agent supplies only ``query`` and an + optional ``k``. + + Register the factory more than once, with distinct ``name`` and ``description`` + values, to expose several searchable corpora; the agent then routes on the + descriptions. + + A ``k`` the model supplies is clamped to ``max_rows``, since anything above it would + have the engine rank and ship rows that are then discarded before the model sees + them. The caller's own ``k`` is trusted and left alone. + """ + _validate_table_ref(table) + validate_identifier(column, label="column") + if k < 1: + raise ValueError(f"k must be >= 1, got {k}") + if max_rows < 1: + raise ValueError(f"max_rows must be >= 1, got {max_rows}") + if columns is not None: + _projection(column, columns) + default_k = k + + def hotdata_search_text(query: str, k: int | None = None) -> str: + """Search indexed text by relevance and return ranked rows as JSON.""" + return bm25_search_json( + client, + table=table, + column=column, + query=query, + k=default_k if k is None else max(1, min(k, max_rows)), + columns=columns, + max_rows=max_rows, + database=database, + ) + + return StructuredTool.from_function( + func=hotdata_search_text, + name=name, + description=description or default_search_description(table, column), + ) diff --git a/hotdata_langchain/tools.py b/hotdata_langchain/tools.py index dc0d518..b6c4027 100644 --- a/hotdata_langchain/tools.py +++ b/hotdata_langchain/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Sequence from typing import Any from hotdata_framework import DEFAULT_SCHEMA, HotdataClient, QueryResult @@ -15,6 +16,62 @@ load_result_summary, managed_database_summary, ) +from hotdata_langchain.schema import ( + DEFAULT_DESCRIBE_TOOL_NAME, + make_hotdata_describe_tables_tool, +) +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + DEFAULT_SEARCH_TOOL_NAME, + make_hotdata_search_tool, +) + + +def sql_tool_description( + search_tool_name: str | None = None, + describe_tool_name: str | None = DEFAULT_DESCRIBE_TOOL_NAME, +) -> str: + """Return the agent-facing description for the SQL tool. + + States the engine's capabilities positively rather than listing what is absent, so + the description does not turn into a false claim as the SQL surface grows. The two + constraints it does name are the ones that silently produce wrong tool calls: SQL + cannot rank text, and an aggregate query that references no column is rejected. + + ``search_tool_name`` is named as the place to do text matching only when a search + tool is actually registered alongside this one. When it is, `LIKE` is framed as a + filter on text you already know rather than a way to find relevant rows: stating + only that it "works" was observed to pull the model into `ILIKE '%word%'` instead of + searching, which returns unranked results and misses related wording. + """ + text_guidance = ( + f"To find which rows are about something, use the {search_tool_name} tool — it " + f"ranks by relevance — and then pass the values it returns into SQL as literals. " + f"SQL cannot rank text. LIKE and ILIKE only test for a literal substring you " + f"already know, so they are a filter, not a substitute for searching: " + f"ILIKE '%word%' returns unranked rows and misses the related wording a search " + f"would find." + if search_tool_name + else "LIKE and ILIKE test for a literal substring, but SQL cannot rank rows by " + "how well their text matches a phrase." + ) + discovery = ( + f"Do not guess table or column names — get them from the {describe_tool_name} tool" + if describe_tool_name + else "Do not guess table or column names — read them from " + "information_schema.tables and information_schema.columns, or DESCRIBE
" + ) + return ( + "Run a read-only SQL query and return the rows as JSON. PostgreSQL dialect: " + "joins, CTEs, subqueries, GROUP BY, window functions, ORDER BY/LIMIT and the " + "usual scalar functions all work.\n" + f"{text_guidance}\n" + "An aggregate query must reference at least one column: COUNT(*) and COUNT(1) " + "are rejected on their own, so write COUNT() or add a GROUP BY.\n" + "Tables are addressed as catalog.schema.table; inside a managed database the " + "catalog is always 'default' (schema-qualified names also resolve). " + f"{discovery}." + ) def result_rows_for_llm(result: QueryResult, *, max_rows: int = 20) -> list[dict[str, Any]]: @@ -41,8 +98,30 @@ def make_hotdata_tools( *, max_rows: int = 100, database: str | None = None, + search_table: str | None = None, + search_column: str | None = None, + search_columns: Sequence[str] | None = None, + search_k: int = DEFAULT_SEARCH_LIMIT, + search_tool_name: str = DEFAULT_SEARCH_TOOL_NAME, + describe_tables: bool = True, ) -> list[StructuredTool]: - """Return LangChain tools for SQL and managed database workflows.""" + """Return LangChain tools for SQL and managed database workflows. + + ``describe_tables`` (on by default) adds a schema-introspection tool, so the agent + can look up tables and columns instead of guessing them. It reads + ``information_schema`` in whichever database the tools are scoped to. + + Passing both ``search_table`` and ``search_column`` appends a full-text search tool + bound to that column, which requires a BM25 index on it. ``search_columns`` selects + the columns each hit returns (default: the searched column). Supplying only one of + ``search_table``/``search_column`` raises ``ValueError``. + + For more than one searchable corpus, call + :func:`hotdata_langchain.search.make_hotdata_search_tool` directly per corpus and + extend this list. + """ + if (search_table is None) != (search_column is None): + raise ValueError("search_table and search_column must be provided together") def hotdata_execute_sql(sql: str) -> str: """Run SQL against the Hotdata workspace and return JSON rows.""" @@ -83,21 +162,63 @@ def hotdata_load_managed_table( ) return json.dumps(load_result_summary(loaded), indent=2) - return [ + has_search = search_table is not None and search_column is not None + tools = [ StructuredTool.from_function( func=hotdata_execute_sql, name="hotdata_execute_sql", + description=sql_tool_description( + search_tool_name if has_search else None, + DEFAULT_DESCRIBE_TOOL_NAME if describe_tables else None, + ), ), StructuredTool.from_function( func=hotdata_list_managed_databases, name="hotdata_list_managed_databases", + description=( + "List the managed databases in this workspace. Returns each database's " + "'id' and its human-readable 'description'. Names are display labels and " + "are not unique — pass the 'id' to other tools, never the description." + ), ), StructuredTool.from_function( func=hotdata_create_managed_database, name="hotdata_create_managed_database", + description=( + "Create a managed database to hold tables you load. 'name' is a display " + "label; the response carries the 'id' to use with the other tools. Declare " + "the tables you intend to load up front as a comma- or newline-separated " + "list, so data loads straight into them." + ), ), StructuredTool.from_function( func=hotdata_load_managed_table, name="hotdata_load_managed_table", + description=( + "Load a parquet file from the local filesystem into a table that was " + "declared on a managed database, replacing whatever the table held. " + "'database' should be a database id. Only local parquet paths are " + "accepted — not URLs, and not other file formats." + ), ), ] + + if describe_tables: + tools.append(make_hotdata_describe_tables_tool(client, database=database)) + + if has_search: + assert search_table is not None and search_column is not None + tools.append( + make_hotdata_search_tool( + client, + table=search_table, + column=search_column, + columns=search_columns, + k=search_k, + name=search_tool_name, + max_rows=max_rows, + database=database, + ) + ) + + return tools diff --git a/pyproject.toml b/pyproject.toml index a91a533..816d814 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata-framework>=0.3.0", - "hotdata>=0.4.1", + "hotdata-framework>=0.9.0", + "hotdata>=0.8.0", "langchain-core>=1.0", ] @@ -21,6 +21,10 @@ dev = [ "ruff>=0.5", "mypy>=1.5", ] +demo = [ + "langchain>=1.0", + "langchain-openai>=1.0", +] [tool.uv] default-groups = ["dev"] @@ -41,6 +45,7 @@ select = ["E", "W", "F", "I", "B", "UP", "N", "C4", "DTZ", "T20", "RET", "SIM", [tool.ruff.lint.per-file-ignores] "examples/**/*.py" = ["T201"] "scripts/**/*.py" = ["T201", "E501"] +"demo/**/*.py" = ["T201"] [tool.mypy] python_version = "3.10" diff --git a/tests/conftest.py b/tests/conftest.py index b1620af..1455fbc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,20 @@ def sample_result() -> QueryResult: ) +@pytest.fixture +def search_result() -> QueryResult: + return QueryResult( + columns=["description", "score"], + rows=[["Cozy apartment with a view", 8.5], ["Cozy studio, great light", 4.25]], + row_count=2, + result_id="res_bm25", + query_run_id="run_bm25", + execution_time_ms=8, + warning=None, + error_message=None, + ) + + @pytest.fixture def mock_client(sample_result: QueryResult): client = MagicMock() diff --git a/tests/test_descriptions.py b/tests/test_descriptions.py new file mode 100644 index 0000000..8200f33 --- /dev/null +++ b/tests/test_descriptions.py @@ -0,0 +1,81 @@ +"""The tool descriptions are the contract the model plans against. + +Each claim asserted here was verified against a live engine; a description that drifts +from the engine's real behaviour misleads the model silently, so the wording that +encodes a real constraint is pinned rather than left free. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from hotdata_langchain.tools import make_hotdata_tools, sql_tool_description + +TABLE = "default.public.listings" +COLUMN = "description" + + +def descriptions(**kwargs: object) -> dict[str, str]: + client = MagicMock() + return {t.name: t.description or "" for t in make_hotdata_tools(client, **kwargs)} # type: ignore[arg-type] + + +def test_every_tool_is_described() -> None: + for name, description in descriptions().items(): + assert len(description) > 40, f"{name} has a description too thin to plan against" + + +def test_sql_description_states_the_dialect() -> None: + assert "postgresql dialect" in sql_tool_description().lower() + + +def test_sql_description_warns_that_aggregates_need_a_column() -> None: + """Verified live: ungrouped COUNT(*)/COUNT(1) are rejected, COUNT() works.""" + description = sql_tool_description() + assert "COUNT(*)" in description + assert "COUNT()" in description + assert "GROUP BY" in description + + +def test_sql_description_points_at_the_search_tool_when_one_exists() -> None: + """Verified live: an unguided agent reaches for to_tsvector, which the engine rejects.""" + description = descriptions(search_table=TABLE, search_column=COLUMN)["hotdata_execute_sql"] + assert "hotdata_search_text" in description + assert "LIKE" in description + + +def test_sql_description_frames_like_as_a_filter_not_a_search() -> None: + """Saying only that LIKE "works" was observed to pull the model away from searching.""" + description = descriptions(search_table=TABLE, search_column=COLUMN)["hotdata_execute_sql"] + assert "not a substitute for searching" in description + # The relevance route must be stated before LIKE is mentioned at all. + assert description.index("hotdata_search_text") < description.index("LIKE") + + +def test_sql_description_omits_the_search_tool_when_none_is_registered() -> None: + description = descriptions()["hotdata_execute_sql"] + assert "hotdata_search_text" not in description + assert "LIKE" in description + + +def test_sql_description_follows_a_custom_search_tool_name() -> None: + description = descriptions( + search_table=TABLE, search_column=COLUMN, search_tool_name="search_listings" + )["hotdata_execute_sql"] + assert "search_listings" in description + assert "hotdata_search_text" not in description + + +def test_database_descriptions_steer_towards_ids() -> None: + """Database names are display labels and are not unique; ids are the safe handle.""" + described = descriptions() + assert "id" in described["hotdata_list_managed_databases"] + assert "not unique" in described["hotdata_list_managed_databases"] + assert "database id" in described["hotdata_load_managed_table"].lower() + + +def test_load_description_states_the_accepted_input() -> None: + """Verified: the tool takes a local parquet path, not a URL.""" + description = descriptions()["hotdata_load_managed_table"].lower() + assert "parquet" in description + assert "url" in description diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..c312ad8 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +from hotdata_framework import QueryResult + +from hotdata_langchain.schema import ( + DEFAULT_MAX_COLUMNS, + default_describe_description, + describe_tables_json, + make_hotdata_describe_tables_tool, + table_columns_sql, + table_overview_sql, +) +from hotdata_langchain.tools import make_hotdata_tools + + +def result(columns: list[str], rows: list[list[object]]) -> QueryResult: + return QueryResult( + columns=columns, + rows=rows, + row_count=len(rows), + result_id="res", + query_run_id="run", + execution_time_ms=3, + warning=None, + error_message=None, + ) + + +OVERVIEW = result( + ["table_schema", "table_name", "column_count"], + [["public", "listings", 85], ["public", "reviews", 6]], +) +COLUMNS = result( + ["table_schema", "table_name", "column_name", "data_type"], + [ + ["public", "listings", "id", "Int64"], + ["public", "listings", "description", "LargeUtf8"], + ], +) + + +def executed_sql(client: MagicMock) -> str: + sql = client.execute_sql.call_args.args[0] + assert isinstance(sql, str) + return sql + + +# --- SQL construction ------------------------------------------------------------- + + +def test_overview_sql_groups_by_table() -> None: + sql = table_overview_sql() + assert "information_schema.columns" in sql + # COUNT(column_name), not COUNT(*): the engine rejects an aggregate that names no column. + assert "COUNT(column_name)" in sql + assert "GROUP BY table_schema, table_name" in sql + + +def test_columns_sql_filters_by_bare_table_name() -> None: + sql = table_columns_sql("listings") + assert "WHERE table_name = 'listings'" in sql + assert "table_schema =" not in sql + assert "ORDER BY table_schema, table_name, ordinal_position" in sql + + +def test_columns_sql_filters_by_schema_qualified_name() -> None: + sql = table_columns_sql("public.listings") + assert "WHERE table_name = 'listings' AND table_schema = 'public'" in sql + + +def test_columns_sql_applies_the_column_cap() -> None: + assert table_columns_sql("listings").endswith(f"LIMIT {DEFAULT_MAX_COLUMNS}") + assert table_columns_sql("listings", limit=10).endswith("LIMIT 10") + + +@pytest.mark.parametrize("table", ["a.b.c", "list ings", "list'ings", "1listings", "", "public."]) +def test_columns_sql_rejects_bad_table_reference(table: str) -> None: + with pytest.raises(ValueError): + table_columns_sql(table) + + +# --- JSON payloads ---------------------------------------------------------------- + + +def test_describe_without_table_lists_tables_and_counts() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + payload = json.loads(describe_tables_json(client)) + assert payload == { + "tables": [ + {"table": "public.listings", "column_count": 85}, + {"table": "public.reviews", "column_count": 6}, + ] + } + + +def test_describe_with_table_lists_columns_and_types() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS + payload = json.loads(describe_tables_json(client, table="listings")) + assert payload["table"] == "public.listings" + assert payload["columns"] == [ + {"name": "id", "type": "Int64"}, + {"name": "description", "type": "LargeUtf8"}, + ] + assert "truncated_at" not in payload + + +def test_describe_reports_truncation_only_when_rows_exceed_the_cap() -> None: + """A table with exactly max_columns columns is complete, not truncated.""" + client = MagicMock() + client.execute_sql.return_value = COLUMNS # two column rows + payload = json.loads(describe_tables_json(client, table="listings", max_columns=2)) + assert len(payload["columns"]) == 2 + assert "truncated_at" not in payload + + +def test_describe_reports_truncation_and_trims_to_the_cap() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS # two column rows, cap of one + payload = json.loads(describe_tables_json(client, table="listings", max_columns=1)) + assert payload["truncated_at"] == 1 + assert [c["name"] for c in payload["columns"]] == ["id"] + + +def test_describe_rejects_a_non_positive_max_columns() -> None: + """Reading one row past the cap makes a zero cap slice to empty and then index it.""" + client = MagicMock() + client.execute_sql.return_value = COLUMNS + with pytest.raises(ValueError, match="max_columns must be >= 1"): + describe_tables_json(client, table="listings", max_columns=0) + + +def test_describe_tool_rejects_a_non_positive_max_columns() -> None: + with pytest.raises(ValueError, match="max_columns must be >= 1"): + make_hotdata_describe_tables_tool(MagicMock(), max_columns=0) + + +def test_describe_queries_one_row_past_the_cap() -> None: + """Distinguishing exact-fit from truncated needs the extra row.""" + client = MagicMock() + client.execute_sql.return_value = COLUMNS + describe_tables_json(client, table="listings", max_columns=25) + assert executed_sql(client).endswith("LIMIT 26") + + +def test_describe_reports_an_unknown_table_rather_than_empty_success() -> None: + client = MagicMock() + client.execute_sql.return_value = result( + ["table_schema", "table_name", "column_name", "data_type"], [] + ) + payload = json.loads(describe_tables_json(client, table="nope")) + assert payload["columns"] == [] + assert "no table named" in payload["error"] + + +def test_describe_scopes_queries_to_the_database() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + describe_tables_json(client, database="sf_airbnb") + assert client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +# --- Tool surface ----------------------------------------------------------------- + + +def test_describe_tool_shape() -> None: + tool = make_hotdata_describe_tables_tool(MagicMock()) + assert tool.name == "hotdata_describe_tables" + assert set(tool.args) == {"table"} + assert tool.description == default_describe_description() + + +def test_describe_tool_defaults_to_the_overview() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + tool = make_hotdata_describe_tables_tool(client) + payload = json.loads(tool.invoke({})) + assert [t["table"] for t in payload["tables"]] == ["public.listings", "public.reviews"] + assert "GROUP BY" in executed_sql(client) + + +def test_describe_tool_drills_into_one_table() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS + tool = make_hotdata_describe_tables_tool(client) + tool.invoke({"table": "public.listings"}) + assert "WHERE table_name = 'listings'" in executed_sql(client) + + +def test_describe_tool_is_registered_by_default() -> None: + names = {t.name for t in make_hotdata_tools(MagicMock())} + assert "hotdata_describe_tables" in names + + +def test_describe_tool_can_be_turned_off() -> None: + names = {t.name for t in make_hotdata_tools(MagicMock(), describe_tables=False)} + assert "hotdata_describe_tables" not in names + + +def test_sql_description_points_at_the_schema_tool_when_registered() -> None: + tools = {t.name: t for t in make_hotdata_tools(MagicMock())} + assert "hotdata_describe_tables" in (tools["hotdata_execute_sql"].description or "") + + +def test_sql_description_falls_back_to_information_schema_without_the_tool() -> None: + tools = {t.name: t for t in make_hotdata_tools(MagicMock(), describe_tables=False)} + description = tools["hotdata_execute_sql"].description or "" + assert "information_schema" in description + assert "hotdata_describe_tables" not in description diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..1186010 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import json +import re +from typing import Any +from unittest.mock import MagicMock + +import pytest +from hotdata_framework import QueryResult + +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + bm25_search_json, + bm25_search_sql, + default_search_description, + make_hotdata_search_tool, +) +from hotdata_langchain.tools import make_hotdata_tools + +TABLE = "default.public.listings" +COLUMN = "description" +QUERY = "cozy apartment" + +# Matches the four-argument bm25_search(...) call, capturing the trailing limit. The +# fourth argument is what bounds the engine's search; a trailing SQL LIMIT does not +# reach the scan through ORDER BY. +_SEARCH_CALL_RE = re.compile( + r"bm25_search\('[^']*', '[^']*', '.*', (\d+)\)", +) + + +def executed_sql(client: MagicMock) -> str: + sql = client.execute_sql.call_args.args[0] + assert isinstance(sql, str) + return sql + + +# --- SQL construction ------------------------------------------------------------- + + +def test_bm25_search_sql_full_shape() -> None: + assert bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=5) == ( + "SELECT description, score " + "FROM bm25_search('default.public.listings', 'description', 'cozy apartment', 5) " + "ORDER BY score DESC " + "LIMIT 5" + ) + + +def test_bm25_search_sql_passes_k_as_explicit_fourth_argument() -> None: + """The search bound must be an argument, not only a trailing LIMIT.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=7) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None, f"bm25_search call is not in four-argument form: {sql}" + assert match.group(1) == "7" + assert sql.endswith("LIMIT 7") + + +def test_bm25_search_sql_orders_by_score_descending() -> None: + """The engine returns hits in rowid order, so ranking has to be requested.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert "ORDER BY score DESC" in sql + assert sql.index("ORDER BY score DESC") < sql.index("LIMIT") + + +def test_bm25_search_sql_defaults_k_to_search_limit() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert sql.endswith(f"LIMIT {DEFAULT_SEARCH_LIMIT}") + + +def test_bm25_search_sql_defaults_projection_to_searched_column_and_score() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert sql.startswith("SELECT description, score FROM") + + +def test_bm25_search_sql_projects_requested_columns_with_score_last() -> None: + sql = bm25_search_sql( + table=TABLE, + column=COLUMN, + query=QUERY, + columns=["id", "name", "description"], + ) + assert sql.startswith("SELECT id, name, description, score FROM") + + +def test_bm25_search_sql_does_not_duplicate_score_column() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=["id", "score", "name"]) + projection = sql[len("SELECT ") : sql.index(" FROM")] + assert projection == "id, name, score" + + +def test_bm25_search_sql_escapes_single_quotes_in_query() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query="host's place") + assert "'host''s place'" in sql + + +def test_bm25_search_sql_neutralises_quote_injection() -> None: + """LLM-authored search text reaches a SQL literal, so quotes must not terminate it.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query="x') OR 1=1 --") + assert "'x'') OR 1=1 --'" in sql + assert _SEARCH_CALL_RE.search(sql) is not None + assert sql.count("bm25_search(") == 1 + + +def test_bm25_search_sql_rejects_null_byte_in_query() -> None: + with pytest.raises(ValueError, match="null bytes"): + bm25_search_sql(table=TABLE, column=COLUMN, query="a\x00b") + + +@pytest.mark.parametrize( + "table", + [ + "listings", + "public.listings", + "default.public.listings.extra", + "default.public.'; DROP TABLE x; --", + "default..listings", + "", + ], +) +def test_bm25_search_sql_rejects_bad_table_reference(table: str) -> None: + with pytest.raises(ValueError, match=r"catalog\.schema\.table"): + bm25_search_sql(table=table, column=COLUMN, query=QUERY) + + +@pytest.mark.parametrize("column", ["desc ription", "desc'ription", "1description", ""]) +def test_bm25_search_sql_rejects_bad_column(column: str) -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + bm25_search_sql(table=TABLE, column=column, query=QUERY) + + +def test_bm25_search_sql_rejects_bad_projection_column() -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=["id; DROP TABLE x"]) + + +@pytest.mark.parametrize("k", [0, -1]) +def test_bm25_search_sql_rejects_non_positive_k(k: int) -> None: + with pytest.raises(ValueError, match="k must be >= 1"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=k) + + +def test_bm25_search_sql_rejects_empty_columns() -> None: + with pytest.raises(ValueError, match="columns must not be empty"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=[]) + + +# --- JSON envelope ---------------------------------------------------------------- + + +def test_bm25_search_json_returns_metadata_and_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + payload = json.loads(bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY)) + assert set(payload) == {"metadata", "rows"} + assert payload["metadata"]["row_count"] == 2 + assert payload["rows"][0] == {"description": "Cozy apartment with a view", "score": 8.5} + + +def test_bm25_search_json_scopes_query_to_database( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY, database="sf_airbnb") + assert mock_client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +def test_bm25_search_json_truncates_rows_to_max_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + payload = json.loads( + bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY, max_rows=1) + ) + assert len(payload["rows"]) == 1 + assert payload["metadata"]["row_count"] == 2 + + +# --- Tool surface ----------------------------------------------------------------- + + +def test_search_tool_name_and_arguments(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + assert tool.name == "hotdata_search_text" + assert set(tool.args) == {"query", "k"} + assert tool.args["query"]["type"] == "string" + + +def test_search_tool_description_grounds_the_agent(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + assert tool.description == default_search_description(TABLE, COLUMN) + assert COLUMN in tool.description + assert TABLE in tool.description + + +def test_search_description_steers_away_from_matching_text_in_sql() -> None: + """The observed failure mode is an agent doing text matching in SQL instead.""" + description = default_search_description(TABLE, COLUMN).lower() + assert "sql cannot rank rows by textual relevance" in description + assert "score" in description + + +def test_search_description_names_the_capability_not_the_index() -> None: + """The contract has to outlive the retrieval strategy behind it.""" + description = default_search_description(TABLE, COLUMN).lower() + for mechanism in ("bm25", "tantivy", "hnsw", "vector", "embedding"): + assert mechanism not in description, f"description leaks the mechanism {mechanism!r}" + + +def test_search_tool_accepts_description_override(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool( + mock_client, table=TABLE, column=COLUMN, description="Search Airbnb blurbs." + ) + assert tool.description == "Search Airbnb blurbs." + + +def test_search_tool_invocation_builds_ranked_sql( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool( + mock_client, table=TABLE, column=COLUMN, columns=["id", "description"] + ) + payload = json.loads(tool.invoke({"query": QUERY})) + assert payload["rows"][0]["score"] == 8.5 + assert executed_sql(mock_client) == ( + "SELECT id, description, score " + "FROM bm25_search('default.public.listings', 'description', 'cozy apartment', 5) " + "ORDER BY score DESC " + "LIMIT 5" + ) + + +def test_search_tool_uses_constructor_k_when_agent_omits_it( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=3) + tool.invoke({"query": QUERY}) + assert executed_sql(mock_client).endswith("LIMIT 3") + + +def test_search_tool_lets_agent_override_k( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=3) + tool.invoke({"query": QUERY, "k": 10}) + sql = executed_sql(mock_client) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None + assert match.group(1) == "10" + assert sql.endswith("LIMIT 10") + + +def test_search_tool_clamps_a_model_supplied_k_to_max_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + """Above max_rows the engine would rank rows that are discarded before the model sees them.""" + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, max_rows=10) + tool.invoke({"query": QUERY, "k": 100_000}) + sql = executed_sql(mock_client) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None + assert match.group(1) == "10" + assert sql.endswith("LIMIT 10") + + +def test_search_tool_leaves_a_caller_supplied_k_alone( + mock_client: MagicMock, search_result: QueryResult +) -> None: + """The caller is trusted; only the model's k is clamped.""" + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=50, max_rows=10) + tool.invoke({"query": QUERY}) + assert executed_sql(mock_client).endswith("LIMIT 50") + + +def test_search_tool_keeps_a_clamped_k_positive( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + tool.invoke({"query": QUERY, "k": 0}) + assert executed_sql(mock_client).endswith("LIMIT 1") + + +def test_search_tool_rejects_a_non_positive_max_rows(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="max_rows must be >= 1"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, max_rows=0) + + +def test_search_tool_validates_corpus_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match=r"catalog\.schema\.table"): + make_hotdata_search_tool(mock_client, table="listings", column=COLUMN) + + +def test_search_tool_validates_k_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="k must be >= 1"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=0) + + +def test_search_tool_validates_columns_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, columns=["bad col"]) + + +# --- Wiring into make_hotdata_tools ----------------------------------------------- + + +def tool_names(tools: list[Any]) -> set[str]: + return {tool.name for tool in tools} + + +def test_make_hotdata_tools_omits_search_tool_by_default(mock_client: MagicMock) -> None: + assert "hotdata_search_text" not in tool_names(make_hotdata_tools(mock_client)) + + +def test_make_hotdata_tools_appends_search_tool_when_configured(mock_client: MagicMock) -> None: + tools = make_hotdata_tools(mock_client, search_table=TABLE, search_column=COLUMN) + assert tool_names(tools) == { + "hotdata_execute_sql", + "hotdata_list_managed_databases", + "hotdata_create_managed_database", + "hotdata_load_managed_table", + "hotdata_describe_tables", + "hotdata_search_text", + } + + +def test_make_hotdata_tools_honours_custom_search_tool_name(mock_client: MagicMock) -> None: + tools = make_hotdata_tools( + mock_client, + search_table=TABLE, + search_column=COLUMN, + search_tool_name="search_listings", + ) + assert "search_listings" in tool_names(tools) + + +@pytest.mark.parametrize( + ("table", "column"), + [(TABLE, None), (None, COLUMN)], +) +def test_make_hotdata_tools_requires_both_search_arguments( + mock_client: MagicMock, table: str | None, column: str | None +) -> None: + with pytest.raises(ValueError, match="must be provided together"): + make_hotdata_tools(mock_client, search_table=table, search_column=column) + + +def test_make_hotdata_tools_shares_database_scope_with_search( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tools = { + tool.name: tool + for tool in make_hotdata_tools( + mock_client, database="sf_airbnb", search_table=TABLE, search_column=COLUMN + ) + } + tools["hotdata_search_text"].invoke({"query": QUERY}) + assert mock_client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +def test_make_hotdata_tools_shares_max_rows_with_search( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tools = { + tool.name: tool + for tool in make_hotdata_tools( + mock_client, max_rows=1, search_table=TABLE, search_column=COLUMN + ) + } + payload = json.loads(tools["hotdata_search_text"].invoke({"query": QUERY})) + assert len(payload["rows"]) == 1 diff --git a/tests/test_tools.py b/tests/test_tools.py index 3afa1c9..7d0b400 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -95,6 +95,7 @@ def test_make_hotdata_tools(mock_client, sample_result): "hotdata_list_managed_databases", "hotdata_create_managed_database", "hotdata_load_managed_table", + "hotdata_describe_tables", } json.loads(by_name["hotdata_execute_sql"].invoke({"sql": "select 1"})) diff --git a/uv.lock b/uv.lock index 31b994b..e4de636 100644 --- a/uv.lock +++ b/uv.lock @@ -200,6 +200,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -262,6 +271,10 @@ dependencies = [ ] [package.dev-dependencies] +demo = [ + { name = "langchain" }, + { name = "langchain-openai" }, +] dev = [ { name = "mypy" }, { name = "pytest" }, @@ -270,12 +283,16 @@ dev = [ [package.metadata] requires-dist = [ - { name = "hotdata", specifier = ">=0.4.1" }, - { name = "hotdata-framework", specifier = ">=0.3.0" }, + { name = "hotdata", specifier = ">=0.8.0" }, + { name = "hotdata-framework", specifier = ">=0.9.0" }, { name = "langchain-core", specifier = ">=1.0" }, ] [package.metadata.requires-dev] +demo = [ + { name = "langchain", specifier = ">=1.0" }, + { name = "langchain-openai", specifier = ">=1.0" }, +] dev = [ { name = "mypy", specifier = ">=1.5" }, { name = "pytest", specifier = ">=8.0" }, @@ -328,6 +345,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, + { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -349,9 +465,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "langchain" +version = "1.3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ec/0f942e78a621f8e3162ff1ed24284f469aaf51fb4607ee5831c626f2b2bc/langchain-1.3.14-py3-none-any.whl", hash = "sha256:4d10dbe91005952cddd56d0dc77aa108964da6bae90ab20063653957e901f782", size = 139560, upload-time = "2026-07-16T13:28:16.498Z" }, +] + [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -364,21 +494,94 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/fc/84e23e8adff5adcd7792273ad610c4972ec534658a52698fb8db6defa87a/langchain_core-1.5.1.tar.gz", hash = "sha256:b0df382704c6403c1e0c9603415bce09290455d4aeeb38f350d15f78ca597483", size = 972219, upload-time = "2026-07-23T20:13:22.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/1e/1eb8833dc59b9c1f49da89a4a1ad7510440c9eb57fb539ea2203294a1acd/langchain_core-1.5.1-py3-none-any.whl", hash = "sha256:c5ec8f51dd05124f950c9afd0fd8bb3f7be4e405eeb868d481b2f8ff652cb9d2", size = 561634, upload-time = "2026-07-23T20:13:20.717Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/1b/a83bf6cae4632363cef0b6f2ee1b4f62c8a5ebcf22cd8ef24430a736c2a8/langchain_openai-1.4.1.tar.gz", hash = "sha256:6d16be615d997db80294731b8e768783f1fb8e0313668e64acd50cd68acbad20", size = 3262416, upload-time = "2026-07-23T20:31:13.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/8b604dc8be2735c8ae5c655e520066231057d1301958c20c776e62bd00fb/langchain_openai-1.4.1-py3-none-any.whl", hash = "sha256:8528bb34cc78fdfd2d895573c7917f9441cbb82db5f18ae0e6b3b75d95bdefb3", size = 122067, upload-time = "2026-07-23T20:31:11.809Z" }, ] [[package]] name = "langchain-protocol" -version = "0.0.15" +version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] @@ -709,6 +912,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[package]] +name = "openai" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427, upload-time = "2026-07-23T20:15:50.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520, upload-time = "2026-07-23T20:15:48.052Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -790,6 +1012,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1252,6 +1530,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/13/bbf7d9d1887fe4a3693527c6caa232c197ea9da91f1212e9672eff60329d/regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b", size = 494009, upload-time = "2026-07-19T00:16:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/a3/19/783688e75a2bec15d50aec0d5e7e317d363808bc82a6eb6750b897bfcd7b/regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52", size = 295287, upload-time = "2026-07-19T00:16:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/cefe4f051302ca298d3f3e79ed6dbd933ac84485b9515acdd6a52d70cef7/regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665", size = 290633, upload-time = "2026-07-19T00:16:17.182Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/1045ca2cabb12e8ec41ad0d138e9f3ff1eb079d30a6f51ccf7d709b44aad/regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0", size = 785300, upload-time = "2026-07-19T00:16:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/20e5bca184e90bf1bd187efdb53363f4a7b7b34f01d54ced5740caf104bd/regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951", size = 854079, upload-time = "2026-07-19T00:16:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/09/9b/5a2e59678be3b24aa6a42b2c6d66a48daa212593e9f4096fe7ba577fa9b1/regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3", size = 899496, upload-time = "2026-07-19T00:16:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/48/9a/7317f14ed8ed9fd998d1978b4802b07bc4d79216353c435dbcc1ddd1301f/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c", size = 793541, upload-time = "2026-07-19T00:16:22.991Z" }, + { url = "https://files.pythonhosted.org/packages/6f/53/833c2db3e274d3c191f4c42fe5bfa358e4c8b617d5d7312d31334965fc46/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6", size = 785515, upload-time = "2026-07-19T00:16:24.654Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/efb2f9fa151d71db09d4015e1fb92fee47416f01c12164836bbd23e2f3c2/regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175", size = 769556, upload-time = "2026-07-19T00:16:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/45610c263f8eadb84e4a1fabd904d81d5176226faa9104ef498bf8a8b285/regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6", size = 774130, upload-time = "2026-07-19T00:16:27.786Z" }, + { url = "https://files.pythonhosted.org/packages/40/95/1b40d87c7a9e5480bec7a87bce9fd67fc3f14b5f106c8ee66d660249072f/regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095", size = 848694, upload-time = "2026-07-19T00:16:29.412Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/bb45968addd5b394ef9cd9184bd9c65ade1a819dbb2b92b71ad52a0c7907/regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0", size = 758505, upload-time = "2026-07-19T00:16:31.006Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/33386c672fbf43e21602135a0f29a97ee251a483f007fe51d10e9b2dbc93/regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a", size = 836985, upload-time = "2026-07-19T00:16:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/22/f1/9112b86e9bb075619862e8e42b604794389f1958faa68fb69bde505dd90e/regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902", size = 782610, upload-time = "2026-07-19T00:16:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f9/13d460d8a385ca0b0be9e6be80a90968b9293b3e30895543ad2d1d1653e4/regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e", size = 266772, upload-time = "2026-07-19T00:16:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/9f/90/29addd7a03e1aea402c1f31467e25c80caabc8c3735b88a23cf73b0aa9c2/regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db", size = 277967, upload-time = "2026-07-19T00:16:36.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ba/ecfce06fe66c122bc6f77ae284887a9282e4411fe1e6268c5266611ca054/regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6", size = 276963, upload-time = "2026-07-19T00:16:38.315Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1313,6 +1712,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1322,6 +1730,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/e3/03c90dadcf5b3f82b83cee9adee60ef666b329c654f58c066af44eae0287/tiktoken-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:47b1df8d73390a24f94980c75158cdd5c56d256f16d55f30cb49c230caba9ba4", size = 1036627, upload-time = "2026-05-15T04:50:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/760463e5b2e8ad2bc229ae0a17ecb06727b6cbc094f08d8f65844315632e/tiktoken-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d40c6c5aab171dcd6eb8455bc567bde404bb9def60cdb8c1299cc782b242bb9", size = 984699, upload-time = "2026-05-15T04:50:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/de/8a/8895f342a6b6aabd1a358e672f6f077b3ae51d0c63ca605d142db3bcd8ab/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b842981fa91accdffd48ff6408a977b7a91c3fbda55d353c3c68114d5c9d69e", size = 1118690, upload-time = "2026-05-15T04:50:14.234Z" }, + { url = "https://files.pythonhosted.org/packages/51/e0/92557768fb0801f0d9dd9243cb9b6d342900b05e4b1006d4771f49ce233e/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5", size = 1138423, upload-time = "2026-05-15T04:50:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b9/a3d99feeedb032ffd09cd6652077f86bdee9a70dd0b990b2b272b445d4c3/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ab10f4a21c2999846940113f6dbd72e0fa06a24119feddd74cc47e85818e06d", size = 1185077, upload-time = "2026-05-15T04:50:17.19Z" }, + { url = "https://files.pythonhosted.org/packages/cc/93/bab868277d475dc6d2aaacd34cdd239c282f4908dcc8702e0a3311a8e032/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a2937ad042d49d50eac6e1ba07c5661d4bd3942a5b1e0c0d08475c4df83676e1", size = 1241702, upload-time = "2026-05-15T04:50:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/27e9f7e0ed76e501cfefc9fb2112df4c7bf70ca96945b15ecb7615aac860/tiktoken-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:44733b99bfd72b590cd0936b1c01b3b4dd73122db2d544bc1ceeb18a7678c910", size = 876565, upload-time = "2026-05-15T04:50:20.268Z" }, + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1376,6 +1845,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.69.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046, upload-time = "2026-07-24T14:22:02.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1528,6 +2009,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "xxhash" version = "3.7.0"