From e837170bcebc1ef216ac879da6a6fd94e2c31b49 Mon Sep 17 00:00:00 2001 From: Justin Mitchel Date: Thu, 25 Jun 2026 18:48:58 -0600 Subject: [PATCH 1/5] Add SQLAlchemy dialects incl. psycopg 3 support (#3) The sqlalchemy.dialects entry points in pyproject.toml pointed at timescaledb.dialect:Timescaledb*Dialect, but that module never existed, so timescaledb:// URLs could not resolve. - Add src/timescaledb/dialect.py defining TimescaledbPsycopg2Dialect, TimescaledbPsycopgDialect (psycopg 3) and TimescaledbAsyncpgDialect as thin subclasses of the matching PostgreSQL dialects. Each sets supports_statement_cache = True to keep SQLAlchemy query caching enabled. - Register the missing timescaledb.psycopg entry point for psycopg 3. - Add tests covering URL resolution and PostgreSQL subclassing. - Document the timescaledb:// driver schemes in the README. --- README.md | 541 ++++++++++++++++++++++++++----------- pyproject.toml | 1 + src/timescaledb/dialect.py | 63 +++++ tests/test_dialect.py | 47 ++++ 4 files changed, 493 insertions(+), 159 deletions(-) create mode 100644 src/timescaledb/dialect.py create mode 100644 tests/test_dialect.py diff --git a/README.md b/README.md index ad8b12a..97f7895 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,39 @@ # TimescaleDB for Python -Python Client for TimescaleDB -- an open-source time-series database built on PostgreSQL. This package is based on SQLModel and SQLAlchemy and designed to be used with FastAPI, Flask, and more. - -Looking for Django? [Check out django-timescaledb](https://github.com/jamessewell/django-timescaledb) +Python client for [TimescaleDB](https://www.tigerdata.com/) — the open-source +time-series database built on PostgreSQL. This package is built on +[SQLModel](https://sqlmodel.tiangolo.com/) and +[SQLAlchemy](https://www.sqlalchemy.org/) and is designed to be used with +FastAPI, Flask, and any other SQLAlchemy-based project. + +It gives you Python helpers for the things you actually do with TimescaleDB: +creating hypertables, enabling the Hypercore columnstore (and legacy +compression), setting retention policies, building continuous aggregates, and +running `time_bucket` / `time_bucket_gapfill` queries. + +> Looking for Django? Check out [django-timescaledb](https://github.com/jamessewell/django-timescaledb). + +- **Supports:** Python 3.11, 3.12, 3.13, and 3.14 +- **Targets:** TimescaleDB 2.x (Hypercore columnstore needs 2.18+; direct-create + hypertables need 2.20+; generated aggregate columns need 2.28+) +- **License:** MIT + +## Contents + +- [Installation](#installation) +- [Quickstart](#quickstart) +- [Creating a hypertable](#creating-a-hypertable) + - [Automatically via `TimescaleModel`](#automatically-via-timescalemodel) + - [Manually via `create_hypertable`](#manually-via-create_hypertable) + - [Direct hypertable creation (2.20+)](#direct-hypertable-creation-220) +- [Hypercore columnstore (2.18+)](#hypercore-columnstore-218) +- [Compression (legacy)](#compression-legacy) +- [Retention policies](#retention-policies) +- [Continuous aggregates](#continuous-aggregates) +- [Querying with `time_bucket`](#querying-with-time_bucket) +- [Sample projects](#sample-projects) +- [FastAPI example](#fastapi-example) +- [Used by](#used-by) ## Installation @@ -10,13 +41,229 @@ Looking for Django? [Check out django-timescaledb](https://github.com/jamessewel pip install timescaledb ``` +You also need a PostgreSQL driver. Any SQLAlchemy-compatible driver works — +`psycopg2`, `psycopg` (v3), or `asyncpg`: + +```bash +pip install "psycopg[binary]" # recommended +``` + +The package registers `timescaledb` SQLAlchemy dialects, so connection URLs such +as `timescaledb://`, `timescaledb+psycopg://`, and `timescaledb+asyncpg://` are +available in addition to the standard `postgresql://` URLs. + ## Quickstart -The timescaledb python package provides helpers for creating hypertables, configuring compression, retention policies, and more. +```python +from sqlmodel import Field, Session, SQLModel, select + +import timescaledb +from timescaledb import TimescaleModel + +DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" + +# create_engine pins the connection timezone (defaults to "UTC") +engine = timescaledb.create_engine(DATABASE_URL, timezone="UTC") + + +class Metric(TimescaleModel, table=True): + # TimescaleModel already provides `id` and a `time` column + sensor_id: int = Field(index=True) + value: float + + +# 1. Create the regular tables +SQLModel.metadata.create_all(engine) +# 2. Convert TimescaleModel tables into hypertables (+ any policies) +timescaledb.metadata.create_all(engine) + +with Session(engine) as session: + session.add(Metric(sensor_id=1, value=42.0)) + session.commit() + + results = timescaledb.time_bucket_query( + session, + Metric, + interval="1 hour", + metric_field="value", + ) + print(results) +``` + +`TimescaleModel` supplies the `id` primary key and a timezone-aware `time` +column for you, so a model only needs its own fields. + +## Creating a hypertable + +There are three ways to turn a table into a hypertable. Pick one: + +1. **Automatically** with `TimescaleModel` + `timescaledb.metadata.create_all` — + least code, configured with class variables. +2. **Manually** with `create_hypertable` on any table that has a `time` column. +3. **Directly** with `create_table_with_hypertable` (TimescaleDB 2.20+), which + creates the table as a hypertable in a single statement. + +### Automatically via `TimescaleModel` + +```python +from sqlmodel import Field, Session, SQLModel + +import timescaledb +from timescaledb import TimescaleModel + +DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" +engine = timescaledb.create_engine(DATABASE_URL, timezone="UTC") + + +class SensorReading(TimescaleModel, table=True): + sensor_id: int = Field(index=True) + value: float + + # __time_column__ = "time" # already set by TimescaleModel + __chunk_time_interval__ = "INTERVAL 7 days" + __drop_after__ = "INTERVAL 1 year" + __enable_compression__ = True + __compress_orderby__ = "time DESC" + __compress_segmentby__ = "sensor_id" + __migrate_data__ = True + __if_not_exists__ = True + + +# Create the tables, then the hypertables + compression + retention policies +SQLModel.metadata.create_all(engine) +timescaledb.metadata.create_all(engine) +``` + +`timescaledb.metadata.create_all(engine)` walks every `TimescaleModel` subclass, +creates the hypertable, and applies whatever compression, columnstore, and +retention settings the model opts into. + +### Database drivers (SQLAlchemy dialects) + +`timescaledb` registers `timescaledb`-scheme SQLAlchemy dialects so you can make +the TimescaleDB backend explicit in your connection URL. Each one is a thin +subclass of the matching PostgreSQL driver, so behavior is identical to +PostgreSQL apart from the URL scheme: -## Hypercore Columnstore +| URL scheme | Driver | Dialect | +| --- | --- | --- | +| `timescaledb://` | `psycopg2` (default) | `TimescaledbPsycopg2Dialect` | +| `timescaledb+psycopg2://` | `psycopg2` | `TimescaledbPsycopg2Dialect` | +| `timescaledb+psycopg://` | `psycopg` (psycopg 3) | `TimescaledbPsycopgDialect` | +| `timescaledb+asyncpg://` | `asyncpg` | `TimescaledbAsyncpgDialect` | -TimescaleDB 2.18+ introduced Hypercore columnstore APIs. This package supports the modern columnstore path while keeping the older compression helpers available. +```python +import timescaledb + +# psycopg (psycopg 3) +engine = timescaledb.create_engine( + "timescaledb+psycopg://user:password@localhost:5432/timescaledb" +) +``` + +Install the driver you intend to use, e.g. `pip install "psycopg[binary]"` for +psycopg 3, `pip install psycopg2-binary` for psycopg2, or `pip install asyncpg` +for asyncpg. Plain `postgresql://` URLs continue to work unchanged. + +### Manually via `create_hypertable` + +Use this on a plain `SQLModel` table (or any existing table) that has a `time` +column. It gives you the most direct control over each step: + +```python +from sqlmodel import Field, Session, SQLModel +from datetime import datetime + +import timescaledb + +DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" +engine = timescaledb.create_engine(DATABASE_URL) + + +class Sensor(SQLModel, table=True): + id: int = Field(default=None, primary_key=True) + time: datetime = Field(default=None, primary_key=True) + sensor_id: int = Field(index=True) + value: float + + __tablename__ = "my_time_series_table" + + +hypertable_options = { + "time_column": "time", + "compress_orderby": "time DESC", + "compress_segmentby": "sensor_id", + "chunk_time_interval": "7 days", + "drop_after": "1 year", + "migrate_data": True, + "if_not_exists": True, +} + +table_name = "my_time_series_table" + +with Session(engine) as session: + # Create the table in the database + SQLModel.metadata.create_all(engine) + + # Create the hypertable + timescaledb.create_hypertable( + session, + commit=True, + table_name=table_name, + hypertable_options=hypertable_options, + ) + + # Enable compression + timescaledb.enable_table_compression( + session, + commit=True, + table_name=table_name, + compress_orderby=hypertable_options["compress_orderby"], + compress_segmentby=hypertable_options["compress_segmentby"], + ) + # Compress chunks once they age past the chunk interval + timescaledb.add_compression_policy( + session, + commit=True, + table_name=table_name, + compress_after=hypertable_options["chunk_time_interval"], + ) + # Drop chunks after the retention window + timescaledb.add_retention_policy( + session, + table_name=table_name, + drop_after=hypertable_options["drop_after"], + ) +``` + +### Direct hypertable creation (2.20+) + +TimescaleDB 2.20+ can create a table as a hypertable in one statement with +`CREATE TABLE ... WITH (tsdb.hypertable)`. For brand-new tables, compile and run +that SQL straight from a model: + +```python +from sqlmodel import Session + +import timescaledb + +with Session(engine) as session: + timescaledb.create_table_with_hypertable( + session, + SensorReading, + chunk_interval="7 days", + ) +``` + +Use `timescaledb.format_create_table_with_hypertable_sql(...)` if you just want +the SQL string without executing it. + +## Hypercore columnstore (2.18+) + +TimescaleDB 2.18 introduced the Hypercore columnstore API. This package supports +the modern columnstore path (`enable_columnstore`, `add_columnstore_policy`, +`convert_to_columnstore` / `convert_to_rowstore`) while keeping the older +compression helpers available. ```python from sqlmodel import Session @@ -38,7 +285,8 @@ with Session(engine) as session: ) ``` -You can also opt in from a `TimescaleModel`: +You can opt in from a `TimescaleModel` instead. `timescaledb.metadata.create_all` +then enables columnstore and adds the policy automatically: ```python from sqlmodel import Field @@ -56,28 +304,43 @@ class SensorReading(TimescaleModel, table=True): __columnstore_after__ = "60 days" ``` -Calling `timescaledb.metadata.create_all(engine)` enables columnstore and adds a columnstore policy for opted-in models. +Available columnstore class variables: `__enable_columnstore__`, +`__columnstore_orderby__`, `__columnstore_segmentby__`, `__columnstore_after__`, +`__columnstore_created_before__`, `__columnstore_if_not_exists__`, +`__columnstore_schedule_interval__`, and `__columnstore_timezone__`. -## Direct Hypertable Creation +Manual chunk conversion and policy inspection are also available via +`convert_to_columnstore`, `convert_to_rowstore`, `list_columnstore_policies`, +`remove_columnstore_policy`, and `sync_columnstore_policies`. -TimescaleDB 2.20+ can create a table as a hypertable directly. For new tables, compile and execute that SQL from a model: +## Compression (legacy) -```python -from sqlmodel import Session +The pre-Hypercore compression helpers (`enable_table_compression`, +`add_compression_policy`, `sync_compression_policies`) remain fully supported for +existing code and older TimescaleDB versions. On TimescaleDB 2.18+, prefer the +[Hypercore columnstore](#hypercore-columnstore-218) API for new work. See the +[manual hypertable example](#manually-via-create_hypertable) above for usage. -import timescaledb +## Retention policies -with Session(engine) as session: - timescaledb.create_table_with_hypertable( - session, - SensorReading, - chunk_interval="7 days", - ) +Drop chunks automatically once they age past a window: + +```python +timescaledb.add_retention_policy( + session, + table_name="my_time_series_table", + drop_after="1 year", +) ``` -## Continuous Aggregate Refresh +Or opt in from a model with `__drop_after__` and let +`timescaledb.metadata.create_all` apply it. Use `sync_retention_policies` to +reconcile policies across all opted-in models. -Continuous aggregates can be created, refreshed, and scheduled from a SQLModel session: +## Continuous aggregates + +Continuous aggregates can be created, scheduled, refreshed, and extended from a +SQLModel session: ```python from datetime import datetime, timezone @@ -112,6 +375,7 @@ with Session(engine) as session: window_end=datetime(2026, 2, 1, tzinfo=timezone.utc), force=True, ) + # TimescaleDB 2.28+: add a generated aggregate column without a full rebuild timescaledb.add_generated_aggregate_column( session, "conditions_summary_hourly", @@ -121,131 +385,85 @@ with Session(engine) as session: ) ``` -## Two ways to create a TimescaleDB Model - -- Automatically via `TimescaleModel` -- Manually via `create_hypertable` on any table with a `time` column +Remove a refresh policy with `remove_continuous_aggregate_policy`. The newer +policy options — `buckets_per_batch`, `max_batches_per_execution`, +`refresh_newest_first`, and `include_tiered_data` — are all supported. -Let's take a look at the manual way first. +## Querying with `time_bucket` +Two helpers wrap the most common time-series read patterns and return a list of +`{"bucket": ..., "avg": ...}` mappings. -### Manually Create a Hypertable +`time_bucket_query` buckets rows by an interval and aggregates a metric field: ```python -from sqlmodel import create_engine, Field, SQLModel -import timescaledb - -TIMESCALE_DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" -engine = create_engine(TIMESCALE_DATABASE_URL) - -class Sensor(SQLModel, table=True): - id: int = Field(default=None, primary_key=True) - time: datetime = Field(default=None, primary_key=True) - sensor_id: int = Field(index=True) - value: float - - __tablename__ = "my_time_series_table" - - -hypertable_options = { - "time_column": "time", - "compress_orderby": "time DESC", - "compress_segmentby": "sensor_id", - "chunk_time_interval": "7 days", - "drop_after": "1 year", - "migrate_data": True, - "if_not_exists": True, -} - -# Create the table and the hypertable -with Session(engine) as session: - # Create the table in the database - SQLModel.metadata.create_all(engine) - # Create the hypertable - table_name="my_time_series_table" - timescaledb.create_hypertable( - session, - commit=True, - table_name=table_name, - hypertable_options=hypertable_options - ) - - # Enable compression - timescaledb.enable_table_compression( - session, - commit=True, - table_name=table_name, - compress_orderby=hypertable_options.get('compress_orderby'), - compress_segmentby=hypertable_options.get('compress_segmentby') - ) - # Add compression interval policy - timescaledb.add_compression_policy( - session, - commit=True, - table_name=table_name, - compress_after=hypertable_options.get('chunk_time_interval') - ) - # Add retention policy - timescaledb.add_retention_policy( - session, - table_name=table_name, - drop_after=hypertable_options.get('drop_after') - ) +rows = timescaledb.time_bucket_query( + session, + Metric, + interval="1 hour", + time_field="time", + metric_field="value", +) ``` - -### Automatically via `TimescaleModel` - +`time_bucket_gapfill_query` fills gaps in a bounded time range, with optional +**LOCF** (last observation carried forward) or **interpolation**: ```python -from sqlmodel import Field - -import timescaledb -from timescaledb import create_engine, TimescaleModel - -TIMESCALE_DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" -engine = create_engine(TIMESCALE_DATABASE_URL, timezone="UTC") - -class SensorDos(TimescaleModel, table=True): - sensor_id: int = Field(index=True) - value: float - - # __time_column__ = "time" # set in TimescaleModel - __chunk_time_interval__ = "INTERVAL 7 days" - __drop_after__ = "INTERVAL 1 year" - __enable_compression__ = True - __compress_orderby__ = "time DESC" - __compress_segmentby__ = "sensor_id" - __migrate_data__ = True - __if_not_exists__ = True - +from datetime import datetime, timezone -# Create the table and the hypertable -with Session(engine) as session: - # Create the table in the database - SQLModel.metadata.create_all(engine) - # Creates all hypertable, add compression policies, and add retention policy - timescaledb.metadata.create_all(engine) +rows = timescaledb.time_bucket_gapfill_query( + session, + Metric, + interval="1 hour", + metric_field="value", + start=datetime(2026, 1, 1, tzinfo=timezone.utc), + finish=datetime(2026, 1, 2, tzinfo=timezone.utc), + use_locf=True, # or use_interpolate=True +) ``` +Both accept a `filters` list of SQLAlchemy conditions for narrowing the query. +## Sample projects -## Used by +The [`samples/`](./samples/) directory has **ten self-contained, fully tested** +example projects, each focused on a different TimescaleDB feature. Every sample +runs against TimescaleDB in Docker and ships with a `pytest` suite that spins up +a throwaway container automatically via +[`testcontainers`](https://testcontainers.com/). -- [analytics-api](https://github.com/codingforentrepreneurs/analytics-api) - Complete tutorial project for building an Analytics API using FastAPI + TimescaleDB +| # | Project | Highlights | +|---|---------|------------| +| 01 | [`iot_sensor_network`](./samples/iot_sensor_network/) | `TimescaleModel`, `create_hypertable`, `time_bucket_query`, last-point query | +| 02 | [`devops_metrics_gapfill`](./samples/devops_metrics_gapfill/) | `time_bucket_gapfill_query` with gapfill, LOCF, and interpolation | +| 03 | [`crypto_ohlcv_candles`](./samples/crypto_ohlcv_candles/) | `first()`/`last()` + `time_bucket` → OHLCV candlesticks | +| 04 | [`energy_metering_compression`](./samples/energy_metering_compression/) | native compression + measuring the ratio | +| 05 | [`hypercore_columnstore`](./samples/hypercore_columnstore/) | Hypercore columnstore (2.18+) | +| 06 | [`ecommerce_clickstream_retention`](./samples/ecommerce_clickstream_retention/) | retention policy + funnel rollups | +| 07 | [`fleet_gps_tracking`](./samples/fleet_gps_tracking/) | manual `create_hypertable` path + downsampling | +| 08 | [`continuous_aggregates_rollups`](./samples/continuous_aggregates_rollups/) | hierarchical continuous aggregates (hourly → daily) | +| 09 | [`fastapi_timeseries_api`](./samples/fastapi_timeseries_api/) | a FastAPI REST API over a hypertable, tested with `TestClient` | +| 10 | [`weather_lifecycle_full`](./samples/weather_lifecycle_full/) | capstone: hypertable + columnstore + retention + continuous aggregate + gapfill | +See [`samples/README.md`](./samples/README.md) for setup and how to run the +suites. There is also a minimal end-to-end FastAPI app in +[`sample_project/`](./sample_project/). -## Sample Usage +## FastAPI example -Below is a sample of using `timescaledb` in a FastAPI app much like the example in [./sample_project](./sample_project). +A minimal FastAPI app over a hypertable. The pattern mirrors +[`sample_project/`](./sample_project/). -`src/models.py` +`models.py` ```python +from datetime import datetime + from sqlmodel import Field, SQLModel from timescaledb import TimescaleModel -# create a model + class Metric(TimescaleModel, table=True): temp: float @@ -254,72 +472,62 @@ class Metric(TimescaleModel, table=True): __drop_after__ = "1 year" -class MetricCreate(Metric): - # not a table but a Pydantic model +class MetricCreate(SQLModel): temp: float -class MetricRead(Metric): - # not a table but a Pydantic model +class MetricRead(SQLModel): id: int temp: float - time: datetime = Field(default=None) + time: datetime ``` - -### Initialize the Database - -The `timescaledb.create_engine` is a wrapper around `sqlmodel.create_engine` (which is a wrapper around `sqlalchemy.create_engine`) that ensures a timezone is set for your database. - -`src/database.py` +`database.py` ```python -import timescaledb from sqlmodel import Session, SQLModel +import timescaledb + DATABASE_URL = "postgresql://user:password@localhost:5432/timescaledb" -TIME_ZONE = "UTC" -ECHO_QUERIES = False -engine = timescaledb.create_engine(DATABASE_URL, timezone=TIME_ZONE, echo=ECHO_QUERIES) +engine = timescaledb.create_engine(DATABASE_URL, timezone="UTC", echo=False) def get_session(): with Session(engine) as session: yield session + def init_db(): - # Create all tables - print("Creating database tables...") - # automatically creates all tables that inherit from SQLModel + # Create all tables that inherit from SQLModel SQLModel.metadata.create_all(engine) - - print("Creating hypertables...") - # automatically creates hypertables for all models that inherit from TimescaleModel + # Create hypertables (+ policies) for all TimescaleModel subclasses timescaledb.metadata.create_all(engine) - ``` - -### Create a FastAPI App - -Put it all together in a FastAPI app. - -`src/main.py` +`main.py` ```python -from fastapi import FastAPI +from contextlib import asynccontextmanager -from .database import init_db, get_session +from fastapi import Depends, FastAPI, HTTPException +from sqlmodel import Session, select + +from .database import get_session, init_db from .models import Metric, MetricCreate, MetricRead -app = FastAPI() -@app.on_event("startup") -def on_startup(): +@asynccontextmanager +async def lifespan(app: FastAPI): init_db() + yield + + +app = FastAPI(lifespan=lifespan) + @app.post("/metrics/", response_model=MetricRead) def create_metric(metric: MetricCreate, session: Session = Depends(get_session)): - db_metric = models.Metric.from_orm(metric) + db_metric = Metric.model_validate(metric) session.add(db_metric) session.commit() session.refresh(db_metric) @@ -330,12 +538,27 @@ def create_metric(metric: MetricCreate, session: Session = Depends(get_session)) def read_metric(metric_id: int, session: Session = Depends(get_session)): metric = session.get(Metric, metric_id) if not metric: - raise HTTPException(status_code=404, message="Metric not found") + raise HTTPException(status_code=404, detail="Metric not found") return metric @app.get("/metrics/", response_model=list[MetricRead]) def list_metrics(session: Session = Depends(get_session)): - metrics = session.query(Metric).all() - return metrics + return session.exec(select(Metric)).all() ``` + +`timescaledb.create_engine` wraps `sqlmodel.create_engine` (itself a wrapper +around `sqlalchemy.create_engine`) and pins the connection timezone for you. + +## Used by + +- [analytics-api](https://github.com/codingforentrepreneurs/analytics-api) — + complete tutorial project for building an Analytics API using FastAPI + + TimescaleDB. + +--- + +For a summary of recent upstream TimescaleDB changes and how they map onto this +package, see [`docs/timescale-recent-updates.md`](./docs/timescale-recent-updates.md). + + diff --git a/pyproject.toml b/pyproject.toml index c7ac0e8..d4e90f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ Repository = "https://github.com/jmitchel3/timescaledb-python" [project.entry-points."sqlalchemy.dialects"] "timescaledb" = "timescaledb.dialect:TimescaledbPsycopg2Dialect" "timescaledb.psycopg2" = "timescaledb.dialect:TimescaledbPsycopg2Dialect" +"timescaledb.psycopg" = "timescaledb.dialect:TimescaledbPsycopgDialect" "timescaledb.asyncpg" = "timescaledb.dialect:TimescaledbAsyncpgDialect" diff --git a/src/timescaledb/dialect.py b/src/timescaledb/dialect.py new file mode 100644 index 0000000..9d1af0a --- /dev/null +++ b/src/timescaledb/dialect.py @@ -0,0 +1,63 @@ +"""SQLAlchemy dialects for TimescaleDB. + +TimescaleDB is a PostgreSQL extension, so these dialects subclass the standard +PostgreSQL dialects shipped with SQLAlchemy and only override the dialect +``name`` so that SQLAlchemy / SQLModel can connect using ``timescaledb://`` +URLs, e.g.:: + + timescaledb://user:pass@host:5432/db # default driver (psycopg2) + timescaledb+psycopg2://user:pass@host:5432/db # psycopg2 + timescaledb+psycopg://user:pass@host:5432/db # psycopg (psycopg 3) + timescaledb+asyncpg://user:pass@host:5432/db # asyncpg + +These are wired up through the ``sqlalchemy.dialects`` entry points declared in +``pyproject.toml``: + + timescaledb -> TimescaledbPsycopg2Dialect + timescaledb.psycopg2 -> TimescaledbPsycopg2Dialect + timescaledb.psycopg -> TimescaledbPsycopgDialect (psycopg 3) + timescaledb.asyncpg -> TimescaledbAsyncpgDialect + +Hypertable, columnstore, compression, retention and continuous-aggregate +behaviour is handled explicitly by the helper functions in this package, so the +dialects intentionally do not layer any DDL-compiler magic on top of +PostgreSQL. They exist purely so that ``timescaledb``-scheme URLs resolve to the +matching PostgreSQL driver. +""" + +from __future__ import annotations + +from sqlalchemy.dialects.postgresql.asyncpg import PGDialect_asyncpg +from sqlalchemy.dialects.postgresql.psycopg import PGDialect_psycopg +from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 + + +# These dialects do not change SQL compilation relative to their PostgreSQL +# parents, so SQLAlchemy's statement caching is safe to enable. Without this, +# SQLAlchemy emits a performance warning and disables query caching entirely. +class TimescaledbPsycopg2Dialect(PGDialect_psycopg2): + """TimescaleDB dialect backed by the ``psycopg2`` driver.""" + + name = "timescaledb" + supports_statement_cache = True + + +class TimescaledbPsycopgDialect(PGDialect_psycopg): + """TimescaleDB dialect backed by the ``psycopg`` (psycopg 3) driver.""" + + name = "timescaledb" + supports_statement_cache = True + + +class TimescaledbAsyncpgDialect(PGDialect_asyncpg): + """TimescaleDB dialect backed by the ``asyncpg`` driver.""" + + name = "timescaledb" + supports_statement_cache = True + + +__all__ = [ + "TimescaledbPsycopg2Dialect", + "TimescaledbPsycopgDialect", + "TimescaledbAsyncpgDialect", +] diff --git a/tests/test_dialect.py b/tests/test_dialect.py new file mode 100644 index 0000000..1a07e8d --- /dev/null +++ b/tests/test_dialect.py @@ -0,0 +1,47 @@ +"""Tests for the TimescaleDB SQLAlchemy dialects. + +These verify that the ``timescaledb`` scheme URLs registered via the +``sqlalchemy.dialects`` entry points (see ``pyproject.toml``) resolve to the +expected PostgreSQL-backed dialect classes. They do not require a database +connection. +""" + +import pytest +from sqlalchemy.dialects.postgresql.base import PGDialect +from sqlalchemy.engine.url import make_url + +from timescaledb.dialect import ( + TimescaledbAsyncpgDialect, + TimescaledbPsycopg2Dialect, + TimescaledbPsycopgDialect, +) + + +@pytest.mark.parametrize( + "url, expected_dialect, expected_driver", + [ + ("timescaledb://u:p@h:5432/db", TimescaledbPsycopg2Dialect, "psycopg2"), + ("timescaledb+psycopg2://u:p@h:5432/db", TimescaledbPsycopg2Dialect, "psycopg2"), + ("timescaledb+psycopg://u:p@h:5432/db", TimescaledbPsycopgDialect, "psycopg"), + ("timescaledb+asyncpg://u:p@h:5432/db", TimescaledbAsyncpgDialect, "asyncpg"), + ], +) +def test_dialect_url_resolution(url, expected_dialect, expected_driver): + dialect_cls = make_url(url).get_dialect() + assert dialect_cls is expected_dialect + assert dialect_cls.driver == expected_driver + assert dialect_cls.name == "timescaledb" + + +@pytest.mark.parametrize( + "dialect_cls", + [ + TimescaledbPsycopg2Dialect, + TimescaledbPsycopgDialect, + TimescaledbAsyncpgDialect, + ], +) +def test_dialects_subclass_postgres(dialect_cls): + # TimescaleDB is a PostgreSQL extension; every dialect must remain a + # PostgreSQL dialect so reflection and SQL compilation behave like Postgres. + assert issubclass(dialect_cls, PGDialect) From c9d00cc0e42af5d832863a5f91b6599e1d5fe236 Mon Sep 17 00:00:00 2001 From: Justin Mitchel Date: Thu, 25 Jun 2026 18:54:20 -0600 Subject: [PATCH 2/5] =?UTF-8?q?Bump=20version:=200.0.6=20=E2=86=92=200.0.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 30 +++++++++++------------------- src/timescaledb/__init__.py | 5 +++-- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index de372e4..a7be11f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.0.6 +current_version = 0.0.7 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+) diff --git a/pyproject.toml b/pyproject.toml index d4e90f5..dec4747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "timescaledb" -version = "0.0.6" +version = "0.0.7" description = "TimescaleDB is a Python Client based on SQLModel and SQLAlchemy for high-performance real-time analytics time-series data." readme = "README.md" authors = [ @@ -27,8 +27,12 @@ classifiers = [ ] requires-python = ">=3.11" dependencies = [ - "fastapi>=0.104.0", "sqlmodel>=0.0.8", +] + +[project.optional-dependencies] +fastapi = [ + "fastapi>=0.104.0", "uvicorn>=0.23.2", ] @@ -43,6 +47,9 @@ Repository = "https://github.com/jmitchel3/timescaledb-python" "timescaledb.asyncpg" = "timescaledb.dialect:TimescaledbAsyncpgDialect" +[tool.hatch.build.targets.wheel] +packages = ["src/timescaledb"] + [tool.isort] force_single_line = true profile = "black" @@ -92,18 +99,3 @@ enable_error_code = [ ] strict = true warn_unreachable = true - -[tool.poetry] -name = "timescaledb" -version = "0.0.6" -description = "TimescaleDB is a Python Client based on SQLModel and SQLAlchemy for high-performance real-time analytics time-series data." -authors = ["Justin Mitchel "] - -[tool.poetry.dependencies] -python = "^3.11" -fastapi = "^0.115.8" -sqlmodel = "^0.0.22" -uvicorn = "^0.34.0" - -[tool.poetry.dev-dependencies] -pytest = "^8.3.4" diff --git a/src/timescaledb/__init__.py b/src/timescaledb/__init__.py index 4209c3b..92d2b86 100644 --- a/src/timescaledb/__init__.py +++ b/src/timescaledb/__init__.py @@ -1,8 +1,8 @@ from __future__ import annotations -__version__ = "0.0.6" +__version__ = "0.0.7" -from . import metadata +from . import defaults, metadata from .activator import activate_timescaledb_extension from .compression import ( add_compression_policy, @@ -16,6 +16,7 @@ refresh_continuous_aggregate, remove_continuous_aggregate_policy, ) +from .defaults import get_defaults from .engine import create_engine from .hypercore import ( add_columnstore_policy, From 160b722956dbf6661e84bdc8b9004e9f6941512b Mon Sep 17 00:00:00 2001 From: Justin Mitchel Date: Thu, 25 Jun 2026 18:58:04 -0600 Subject: [PATCH 3/5] docs: refresh README and modernize FastAPI sample - README: document the sample projects, time_bucket query helpers, retention, version support, and driver dialects; add a quickstart and table of contents. - sample_project/main.py: replace deprecated on_event with lifespan, from_orm with model_validate, the ignored HTTPException message= kwarg with detail=, and legacy session.query with session.exec(select(...)). --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++ sample_project/main.py | 25 ++++++++++--------- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 97f7895..ceb762f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ running `time_bucket` / `time_bucket_gapfill` queries. ## Contents +- [Requirements](#requirements) - [Installation](#installation) - [Quickstart](#quickstart) - [Creating a hypertable](#creating-a-hypertable) @@ -33,8 +34,28 @@ running `time_bucket` / `time_bucket_gapfill` queries. - [Querying with `time_bucket`](#querying-with-time_bucket) - [Sample projects](#sample-projects) - [FastAPI example](#fastapi-example) +- [Limitations & status](#limitations--status) +- [Contributing](#contributing) - [Used by](#used-by) +## Requirements + +- **Python:** 3.11, 3.12, 3.13, or 3.14. +- **PostgreSQL:** a PostgreSQL server with the TimescaleDB extension installed + (the official `timescale/timescaledb` Docker images bundle both). The package + targets PostgreSQL 15+ in CI. +- **TimescaleDB:** 2.x. Some features require newer releases: + + | Feature | Minimum TimescaleDB | + | --- | --- | + | Hypertables, compression, retention, continuous aggregates | 2.x | + | Hypercore columnstore (`enable_columnstore`, `add_columnstore_policy`, …) | **2.18+** | + | Direct `CREATE TABLE ... WITH (tsdb.hypertable)` (`create_table_with_hypertable`) | **2.20+** | + | Generated aggregate columns on continuous aggregates (`add_generated_aggregate_column`) | **2.28+** | + +- **A PostgreSQL driver:** any SQLAlchemy-compatible driver — `psycopg` + (psycopg 3), `psycopg2`, or `asyncpg`. See [Installation](#installation). + ## Installation ```bash @@ -52,6 +73,21 @@ The package registers `timescaledb` SQLAlchemy dialects, so connection URLs such as `timescaledb://`, `timescaledb+psycopg://`, and `timescaledb+asyncpg://` are available in addition to the standard `postgresql://` URLs. +### Optional dependencies + +The core install is intentionally lightweight — it only depends on `SQLModel` +(plus the PostgreSQL driver you choose). FastAPI and uvicorn are **not** required +to use the library; they are only needed for the example apps. Install them via +the `fastapi` extra: + +```bash +pip install "timescaledb[fastapi]" +``` + +This pulls in FastAPI + uvicorn so you can run the example FastAPI apps (see +[`samples/fastapi_timeseries_api`](./samples/fastapi_timeseries_api/) and +[`sample_project/`](./sample_project/)). + ## Quickstart ```python @@ -550,6 +586,24 @@ def list_metrics(session: Session = Depends(get_session)): `timescaledb.create_engine` wraps `sqlmodel.create_engine` (itself a wrapper around `sqlalchemy.create_engine`) and pins the connection timezone for you. +## Limitations & status + +- **Beta.** The package is in the `0.0.x` series — the public API is still + settling and may change between releases. Pin a version if you need stability. +- **Helpers are synchronous.** The `timescaledb.asyncpg` dialect is registered so + you can use a `timescaledb+asyncpg://` URL with raw/async SQLAlchemy, but the + helper functions in this package (`create_hypertable`, `time_bucket_query`, + `enable_columnstore`, the continuous-aggregate helpers, etc.) are all + synchronous and operate on a SQLModel/SQLAlchemy `Session`. There is no async + helper API yet. + +## Contributing + +Contributions are welcome. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for how to +set up a dev environment, run the test suite (Docker + `testcontainers`), run +lint/mypy, and the release process. For runnable, end-to-end examples of every +feature, see the [`samples/`](./samples/) directory. + ## Used by - [analytics-api](https://github.com/codingforentrepreneurs/analytics-api) — diff --git a/sample_project/main.py b/sample_project/main.py index a380652..ae9e1bd 100644 --- a/sample_project/main.py +++ b/sample_project/main.py @@ -1,19 +1,22 @@ +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from app import models from app.database import get_session, init_db from fastapi import Depends, FastAPI, HTTPException -from sqlmodel import Session, desc +from sqlmodel import Session, desc, select from timescaledb import list_hypertables from timescaledb.queries import time_bucket_gapfill_query -app = FastAPI(title="FastAPI SQLModel Demo") - -@app.on_event("startup") -def on_startup(): +@asynccontextmanager +async def lifespan(app: FastAPI): init_db() + yield + + +app = FastAPI(title="FastAPI SQLModel Demo", lifespan=lifespan) @app.get("/") @@ -27,7 +30,7 @@ def root(session: Session = Depends(get_session)): @app.post("/metrics/", response_model=models.MetricRead) def create_metric(metric: models.MetricCreate, session: Session = Depends(get_session)): - db_metric = models.Metric.from_orm(metric) + db_metric = models.Metric.model_validate(metric) session.add(db_metric) session.commit() session.refresh(db_metric) @@ -38,13 +41,13 @@ def create_metric(metric: models.MetricCreate, session: Session = Depends(get_se def read_metric(metric_id: int, session: Session = Depends(get_session)): metric = session.get(models.Metric, metric_id) if not metric: - raise HTTPException(status_code=404, message="Metric not found") + raise HTTPException(status_code=404, detail="Metric not found") return metric @app.get("/metrics/", response_model=list[models.MetricRead]) def list_metrics(session: Session = Depends(get_session)): - metrics = session.query(models.Metric).all() + metrics = session.exec(select(models.Metric)).all() return metrics @@ -53,9 +56,9 @@ def get_metric_buckets( interval: str = "1 hour", session: Session = Depends(get_session) ): """Get metrics aggregated into time buckets""" - latest_metric = ( - session.query(models.Metric).order_by(desc(models.Metric.time)).first() - ) + latest_metric = session.exec( + select(models.Metric).order_by(desc(models.Metric.time)) + ).first() if not latest_metric: return [] From 87b7129f77799515d1cf1564bc80c2cda6dd3a93 Mon Sep 17 00:00:00 2001 From: Justin Mitchel Date: Thu, 25 Jun 2026 19:08:56 -0600 Subject: [PATCH 4/5] Harden sync error handling and finalize 0.0.7 release Consolidate the production-readiness work for 0.0.7: - Sync helpers (hypertable/compression/columnstore/retention) now catch SQLAlchemyError, roll back the session, and re-raise instead of silently swallowing failures. - Add get_defaults() helper and a py.typed marker. - Add LICENSE, CONTRIBUTING.md, and a Keep a Changelog CHANGELOG. - Add flake8 lint and (advisory) mypy CI jobs; enforce the 85% coverage gate instead of warning. - Add database-free unit tests for the engine and query builders. - Drop unused imports and trailing whitespace surfaced by flake8. All 104 tests pass against a live TimescaleDB container; coverage 86%. --- .github/workflows/workflow.yaml | 53 ++- CHANGELOG.md | 86 ++++ CONTRIBUTING.md | 154 +++++++ LICENSE | 21 + src/timescaledb/compression/sync.py | 15 +- src/timescaledb/compression/validators.py | 1 - src/timescaledb/defaults.py | 19 + src/timescaledb/hypercore/sync.py | 15 +- src/timescaledb/hypertables/create.py | 1 - src/timescaledb/hypertables/sql_statements.py | 4 +- src/timescaledb/hypertables/sync.py | 5 +- src/timescaledb/py.typed | 0 src/timescaledb/retention/sync.py | 1 - tests/test_engine.py | 103 +++++ tests/test_manual_hypertable.py | 14 +- tests/test_queries.py | 274 ++++++++++++ tests/test_retention_policy.py | 2 - tests/test_validators.py | 6 +- uv.lock | 414 ++++++++++++++++++ 19 files changed, 1164 insertions(+), 24 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 src/timescaledb/py.typed create mode 100644 tests/test_engine.py create mode 100644 tests/test_queries.py create mode 100644 uv.lock diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index f7f4c58..d08c2da 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -21,6 +21,58 @@ concurrency: cancel-in-progress: true jobs: + lint: + name: Lint (flake8) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + + - name: Install dependencies + run: uv pip install --system flake8 + # Bound so a stuck index resolve can't hang the job. + timeout-minutes: 5 + + - name: Run flake8 + # flake8 reads its [flake8] config from tox.ini. + run: flake8 src tests + timeout-minutes: 5 + + typecheck: + name: Type check (mypy) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Advisory for now: the codebase has a backlog of strict-mode errors to + # burn down. Surface them on every run, but don't block merges yet. + # Flip continue-on-error to false once `mypy src/timescaledb` is clean. + continue-on-error: true + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + + - name: Install package and type deps + # Installs the project (so mypy resolves its deps/stubs) plus mypy. + # [tool.mypy] strict = true lives in pyproject.toml. + run: uv pip install --system mypy . + timeout-minutes: 5 + + - name: Run mypy + run: mypy src/timescaledb + timeout-minutes: 5 + tests: name: Python ${{ matrix.python-version }} runs-on: ubuntu-24.04 @@ -94,7 +146,6 @@ jobs: run: | coverage combine coverage report --fail-under=85 - continue-on-error: true - name: Upload HTML report if: ${{ failure() }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1bedbf9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.7] - 2026-06-25 + +Production-readiness hardening release. + +### Added + +- SQLAlchemy dialect support, including psycopg 3. +- `py.typed` marker so downstream consumers pick up the package's type hints. +- `LICENSE` and `CONTRIBUTING.md` files. +- mypy type checking and flake8 linting jobs in CI. +- Python 3.14 to the CI test matrix. +- `get_defaults()` helper to inspect the package's default settings. +- Database-free unit tests for the engine and query builders. + +### Changed + +- Moved `fastapi` and `uvicorn` out of the core dependencies into optional extras. +- Bumped GitHub Actions off the deprecated Node 20 runtime and added job timeouts. +- Pinned `setup-uv` to a fixed version. +- Cleaned up the requirements compile script (dropped a vestigial `--constraint -`). +- Coverage now fails the build below 85% instead of merely warning. + +### Fixed + +- Repaired broken `__all__` exports. +- Hardened sync error handling so failures roll back the session and propagate + instead of being silently swallowed. + +## [0.0.6] - 2026-06-25 + +### Changed + +- Version bump release (0.0.5 → 0.0.6). + +## [0.0.5] - 2026-06-25 + +### Added + +- Hypercore columnstore support. +- Continuous aggregate support. +- `samples/`: 10 runnable, Docker-tested TimescaleDB sample projects. + +## [0.0.4] - 2025-03-20 + +### Changed + +- Reworked retention handling. +- Dropped Python 3.10 support. + +### Fixed + +- Updated and expanded the test suite. + +## [0.0.3] - 2025-03-20 + +### Added + +- Compression policies. + +### Changed + +- Updates to hypertable creation, compression, and retention. +- Reorganized the codebase into focused modules. +- Improved test coverage and updated the README example. + +## [0.0.2] - 2025-02-19 + +### Changed + +- Updated dependencies. +- Updated the sample project. + +## [0.0.1] - 2025-02-17 + +### Added + +- Initial release: the TimescaleDB model and base package layout. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6e38a6a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing + +Thanks for your interest in improving `timescaledb`. This guide covers setting up +a development environment, running the tests and checks, and the release process. + +By contributing you agree that your work is licensed under the project's +[MIT License](./LICENSE). + +## Requirements + +- Python 3.11, 3.12, 3.13, or 3.14 +- **Docker** running locally (Docker Desktop, Colima, OrbStack, …). The test + suite starts a real TimescaleDB container via + [`testcontainers`](https://testcontainers.com/), so a working Docker daemon is + required to run the tests. + +## Development environment + +Either [`uv`](https://docs.astral.sh/uv/) or a plain `venv` works. The CI uses +`uv`. + +### Using uv + +```bash +uv venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# Install the package in editable mode +uv pip install -e . + +# Install the dev/test tooling +uv pip install -r requirements.dev.txt +``` + +### Using venv + pip + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +pip install -e . +pip install -r requirements.dev.txt +``` + +`requirements.dev.txt` pulls in `tox`, `coverage`, `pytest`, `bump2version`, +`pre_commit`, and `testcontainers[postgres]`. + +## Running the test suite + +The tests need **no manual database setup** — `testcontainers` starts a +throwaway TimescaleDB container and tears it down afterwards. Make sure Docker is +running first. + +Run the tests directly with `pytest`: + +```bash +python -m pytest tests +``` + +Or run them across an interpreter with `tox` (the locked dependency sets for +each Python version live in `tests/requirements/`): + +```bash +# all configured interpreters (py311–py314) +tox + +# a single interpreter, e.g. 3.12 +tox run -f py312 +``` + +The first run is slow because Docker pulls the TimescaleDB image; subsequent runs +reuse the cached image. + +### Coverage + +`tox` runs the suite under `coverage`. To combine results and view a report +(CI fails under 85%): + +```bash +coverage combine +coverage report +``` + +### Sample projects + +The [`samples/`](./samples/) directory has its own fully-tested example projects +with their own dependencies. See [`samples/README.md`](./samples/README.md) for +how to run those suites. + +### Long-lived database (optional) + +For manual experimentation against a persistent database instead of throwaway +containers, a `compose.yaml` is provided: + +```bash +docker compose up -d +# DATABASE_URL=postgresql+psycopg://timescaledb:timescaledb@localhost:5432/timescaledb +docker compose down -v # stop + wipe when finished +``` + +## Lint and type checks + +Both run in CI and should pass before opening a PR. + +```bash +# flake8 (config lives in the [flake8] section of tox.ini) +flake8 src tests + +# mypy (strict mode, configured in [tool.mypy] in pyproject.toml) +mypy src/timescaledb +``` + +Imports are sorted with `isort` (`force_single_line`, black profile) — see the +`[tool.isort]` config in `pyproject.toml`. A `pre-commit` config can be installed +with `pre-commit install` if you use it. + +## Pull request guidelines + +- Keep PRs focused on a single change. +- Add or update tests for any behavior change; new helpers should ship with + coverage (CI enforces an 85% floor). +- Make sure `flake8`, `mypy`, and the test suite pass locally. +- Update the `README.md` and/or `samples/` when you add or change public API. +- Do **not** bump the version in your PR — releases are cut separately (see + below). + +## Release process + +Releases are published to [PyPI](https://pypi.org/project/timescaledb/) by CI +when a tag is pushed. Versioning is `MAJOR.MINOR.PATCH` and driven by +[`bump2version`](https://github.com/c4urself/bump2version) (config in +`.bumpversion.cfg`, which updates `pyproject.toml` and +`src/timescaledb/__init__.py`). + +1. On `main`, bump the version. `bump2version` is configured to create the + commit **and** the git tag automatically (`commit = True`, `tag = True`): + + ```bash + bump2version patch # or: minor / major + ``` + +2. Push the commit and the tag: + + ```bash + git push origin main --follow-tags + ``` + +3. The `Release` workflow (`.github/workflows/workflow.yaml`) runs lint, type + checks, and the full test matrix. When the pushed ref is a tag and everything + passes, the `release` job builds the package with `uv build` and publishes it + to PyPI via trusted publishing (OIDC) using + `pypa/gh-action-pypi-publish`. + +No PyPI token is needed locally — publishing happens entirely in CI. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b44e1c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Justin Mitchel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/timescaledb/compression/sync.py b/src/timescaledb/compression/sync.py index 1de2083..dc818df 100644 --- a/src/timescaledb/compression/sync.py +++ b/src/timescaledb/compression/sync.py @@ -1,11 +1,15 @@ +import logging from typing import Type +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session, SQLModel from timescaledb.compression.add import add_compression_policy from timescaledb.compression.enable import enable_table_compression from timescaledb.models import TimescaleModel +logger = logging.getLogger(__name__) + def sync_compression_policies(session: Session, *models: Type[SQLModel]) -> None: """ @@ -23,6 +27,13 @@ def sync_compression_policies(session: Session, *models: Type[SQLModel]) -> None compress_enabled = model.__enable_compression__ if not compress_enabled: continue - enable_table_compression(session, model, commit=False) - add_compression_policy(session, model, commit=False) + try: + enable_table_compression(session, model, commit=False) + add_compression_policy(session, model, commit=False) + except SQLAlchemyError as e: + session.rollback() + logger.error( + f"Error syncing compression policy for {model.__name__}: {e}" + ) + raise session.commit() diff --git a/src/timescaledb/compression/validators.py b/src/timescaledb/compression/validators.py index 16d6e64..2c6ea2a 100644 --- a/src/timescaledb/compression/validators.py +++ b/src/timescaledb/compression/validators.py @@ -1,4 +1,3 @@ -from datetime import timedelta from typing import Type import sqlalchemy diff --git a/src/timescaledb/defaults.py b/src/timescaledb/defaults.py index dd4d66f..68dde0d 100644 --- a/src/timescaledb/defaults.py +++ b/src/timescaledb/defaults.py @@ -1,6 +1,25 @@ +from __future__ import annotations + TIME_COLUMN = "time" CHUNK_TIME_INTERVAL = "INTERVAL 7 days" COMPRESS_SEGMENTBY = "identifier" COMPRESS_AFTER = "INTERVAL 7 days" COMPRESS_ORDERBY = "time DESC" DROP_AFTER = "INTERVAL 3 months" + + +def get_defaults() -> dict[str, str]: + """Return the current TimescaleDB default settings as a dict. + + Maps each default setting name to its current value. Useful for + inspecting the values that hypertable, compression, and retention + helpers fall back to when an explicit argument is not provided. + """ + return { + "TIME_COLUMN": TIME_COLUMN, + "CHUNK_TIME_INTERVAL": CHUNK_TIME_INTERVAL, + "COMPRESS_SEGMENTBY": COMPRESS_SEGMENTBY, + "COMPRESS_AFTER": COMPRESS_AFTER, + "COMPRESS_ORDERBY": COMPRESS_ORDERBY, + "DROP_AFTER": DROP_AFTER, + } diff --git a/src/timescaledb/hypercore/sync.py b/src/timescaledb/hypercore/sync.py index a9ae9b6..80decfe 100644 --- a/src/timescaledb/hypercore/sync.py +++ b/src/timescaledb/hypercore/sync.py @@ -1,11 +1,15 @@ +import logging from typing import Type +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session, SQLModel from timescaledb.hypercore.add import add_columnstore_policy from timescaledb.hypercore.enable import enable_columnstore from timescaledb.models import TimescaleModel +logger = logging.getLogger(__name__) + def sync_columnstore_policies(session: Session, *models: Type[SQLModel]) -> None: """ @@ -23,6 +27,13 @@ def sync_columnstore_policies(session: Session, *models: Type[SQLModel]) -> None for model in model_list: if not getattr(model, "__enable_columnstore__", False): continue - enable_columnstore(session, model=model, commit=False) - add_columnstore_policy(session, model=model, commit=False) + try: + enable_columnstore(session, model=model, commit=False) + add_columnstore_policy(session, model=model, commit=False) + except SQLAlchemyError as e: + session.rollback() + logger.error( + f"Error syncing columnstore policy for {model.__name__}: {e}" + ) + raise session.commit() diff --git a/src/timescaledb/hypertables/create.py b/src/timescaledb/hypertables/create.py index 06ef0a4..7a56936 100644 --- a/src/timescaledb/hypertables/create.py +++ b/src/timescaledb/hypertables/create.py @@ -1,4 +1,3 @@ -from datetime import timedelta from typing import Type import sqlalchemy diff --git a/src/timescaledb/hypertables/sql_statements.py b/src/timescaledb/hypertables/sql_statements.py index 7a12ea2..0f68a94 100644 --- a/src/timescaledb/hypertables/sql_statements.py +++ b/src/timescaledb/hypertables/sql_statements.py @@ -1,6 +1,6 @@ CREATE_HYPERTABLE_SQL_VIA_INTERVAL = """ SELECT create_hypertable( - :table_name, + :table_name, by_range(:time_column, INTERVAL :chunk_time_interval), if_not_exists => :if_not_exists, migrate_data => :migrate_data @@ -10,7 +10,7 @@ CREATE_HYPERTABLE_SQL_VIA_INTEGER = """ SELECT create_hypertable( - :table_name, + :table_name, by_range(:time_column, :chunk_time_interval), if_not_exists => :if_not_exists, migrate_data => :migrate_data diff --git a/src/timescaledb/hypertables/sync.py b/src/timescaledb/hypertables/sync.py index ec97727..81ccf60 100644 --- a/src/timescaledb/hypertables/sync.py +++ b/src/timescaledb/hypertables/sync.py @@ -1,6 +1,7 @@ import logging from typing import Type +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session, SQLModel from timescaledb.hypertables.create import create_hypertable @@ -44,6 +45,8 @@ def sync_all_hypertables(session: Session, *models: Type[SQLModel]) -> None: "migrate_data": True, }, ) - except Exception as e: + except SQLAlchemyError as e: + session.rollback() logger.error(f"Error creating hypertable for {model.__name__}: {e}") + raise session.commit() diff --git a/src/timescaledb/py.typed b/src/timescaledb/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/timescaledb/retention/sync.py b/src/timescaledb/retention/sync.py index 88d8dfe..1d40e9e 100644 --- a/src/timescaledb/retention/sync.py +++ b/src/timescaledb/retention/sync.py @@ -1,7 +1,6 @@ import logging from typing import Type -from sqlalchemy.exc import OperationalError from sqlmodel import Session, SQLModel from timescaledb.models import TimescaleModel diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..1a805f4 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,103 @@ +"""Unit tests for timescaledb.engine.create_engine. + +Creating a SQLAlchemy engine does NOT open a database connection, so these +tests run without Docker / a live database. Argument forwarding is verified by +monkeypatching the underlying ``sqlalchemy.create_engine`` so no real engine is +even constructed for those cases. +""" + +import pytest +from sqlalchemy.engine import Engine + +from timescaledb import engine as engine_module +from timescaledb.engine import create_engine + +# A syntactically valid URL that is never actually connected to. +DUMMY_URL = "postgresql+psycopg://user:pass@localhost:5432/does_not_connect" + + +def test_create_engine_returns_engine_instance(): + eng = create_engine(DUMMY_URL) + try: + assert isinstance(eng, Engine) + assert eng.url.database == "does_not_connect" + finally: + eng.dispose() + + +def test_create_engine_sets_read_committed_isolation(): + eng = create_engine(DUMMY_URL) + try: + assert ( + eng.get_execution_options().get("isolation_level") + == "READ COMMITTED" + ) + finally: + eng.dispose() + + +def test_create_engine_forwards_kwargs(): + eng = create_engine(DUMMY_URL, echo=True) + try: + assert eng.echo is True + finally: + eng.dispose() + + +@pytest.fixture(name="captured_create_engine") +def captured_create_engine_fixture(monkeypatch): + """Replace sqlalchemy.create_engine with a capturing stub.""" + captured = {} + + def _fake_create_engine(url, **kwargs): + captured["url"] = url + captured["kwargs"] = kwargs + return "SENTINEL_ENGINE" + + monkeypatch.setattr( + engine_module.sqlalchemy, "create_engine", _fake_create_engine + ) + return captured + + +def test_create_engine_default_timezone_is_utc(captured_create_engine): + result = create_engine(DUMMY_URL) + + assert result == "SENTINEL_ENGINE" + assert captured_create_engine["url"] == DUMMY_URL + connect_args = captured_create_engine["kwargs"]["connect_args"] + assert connect_args["options"] == "-c timezone=UTC" + + +def test_create_engine_custom_timezone(captured_create_engine): + create_engine(DUMMY_URL, timezone="America/New_York") + + connect_args = captured_create_engine["kwargs"]["connect_args"] + assert connect_args["options"] == "-c timezone=America/New_York" + + +def test_create_engine_sets_isolation_execution_option(captured_create_engine): + create_engine(DUMMY_URL) + + execution_options = captured_create_engine["kwargs"]["execution_options"] + assert execution_options == {"isolation_level": "READ COMMITTED"} + + +def test_create_engine_forwards_extra_kwargs(captured_create_engine): + create_engine(DUMMY_URL, echo=True, pool_pre_ping=True) + + kwargs = captured_create_engine["kwargs"] + assert kwargs["echo"] is True + assert kwargs["pool_pre_ping"] is True + + +def test_create_engine_preserves_user_connect_args(captured_create_engine): + """A caller-supplied connect_args dict is preserved and augmented.""" + create_engine( + DUMMY_URL, + connect_args={"application_name": "my_app"}, + ) + + connect_args = captured_create_engine["kwargs"]["connect_args"] + assert connect_args["application_name"] == "my_app" + assert connect_args["options"] == "-c timezone=UTC" diff --git a/tests/test_manual_hypertable.py b/tests/test_manual_hypertable.py index e3d7b01..1d632f0 100644 --- a/tests/test_manual_hypertable.py +++ b/tests/test_manual_hypertable.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from sqlmodel import Session, text from timescaledb import create_hypertable @@ -50,8 +48,8 @@ def test_enable_table_compression(session: Session): # Verify compression is enabled query = text(f""" - SELECT compression_enabled - FROM timescaledb_information.hypertables + SELECT compression_enabled + FROM timescaledb_information.hypertables WHERE hypertable_name = '{table_name}' """) result = session.execute(query).fetchone() @@ -89,9 +87,9 @@ def test_add_compression_policy(session: Session): # Verify compression policy exists query = text(f""" - SELECT count(*) + SELECT count(*) FROM timescaledb_information.jobs - WHERE hypertable_name = '{table_name}' + WHERE hypertable_name = '{table_name}' AND proc_name = 'policy_compression' """) result = session.execute(query).fetchone() @@ -135,9 +133,9 @@ def test_compression_with_created_before(session: Session): # Verify compression policy exists query = text(f""" - SELECT count(*) + SELECT count(*) FROM timescaledb_information.jobs - WHERE hypertable_name = '{table_name}' + WHERE hypertable_name = '{table_name}' AND proc_name = 'policy_compression' """) result = session.execute(query).fetchone() diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000..a005574 --- /dev/null +++ b/tests/test_queries.py @@ -0,0 +1,274 @@ +"""Pure-unit tests for timescaledb.queries SQL builders. + +These tests do NOT require a live database. They use a lightweight capturing +fake session that records the SQLAlchemy ``select`` construct passed to +``session.exec`` and returns an empty result, so the generated SQL can be +compiled and asserted on directly. +""" + +from datetime import datetime, timezone + +import pytest +from sqlalchemy.dialects import postgresql + +from timescaledb.queries import time_bucket_gapfill_query, time_bucket_query + +from .conftest import Metric + + +class _FakeResult: + """Mimics the object returned by ``session.exec``.""" + + def mappings(self): + return self + + def all(self): + return [] + + +class _CapturingSession: + """Captures the query handed to ``exec`` without touching a database.""" + + def __init__(self): + self.last_query = None + + def exec(self, query): + self.last_query = query + return _FakeResult() + + +def _compile(query) -> str: + """Compile a query to a PostgreSQL SQL string.""" + return str(query.compile(dialect=postgresql.dialect())) + + +# --------------------------------------------------------------------------- +# time_bucket_query +# --------------------------------------------------------------------------- + + +def test_time_bucket_query_returns_list(): + session = _CapturingSession() + result = time_bucket_query(session, Metric, metric_field="value") + assert result == [] + + +def test_time_bucket_query_builds_time_bucket_sql(): + session = _CapturingSession() + time_bucket_query(session, Metric, interval="1 hour", metric_field="value") + sql = _compile(session.last_query) + + assert "time_bucket(INTERVAL '1 hour', metric.time)" in sql + assert "AS bucket" in sql + assert "GROUP BY" in sql + # Default ordering is descending on the bucket + assert "ORDER BY" in sql + assert "DESC" in sql + + +def test_time_bucket_query_rounds_average_by_default(): + """round_to_nearest=True wraps avg in round()/cast().""" + session = _CapturingSession() + time_bucket_query(session, Metric, metric_field="value") + sql = _compile(session.last_query) + + assert "avg(metric.value)" in sql + assert "round(" in sql + assert "NUMERIC" in sql + assert "AS avg" in sql + + +def test_time_bucket_query_without_rounding(): + """round_to_nearest=False produces a plain avg().""" + session = _CapturingSession() + time_bucket_query( + session, Metric, metric_field="value", round_to_nearest=False + ) + sql = _compile(session.last_query) + + assert "avg(metric.value)" in sql + assert "round(" not in sql + + +def test_time_bucket_query_accepts_instrumented_attributes(): + """time_field / metric_field may be passed as model attributes.""" + session = _CapturingSession() + time_bucket_query( + session, Metric, time_field=Metric.time, metric_field=Metric.value + ) + sql = _compile(session.last_query) + + assert "time_bucket(INTERVAL '1 hour', metric.time)" in sql + assert "avg(metric.value)" in sql + + +def test_time_bucket_query_applies_filters(): + session = _CapturingSession() + time_bucket_query( + session, + Metric, + metric_field="value", + filters=[Metric.value > 10], + ) + sql = _compile(session.last_query) + + assert "WHERE" in sql + assert "metric.value >" in sql + + +def test_time_bucket_query_missing_metric_field_raises(): + session = _CapturingSession() + with pytest.raises(ValueError, match="not found in model Metric"): + time_bucket_query(session, Metric, metric_field="does_not_exist") + + +def test_time_bucket_query_missing_time_field_raises(): + session = _CapturingSession() + with pytest.raises(ValueError, match="not found in model Metric"): + time_bucket_query( + session, Metric, time_field="nope", metric_field="value" + ) + + +# --------------------------------------------------------------------------- +# time_bucket_gapfill_query +# --------------------------------------------------------------------------- + + +def test_time_bucket_gapfill_query_returns_list(): + session = _CapturingSession() + result = time_bucket_gapfill_query(session, Metric, metric_field="value") + assert result == [] + + +def test_time_bucket_gapfill_query_basic_sql(): + session = _CapturingSession() + time_bucket_gapfill_query( + session, Metric, interval="1 hour", metric_field="value" + ) + sql = _compile(session.last_query) + + assert "time_bucket_gapfill(INTERVAL '1 hour', metric.time" in sql + assert "avg(metric.value)" in sql + assert "AS bucket" in sql + assert "AS avg" in sql + # Gapfill orders ascending on the bucket label + assert "ORDER BY bucket ASC" in sql + + +def test_time_bucket_gapfill_query_with_locf(): + session = _CapturingSession() + time_bucket_gapfill_query( + session, Metric, metric_field="value", use_locf=True + ) + sql = _compile(session.last_query) + + assert "locf(avg(metric.value))" in sql + assert "interpolate(" not in sql + + +def test_time_bucket_gapfill_query_with_interpolate(): + session = _CapturingSession() + time_bucket_gapfill_query( + session, Metric, metric_field="value", use_interpolate=True + ) + sql = _compile(session.last_query) + + assert "interpolate(avg(metric.value))" in sql + assert "locf(" not in sql + + +def test_time_bucket_gapfill_query_without_strategy_is_plain_avg(): + session = _CapturingSession() + time_bucket_gapfill_query(session, Metric, metric_field="value") + sql = _compile(session.last_query) + + assert "avg(metric.value)" in sql + assert "locf(" not in sql + assert "interpolate(" not in sql + + +def test_time_bucket_gapfill_query_with_range_adds_filters(): + session = _CapturingSession() + start = datetime(2024, 1, 1) + finish = datetime(2024, 1, 2) + time_bucket_gapfill_query( + session, + Metric, + metric_field="value", + start=start, + finish=finish, + ) + sql = _compile(session.last_query) + + assert "WHERE" in sql + assert "metric.time >=" in sql + assert "metric.time <=" in sql + # start/finish are embedded into the gapfill call as literals + assert "2024-01-01 00:00:00" in sql + assert "2024-01-02 00:00:00" in sql + + +def test_time_bucket_gapfill_query_strips_timezone_from_bounds(): + """Aware datetimes should be coerced to naive ones in the SQL literals.""" + session = _CapturingSession() + start = datetime(2024, 1, 1, tzinfo=timezone.utc) + finish = datetime(2024, 1, 2, tzinfo=timezone.utc) + time_bucket_gapfill_query( + session, + Metric, + metric_field="value", + start=start, + finish=finish, + ) + sql = _compile(session.last_query) + + # No timezone offset should remain in the embedded literals + assert "2024-01-01 00:00:00" in sql + assert "+00:00" not in sql + + +def test_time_bucket_gapfill_query_custom_labels(): + session = _CapturingSession() + time_bucket_gapfill_query( + session, + Metric, + metric_field="value", + bucket_label="ts", + value_label="mean", + ) + sql = _compile(session.last_query) + + assert "AS ts" in sql + assert "AS mean" in sql + assert "ORDER BY ts ASC" in sql + + +def test_time_bucket_gapfill_query_finish_before_start_raises(): + session = _CapturingSession() + start = datetime(2024, 1, 2) + finish = datetime(2024, 1, 1) + with pytest.raises(ValueError, match="Finish time must be after start time"): + time_bucket_gapfill_query( + session, + Metric, + metric_field="value", + start=start, + finish=finish, + ) + + +def test_time_bucket_gapfill_query_missing_metric_field_raises(): + session = _CapturingSession() + with pytest.raises(ValueError, match="not found in model Metric"): + time_bucket_gapfill_query( + session, Metric, metric_field="does_not_exist" + ) + + +def test_time_bucket_gapfill_query_missing_time_field_raises(): + session = _CapturingSession() + with pytest.raises(ValueError, match="not found in model Metric"): + time_bucket_gapfill_query( + session, Metric, time_field="nope", metric_field="value" + ) diff --git a/tests/test_retention_policy.py b/tests/test_retention_policy.py index 7aa6f63..d676f39 100644 --- a/tests/test_retention_policy.py +++ b/tests/test_retention_policy.py @@ -1,5 +1,3 @@ -from datetime import timedelta - import pytest import sqlalchemy from sqlmodel import Session diff --git a/tests/test_validators.py b/tests/test_validators.py index 443ca9b..af97510 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,7 +1,7 @@ -from datetime import datetime, timedelta +from datetime import timedelta import pytest -from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy import String from sqlmodel import Field, SQLModel from timescaledb.exceptions import ( @@ -14,7 +14,7 @@ validate_time_column, ) -from .conftest import ManualHypertable, Metric, Record +from .conftest import Metric, Record def test_validate_time_column_valid(): diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9a2211d --- /dev/null +++ b/uv.lock @@ -0,0 +1,414 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "fastapi" +version = "0.138.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/68/371ee6dad168be3386c46030bedaa8e3e7e3cf3d203621d4529e78ff36ef/greenlet-3.5.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5", size = 286925, upload-time = "2026-06-17T17:33:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/ed5706c26b4d26f3fabceb79abca992654eac8b0fa435def2ac6dbd92122/greenlet-3.5.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6", size = 606036, upload-time = "2026-06-17T18:07:18.538Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/f9c77093af9f5f96615922b7e3fe3690a9faff02adb89f1d74e21578b147/greenlet-3.5.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2", size = 617821, upload-time = "2026-06-17T18:29:41.317Z" }, + { url = "https://files.pythonhosted.org/packages/27/f5/a963a939039aa5acafc2f9535f6cc8958ad30afe1478e2e37ab5098af74d/greenlet-3.5.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8", size = 625675, upload-time = "2026-06-17T18:39:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d4/642833e778c17d32b5cabb793e14ce7364c55952462fc506fecdee55d485/greenlet-3.5.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1", size = 616877, upload-time = "2026-06-17T17:39:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c8/995a898ebbf44e3da0b7ea6fbc1631518c185fb83467a5d6cf408d6d3ced/greenlet-3.5.2-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6", size = 420572, upload-time = "2026-06-17T18:41:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/7120f83e78b8be3cf7acbe2306b3b7bd2cbf99f5ad12e85e2f05d7b31961/greenlet-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f", size = 1577274, upload-time = "2026-06-17T18:22:10.692Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/05a0074ee485dd51c320fd706fd7ed48006b9cad3443092d7df1a655f0d2/greenlet-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f", size = 1643566, upload-time = "2026-06-17T17:40:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/35/fe/9fe2060bdeece682e38d381184ae66045b48ed183c107ab3f88b9886a630/greenlet-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e", size = 238643, upload-time = "2026-06-17T17:37:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/41/13/a9db72f5b6b700977ebd371d6a1f2984a08838357de924fcd5571607b1bf/greenlet-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d", size = 237135, upload-time = "2026-06-17T17:34:34.14Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" }, + { url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/cdb1f75f07214f13110e7e3879531f11c26083bd480a56a9474c430ec44c/greenlet-3.5.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd", size = 621843, upload-time = "2026-06-17T18:39:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" }, + { url = "https://files.pythonhosted.org/packages/2e/25/36a3628a7edcfeefddd3101dc88039c79721c5f8d688db7ebed1cbaaa789/greenlet-3.5.2-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728", size = 424889, upload-time = "2026-06-17T18:41:19.469Z" }, + { url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" }, + { url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044, upload-time = "2026-06-17T18:39:29.046Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" }, + { url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322, upload-time = "2026-06-17T18:41:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" }, + { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" }, + { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" }, + { url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" }, + { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" }, + { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.39" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "timescaledb" +version = "0.0.7" +source = { editable = "." } +dependencies = [ + { name = "sqlmodel" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.104.0" }, + { name = "sqlmodel", specifier = ">=0.0.8" }, + { name = "uvicorn", marker = "extra == 'fastapi'", specifier = ">=0.23.2" }, +] +provides-extras = ["fastapi"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] From 59d9775944c0bea3f6c9bb378ee7770684260bed Mon Sep 17 00:00:00 2001 From: Justin Mitchel Date: Thu, 25 Jun 2026 20:36:22 -0600 Subject: [PATCH 5/5] Fix coverage path mapping so the combine job can find sources The strict 85% coverage gate (continue-on-error removed) exposed a pre-existing misconfiguration: [tool.coverage.paths].source listed `timescaledb` as the canonical (first) path, which does not exist at the repo root. When the Coverage job combined the tox-recorded data, every path remapped to a non-existent `timescaledb/...` location and `coverage report` failed with "No source for code". Make `src/timescaledb` (the path that exists in the checkout) the canonical entry. Verified by reproducing the tox flow locally: combine succeeds and report passes at 86%. --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dec4747..8ee7bc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,10 +72,13 @@ omit = [ ] [tool.coverage.paths] +# The canonical (first) path must exist in the checkout where `coverage +# combine`/`report` runs, otherwise tox-recorded paths remap to a +# non-existent location and report fails with "No source for code". source = [ + "src/timescaledb", "timescaledb", "*/timescaledb", - "src/timescaledb", ".tox/*/lib/python*/site-packages/timescaledb", ]