diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c0d467..3f80b9b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,22 +12,22 @@ jobs: ruff-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v3 - with: - version: "0.12.12" - args: check - - uses: astral-sh/ruff-action@v3 - with: - args: format --check + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: version: "0.7.3" - name: Set up Python run: uv python install - name: Sync project, including dev dependencies run: uv sync --all-extras + - uses: astral-sh/ruff-action@v3 + with: + version: "0.12.12" + args: check + - uses: astral-sh/ruff-action@v3 + with: + args: format --check - name: Build static content run: | uv run python ./scripts/gen_api_docs.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f84b8d..70b6c98 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,9 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: version: "0.7.3" - name: Set up Python diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 88ee99c..9397768 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -20,9 +20,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: version: "0.7.3" - name: Set up Python @@ -36,7 +36,7 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: 'site' - name: Deploy to GitHub Pages diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7a2b713..fbe72d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,13 +14,19 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 + + - name: Install PostgreSQL and PostGIS tooling + run: | + sudo apt-get update + sudo apt-get install -y postgresql-16 postgresql-client-16 postgresql-16-postgis-3 postgresql-16-postgis-3-scripts + echo "/usr/lib/postgresql/16/bin" >> "$GITHUB_PATH" - name: Install the project run: uv sync --all-extras --dev - name: Run tests - run: uv run pytest tests/ \ No newline at end of file + run: uv run pytest tests/ diff --git a/.gitignore b/.gitignore index 41e29a2..8d8939a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# OpenSAMPL data paths +archive/ +ntp-snapshots/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index c69d215..d0efdd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,31 @@ This project adheres to [Semantic Versioning](https://semver.org/). *Unreleased* versions radiate potential—-and dread. Once you merge an infernal PR, move its bullet under a new version heading with the actual release date.* --> +## [1.2.0] - 2026-04-29 +### Added +- 🔥 First-class NTP vendor and probe support using the existing OpenSAMPL extension model +- 🔥 Local and remote NTP collection paths, including `ntp_metadata` loading behavior +- 🔥 NTP-focused metrics such as jitter, delay, stratum, reachability, root delay, root dispersion, poll interval, and sync health +- 🔥 Additional NTP metadata handling for collector/target probe relationships and reference-backed loading +- 🔥 Compact reference/source metadata views in dashboards to improve interpretation of NTP-backed timing data +- 🔥 Documentation covering the NTP extension path, collection semantics, and geolocation behavior +- 🔥 Additional unit and integration-style tests for NTP collection, loading, geolocation helpers, and seeded database defaults +- 🔥 Moved alembic migration code into openSAMPL along with Docker image information +- 🔥 Moved backend api code into openSAMPL along with Docker image information +- 🔥 Docker-compose for developers which installs openSAMPL as editable on backend image + +### Changed +- ⚡ Hardened dashboard queries and variables to avoid brittle empty-filter handling and varchar-versus-UUID failures +- ⚡ Updated timing dashboards and wording to use reference-safe terminology for NTP-backed demo paths +- ⚡ Reworked integration-style tests to use the project MockDB harness instead of requiring a locally spawned PostgreSQL instance +- ⚡ Updated CI to install PostgreSQL/PostGIS tooling so the workflow can support `pytest-postgresql`-style environments when needed + +### Fixed +- 🩹 Seeded default metric UUID handling in the MockDB test harness now points to the UNKNOWN metric as intended +- 🩹 Bug which caused random data duration to always be 1 hour +- 🩹 Issue with CI order causing linting to fail +- 🩹 Fixed variable assignment for mkdocs-click integration + ## [1.1.5] - 2025-09-22 ### Fixed - 🩹 More durable timestamp extrapolation in time data insertion diff --git a/README.md b/README.md index f67f9ae..ce8d3a5 100644 --- a/README.md +++ b/README.md @@ -20,143 +20,153 @@ -OpenSAMPL was created to provide a set of Python tools for managing clock data in a TimescaleDB database, specifically designed for synchronization analytics and monitoring. -This project came out of [**CAST**](https://cast.ornl.gov), the **C**enter for **A**lternative **S**yncrhonization and **T**iming, a research group at Oak Ridge National Laboratory (ORNL). -The name OpenSAMPL stands for **O**pen **S**ynchronization **A**nalytics and **M**onitoring **PL**atform, and provides the code and logic for uploading, managing, and visualizing clock data from various sources, including ADVA probes and Microchip TWST data files, -with the goal of this project being to provide a comprehensive and open-source solution for clock data management and analysis. -Visualizations are provided via [grafana](https://grafana.com/), and the data is stored in a [TimescaleDB](https://www.timescale.com/) database, which is a time-series database built on PostgreSQL. +OpenSAMPL provides Python tools for collecting, loading, and visualizing clock data in a +TimescaleDB-backed synchronization analytics stack. +This project came out of [**CAST**](https://cast.ornl.gov), the **C**enter for +**A**lternative **S**ynchronization and **T**iming at Oak Ridge National Laboratory (ORNL). +The name OpenSAMPL stands for **O**pen **S**ynchronization **A**nalytics and +**M**onitoring **PL**atform. + +The current codebase supports loading and analysis workflows for ADVA, Microchip TWST, +Microchip TP4100, and NTP-derived probe data. Visualization is provided through +[Grafana](https://grafana.com/), and the data is stored in +[TimescaleDB](https://www.timescale.com/), which is built on PostgreSQL. ### (**O**pen **S**ynchronization **A**nalytics and **M**onitoring **PL**atform) -python tools for adding clock data to a timescale db. +Python tools for adding clock and timing data to a TimescaleDB database. -## CLI TOOL +## Installation -### Installation +1. Ensure you have Python 3.10 or higher installed. +2. Install the latest release: -1. Ensure you have Python 3.9 or higher installed -2. Pip install the latest version of opensampl: ```bash pip install opensampl ``` ### Development Setup + ```bash uv venv -uv sync --extra all +uv sync --all-extras --dev source .venv/bin/activate ``` -This will create a virtual environment and install the development dependencies. +This creates a virtual environment and installs the development dependencies. ### Environment Setup -The tool requires several environment variables. Create a `.env` file in your project root: +The CLI reads configuration from environment variables or a local `.env` file. -When routing through a backend: +When routing through a backend service: ```bash -ROUTE_TO_BACKEND=true # Set to true if using backend service -BACKEND_URL=http://localhost:8000 # Only needed if ROUTE_TO_BACKEND is true +ROUTE_TO_BACKEND=true +BACKEND_URL=http://localhost:8000 -# Archive configuration -ARCHIVE_PATH=/path/to/archive # Where processed files are stored +ARCHIVE_PATH=/path/to/archive ``` -When directly accessing db: + +When connecting directly to PostgreSQL / TimescaleDB: ```bash -# Database connection DATABASE_URL=postgresql://:@:/ - -# Archive configuration -ARCHIVE_PATH=/path/to/archive # Where processed files are stored +ARCHIVE_PATH=/path/to/archive ``` -### Basic Usage +Use `opensampl config show` to inspect the current resolved configuration. -The CLI tool provides several commands. You can use `opensampl --help` (or, any deeper `opensampl [command] --help`) to get details +## CLI -#### Load Probe Data +The main CLI exposes `collect`, `config`, `create`, `init`, and `load`. +Use `opensampl --help` and `opensampl --help` for current options. -Load data from ADVA probes: +If you plan to use the NTP, Microchip TWST, or Microchip TP4100 collectors, install the optional collection dependencies: ```bash -# Load single file -opensampl load probe adva path/to/file.txt.gz - -# Load directory of files -opensampl load probe adva path/to/directory/ +pip install "opensampl[collect]" ``` -ADVA probes have all their metadata and their time data in each file, so no need to use the `-m` or `-t` options, though if you want to skip loading one or the other it becomes useful! -options: -- `--metadata` (`-m`): Only load probe metadata -- `--time-data` (`-t`): Only load time series data -- `--no-archive` (`-n`): Don't archive processed files -- `--archive-path` (`-a`): Override default archive directory -- `--max-workers` (`-w`): Maximum number of worker threads (default: 4) -- `--chunk-size` (`-c`): Number of time data entries per batch (default: 10000) +### Load Probe Data -#### Load Direct Table Data +Load data with the probe type name directly: -Load data directly into a database table. Format can be yaml or json. Can be a list of dictionaries or a single dictionary. +```bash +opensampl load ADVA path/to/file.txt.gz +opensampl load ADVA path/to/directory/ +``` -you do not have to specify schema, is assumed to be castdb. +ADVA files bundle metadata and time-series data in a single file, so the split flags are +usually not needed. -The --if-exists option controls how to handle conflicts: - - update: Only update fields that are provided and non-default (default) - - error: Raise an error if entry exists - - replace: Replace all non-primary-key fields with new values - - ignore: Skip if entry exists +```bash +opensampl load MicrochipTWST path/to/twst-output +opensampl load MicrochipTP4100 path/to/tp4100-output +``` + +NTP data is collected first and then loaded from the output directory: ```bash -opensampl load table table_name path/to/data.yaml +opensampl collect ntp --mode remote --server pool.ntp.org --output-path ./ntp-out +opensampl load NTP ./ntp-out ``` -So, you can do things like the following +Load options: + +- `--metadata` / `-m`: load only probe metadata +- `--time-data` / `-t`: load only time-series data +- `--no-archive` / `-n`: skip archiving processed files +- `--archive-path` / `-a`: override the archive directory +- `--max-workers` / `-w`: set the worker count +- `--chunk-size` / `-c`: set the batch size for time-series inserts + +### Load Direct Table Data + +Load YAML or JSON directly into a table: + ```bash -opensampl load table locations --if-exists replace updated_location.yaml +opensampl load table locations updated_location.yaml ``` -Where this is the updated_location + +Conflict handling is controlled by `--if-exists`: + +- `update`: fill null fields in an existing row +- `error`: raise if the row exists +- `replace`: replace non-primary-key values +- `ignore`: skip existing rows + +Example input: + ```yaml name: EPB Chattanooga lat: 35.9311256 lon: -84.3292469 ``` -And it will overwrite the existing entry for EPB Chattanooga, or create a new one if it doesn't exist yet. - ### View Configuration -Display current environment configuration: - ```bash -# Show all variables -poetry run opensampl config show - -# Show with descriptions -poetry run opensampl config show --explain - -# Show specific variable -poetry run opensampl config show --var DATABASE_URL +opensampl config show +opensampl config show --explain +opensampl config show --var DATABASE_URL ``` ### Set Configuration -Update environment variables: - ```bash -poetry run opensampl config set VARIABLE_NAME value +opensampl config set VARIABLE_NAME value ``` ## File Format Support -The tool currently supports: - -ADVA probe data files with the following naming convention: -`CLOCK_PROBE--YYYY-MM-DD-HH-MM-SS.txt.gz` +The loaders currently support: -Example: `10.0.0.121CLOCK_PROBE-1-1-2024-01-02-18-24-56.txt.gz` +- ADVA probe files named like + `CLOCK_PROBE--YYYY-MM-DD-HH-MM-SS.txt.gz>` +- Microchip TWST and TP4100 output produced by the collector tooling +- NTP snapshot output produced by `opensampl collect ntp` -Microchip TWST Data Files as generated by the script available. +Example ADVA file: +`10.0.0.121CLOCK_PROBE-1-1-2024-01-02-18-24-56.txt.gz` # Contributing We welcome contributions! Please see our [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started. @@ -234,4 +244,3 @@ adva_mask_margin: 0 # Mask margin - Table relationships are maintained through UUID references - Geographic coordinates use WGS84 projection (SRID 4326) by default - Boolean fields (public) are optional and can be null - diff --git a/docs/api/collect/cli.md b/docs/api/collect/cli.md new file mode 100644 index 0000000..c0d59d8 --- /dev/null +++ b/docs/api/collect/cli.md @@ -0,0 +1,8 @@ +# CLI Reference + +This page provides documentation for our command line tools. + +::: mkdocs-click + :module: opensampl.collect.cli + :command: cli + :prog_name: opensampl-collect \ No newline at end of file diff --git a/docs/api/collect/microchip/tp4100/collect_4100.md b/docs/api/collect/microchip/tp4100/collect_4100.md new file mode 100644 index 0000000..a038496 --- /dev/null +++ b/docs/api/collect/microchip/tp4100/collect_4100.md @@ -0,0 +1,7 @@ +# `opensampl.collect.microchip.tp4100.collect_4100` + +::: opensampl.collect.microchip.tp4100.collect_4100 + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/collect/microchip/twst/context.md b/docs/api/collect/microchip/twst/context.md new file mode 100644 index 0000000..259ba2e --- /dev/null +++ b/docs/api/collect/microchip/twst/context.md @@ -0,0 +1,7 @@ +# `opensampl.collect.microchip.twst.context` + +::: opensampl.collect.microchip.twst.context + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/collect/microchip/twst/generate_twst_files.md b/docs/api/collect/microchip/twst/generate_twst_files.md new file mode 100644 index 0000000..e4f4d19 --- /dev/null +++ b/docs/api/collect/microchip/twst/generate_twst_files.md @@ -0,0 +1,7 @@ +# `opensampl.collect.microchip.twst.generate_twst_files` + +::: opensampl.collect.microchip.twst.generate_twst_files + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/collect/microchip/twst/readings.md b/docs/api/collect/microchip/twst/readings.md new file mode 100644 index 0000000..d92238c --- /dev/null +++ b/docs/api/collect/microchip/twst/readings.md @@ -0,0 +1,7 @@ +# `opensampl.collect.microchip.twst.readings` + +::: opensampl.collect.microchip.twst.readings + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/ats6502/modem.md b/docs/api/collect/modem.md similarity index 63% rename from docs/api/ats6502/modem.md rename to docs/api/collect/modem.md index 383ac06..b7b3ca1 100644 --- a/docs/api/ats6502/modem.md +++ b/docs/api/collect/modem.md @@ -1,6 +1,6 @@ -# `opensampl.ats6502.modem` +# `opensampl.collect.modem` -::: opensampl.ats6502.modem +::: opensampl.collect.modem options: show_root_heading: false show_submodules: true diff --git a/docs/api/ats6502/context.md b/docs/api/config/server.md similarity index 61% rename from docs/api/ats6502/context.md rename to docs/api/config/server.md index 152da13..8e39fe5 100644 --- a/docs/api/ats6502/context.md +++ b/docs/api/config/server.md @@ -1,6 +1,6 @@ -# `opensampl.ats6502.context` +# `opensampl.config.server` -::: opensampl.ats6502.context +::: opensampl.config.server options: show_root_heading: false show_submodules: true diff --git a/docs/api/ats6502/readings.md b/docs/api/config/tp4100.md similarity index 61% rename from docs/api/ats6502/readings.md rename to docs/api/config/tp4100.md index 8d282af..73f4740 100644 --- a/docs/api/ats6502/readings.md +++ b/docs/api/config/tp4100.md @@ -1,6 +1,6 @@ -# `opensampl.ats6502.readings` +# `opensampl.config.tp4100` -::: opensampl.ats6502.readings +::: opensampl.config.tp4100 options: show_root_heading: false show_submodules: true diff --git a/docs/api/create/create_vendor.md b/docs/api/create/create_vendor.md new file mode 100644 index 0000000..a645c13 --- /dev/null +++ b/docs/api/create/create_vendor.md @@ -0,0 +1,7 @@ +# `opensampl.create.create_vendor` + +::: opensampl.create.create_vendor + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/helpers/create_vendor.md b/docs/api/create/insert_markers.md similarity index 57% rename from docs/api/helpers/create_vendor.md rename to docs/api/create/insert_markers.md index 35f5b08..6f5c0b8 100644 --- a/docs/api/helpers/create_vendor.md +++ b/docs/api/create/insert_markers.md @@ -1,6 +1,6 @@ -# `opensampl.helpers.create_vendor` +# `opensampl.create.insert_markers` -::: opensampl.helpers.create_vendor +::: opensampl.create.insert_markers options: show_root_heading: false show_submodules: true diff --git a/docs/api/helpers/geolocator.md b/docs/api/helpers/geolocator.md new file mode 100644 index 0000000..12509a3 --- /dev/null +++ b/docs/api/helpers/geolocator.md @@ -0,0 +1,7 @@ +# `opensampl.helpers.geolocator` + +::: opensampl.helpers.geolocator + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/helpers/insert_markers.md b/docs/api/helpers/insert_markers.md deleted file mode 100644 index 62a679d..0000000 --- a/docs/api/helpers/insert_markers.md +++ /dev/null @@ -1,7 +0,0 @@ -# `opensampl.helpers.insert_markers` - -::: opensampl.helpers.insert_markers - options: - show_root_heading: false - show_submodules: true - show_source: true diff --git a/docs/api/helpers/source_writer.md b/docs/api/helpers/source_writer.md deleted file mode 100644 index 37d6139..0000000 --- a/docs/api/helpers/source_writer.md +++ /dev/null @@ -1,7 +0,0 @@ -# `opensampl.helpers.source_writer` - -::: opensampl.helpers.source_writer - options: - show_root_heading: false - show_submodules: true - show_source: true diff --git a/docs/api/index.md b/docs/api/index.md index 8b77d05..e3bb38d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,32 +4,49 @@ Welcome to the OpenSAMPL API documentation. Browse the modules and packages below: -- Ats6502 - - [Context](ats6502/context.md) - - [Modem](ats6502/modem.md) - - [Readings](ats6502/readings.md) - [Cli](cli.md) +- Collect + - [Cli](collect/cli.md) + - Microchip + - Tp4100 + - [Collect 4100](collect/microchip/tp4100/collect_4100.md) + - Twst + - [Context](collect/microchip/twst/context.md) + - [Generate Twst Files](collect/microchip/twst/generate_twst_files.md) + - [Readings](collect/microchip/twst/readings.md) + - [Modem](collect/modem.md) - Config - [Base](config/base.md) + - [Server](config/server.md) + - [Tp4100](config/tp4100.md) +- Create + - [Create Vendor](create/create_vendor.md) + - [Insert Markers](create/insert_markers.md) - Db - [Access Orm](db/access_orm.md) - [Orm](db/orm.md) - Helpers - - [Create Vendor](helpers/create_vendor.md) - - [Insert Markers](helpers/insert_markers.md) - - [Source Writer](helpers/source_writer.md) + - [Geolocator](helpers/geolocator.md) - Load - [Data](load/data.md) - - [Probe](load/probe.md) - [Routing](load/routing.md) - [Table Factory](load/table_factory.md) - [Load Data](load_data.md) - [Metrics](metrics.md) +- Mixins + - [Collect](mixins/collect.md) + - [Random Data](mixins/random_data.md) - [References](references.md) - Server + - Backend + - [Main](server/backend/main.md) - [Cli](server/cli.md) + - [Cli2](server/cli2.md) - Vendors - [Adva](vendors/adva.md) - [Base Probe](vendors/base_probe.md) - [Constants](vendors/constants.md) - - [Microsemi Twst](vendors/microsemi_twst.md) \ No newline at end of file + - Microchip + - [Tp4100](vendors/microchip/tp4100.md) + - [Twst](vendors/microchip/twst.md) + - [Ntp](vendors/ntp.md) \ No newline at end of file diff --git a/docs/api/mixins/collect.md b/docs/api/mixins/collect.md new file mode 100644 index 0000000..672fcaa --- /dev/null +++ b/docs/api/mixins/collect.md @@ -0,0 +1,7 @@ +# `opensampl.mixins.collect` + +::: opensampl.mixins.collect + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/mixins/random_data.md b/docs/api/mixins/random_data.md new file mode 100644 index 0000000..e9a95b8 --- /dev/null +++ b/docs/api/mixins/random_data.md @@ -0,0 +1,7 @@ +# `opensampl.mixins.random_data` + +::: opensampl.mixins.random_data + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/server/backend/main.md b/docs/api/server/backend/main.md new file mode 100644 index 0000000..f283af2 --- /dev/null +++ b/docs/api/server/backend/main.md @@ -0,0 +1,7 @@ +# `opensampl.server.backend.main` + +::: opensampl.server.backend.main + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/server/cli2.md b/docs/api/server/cli2.md new file mode 100644 index 0000000..9f31606 --- /dev/null +++ b/docs/api/server/cli2.md @@ -0,0 +1,8 @@ +# CLI Reference + +This page provides documentation for our command line tools. + +::: mkdocs-click + :module: opensampl.server.cli2 + :command: cli + :prog_name: opensampl-server2 \ No newline at end of file diff --git a/docs/api/vendors/microchip/tp4100.md b/docs/api/vendors/microchip/tp4100.md new file mode 100644 index 0000000..6f6374e --- /dev/null +++ b/docs/api/vendors/microchip/tp4100.md @@ -0,0 +1,7 @@ +# `opensampl.vendors.microchip.tp4100` + +::: opensampl.vendors.microchip.tp4100 + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/vendors/microchip/twst.md b/docs/api/vendors/microchip/twst.md new file mode 100644 index 0000000..f913118 --- /dev/null +++ b/docs/api/vendors/microchip/twst.md @@ -0,0 +1,7 @@ +# `opensampl.vendors.microchip.twst` + +::: opensampl.vendors.microchip.twst + options: + show_root_heading: false + show_submodules: true + show_source: true diff --git a/docs/api/vendors/microsemi_twst.md b/docs/api/vendors/microsemi_twst.md deleted file mode 100644 index fe350c8..0000000 --- a/docs/api/vendors/microsemi_twst.md +++ /dev/null @@ -1,7 +0,0 @@ -# `opensampl.vendors.microsemi_twst` - -::: opensampl.vendors.microsemi_twst - options: - show_root_heading: false - show_submodules: true - show_source: true diff --git a/docs/api/load/probe.md b/docs/api/vendors/ntp.md similarity index 65% rename from docs/api/load/probe.md rename to docs/api/vendors/ntp.md index d650317..e280bc2 100644 --- a/docs/api/load/probe.md +++ b/docs/api/vendors/ntp.md @@ -1,6 +1,6 @@ -# `opensampl.load.probe` +# `opensampl.vendors.ntp` -::: opensampl.load.probe +::: opensampl.vendors.ntp options: show_root_heading: false show_submodules: true diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 982c177..7a99120 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,18 +1,41 @@ -# Installation of openSAMPL +# Installation -## Installation for Users +## User install + +OpenSAMPL requires Python 3.10 or newer. + +Install the published package with `pip`: -1. Ensure you have Python 3.9 or higher installed -2. Pip install the latest version of openSAMPL: ```bash pip install opensampl ``` -## Installation for Developers -[Poetry](https://python-poetry.org/) is used for managing dependencies. To install the package in development mode, run the following commands: +If you plan to use collection features such as remote NTP or Microchip collectors, install the optional collect dependencies: + +```bash +pip install "opensampl[collect]" +``` + +If you want the packaged Docker-backed server tooling as well: + +```bash +pip install "opensampl[server]" +``` + +## Developer Installation + +The repository uses `uv` for local development workflows. + ```bash -# Clone the repository git clone git@github.com:ORNL/OpenSAMPL.git -cd opensampl -poetry install +cd OpenSAMPL +uv venv +uv sync --all-extras --dev +source .venv/bin/activate ``` + +That installs: + +- the package into the local virtual environment +- all optional extras +- development tools such as `pytest`, `ruff`, and `mkdocs` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 9d5cba5..fd71e7a 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,16 +1,69 @@ -# Quick Start using Server +# Quickstart -By running `opensampl-server up`, you get a full stack ready to receive your time data. +This quickstart uses the packaged Docker-backed server stack. -This creates: +## Start the stack -1. A TimescaleDB instance, with schema already fully formatted -1. A BackendAPI ready to be used to ingest data - * Complete with Swagger documentation at: [http://localhost:8015](http://localhost:8015) -1. A [Grafana](https://grafana.com/) server with a dashboard already built to visualize clock data. - * Accessible at [http://localhost:3015](http://localhost:3015) +Install the server extra first: -In order to ingest data, you simply have to run `opensampl load` with appropriate arguments, and it will go straight into your new TimescaleDB -instance and become visible on Grafana. +```bash +pip install "opensampl[server]" +``` -See the [Configuration](../guides/configuration.md#opensampl-server) page on configuring your server instance. \ No newline at end of file +Then start the default stack: + +```bash +opensampl-server up +``` + +That starts: + +1. PostgreSQL / TimescaleDB on `localhost:5415` +2. the backend API on `http://localhost:8015` +3. Grafana on `http://localhost:3015` + +When `opensampl-server up` completes, it also updates your local OpenSAMPL environment so future `opensampl load ...` commands route through the backend API by default. + +## Initialize and load data + +Create the schema if needed: + +```bash +opensampl init +``` + +Load a probe file: + +```bash +opensampl load ADVA ./path/to/file.txt.gz +``` + +Or load a directory: + +```bash +opensampl load ADVA ./path/to/data-dir +``` + +The loaded data should then be visible through Grafana. + +## Inspect and stop + +Check service status: + +```bash +opensampl-server ps +``` + +Follow logs: + +```bash +opensampl-server logs +``` + +Stop the stack: + +```bash +opensampl-server down +``` + +See the [Configuration](../guides/configuration.md#opensampl-server) and [Server guide](../guides/opensampl-server.md) pages for environment customization and compose overrides. diff --git a/docs/guides/collection.md b/docs/guides/collection.md index 222c4d7..96cc903 100644 --- a/docs/guides/collection.md +++ b/docs/guides/collection.md @@ -8,10 +8,9 @@ The collect API enables automated collection of measurement data from network-co - **Microchip TWST Modems** (ATS6502 series): Collect offset and EBNO tracking values along with contextual information - **Microchip TimeProvider® 4100** (TP4100): Collect timing performance metrics from various input channels via web interface +- **NTP**: Collect local synchronization state or query remote NTP servers -## CLI Usage - -### Installation +## Installation The collect functionality is included with the main OpenSAMPL installation: @@ -19,9 +18,67 @@ The collect functionality is included with the main OpenSAMPL installation: pip install opensampl ``` -### Basic CLI Commands +For NTP or Microchip collection, install the optional collect extras so additional requirements are available: + +```bash +pip install "opensampl[collect]" +``` + + +## Basic CLI Commands + +The collect CLI for probes is accessed through the `opensampl collect` command. + +### Collecting from NTP Sources + +The NTP collector supports two modes: + +- `local`: reads host sync state from `chronyc`, `ntpq`, `timedatectl`, and `systemctl` when they are available +- `remote`: sends an NTP query to a target host and records the returned delay, offset, stratum, and metadata + +Basic command structure: + +```bash +opensampl collect ntp [OPTIONS] +``` + +#### Common options + +- `--probe-id`: stable identifier for the target probe written into `probe_metadata` +- `--output-dir`: directory to write collected files instead of loading directly +- `--load`: load the collected artifact directly into the configured database +- `--interval`: seconds between samples; `0` means collect once and exit +- `--count`: number of samples to collect when `--interval` is greater than `0` +- `--collection-id`: override the collector host reference probe id +- `--collection-ip`: override the collector host reference IP + +#### Local mode examples + +```bash +opensampl collect ntp --mode local --probe-id local-chrony --output-dir ./ntp-out +opensampl collect ntp --mode local --probe-id local-chrony --load +``` + +#### Remote mode examples + +```bash +opensampl collect ntp --mode remote --host time.cloudflare.com --probe-id public-time --output-dir ./ntp-out +opensampl collect ntp --mode remote --host 127.0.0.1 --port 10123 --probe-id mock-a --count 5 --interval 10 --load +``` + +#### NTP metadata behavior + +Each NTP collection writes metadata for two probes: + +- the collector host, marked with `reference: true` +- the target probe being measured, which stores mode, target host/port, sync status, observed sources, and any extra metadata in `ntp_metadata` + +Remote single-response collections estimate jitter from delay and root dispersion when the server does not provide enough information to compute peer jitter directly. Local chrony and `ntpq` paths keep using measured jitter when it is available. + + +## Microchip CLI Commands -The collect CLI is accessed through the `opensampl-collect` command: +The collect CLI for Microchip TP4100 and TWST is accessed through the `opensampl-collect` command: ```bash # View available collection tools @@ -516,4 +573,4 @@ For CLI usage, the logging level is controlled by the OpenSAMPL configuration sy Once data is collected, you can load it into the OpenSAMPL database using the standard load commands. The collect API is designed to generate data files that are compatible with the OpenSAMPL data loading system. -See the main OpenSAMPL documentation for details on loading collected data into the database. \ No newline at end of file +See the main OpenSAMPL documentation for details on loading collected data into the database. diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index feb910b..70c2d37 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -28,7 +28,10 @@ You can manually set `ROUTE_TO_BACKEND=false` using `opensampl config set ROUTE_ ## opensampl-server By default, the server will be created using the `docker-compose.yaml` found in opensampl/server/. If you wish to set a different compose to further control -your server, you can export the `OPENSAMPL_SERVER__COMPOSE_FILE` env var to be the full path to your own compose file. +your server, you can export the `OPENSAMPL_SERVER__COMPOSE_FILE` env var to be the full path to your own compose file. + +You can also use the `OPENSAMPL_SERVER__OVERRIDE_FILE` env var which will be applied overtop the existing default compose file. This allows a user to replace just ports +or other small changes to the existing architecture. To customize your server, specify a new env file using the `OPENSAMPL_SERVER__DOCKER_ENV_FILE` env var. It will default to the one found in opensampl/server/default.env Here is what the default environment has configured: diff --git a/docs/guides/create_probe_type.md b/docs/guides/create_probe_type.md index 75c69fa..b7282da 100644 --- a/docs/guides/create_probe_type.md +++ b/docs/guides/create_probe_type.md @@ -1,38 +1,65 @@ # Create your own Probe Type -WARNING! This is an experimental tool, and may have breaking changes between release versions until it is marked as fully supported -Clock types can be added to generate new ORMs for different clock types via skeleton files, which can then be further -configured to report the type of clock data that is being added to the database. +This workflow is still experimental. It is useful when you want to scaffold a new vendor / +probe family inside a local clone of the repository. -It is recommended that you only use `opensampl create` with the package cloned down. See [Installation for developers](../getting-started/installation.md#installation-for-developers) for more details on how to do so. +## Recommended setup + +1. Clone the repository locally. +2. Install OpenSAMPL in the development environment. +3. Run `opensampl create` to generate the scaffold. + 4. include `--collect-mixin` flag if you intend to implement timing collection as well to prefill those functions +4. Fill in the generated parser, metadata model, and any collector mixins you need. +5. Run `opensampl init` or `opensampl create --update-db ...` to create the new tables in the database. + +```bash +git clone git@github.com:ORNL/OpenSAMPL.git +cd OpenSAMPL +uv venv +uv sync --all-extras --dev +source .venv/bin/activate +``` + +If you plan to contribute the new probe type back to the repository, you will also need to +add any required schema or migration updates alongside the generated code. ## Usage -Command: `opensampl create [OPTIONS]`
-Arguments: +Command: `opensampl create [OPTIONS]` + +Arguments: * `CONFIG PATH`: The path to the config file defining the new probe type Options: -* `--update-db` (`-u`): Update the database with the new probe type +* `--update-db` (`-u`): Update the database with the new probe type ## Config File Formatting -`name`: The name of the probe type. Should not have spaces or special characters outside of `-` or `_`
+`name`: The name of the probe type. It should not contain spaces or special characters +outside of `-` or `_`. + +`parser_class`: Optional. The class name for the probe implementation. By default it is +`f'{name.capitalize()}Probe'`. + +`parser_module`: Optional. The Python module name for your probe type. By default it is +`name.lower()`. + +`metadata_orm`: Optional. The SQLAlchemy ORM class name for the metadata table. By default +it is `f'{name.capitalize()}Metadata'`. -`parser_class`: Optional: The name of the object that manages your probe type. By default, will be `f'{name.capitalize()}Probe'`
-`parser_module`: Optional: The module name for your probe type (ie, stem of the python file). By default, will be `name.lower()`
-`metadata_orm`: Optional: The name of the sqlalchemy ORM representation of the database table storing your probe type's metadata. By default, will be `f'{name.capitalize()}Metadata'`
-`metadata_table`: Optional: The table name that will store your probe type's metadata. By default, will be `f'{name.lower()}_metadata'`
+`metadata_table`: Optional. The database table name for the metadata table. By default it is +`f'{name.lower()}_metadata'`. -`metadata_fields`: A dictionary of metadata fields that will be provided for your new probe type. +`metadata_fields`: A dictionary of metadata fields that will be provided for your new probe type. -* The keys of this dict will be the names of the fields, and they will become columns in the dictionary table. -* The values of the dict are optional, but when provided they are the SQLALCHEMY type of that field (defaults to `Text`) +* The keys become column names in the generated metadata table. +* The values are optional SQLAlchemy type names. If omitted, the field defaults to `Text`. -For a full example, this is the definition of the config that would create the (already existing) ADVA probe type. +For a concrete example, this is the configuration that would scaffold the existing ADVA +probe type: ```yaml name: ADVA parser_class: AdvaProbe @@ -55,4 +82,4 @@ metadata_fields: adva_status: adva_mtie_mask: adva_mask_margin: Integer -``` \ No newline at end of file +``` diff --git a/docs/guides/index.md b/docs/guides/index.md index d46d42d..d2a237f 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -1,6 +1,8 @@ # Guides -* [Configuration](configuration.md) +* [Configuration](configuration.md) * [Using the `opensampl` CLI](opensampl-cli.md) -* [Using the `opensampl-server` CLI](opensampl-server.md) -* [Using the `opensampl-collect` CLI](opensampl-cli.md) \ No newline at end of file +* [Using the `opensampl-server` CLI](opensampl-server.md) +* [Collection Guide](collection.md) +* [Random Data Guide](random-data-generation.md) +* [NTP Extension Guide](ntp-extension.md) diff --git a/docs/guides/ntp-extension.md b/docs/guides/ntp-extension.md new file mode 100644 index 0000000..a2c86b8 --- /dev/null +++ b/docs/guides/ntp-extension.md @@ -0,0 +1,105 @@ +# NTP Extension Guide + +This guide explains how NTP support was added to OpenSAMPL, what behaviors are specific to the NTP probe family, and what assumptions the dashboards and loader make when visualizing NTP-backed timing data. + +## Overview + +The NTP work adds a first-class `NTP` vendor family to OpenSAMPL rather than treating NTP snapshots as an external pre-processing concern. + +That includes: + +- a vendor/probe implementation in `opensampl.vendors.ntp` +- local and remote collection paths +- `ntp_metadata` rows for probe-specific metadata +- NTP-specific metric loading into `probe_data` +- dashboard and query changes so NTP-backed references are handled safely + +## Probe model + +NTP uses two related probe identities: + +- the target probe, which represents the NTP server or local host being measured +- the collection probe, which represents the host performing the observation + +Time-series rows are written for the target probe and use the collection probe as the reference dimension. This keeps NTP data aligned with existing OpenSAMPL reference semantics without pretending the reference is GNSS-grounded. + +## Collection modes + +The NTP probe supports two collection modes: + +- `local` + Uses locally available tools such as `chronyc`, `ntpq`, `timedatectl`, and `systemctl` to infer synchronization state and measured metrics where available. +- `remote` + Sends a single NTP query to a remote host using `ntplib` and extracts values such as offset, delay, stratum, and root dispersion. + +The main CLI path is: + +```bash +opensampl collect ntp --mode remote --host time.cloudflare.com --probe-id public-time --output-dir ./ntp-out +opensampl collect ntp --mode local --probe-id local-chrony --load +``` + +## Metadata and loading + +Each NTP artifact contains: + +- file header metadata describing the target and collection probe relationship +- time-series rows for each collected metric + +During load: + +1. the collection probe is inserted into `probe_metadata` with `reference: true` +2. the target probe is inserted into `probe_metadata` +3. NTP-specific metadata is written into `ntp_metadata` +4. each collected metric is written into `probe_data` using the collection probe as the reference + +This design keeps NTP aligned with the existing OpenSAMPL loading model and allows dashboards to filter across vendors using the same reference tables. + +## Jitter semantics + +Remote NTP responses do not always provide a true measured peer jitter value from a single sample. When OpenSAMPL has only a single remote response, it stores a documented estimate/bound derived from delay and root dispersion rather than leaving jitter empty. + +Local `chronyc` and `ntpq` paths continue to use measured jitter when those tools expose it. + +The dashboards and docs should therefore distinguish between: + +- measured jitter from local/system tooling +- estimated jitter from single-response remote NTP queries + +## Geolocation behavior + +NTP geolocation happens at metadata ingest time, not in Grafana. + +The loader uses `ENABLE_GEOLOCATE` and the geolocation helper to decide whether to create `locations` rows: + +- if an explicit override is provided, that override wins +- if geolocation is enabled and the host resolves to a public IP, OpenSAMPL can look up coordinates through `ip-api.com` +- if the host resolves to a private or loopback IP, default lab coordinates can be used +- if there is not enough information to name and place a location, location creation is skipped + +This keeps external lookup behavior isolated to ingest time and makes the resulting dashboard state reproducible from the database alone. + +## Dashboard semantics + +For NTP-backed demo paths, the word `Reference` is intentional. + +It means: + +- the OpenSAMPL reference dimension used for joins and variable resolution + +It does not mean: + +- a claim that the underlying timing source is GNSS-truth-backed + +The backend model still preserves GNSS extensibility for future probe families. The NTP work only makes the current semantics safer and more explicit. + +## Testing and CI + +The NTP work added: + +- collector tests for local and remote parsing behavior +- metadata/load tests for `ntp_metadata` and collection-probe creation +- geolocator unit tests that mock outbound lookup behavior +- integration-style seeded database tests using MockDB + +The CI workflow also installs PostgreSQL/PostGIS tooling so environments that rely on `pytest-postgresql` can still be provisioned when needed, while the default suite remains stable on developer machines that cannot easily run a local PostGIS-backed PostgreSQL instance. diff --git a/docs/guides/opensampl-cli.md b/docs/guides/opensampl-cli.md index f38eb4f..cf27934 100644 --- a/docs/guides/opensampl-cli.md +++ b/docs/guides/opensampl-cli.md @@ -1,19 +1,21 @@ # CLI -The CLI tool provides several commands. You can use `opensampl --help` (or, any deeper `opensampl [command] --help`) to get details +Use `opensampl --help` to see the top-level commands, or `opensampl --help` +for subcommand-specific options. ## Load Data ### Probe Data -Command: `opensampl load [OPTIONS]`
-Arguments: +Command: `opensampl load [OPTIONS]` +Arguments: + +* `PROBE TYPE`: The loader implementation to use for the input files +* `INPUT PATH`: A single file or a directory of files -* `PROBE TYPE`: Specify the probe type, which defines how to process the data files - * See API reference for currently supported Probe Types. - * You can also try the experimental `create` method, described [below](#create), to define your own probe type. -* `INPUT PATH`: The path to the input. Can be a single file, or a directory of files. +Supported probe types currently exposed by the CLI include `ADVA`, `MicrochipTWST`, +`MicrochipTP4100`, `NTP`, and `random`. -Options: +Options: * `--metadata` (`-m`): Only load probe metadata * `--time-data` (`-t`): Only load time series data @@ -23,41 +25,57 @@ Options: * `--chunk-size` (`-c`): Number of time data entries per batch (default: 10000) #### ADVA -The tool currently supports ADVA probe data files with the following naming convention: +The CLI supports ADVA probe data files with the following naming convention: > `CLOCK_PROBE--YYYY-MM-DD-HH-MM-SS.txt.gz` -Example: +Example: > `10.0.0.121CLOCK_PROBE-1-1-2024-01-02-18-24-56.txt.gz` -With the file format of having metadata at the beginning (on lines starting with `#`), followed by -tab separated `time value` measurements. +The file format contains metadata at the beginning on lines starting with `#`, followed by +tab-separated `time value` measurements. -As ADVA probes have all their metadata and their time data in each file, there is no need to use the `-m` or `-t` options, though if you want to skip loading one or the other it becomes useful! +As ADVA probes have metadata and time-series data in each file, there is usually no need +to split metadata and time-data loading. #### Microchip -Microchip TWST and TP4100 modems are also supported, the file names are supported as created via `opensampl-collect` +Microchip TWST and TP4100 files are supported. Collection for those devices is still done +through the dedicated `opensampl-collect` entry point, while loading is handled through +`opensampl load MicrochipTWST ...` or `opensampl load MicrochipTP4100 ...`. + +#### NTP +NTP data can be collected with the main CLI and then loaded with the `NTP` probe type. + +```bash +opensampl collect ntp --mode remote --server pool.ntp.org --output-path ./ntp-out +opensampl load NTP ./ntp-out +``` + +For local collection, point the collector at an `ntpq` output file or let it inspect the +local system directly, depending on your deployment. See the [collection guide](collection.md) +for the current collection modes and configuration options. ### Direct Table Entries -Load data directly into a database table from a file, whose format can be yaml or json. +Load data directly into a database table from a file. The file format can be YAML or JSON. -The file should contain either one dictionary (for one entry) or a list of dictionaries (for many entries) +The file should contain either one dictionary or a list of dictionaries. -Command: `opensampl load table [OPTIONS]`
-Arguments: +Command: `opensampl load table
[OPTIONS]` +Arguments: -* `TABLE NAME`: Which table to write to. +* `TABLE NAME`: Which table to write to * `INPUT PATH`: The path to the input file, whose format can be yaml or json. See the [page on expected formatting for writing to table](expected_table_format.md) to ensure the provided file matches the table's expected format. Options: * `--if-exists` (`-i`): How to handle conflicts: - - `update`: Insert non-primary-key fields that are `NULL` in existing entry (default) - - `error`: Raise an error if entry exists - - `replace`: Replace all non-primary-key fields with new values - - `ignore`: Skip if entry exists + - `update`: Insert non-primary-key fields that are `NULL` in an existing entry (default) + - `error`: Raise an error if the entry already exists + - `replace`: Replace all non-primary-key fields with new values + - `ignore`: Skip the entry if it already exists ## Configuration -See the [configuration](configuration.md) page for details on how the config command works +See the [configuration](configuration.md) page for how `opensampl config` reads and writes +environment-backed settings. ### View @@ -78,15 +96,15 @@ opensampl config show -e -v BACKEND_URL # Show specific variable with descripti ### Set Command: `opensampl config set `
-Arguments: +Arguments: * `VAR NAME`: the env var you want to change * `VAR VALUE`: the new value to set it as ## Create -** Experimental **
-Create a new probe type with scaffolding, based on a config file. -See the [Create page](create_probe_type.md) for how to use +**Experimental** +Create a new probe type scaffold from a configuration file. See the +[Create page](create_probe_type.md) for the current workflow and limitations. Command: `opensampl create [OPTIONS]`
Arguments: @@ -95,6 +113,5 @@ Arguments: Options: -* `--update-db` (`-u`): Update the database with the new probe type - +* `--update-db` (`-u`): Update the database with the new probe type diff --git a/docs/guides/opensampl-server.md b/docs/guides/opensampl-server.md index e260000..5755e0c 100644 --- a/docs/guides/opensampl-server.md +++ b/docs/guides/opensampl-server.md @@ -1,187 +1,127 @@ # openSAMPL Server CLI Usage Guide -This guide explains how to use the openSAMPL Server command-line interface (CLI) tool to manage your openSAMPL server deployment. +The `opensampl-server` CLI is a thin wrapper around the packaged Docker Compose stack in `opensampl.server`. -## Overview - -The openSAMPL Server CLI provides commands for managing a Docker Compose deployment of the openSAMPL server infrastructure. It handles: - -- Starting and stopping services -- Viewing logs -- Checking service status -- Running custom commands +It is useful when you want a local database, backend API, migrations container, and Grafana instance without managing the compose arguments yourself. ## Prerequisites -- Docker and Docker Compose installed -- The openSAMPL Python package installed +- Docker with either `docker compose` or `docker-compose` +- OpenSAMPL installed with the server extra -Install openSAMPL as normal: -``` -pip install opensampl +```bash +pip install "opensampl[server]" ``` -## Basic Commands +## What `opensampl-server up` does -### Starting the Server - -To start the openSAMPL server: +Running: ```bash opensampl-server up ``` -This command: -- Starts all services defined in the Docker Compose file -- Runs containers in detached mode (`-d`) -- Sets local environment variables to route `opensampl load` commands via the backend +starts the packaged compose stack in detached mode and updates your local OpenSAMPL environment so future `opensampl load ...` commands route through the backend API. -**Starting a specific service:** +Specifically, it writes: -```bash -opensampl-server up --service grafana -``` -this will start up just the specified container, choices are: -- db -- backend -- grafana -- migrations +- `ROUTE_TO_BACKEND=true` +- `BACKEND_URL=http://localhost:8015` +- `DATABASE_URL=postgresql://...@localhost:5415/...` -**Using a custom .env file:** +The default stack exposes: -```bash -opensampl-server up --env-file /path/to/custom.env -``` -uses default.env with values: -```dotenv -COMPOSE_PROJECT_NAME=opensampl +- PostgreSQL / TimescaleDB on `localhost:5415` +- the backend API on `localhost:8015` +- Grafana on `localhost:3015` -POSTGRES_DB=castdb -POSTGRES_USER=castuser -POSTGRES_PASSWORD=castpassword +## Commands -DB_URI="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" -GF_SECURITY_ADMIN_PASSWORD=secret +### Start all services -BACKEND_LOG_LEVEL=DEBUG +```bash +opensampl-server up ``` -### Stopping the Server -To stop all services: +### Start only selected services + +Additional arguments after `up` are passed through to Docker Compose. For example: ```bash -opensampl-server down +opensampl-server up grafana +opensampl-server up db backend ``` -**Stopping a specific service:** +### Stop services ```bash -opensampl-server down --service db +opensampl-server down ``` -### Viewing Logs - -To view logs from all containers: +### Show logs ```bash opensampl-server logs ``` -This command follows the logs (`-f`), showing new log entries as they arrive. - -### Checking Service Status - -To see the status of all services: +### Show service status ```bash opensampl-server ps ``` -This displays: -- Container names -- Status (running, stopped, etc.) -- Ports -- Other container information - -### Running Custom Commands - -The `run` command allows you to execute commands in a service container: +### Run a one-off compose command ```bash opensampl-server run backend python -m opensampl.cli init ``` -This example: -- Creates a temporary container for the `backend` service -- Runs the specified command -- Removes the container after completion (`--rm`) +This maps directly to `docker compose run --rm ...`. -## Environment Configuration +## Using a custom env file -By default, the CLI uses the packaged `default.env` file for configuration. You can specify a custom environment file with the `--env-file` option for any command: +`--env-file` is a top-level CLI option, so it must appear before the subcommand: ```bash -opensampl-server up --env-file ./my-custom-env.env +opensampl-server --env-file ./dev.env up +opensampl-server --env-file ./dev.env ps ``` -## Technical Details +By default, the server wrapper uses the packaged `default.env` values through `ServerConfig`. -- The CLI automatically detects whether to use `docker-compose` or `docker compose` based on your system configuration -- When starting the server, it configures your local environment to use the backend by setting: - - `BACKEND_URL=http://localhost:8015` - - `ROUTE_TO_BACKEND=true` +The shipped defaults include: -## Power users - -For those who are more familiar with docker, there is a `opensampl-server2` which corresponds to the following, more directly -exposing the docker to users. -`OPENSAMPL_SERVER__COMPOSE_FILE` is set in your .env file or environment. - -```bash -opensampl-server2 --env-file ENV_FILE args -docker compose --env-file ${ENV_FILE} -f ${OPENSAMPL_SERVER__COMPOSE_FILE} $@ +```dotenv +COMPOSE_PROJECT_NAME=opensampl +POSTGRES_DB=castdb +POSTGRES_USER=castuser +POSTGRES_PASSWORD=castpassword +GF_SECURITY_ADMIN_PASSWORD=secret +BACKEND_LOG_LEVEL=DEBUG +USE_API_KEY=false +API_KEYS=changeme123 ``` -## Troubleshooting +## Advanced configuration + +The server wrapper can be redirected to other compose files or docker env files with the server-specific environment variables described in the [configuration guide](configuration.md#opensampl-server): -If you encounter issues: +- `OPENSAMPL_SERVER__COMPOSE_FILE` +- `OPENSAMPL_SERVER__OVERRIDE_FILE` +- `OPENSAMPL_SERVER__DOCKER_ENV_FILE` -1. Check that Docker and Docker Compose are installed and running -2. Verify your environment file contains the necessary configuration -3. Check the logs for specific error messages: `opensampl-server logs` +## Troubleshooting -## Examples +1. Confirm Docker is installed and running. +2. Run `opensampl-server ps` to see whether services came up. +3. Run `opensampl-server logs` to inspect startup failures. +4. If you changed compose or env settings, confirm the files exist and are readable. -**Full development workflow:** +## Example workflow ```bash -# Start the entire stack opensampl-server up - -# Check that all services are running opensampl-server ps - -# View logs to ensure everything started correctly -opensampl-server logs - -# load clock data into the db -opensampl load probe ADVA ./data - -# When finished, shut down the stack +opensampl load ADVA ./data opensampl-server down ``` - -**Development with custom environment:** - -```bash -# Create a custom environment file -cp $(opensampl-server get-default-env) ./dev.env - -# Edit the file with custom settings -nano ./dev.env - -# Start the server with custom environment -opensampl-server up --env-file ./dev.env -``` - - diff --git a/docs/index.md b/docs/index.md index 56e5125..219c4be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,47 +1,40 @@ # openSAMPL Documentation -python tools for adding clock data to a timescale db. -## Overview +Python tools for loading, storing, and visualizing clock data in PostgreSQL / TimescaleDB. -`opensampl` is a package released by the Oak Ridge National Laboratory (ORNL) that provides tools for adding clock -data to a postgres database. +## Overview -The package includes a CLI tool that allows users to load data from ADVA probes into a -PostgreSQL database (preferably a [TimescaleDB](https://www.timescale.com/) flavor of PostgreSQL, but it can work with -any PostgreSQL-based databases) +`opensampl` is an Oak Ridge National Laboratory (ORNL) project for synchronization analytics and monitoring data. -The tool supports loading both metadata and time series data from files, with options to -skip loading one or the other. It also provides features for archiving processed files and setting the maximum number -of worker threads for parallel processing, and helper functions for easily adding additional clocks to the database. +The package provides: -There is also an optional `server` extra which can be used to set your environment up more easily. +- a CLI for loading probe files and direct table data +- collection tooling for supported probe families +- optional Docker-backed server helpers +- Grafana dashboards and supporting backend components -## Getting Started +OpenSAMPL currently supports probe families including ADVA, Microchip TWST, Microchip TP4100, and NTP. -If you're new here, start your journey: +## Getting started -- **Installation:** Follow the steps in [Installation](getting-started/installation.md) to arm yourself with the essentials. -- **Quickstart:** For a rapid initiation, dive into our [Quickstart](getting-started/quickstart.md) guide. +- [Installation](getting-started/installation.md) +- [Quickstart](getting-started/quickstart.md) ## Guides -Master the art of customization and configuration: +- [Configuration](guides/configuration.md) +- [Using the `opensampl` CLI](guides/opensampl-cli.md) +- [Using the `opensampl-server` CLI](guides/opensampl-server.md) +- [Collection Guide](guides/collection.md) +- [Random Data Guide](guides/random-data-generation.md) +- [NTP Extension Guide](guides/ntp-extension.md) -[//]: # (* [Configuration](guides/configuration.md) – Learn how to set up and tweak `opensampl` to your liking.) -* [Customization](guides/configuration) – Discover advanced tweaks that put you in complete control. -* [Using the `opensampl` CLI](guides/opensampl-cli.md) – A comprehensive guide to using the `opensampl` CLI tool. -* [Using the `opensampl-server` CLI](guides/opensampl-server.md) – A comprehensive guide to using the `opensampl-server` CLI tool. +## API references -## API Reference +- [openSAMPL API Reference](api/index.md) -Dare to explore our API: +## Repository -* [openSAMPL API Reference](python-api-reference.md) – A comprehensive look into every function, class, and module that defines `opensampl` -* [Server API Reference](server-api-reference.md) – A detailed breakdown of the Python API that powers `opensampl`. ---- - -Each word here is designed to illuminate your path through the codebase. If you find yourself lost, refer back to this -document for guidance. And remember, the journey of a thousand commits begins with a single `git clone`. 🚀 -``` +```bash git clone git@github.com:ORNL/OpenSAMPL.git ``` diff --git a/docs/python-api-reference.md b/docs/python-api-reference.md deleted file mode 100644 index 8cd1efa..0000000 --- a/docs/python-api-reference.md +++ /dev/null @@ -1,18 +0,0 @@ -# openSAMPL API Reference - -This section contains the automatically generated API reference for the openSAMPL library. - -## Module Index - -* [cli](api/cli.md) -* [constants](api/constants.md) -* [load_data](api/load_data.md) -* **db/** - * [access_orm](api/db/access_orm.md) - * [orm](api/db/orm.md) -* **server/** - * [cli](api/server/cli.md) -* **vendors/** - * [adva](api/vendors/adva.md) - * [base_probe](api/vendors/base_probe.md) - * [constants](api/vendors/constants.md) diff --git a/docs/server-api-reference.md b/docs/server-api-reference.md deleted file mode 100644 index 887de4f..0000000 --- a/docs/server-api-reference.md +++ /dev/null @@ -1,3 +0,0 @@ -# The `opensampl` API -The `opensampl[server]` API is a RESTful API that is built using FastAPI. The API is designed to be a backend API for -clocks to push data to. \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index 848aee3..c439a1f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -14,38 +14,57 @@ nav: - Create: guides/create_probe_type.md - Server: guides/opensampl-server.md - Collect: guides/collection.md + - Automatic Ingest: guides/automatic_ingest.md + - NTP Extension: guides/ntp-extension.md - Random Data: guides/random-data-generation.md - API: - index: api/index.md - - ats6502: - - context: api/ats6502/context.md - - modem: api/ats6502/modem.md - - readings: api/ats6502/readings.md - cli: api/cli.md + - collect: + - cli: api/collect/cli.md + - microchip: + - tp4100: + - collect_4100: api/collect/microchip/tp4100/collect_4100.md + - twst: + - context: api/collect/microchip/twst/context.md + - generate_twst_files: api/collect/microchip/twst/generate_twst_files.md + - readings: api/collect/microchip/twst/readings.md + - modem: api/collect/modem.md - config: - base: api/config/base.md + - server: api/config/server.md + - tp4100: api/config/tp4100.md + - create: + - create_vendor: api/create/create_vendor.md + - insert_markers: api/create/insert_markers.md - db: - access_orm: api/db/access_orm.md - orm: api/db/orm.md - helpers: - - create_vendor: api/helpers/create_vendor.md - - insert_markers: api/helpers/insert_markers.md - - source_writer: api/helpers/source_writer.md + - geolocator: api/helpers/geolocator.md - load: - data: api/load/data.md - - probe: api/load/probe.md - routing: api/load/routing.md - table_factory: api/load/table_factory.md - load_data: api/load_data.md - metrics: api/metrics.md + - mixins: + - collect: api/mixins/collect.md + - random_data: api/mixins/random_data.md - references: api/references.md - server: + - backend: + - main: api/server/backend/main.md - cli: api/server/cli.md + - cli2: api/server/cli2.md - vendors: - adva: api/vendors/adva.md - base_probe: api/vendors/base_probe.md - constants: api/vendors/constants.md - - microsemi_twst: api/vendors/microsemi_twst.md + - microchip: + - tp4100: api/vendors/microchip/tp4100.md + - twst: api/vendors/microchip/twst.md + - ntp: api/vendors/ntp.md plugins: - search - mkdocstrings: diff --git a/myvendor.yaml b/myvendor.yaml new file mode 100644 index 0000000..98e4c1f --- /dev/null +++ b/myvendor.yaml @@ -0,0 +1,47 @@ +# ============================================================================= +# openSAMPL Vendor Config Template +# Usage: pass this file to the create_vendor command +# ============================================================================= + +# REQUIRED: Human-readable vendor name. Used to auto-generate all fields below +# if they are not explicitly provided. Spaces permitted as they are removed/replaced as needed +name: My Vendor + +# OPTIONAL: The Python class name for the probe parser. +# Auto-generated from name if omitted → e.g. "My Vendor" → "MyVendorProbe" +# parser_class: MyVendorProbe + +# OPTIONAL: The Python module name (file) under opensampl/vendors/. +# Auto-generated from name if omitted → e.g. "My Vendor" → "my_vendor" +#parser_module: my_vendor + +# OPTIONAL: The database table name for this vendor's metadata. +# Auto-generated from name if omitted → e.g. "My Vendor" → "my_vendor_metadata" +#metadata_table: my_vendor_metadata + +# OPTIONAL: The SQLAlchemy ORM class name for the metadata table. +# Auto-generated from name if omitted → e.g. "My Vendor" → "MyVendorMetadata" +# metadata_orm: MyVendorMetadata + +# OPTIONAL: Vendor-specific metadata fields to store alongside each probe. +# If omitted or malformed, defaults to only `additional_metadata` (JSONB) and probe uuid +# +# Each field requires: +# - name: column name in the metadata table +# - type: SQLAlchemy column type (default: Text if omitted) +# +# Common SQLAlchemy types: +# Text, String, Integer, Float, Boolean, DateTime, JSONB, Numeric +metadata_fields: + - name: serial_number + type: Text + - name: firmware_version + type: Text + - name: location + type: Text + - name: sample_rate_hz + type: Integer + # Add more fields as needed... + # + # NOTE: `additional_metadata` (JSONB) and 'probe_uuid' (Text) is always appended automatically — + # no need to include it here. \ No newline at end of file diff --git a/opensampl/cli.py b/opensampl/cli.py index da144d6..3137c44 100644 --- a/opensampl/cli.py +++ b/opensampl/cli.py @@ -9,7 +9,7 @@ import os import sys from pathlib import Path -from typing import Literal, Optional, Union +from typing import Literal import click import yaml @@ -19,6 +19,8 @@ from opensampl.config.base import BaseConfig as CLIConfig from opensampl.db.orm import get_table_names from opensampl.load_data import create_new_tables, write_to_table +from opensampl.mixins.collect import CollectMixin +from opensampl.mixins.random_data import RandomDataMixin from opensampl.vendors.constants import VENDORS BANNER = r""" @@ -33,7 +35,7 @@ """ -def load_config(env_file: Optional[str] = None) -> CLIConfig: +def load_config(env_file: str | None = None) -> CLIConfig: """ Load the configuration settings @@ -62,7 +64,7 @@ def load_config(env_file: Optional[str] = None) -> CLIConfig: class CaseInsensitiveGroup(click.Group): """Defines Click group options as case-insensitive. By default, click groups are case-sensitive.""" - def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: # noqa: ARG002 + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: # noqa: ARG002 """Normalize command name to lower case""" cmd_name = cmd_name.lower() # Match against lowercased command names @@ -184,12 +186,21 @@ def random(): """Generate and send random test data to the database""" +@cli.group(cls=CaseInsensitiveGroup) +def collect(): + """Collect and send data to the database""" + + for vendor in VENDORS.all(): - load.add_command(vendor.get_parser().get_cli_command(), name=vendor.name) - random.add_command(vendor.get_parser().get_random_data_cli_command(), name=vendor.name) + _vend = vendor.get_parser() + load.add_command(_vend.get_cli_command(), name=vendor.name) + if issubclass(_vend, RandomDataMixin): + random.add_command(_vend.get_random_data_cli_command(), name=vendor.name) + if issubclass(_vend, CollectMixin): + collect.add_command(_vend.get_collect_cli_command(), name=vendor.name) -def path_or_string(value: str) -> Union[dict, list]: +def path_or_string(value: str) -> dict | list: """Get content from a file or use the string directly""" # Get content - either from file or use the string directly content = value @@ -225,9 +236,7 @@ def path_or_string(value: str) -> Union[dict, list]: ) @click.argument("table_name", type=click.Choice(get_table_names())) @click.argument("filepath", type=path_or_string) -def table_load( - filepath: Union[dict, list], table_name: str, if_exists: Literal["update", "error", "replace", "ignore"] -): +def table_load(filepath: dict | list, table_name: str, if_exists: Literal["update", "error", "replace", "ignore"]): r""" Perform a Table load into the database. @@ -266,12 +275,19 @@ def table_load( is_flag=True, help="Update the database with the new probe type", ) -def create_probe_command(config_path: Path, update_db: bool): +@click.option( + "--collect-mixin", + "-c", + is_flag=True, + help="include shell for collect mixin ", +) +def create_probe_command(config_path: Path, update_db: bool, collect_mixin: bool): """Create a new probe type with scaffolding, based on a config file.""" from opensampl.create.create_vendor import VendorConfig + # TODO figure out best way to allow Vendor Config be through cli flags (too complicated nesting for pydanclick) vendor_config = VendorConfig.from_config_file(config_path) - vendor_config.create() + vendor_config.create(collect_mixin=collect_mixin) if update_db: create_new_tables() diff --git a/opensampl/collect/cli.py b/opensampl/collect/cli.py index e65df6e..6a07e69 100644 --- a/opensampl/collect/cli.py +++ b/opensampl/collect/cli.py @@ -1,7 +1,7 @@ """Consolidated CLI entry point for opensampl.collect tools.""" import sys -from typing import Literal, Optional +from typing import Literal import click from loguru import logger @@ -84,8 +84,8 @@ def tp4100( port: int, output_dir: str, duration: int, - channels: Optional[list[str]], - metrics: Optional[list[str]], + channels: list[str] | None, + metrics: list[str] | None, method: Literal["chart_data", "download_file"], save_full_status: bool, verbose: bool, diff --git a/opensampl/collect/microchip/tp4100/__init__.py b/opensampl/collect/microchip/tp4100/__init__.py index d2434d0..9246439 100644 --- a/opensampl/collect/microchip/tp4100/__init__.py +++ b/opensampl/collect/microchip/tp4100/__init__.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import ClassVar, Optional +from typing import ClassVar @dataclass @@ -76,7 +76,7 @@ class MonitoringConfig: channel_str: str ids: list[int] metrics: list[MetricInfo] - default_download: Optional[dict] = None + default_download: dict | None = None @property def download_path(self): @@ -90,7 +90,7 @@ def download_path(self): return f"perfmon_{self.channel_str}_stat" def download_payload( - self, download: Optional[dict] = None, which_id: int = 1, down_metric: MetricInfo = MONITOR_METRIC.TE + self, download: dict | None = None, which_id: int = 1, down_metric: MetricInfo = MONITOR_METRIC.TE ): """ Generate payload for data download requests. diff --git a/opensampl/collect/microchip/tp4100/collect_4100.py b/opensampl/collect/microchip/tp4100/collect_4100.py index f3de6ba..20de8f4 100644 --- a/opensampl/collect/microchip/tp4100/collect_4100.py +++ b/opensampl/collect/microchip/tp4100/collect_4100.py @@ -11,7 +11,7 @@ from datetime import datetime, timezone from pathlib import Path from pprint import pformat -from typing import Any, Literal, Optional +from typing import Any, Literal import pandas as pd import requests @@ -44,8 +44,8 @@ def __init__( port: int = 443, output_dir: str = "./output", duration: int = 600, - channels: Optional[list[str]] = None, - metrics: Optional[list[str]] = None, + channels: list[str] | None = None, + metrics: list[str] | None = None, method: Literal["chart_data", "download_file"] = "chart_data", save_full_status: bool = False, ): @@ -171,7 +171,7 @@ def collect_readings(self): elif self.method == "download_file": self.download_files(request_tpl) - def get_filename(self, detail: Optional[str] = None, extension: str = ".txt"): + def get_filename(self, detail: str | None = None, extension: str = ".txt"): """ Generate timestamped filename for probe connection @@ -194,7 +194,7 @@ def get_filename(self, detail: Optional[str] = None, extension: str = ".txt"): return filename def collect_chart_data( - self, request_key: tuple[MonitoringConfig, int, MetricInfo], download_dict: Optional[dict[str, Any]] = None + self, request_key: tuple[MonitoringConfig, int, MetricInfo], download_dict: dict[str, Any] | None = None ): """ Collect chart data for a specific metric and channel. @@ -271,7 +271,7 @@ def format_utc_second(tai_sec: str, utc_offset: str) -> datetime: df.to_csv(new_file, mode="a", index=False) def download_files( - self, request_key: tuple[MonitoringConfig, int, MetricInfo], download_dict: Optional[dict[str, Any]] = None + self, request_key: tuple[MonitoringConfig, int, MetricInfo], download_dict: dict[str, Any] | None = None ): """ Download data files directly from the device. @@ -341,8 +341,8 @@ def main( port: int = 443, output_dir: str = "./output", duration: int = 600, - channels: Optional[list[str]] = None, - metrics: Optional[list[str]] = None, + channels: list[str] | None = None, + metrics: list[str] | None = None, method: Literal["chart_data", "download_file"] = "chart_data", save_full_status: bool = False, ): diff --git a/opensampl/collect/microchip/twst/context.py b/opensampl/collect/microchip/twst/context.py index 688a39e..3d4f2fc 100644 --- a/opensampl/collect/microchip/twst/context.py +++ b/opensampl/collect/microchip/twst/context.py @@ -9,7 +9,7 @@ import textwrap from datetime import datetime, timezone from types import SimpleNamespace -from typing import Any, Optional +from typing import Any import yaml from loguru import logger @@ -54,7 +54,7 @@ def finished_ok(line: str) -> bool: return re.match(r"^\[OK\]", line) is not None @staticmethod - def finished_error(line: str) -> tuple[bool, Optional[str]]: + def finished_error(line: str) -> tuple[bool, str | None]: """ Check if a command completed with an error. diff --git a/opensampl/collect/microchip/twst/generate_twst_files.py b/opensampl/collect/microchip/twst/generate_twst_files.py index 5f145dc..da15612 100644 --- a/opensampl/collect/microchip/twst/generate_twst_files.py +++ b/opensampl/collect/microchip/twst/generate_twst_files.py @@ -24,7 +24,6 @@ import csv import time from pathlib import Path -from typing import Optional from loguru import logger @@ -57,7 +56,7 @@ def collect_files( status_port: int = 1900, output_dir: str = "./output", dump_interval: int = 300, - total_duration: Optional[int] = None, + total_duration: int | None = None, ): """ Continuously collect blocks of modem measurements and save to timestamped CSV files. diff --git a/opensampl/collect/microchip/twst/readings.py b/opensampl/collect/microchip/twst/readings.py index 622cce3..02fb712 100644 --- a/opensampl/collect/microchip/twst/readings.py +++ b/opensampl/collect/microchip/twst/readings.py @@ -6,7 +6,6 @@ """ import asyncio -from typing import Optional from loguru import logger @@ -23,7 +22,7 @@ class ModemStatusReader(ModemReader): status readings over a specified duration. """ - def __init__(self, host: str, duration: int = 60, keys: Optional[list[str]] = None, port: int = 1900): + def __init__(self, host: str, duration: int = 60, keys: list[str] | None = None, port: int = 1900): """ Initialize ModemStatusReader. diff --git a/opensampl/collect/modem.py b/opensampl/collect/modem.py index 95b62cc..d602aa0 100644 --- a/opensampl/collect/modem.py +++ b/opensampl/collect/modem.py @@ -5,9 +5,9 @@ for interfacing with modems via telnet. """ +from collections.abc import Callable from contextlib import asynccontextmanager from functools import wraps -from typing import Callable, Optional from loguru import logger @@ -36,7 +36,7 @@ def require_conn(method: Callable): """ @wraps(method) - async def wrapper(self: "ModemReader", *args: list, **kwargs: dict) -> Optional[Callable]: + async def wrapper(self: "ModemReader", *args: list, **kwargs: dict) -> Callable | None: if not getattr(self, "open", False): raise RuntimeError( "Telnet connection not active: reader/writer cannot be used outside of 'async with connect()'" @@ -72,8 +72,8 @@ def __init__( self.host = host self.port = port self.encoding = encoding - self.reader: Optional[telnetlib3.TelnetReader] = None - self.writer: Optional[telnetlib3.TelnetWriter] = None + self.reader: telnetlib3.TelnetReader | None = None + self.writer: telnetlib3.TelnetWriter | None = None self.open: bool = False @asynccontextmanager diff --git a/opensampl/config/base.py b/opensampl/config/base.py index aa2af28..145b1f5 100644 --- a/opensampl/config/base.py +++ b/opensampl/config/base.py @@ -6,7 +6,7 @@ """ from pathlib import Path -from typing import Any, Optional +from typing import Any from dotenv import set_key from loguru import logger @@ -28,21 +28,27 @@ class BaseConfig(BaseSettings): ROUTE_TO_BACKEND: bool = Field( False, description="URL of the backend service when routing is enabled", alias="ROUTE_TO_BACKEND" ) - BACKEND_URL: Optional[str] = Field( + BACKEND_URL: str | None = Field( None, description="URL of the backend service when routing is enabled", alias="BACKEND_URL" ) - DATABASE_URL: Optional[str] = Field(None, description="URL for direct database connections", alias="DATABASE_URL") + DATABASE_URL: str | None = Field(None, description="URL for direct database connections", alias="DATABASE_URL") ARCHIVE_PATH: Path = Field( Path("archive"), description="Default path that files are moved to after they have been processed", alias="ARCHIVE_PATH", ) LOG_LEVEL: str = Field("INFO", description="Log level for opensampl cli", alias="LOG_LEVEL") - API_KEY: Optional[str] = Field(None, description="Access key for interacting with the backend", alias="API_KEY") + API_KEY: str | None = Field(None, description="Access key for interacting with the backend", alias="API_KEY") INSECURE_REQUESTS: bool = Field( False, description="Allow insecure requests to be made to the backend", alias="INSECURE_REQUESTS" ) + ENABLE_GEOLOCATE: bool = Field( + False, + description="Enable geolocate features which extract a location from ip addresses", + alias="ENABLE_GEOLOCATE", + ) + @field_serializer("ARCHIVE_PATH") def convert_to_str(self, v: Path) -> str: """Convert archive path to a string for serialization""" @@ -111,7 +117,7 @@ def set_by_name(self, name: str, value: Any): set_key(self.env_file, name, str(value)) - def save_config(self, values: Optional[list[str]] = None): + def save_config(self, values: list[str] | None = None): """ Save the current env configuration. diff --git a/opensampl/config/server.py b/opensampl/config/server.py index 6478145..16d061b 100644 --- a/opensampl/config/server.py +++ b/opensampl/config/server.py @@ -5,11 +5,12 @@ configuration validation, and settings management. """ +from __future__ import annotations + import shlex from importlib.resources import as_file, files from pathlib import Path -from types import ModuleType -from typing import Any, Union +from typing import TYPE_CHECKING, Any from dotenv import dotenv_values, set_key from loguru import logger @@ -20,8 +21,11 @@ from opensampl.config.base import BaseConfig from opensampl.server import check_command +if TYPE_CHECKING: + from types import ModuleType + -def get_resolved_resource_path(pkg: Union[str, ModuleType], relative_path: str) -> str: +def get_resolved_resource_path(pkg: str | ModuleType, relative_path: str) -> str: """Retrieve the resolved path to a resource in a package.""" resource = files(pkg).joinpath(relative_path) with as_file(resource) as real_path: @@ -35,6 +39,8 @@ class ServerConfig(BaseConfig): COMPOSE_FILE: str = Field(default="", description="Fully resolved path to the Docker Compose file.") + OVERRIDE_FILE: str | None = Field(default=None, description="Override for the compose file") + DOCKER_ENV_FILE: str = Field(default="", description="Fully resolved path to the Docker .env file.") docker_env_values: dict[str, Any] = Field(default_factory=dict, init=False) @@ -54,7 +60,7 @@ def _ignore_in_set(self) -> list[str]: return ignored @model_validator(mode="after") - def get_docker_values(self) -> "ServerConfig": + def get_docker_values(self) -> ServerConfig: """Get the values that the docker containers will use on startup""" self.docker_env_values = dotenv_values(self.DOCKER_ENV_FILE) return self @@ -67,6 +73,14 @@ def resolve_compose_file(cls, v: Any) -> str: return get_resolved_resource_path(opensampl.server, "docker-compose.yaml") return str(Path(v).expanduser().resolve()) + @field_validator("OVERRIDE_FILE", mode="before") + @classmethod + def resolve_override_file(cls, v: Any) -> str: + """Resolve the provided compose file for docker to use, or default to the docker-compose.yaml provided""" + if v: + return str(Path(v).expanduser().resolve()) + return v + @field_validator("DOCKER_ENV_FILE", mode="before") @classmethod def resolve_docker_env_file(cls, v: Any) -> str: @@ -89,6 +103,8 @@ def build_docker_compose_base(self): compose_command = self.get_compose_command() command = shlex.split(compose_command) command.extend(["--env-file", self.DOCKER_ENV_FILE, "-f", self.COMPOSE_FILE]) + if self.OVERRIDE_FILE: + command.extend(["-f", self.OVERRIDE_FILE]) return command def set_by_name(self, name: str, value: Any): diff --git a/opensampl/create/create_vendor.py b/opensampl/create/create_vendor.py index 3372700..0a8d0d6 100644 --- a/opensampl/create/create_vendor.py +++ b/opensampl/create/create_vendor.py @@ -10,9 +10,11 @@ """ +import textwrap from pathlib import Path +from pprint import pformat from string import Template -from typing import Any, Optional, Union +from typing import Any import yaml from loguru import logger @@ -34,8 +36,8 @@ class MetadataField(BaseModel): """ name: str - sqlalchemy_type: Optional[str] = Field(default="Text") - primary_key: Optional[bool] = False + sqlalchemy_type: str | None = Field(default="Text") + primary_key: bool | None = False class DEFAULT_METADATA: # noqa N801 @@ -68,7 +70,7 @@ class VendorConfig(VendorType): metadata_fields: list[MetadataField] @classmethod - def from_config_file(cls, config_path: Union[str, Path]) -> "VendorConfig": + def from_config_file(cls, config_path: str | Path) -> "VendorConfig": """ Convert file config into Config object. @@ -106,16 +108,16 @@ def generate_default_fields(cls, data: Any) -> Any: # Generate default values if not provided if not data.get("metadata_table"): - data["metadata_table"] = f"{name.lower()}_metadata" + data["metadata_table"] = f"{name.lower().replace(' ', '_')}_metadata" if not data.get("metadata_orm"): - data["metadata_orm"] = f"{name.capitalize()}Metadata" + data["metadata_orm"] = f"{name.title().replace(' ', '')}Metadata" if not data.get("parser_class"): - data["parser_class"] = f"{name.capitalize()}Probe" + data["parser_class"] = f"{name.title().replace(' ', '')}Probe" if not data.get("parser_module"): - data["parser_module"] = f"{name.lower()}" + data["parser_module"] = f"{name.lower().replace(' ', '_')}" fields = [] metadata_fields = data.get("metadata_fields", None) @@ -134,7 +136,7 @@ def generate_default_fields(cls, data: Any) -> Any: data["metadata_fields"] = fields return data - def create_probe_file(self) -> Path: + def create_probe_file(self, collect_mixin: bool = False) -> Path: """ Create a new probe class file. @@ -145,10 +147,17 @@ def create_probe_file(self) -> Path: # Create the probe file probe_file = self.base_path / "vendors" / f"{self.parser_module}.py" # TODO in write time data, optionally add value_str to df ensure maximum precision when sending through backend. + template_prefix = "collect_mixin" if collect_mixin else "parser" + template_file = Path(__file__).parent / "templates" / f"{template_prefix}_template.txt" + + raw = pformat([field.name for field in self.metadata_fields]) + formatted_metadata = textwrap.indent(raw, prefix="\t\t") - template_file = Path(__file__).parent / "templates" / "parser_template.txt" content = Template(template_file.read_text()).safe_substitute( - name=self.name, upper_name=self.parser_class.upper(), parser_class=self.parser_class + name=self.name, + upper_name=self.parser_module.replace(".", "_").upper(), + parser_class=self.parser_class, + metadata_fields=formatted_metadata, ) probe_file.write_text(content) @@ -223,8 +232,8 @@ def update_constants(self): """Update the constants.py file with the new vendor type.""" template_file = INSERT_MARKERS.VENDOR.template_path content = Template(template_file.read_text()).safe_substitute( - upper_name=self.parser_class.upper(), - name=self.name, + upper_name=self.parser_module.replace(".", "_").upper(), + name=self.name.replace(" ", "-"), parser_class=self.parser_class, parser_module=self.parser_module, metadata_table=self.metadata_table, @@ -232,8 +241,8 @@ def update_constants(self): ) self.insert_content_at_marker(INSERT_MARKERS.VENDOR, content) - def create(self): + def create(self, collect_mixin: bool = False) -> None: """Create the new vendor by generating probe file, ORM class, and updating constants.""" - self.create_probe_file() + self.create_probe_file(collect_mixin=collect_mixin) self.create_orm_class() self.update_constants() diff --git a/opensampl/create/templates/collect_mixin_template.txt b/opensampl/create/templates/collect_mixin_template.txt new file mode 100644 index 0000000..65b8670 --- /dev/null +++ b/opensampl/create/templates/collect_mixin_template.txt @@ -0,0 +1,116 @@ +"""${name} clock Parser implementation""" + +import pandas as pd + +from opensampl.vendors.base_probe import BaseProbe +from opensampl.vendors.constants import ProbeKey, VENDORS +from opensampl.references import REF_TYPES +from opensampl.mixins.collect import CollectMixin + +class $parser_class(BaseProbe, CollectMixin): + """Probe parser for $name vendor data files""" + + vendor = VENDORS.$upper_name + + class CollectConfig(CollectMixin.CollectConfig): + """ + The following configuration fields are inherited from the Collect mixin. + Change the defaults by uncommenting and changing value + + Add additional fields, which will automatically be added to the collect click options + and provided to calls to collect + output_dir: Optional[Path] = None + load: bool = False + duration: int = 300 + + ip_address: str = '127.0.0.1' + probe_id: str = '1-1' + """ + + + def __init__(self, input_file: str, **kwargs): + """Initialize $parser_class from input file""" + super().__init__(input_file, **kwargs) + # TODO: parse self.input_file contents or file name to extract ip_address and id to self.probe_key + # self.probe_key = ProbeKey(probe_id=..., ip_address=...) + + def process_metadata(self) -> dict[str, Any]: + """ + Parse and return probe metadata from input file. + + Expected metadata fields: +$metadata_fields + + Returns: + dict with metadata field names as keys + """ + # TODO: implement metadata parsing (see metadata fields above) + # return { + # "field_name": value, + # ... + # } + raise NotImplementedError + + def process_time_data(self) -> None: + """ + Parse and load time series data from self.input_file. + + Use either send_time_data (which prefills METRICS.PHASE_OFFSET) + or send_data and provide alternative METRICS type. + Both require a df as follows: + pd.DataFrame with columns: + - time (datetime64[ns]): timestamp for each measurement + - value (float64): measured value at each timestamp + """ + # TODO: Parse data from self.input_file into a pandas df and call self.send_time_data(df, reference_type) + # or self.send_data(df, metric_type, reference_type) to load it into db + # If self.input_file is a simple two column csv with commented out header: + # df = pd.read_csv( + # self.input_file, + # comment="#", + # names=["time", "value"], + # dtype={"time": "float64", "value": "float64"}, + # engine="python", + # sep=r",\s*", + # ) + # Or extract time, value pairs from the file other ways (like loading from json or other custom format). + # + # Can call send_data multiple times for different metrics. + # df = pd.DataFrame({"time": [...], "value": [...]}) + # self.send_time_data(df, reference_type=...) + + # Ensure the format it is reading in matches that in your save_to_file implementation + raise NotImplementedError + + @classmethod + def collect(cls, collect_config: CollectConfig) -> CollectMixin.CollectArtifact: + """ + Create a collect artifact defined as follows + class CollectArtifact(BaseModel): + data: pd.DataFrame + metric: MetricType = METRICS.UNKNOWN + reference_type: ReferenceType = REF_TYPES.UNKNOWN + compound_reference: Optional[dict[str, Any]] = None + probe_key: Optional[ProbeKey] = None + metadata: Optional[dict] = Field(default_factory=dict) + + on a collect_config.load, the metadata and data will be loaded into db. + + define logic for the save_to_file as well. + """ + # TODO: implement the logic for creating a CollectArtifact, as above. + # + + raise NotImplementedError + + @classmethod + def create_file_content(cls, collected: CollectMixin.CollectArtifact) -> str: + # TODO: Create the str content for an output file. Ensure readable by parse functions & that required metadata is available + # Filename will be automatically generated as {ip_address}_{probe_id}_{vendor}_{timestamp}.txt and saved to directory provided by cli + raise NotImplementedError + + @classmethod + def load_metadata(cls, probe_key: ProbeKey, metadata: dict): + # Optionally, override load_metadata to perform any extra loading required for collect and load without intermediate file. + # This is a class method distinct from process_metadata above as it will not have access to the self.input_file + load_probe_metadata(vendor=cls.vendor, probe_key=probe_key, data=metadata) diff --git a/opensampl/create/templates/parser_template.txt b/opensampl/create/templates/parser_template.txt index 671e71a..648c618 100644 --- a/opensampl/create/templates/parser_template.txt +++ b/opensampl/create/templates/parser_template.txt @@ -1,35 +1,65 @@ """${name} clock Parser implementation""" -from opensampl.vendors.base_probe import BaseProbe import pandas as pd -from opensampl.vendors.constants import VENDORS, ProbeKey -class ${parser_class}(BaseProbe): +from opensampl.vendors.base_probe import BaseProbe +from opensampl.vendors.constants import ProbeKey, VENDORS +from opensampl.references import REF_TYPES + +class $parser_class(BaseProbe): + """Probe parser for $name vendor data files""" - vendor = VENDORS.${upper_name} + vendor = VENDORS.$upper_name - def __init__(self, input_file, **kwargs): - # TODO: define how to find probe_key (probe_id and ip_address) for identifying unique location/instance - super().__init__(input_file=input_file, **kwargs) + def __init__(self, input_file: str, **kwargs): + """Initialize $parser_class from input file""" + super().__init__(input_file, **kwargs) + # TODO: parse self.input_file contents or file name to extract ip_address and id to self.probe_key + # self.probe_key = ProbeKey(probe_id=..., ip_address=...) - def process_time_data(self) -> pd.DataFrame: + def process_metadata(self) -> dict[str, Any]: """ - Process time series data from the input file. + Parse and return probe metadata from input file. + + Expected metadata fields: +$metadata_fields Returns: - pd.DataFrame: DataFrame with columns: - - time (datetime64[ns]): timestamp for each measurement - - value (float64): measured value at each timestamp + dict with metadata field names as keys. """ - # TODO: Implement time series data processing logic specific to ${name} probes - raise NotImplementedError("Time data processing not implemented for ${parser_class}") + # TODO: implement metadata parsing (see metadata fields above) + # return { + # "field_name": value, + # ... + # } + raise NotImplementedError - def process_metadata(self) -> dict: + def process_time_data(self) -> None: """ - Process metadata from the input file. + Parse and load time series data from self.input_file. - Returns: - dict: Dictionary mapping table names to ORM objects + Use either send_time_data (which prefills METRICS.PHASE_OFFSET) + or send_data and provide alternative METRICS type. + Both require a df as follows: + pd.DataFrame with columns: + - time (datetime64[ns]): timestamp for each measurement + - value (float64): measured value at each timestamp """ - # TODO: Implement metadata processing logic specific to ${name} - raise NotImplementedError("Metadata processing not implemented for ${parser_class}") \ No newline at end of file + # TODO: Parse data from self.input_file into a pandas df and call self.send_time_data(df, reference_type) + # or self.send_data(df, metric_type, reference_type) to load it into db + # If self.input_file is a simple two column csv with commented out header: + # df = pd.read_csv( + # self.input_file, + # comment="#", + # names=["time", "value"], + # dtype={"time": "float64", "value": "float64"}, + # engine="python", + # sep=r",\s*", + # ) + # Or extract time, value pairs from the file other ways (like loading from json or other custom format). + # + # Can call send_data multiple times for different metrics. + # df = pd.DataFrame({"time": [...], "value": [...]}) + # self.send_time_data(df, reference_type=...) + + raise NotImplementedError \ No newline at end of file diff --git a/opensampl/db/access_orm.py b/opensampl/db/access_orm.py index 715b6f1..e07f052 100644 --- a/opensampl/db/access_orm.py +++ b/opensampl/db/access_orm.py @@ -3,7 +3,7 @@ import secrets import uuid from datetime import datetime, timezone -from typing import Optional, Union +from typing import Optional from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Session, declarative_base, relationship @@ -53,7 +53,7 @@ class Views(Base): name = Column(Text) @staticmethod - def get_view_by_name(session: Session, name: str) -> Optional[type["Views"]]: + def get_view_by_name(session: Session, name: str) -> type["Views"] | None: """ Get view by name. @@ -81,7 +81,7 @@ class Roles(Base): view_id = Column(Text, ForeignKey("views.view_id")) @staticmethod - def get_role_by_name(session: Session, name: str) -> Optional[type["Roles"]]: + def get_role_by_name(session: Session, name: str) -> type["Roles"] | None: """ Get role by name. @@ -135,7 +135,7 @@ class UserRole(Base): role_id = Column(Text, ForeignKey("roles.role_id"), primary_key=True) -def add_user_role(emails: Union[str, list[str]], role_name: str, session: Session): +def add_user_role(emails: str | list[str], role_name: str, session: Session): """ Add user role to the database. diff --git a/opensampl/db/orm.py b/opensampl/db/orm.py index 5d645b1..2e104e6 100644 --- a/opensampl/db/orm.py +++ b/opensampl/db/orm.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime -from typing import Any, Optional +from typing import Any from geoalchemy2 import Geometry, WKTElement from geoalchemy2.shape import to_shape @@ -43,7 +43,7 @@ def convert_value(value: Any) -> Any: return {c.name: convert_value(getattr(self, c.name)) for c in self.__table__.columns} @classmethod - def identifiable_constraint(cls) -> Optional[str]: + def identifiable_constraint(cls) -> str | None: """ Get the name of the unique constraint used for identification. @@ -57,7 +57,7 @@ def identifiable_constraint(cls) -> Optional[str]: """ return None - def resolve_references(self, session: Optional[Session] = None) -> None: # noqa: ARG002 + def resolve_references(self, session: Session | None = None) -> None: # noqa: ARG002 """ Resolve UUIDs for other entries in the database given a unique constraint. @@ -181,6 +181,7 @@ class ProbeMetadata(Base): adva_metadata = relationship("AdvaMetadata", back_populates="probe", uselist=False) microchip_twst_metadata = relationship("MicrochipTWSTMetadata", back_populates="probe", uselist=False) microchip_tp4100_metadata = relationship("MicrochipTP4100Metadata", back_populates="probe", uselist=False) + ntp_metadata = relationship("NtpMetadata", back_populates="probe", uselist=False) # --- CUSTOM PROBE METADATA RELATIONSHIP --- @@ -202,7 +203,7 @@ def __init__(self, **kwargs: Any): self._test_name = test_name @classmethod - def identifiable_constraint(cls) -> Optional[str]: + def identifiable_constraint(cls) -> str | None: """ Get the name of the unique constraint used for identification. @@ -212,7 +213,7 @@ def identifiable_constraint(cls) -> Optional[str]: """ return "uq_probe_metadata_ipaddress_probeid" - def resolve_references(self, session: Optional[Session] = None): + def resolve_references(self, session: Session | None = None): """ Resolve references to location and/or test entries when given just the name. @@ -433,6 +434,27 @@ class MicrochipTP4100Metadata(Base): probe = relationship("ProbeMetadata", back_populates="microchip_tp4100_metadata") +class NtpMetadata(Base): + """NTP Clock Probe specific metadata""" + + __tablename__ = "ntp_metadata" + + probe_uuid = Column(String, ForeignKey("probe_metadata.uuid"), primary_key=True) + mode = Column(Text) + reference = Column(Boolean, comment="Is used as a reference for other probes") + target_host = Column(Text) + target_port = Column(Integer) + sync_status = Column(Text) + leap_status = Column(Text) + reference_id = Column(Text) + observation_sources = Column(JSONB) + collection_id = Column(Text) + collection_ip = Column(Text) + timeout = Column(Float) + additional_metadata = Column(JSONB) + probe = relationship("ProbeMetadata", back_populates="ntp_metadata") + + # --- CUSTOM TABLES --- !! Do not remove line, used as reference when inserting metadata table diff --git a/opensampl/helpers/geolocator.py b/opensampl/helpers/geolocator.py new file mode 100644 index 0000000..adf2be4 --- /dev/null +++ b/opensampl/helpers/geolocator.py @@ -0,0 +1,124 @@ +"""Associate NTP probes with ``castdb.locations`` for the geospatial Grafana dashboard.""" + +from __future__ import annotations + +import ipaddress +import json +import os +import socket +import urllib.request +from typing import TYPE_CHECKING + +from loguru import logger + +from opensampl.load.table_factory import TableFactory + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +_GEO_CACHE: dict[str, tuple[float, float, str]] = {} + + +def _env_bool(name: str, default: bool) -> bool: + v = os.getenv(name) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + +def _default_lab_coords() -> tuple[float, float]: + lat = float(os.getenv("DEFAULT_LAT", "35.9312")) + lon = float(os.getenv("DEFAULT_LON", "-84.3101")) + return lat, lon + + +def _is_private_or_loopback(ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return True + return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved) + + +def _lookup_geo_ipapi(ip: str) -> tuple[float, float, str] | None: + if ip in _GEO_CACHE: + return _GEO_CACHE[ip] + url = f"http://ip-api.com/json/{ip}?fields=status,lat,lon,city,country" + try: + with urllib.request.urlopen(url, timeout=4.0) as resp: # noqa: S310 + body = json.loads(resp.read().decode("utf-8")) + except Exception as e: + logger.warning("ip-api geolocation failed for {}: {}", ip, e) + return None + + if body.get("status") != "success" or body.get("lat") is None or body.get("lon") is None: + logger.warning("ip-api returned no coordinates for {}", ip) + return None + + city = body.get("city") or "" + country = body.get("country") or "" + label = ", ".join(x for x in (city, country) if x) + out = (float(body["lat"]), float(body["lon"]), label or ip) + _GEO_CACHE[ip] = out + return out + + +def create_location(session: Session, geolocate_enabled: bool, ip_address: str, geo_override: dict) -> str | None: + """ + Set probe ``name``, ``public``, and ``location_uuid`` on NTP metadata before ``probe_metadata`` insert. + + Uses ``additional_metadata.geo_override`` when present (lat/lon/label). Otherwise resolves the remote + host, uses RFC1918/loopback defaults from env, or ip-api.com for public IPs (HTTP, no API key). + """ + lat: float | None = None + lon: float | None = None + name: str | None = None + + if geo_override.get("lat") is not None and geo_override.get("lon") is not None: + lat = float(geo_override["lat"]) + lon = float(geo_override["lon"]) + + name = geo_override.get("name") + + if geolocate_enabled and lat is None and lon is None: + ip_for_geo = ip_address + try: + ip_for_geo = socket.gethostbyname(ip_address) + except OSError as e: + logger.debug("Could not resolve {}: {}", ip_address, e) + + if _is_private_or_loopback(ip_for_geo): + lat, lon = _default_lab_coords() + else: + geo = _lookup_geo_ipapi(ip_for_geo) + if geo: + lat, lon, _name = geo + name = name or _name + else: + lat, lon = _default_lab_coords() + + loc_factory = TableFactory("locations", session=session) + loc = None + if name: + loc = loc_factory.find_existing({"name": name}) + + if loc is None: + if any(x is None for x in [lat, lon, name]): + logger.warning( + "Skipping location creation for {}: insufficient location data (name={!r}, lat={!r}, lon={!r})", + ip_address, + name, + lat, + lon, + ) + return None + + loc = loc_factory.write( + {"name": name, "lat": lat, "lon": lon, "public": True}, + if_exists="ignore", + ) + + if loc: + return loc.uuid + return None diff --git a/opensampl/load/data.py b/opensampl/load/data.py index 591fb5b..18d8ab2 100644 --- a/opensampl/load/data.py +++ b/opensampl/load/data.py @@ -1,6 +1,6 @@ """Data Factory for defining the unique probe/metric/reference combination to use for Data readings""" -from typing import Any, Optional +from typing import Any from loguru import logger from sqlalchemy.inspection import inspect @@ -25,11 +25,11 @@ class DataFactory: object will also have the database object for any compound references filled as well. """ - probe: Optional[DBProbe] = None - metric: Optional[DBMetricType] = None - db_ref_type: Optional[DBReferenceType] = None - reference: Optional[DBReference] = None - db_compound_reference: Optional[Base] = None + probe: DBProbe | None = None + metric: DBMetricType | None = None + db_ref_type: DBReferenceType | None = None + reference: DBReference | None = None + db_compound_reference: Base | None = None def __init__( self, @@ -37,7 +37,7 @@ def __init__( metric_type: MetricType, reference_type: ReferenceType, session: Session, - compound_key: Optional[dict[str, Any]] = None, + compound_key: dict[str, Any] | None = None, strict: bool = True, ): """ diff --git a/opensampl/load/routing.py b/opensampl/load/routing.py index c1205df..2e602d5 100644 --- a/opensampl/load/routing.py +++ b/opensampl/load/routing.py @@ -1,8 +1,9 @@ """Decorator which ensures we are routing our db operations through a backend if configured, or directly if not.""" import json +from collections.abc import Callable from functools import wraps -from typing import Callable, Literal, Optional +from typing import Literal import requests import requests.exceptions @@ -45,7 +46,7 @@ def decorator(func: Callable) -> Callable: """ @wraps(func) - def wrapper(*args: list, **kwargs: dict) -> Optional[Callable]: + def wrapper(*args: list, **kwargs: dict) -> Callable | None: """ Handle the actual routing logic. diff --git a/opensampl/load/table_factory.py b/opensampl/load/table_factory.py index 0f4a791..f2d9002 100644 --- a/opensampl/load/table_factory.py +++ b/opensampl/load/table_factory.py @@ -1,6 +1,6 @@ """Database table factory for handling CRUD operations with conflict resolution.""" -from typing import Any, Literal, Optional, Union +from typing import Any, Literal from loguru import logger from sqlalchemy import UniqueConstraint, and_, inspect, select @@ -89,7 +89,7 @@ def create_col_filter(self, data: dict[str, Any], cols: list[str]): logger.debug(f"some or all columns from {cols} missing in data") return None - def print_filter_debug(self, filter_expr: Optional[Union[BinaryExpression, BooleanClauseList]], label: str): + def print_filter_debug(self, filter_expr: BinaryExpression | BooleanClauseList | None, label: str): """ Print debug information for a filter expression. @@ -102,7 +102,7 @@ def print_filter_debug(self, filter_expr: Optional[Union[BinaryExpression, Boole compiled = filter_expr.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}) logger.debug(f"{label}: {compiled}") - def find_existing(self, data: dict[str, Any]) -> Optional[Base]: + def find_existing(self, data: dict[str, Any]) -> Base | None: """ Find an existing record that matches the provided data. diff --git a/opensampl/load_data.py b/opensampl/load_data.py index f427167..d21f5ce 100644 --- a/opensampl/load_data.py +++ b/opensampl/load_data.py @@ -1,7 +1,7 @@ """Main functionality for loading data into the database""" import json -from typing import Any, Literal, Optional +from typing import Any, Literal import pandas as pd from loguru import logger @@ -10,6 +10,7 @@ from opensampl.config.base import BaseConfig from opensampl.db.orm import Base, ProbeData +from opensampl.helpers.geolocator import create_location from opensampl.load.routing import route from opensampl.load.table_factory import TableFactory from opensampl.metrics import MetricType @@ -25,7 +26,7 @@ def write_to_table( data: dict[str, Any], _config: BaseConfig, if_exists: conflict_actions = "update", - session: Optional[Session] = None, + session: Session | None = None, ): """ Write object to table with configurable behavior for handling conflicts. @@ -79,9 +80,9 @@ def load_time_data( reference_type: ReferenceType, data: pd.DataFrame, _config: BaseConfig, - compound_key: Optional[dict[str, Any]] = None, + compound_key: dict[str, Any] | None = None, strict: bool = True, - session: Optional[Session] = None, + session: Session | None = None, ): """ Write time data to probe_data table @@ -125,9 +126,9 @@ def load_time_data( strict=strict, session=session, ) + probe = data_definition.probe # ty: ignore[possibly-unbound-attribute] probe_readable = ( - data_definition.probe.name # ty: ignore[possibly-unbound-attribute] - or f"{data_definition.probe.ip_address} ({data_definition.probe.probe_id})" # ty: ignore[possibly-unbound-attribute] + probe.name or f"{probe.ip_address} ({probe.probe_id})" # ty: ignore[possibly-unbound-attribute] ) if any(x is None for x in [data_definition.probe, data_definition.metric, data_definition.reference]): @@ -156,11 +157,13 @@ def load_time_data( total_rows = len(records) inserted = result.rowcount # ty: ignore[unresolved-attribute] excluded = total_rows - inserted - - logger.warning( - f"Inserted {inserted}/{total_rows} rows for {probe_readable}; " - f"{excluded}/{total_rows} rejected due to conflicts" - ) + if excluded > 0: + logger.warning( + f"Inserted {inserted}/{total_rows} rows for {probe_readable}; " + f"{excluded}/{total_rows} rejected due to conflicts" + ) + else: + logger.info(f"Inserted {inserted}/{total_rows} rows for {probe_readable}") except Exception as e: # In case of an error, roll back the session @@ -181,7 +184,7 @@ def load_probe_metadata( probe_key: ProbeKey, data: dict[str, Any], _config: BaseConfig, - session: Optional[Session] = None, + session: Session | None = None, ): """Write object to table""" if _config.ROUTE_TO_BACKEND: @@ -199,6 +202,19 @@ def load_probe_metadata( pm_cols = {col.name for col in pm_factory.inspector.columns} probe_info = {k: data.pop(k) for k in list(data.keys()) if k in pm_cols} + location_name = probe_info.pop("location_name", None) + geolocation = ({"name": location_name} if location_name else {}) | probe_info.pop("geolocation", {}) + + if geolocation or _config.ENABLE_GEOLOCATE: + location_uuid = create_location( + session, + geolocate_enabled=_config.ENABLE_GEOLOCATE, + geo_override=geolocation, + ip_address=probe_key.ip_address, + ) + if location_uuid: + probe_info.update({"location_uuid": location_uuid}) + probe_info.update({"probe_id": probe_key.probe_id, "ip_address": probe_key.ip_address, "vendor": vendor.name}) probe = pm_factory.write(data=probe_info, if_exists="update") @@ -214,7 +230,7 @@ def load_probe_metadata( @route("create_new_tables", method="GET") -def create_new_tables(*, _config: BaseConfig, create_schema: bool = True, session: Optional[Session] = None): +def create_new_tables(*, _config: BaseConfig, create_schema: bool = True, session: Session | None = None): """Use the ORM definition to create all tables, optionally creating the schema as well""" if _config.ROUTE_TO_BACKEND: return {"create_schema": create_schema} @@ -227,6 +243,7 @@ def create_new_tables(*, _config: BaseConfig, create_schema: bool = True, sessio session.execute(text(f"CREATE SCHEMA IF NOT EXISTS {Base.metadata.schema}")) session.commit() Base.metadata.create_all(session.bind) + session.commit() except Exception as e: session.rollback() logger.error(f"Error writing to table: {e}") diff --git a/opensampl/metrics.py b/opensampl/metrics.py index 017deb5..27b6b8a 100644 --- a/opensampl/metrics.py +++ b/opensampl/metrics.py @@ -1,6 +1,8 @@ """Functions and objects for managing openSAMPL Metric Types""" -from typing import Any, Union +from __future__ import annotations + +from typing import Any from pydantic import BaseModel, field_serializer, field_validator @@ -26,7 +28,7 @@ def serialize_type(self, value: type): @field_validator("value_type", mode="before") @classmethod - def validate_type(cls, value: Union[str, type]) -> Any: + def validate_type(cls, value: str | type) -> Any: """Ensure the value_type field is converted to a type if provided as a string""" if isinstance(value, str): value = value.strip() @@ -60,5 +62,70 @@ class METRICS: unit="unknown", value_type=object, ) + DELAY = MetricType( + name="Delay", + description=( + "Round-trip delay (RTD) or Round-Trip Time (RTT). The time in seconds it takes for a data signal to " + "travel from a source to a destination and back, including acknowledgement." + ), + unit="s", + value_type=float, + ) + JITTER = MetricType( + name="Jitter", + description=("Jitter or offset variation in delay in seconds. Represents inconsistent response times."), + unit="s", + value_type=float, + ) + STRATUM = MetricType( + name="Stratum", + description=( + 'Stratum level. Hierarchical layer defining the distance (or "hops") between device and reference.' + ), + unit="level", + value_type=int, + ) + REACHABILITY = MetricType( + name="Reachability", + description=( + "Reachability register (0-255) as a scalar for plotting. Ability of a source node to communicate " + "with a target node." + ), + unit="count", + value_type=float, + ) + DISPERSION = MetricType( + name="Dispersion", + description="Uncertainty in a clock's time relative to its reference source in seconds", + unit="s", + value_type=float, + ) + NTP_ROOT_DELAY = MetricType( + name="NTP Root Delay", + description=( + "Total round-trip network delay from the local system" + " all the way to the primary reference clock (stratum 0)" + ), + unit="s", + value_type=float, + ) + NTP_ROOT_DISPERSION = MetricType( + name="NTP Root Dispersion", + description="The total accumulated clock uncertainty from the local system back to the primary reference clock", + unit="s", + value_type=float, + ) + POLL_INTERVAL = MetricType( + name="Poll Interval", + description="Time between requests sent to a time server in seconds", + unit="s", + value_type=float, + ) + SYNC_HEALTH = MetricType( + name="Sync Health", + description="1.0 if synchronized/healthy, 0.0 otherwise (probe-defined)", + unit="ratio", + value_type=float, + ) # --- CUSTOM METRICS --- !! Do not remove line, used as reference when inserting metric diff --git a/opensampl/mixins/__init__.py b/opensampl/mixins/__init__.py new file mode 100644 index 0000000..dcac28b --- /dev/null +++ b/opensampl/mixins/__init__.py @@ -0,0 +1 @@ +"""mixins to add additional functionality to base probes""" diff --git a/opensampl/mixins/collect.py b/opensampl/mixins/collect.py new file mode 100644 index 0000000..7c973d7 --- /dev/null +++ b/opensampl/mixins/collect.py @@ -0,0 +1,168 @@ +"""Tools for adding data collection functionality to probes""" + +import json +from abc import ABC, abstractmethod +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import click +import pandas as pd +from loguru import logger +from pydanclick import from_pydantic +from pydantic import BaseModel, ConfigDict, Field + +from opensampl.load_data import load_probe_metadata +from opensampl.metrics import METRICS, MetricType +from opensampl.references import REF_TYPES, ReferenceType +from opensampl.vendors.constants import ProbeKey + + +class CollectMixin(ABC): + """Mixin to add data collection capabilities to a probe class""" + + class DataArtifact(BaseModel): + """Model for a single metric type of collected data""" + + value: pd.DataFrame + metric: MetricType = METRICS.UNKNOWN + reference_type: ReferenceType = REF_TYPES.UNKNOWN + compound_reference: dict[str, Any] | None = None + model_config = ConfigDict(arbitrary_types_allowed=True) + + class CollectArtifact(BaseModel): + """Model for a single probe's collected data""" + + data: list["CollectMixin.DataArtifact"] + probe_key: ProbeKey | None = None + metadata: dict | None = Field(default_factory=dict) + model_config = ConfigDict(arbitrary_types_allowed=True) + + @property + def single_reference(self): + """All individual data artifacts use the same reference""" + if len(self.data) <= 1: + return True + return len({json.dumps(x.compound_reference, sort_keys=True) for x in self.data or []}) == 1 + + @property + def single_reference_type(self) -> bool: + """All individual data artifacts use the same reference type""" + if len(self.data) <= 1: + return True + return len({x.reference_type.name for x in self.data or []}) == 1 + + class CollectConfig(BaseModel): + """ + Configuration for collecting data + + Attributes: + output_dir: When provided, will save collected data as a file to provided directory. + Filename will be automatically generated as {vendor}_{ip_address}_{probe_id}_{vendor}_{timestamp}.txt + load: Whether to load collected data directly to the database + duration: Number of seconds to collect data for + + """ + + output_dir: Path | None = None + load: bool = False + duration: int = 300 + + ip_address: str = "127.0.0.1" + probe_id: str = "1-1" + + @classmethod + def collect_help_str(cls) -> str: + """Help string for use in the collect CLI.""" + return ( + f"Collect data readings for {cls.__name__}\n\n" + "Can collect data to a directory (using --output-dir), straight into the database (--load), or both" + ) + + @classmethod + def get_collect_cli_options(cls) -> list[Callable]: + """Return the click options/arguments for collecting probe data.""" + return [ + from_pydantic(cls.CollectConfig), + click.pass_context, + ] + + @classmethod + def get_collect_cli_command(cls) -> Callable: + """ + Create a click command that handles data collection + + Returns + ------- + A click CLI command that collects probe data + + """ + + def make_command(f: Callable) -> Callable: + for option in reversed(cls.get_collect_cli_options()): + f = option(f) + return click.command(name=cls.vendor.name.lower(), help=cls.collect_help_str())(f) + + def collect_callback( + ctx: click.Context, # noqa: ARG001 + collect_config: CollectMixin.CollectConfig, + ) -> None: + """Load probe data from file or directory.""" + try: + cls._collect_and_save(collect_config) + + except Exception as e: + logger.exception(f"Error: {e!s}") + raise click.Abort(f"Error: {e!s}") from e + + return make_command(collect_callback) + + @classmethod + def _collect_and_save(cls, collect_config: CollectConfig) -> None: + data: CollectMixin.CollectArtifact = cls.collect(collect_config) + if data.probe_key is None: + data.probe_key = ProbeKey(ip_address=collect_config.ip_address, probe_id=collect_config.probe_id) + if collect_config.load: + cls.load_metadata(probe_key=data.probe_key, metadata=data.metadata) + + for art in data.data: + cls.send_data( + data=art.value, + metric=art.metric, + reference_type=art.reference_type, + compound_reference=art.compound_reference, + probe_key=data.probe_key, + ) + if collect_config.output_dir: + file_content = cls.create_file_content(data) + collect_config.output_dir.mkdir(parents=True, exist_ok=True) + now_stamp = datetime.now(tz=timezone.utc).timestamp() + output = collect_config.output_dir / f"{cls.vendor.parser_class}_{data.probe_key!r}_{now_stamp}.txt" + output.write_text(file_content) + + @classmethod + def filter_files(cls, files: list[Path]) -> list[Path]: + """Filter the files found in the input directory when loading this vendor's data files""" + return [f for f in files if f.name.startswith(f"{cls.vendor.parser_class}_") and f.suffix == ".txt"] + + @classmethod + def load_metadata(cls, probe_key: ProbeKey, metadata: dict) -> None: + """ + Load provided metadata associated with given probe_key + + Distinct from BaseProbe.parse_metadata because it is a class method without access to self.input_file + """ + load_probe_metadata(vendor=cls.vendor, probe_key=probe_key, data=metadata) + + @classmethod + @abstractmethod + def collect(cls, collect_config: CollectConfig) -> CollectArtifact: + """Collect data and output CollectArtifact using collect_config""" + pass + + @classmethod + @abstractmethod + def create_file_content(cls, collect_artifact: CollectArtifact) -> str: + """Given a CollectArtifact, create the str content for a file""" + pass diff --git a/opensampl/mixins/random_data.py b/opensampl/mixins/random_data.py new file mode 100644 index 0000000..0d8ea1c --- /dev/null +++ b/opensampl/mixins/random_data.py @@ -0,0 +1,382 @@ +"""Tools for adding random data generation functionality to probes""" + +import random +from abc import abstractmethod +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import click +import numpy as np +import pandas as pd +import yaml +from loguru import logger +from pydantic import BaseModel, ValidationInfo, field_serializer, field_validator, model_validator + +from opensampl.vendors.constants import ProbeKey + + +class RandomDataMixin: + """Mixin for adding random data generation functionality to probes""" + + class RandomDataConfig(BaseModel): + """Model for storing random data generation configurations as provided by CLI or YAML""" + + # General configuration + num_probes: int = 1 + duration_hours: float = 1.0 + seed: int | None = None + + # Time series parameters + sample_interval: float = 1 + + base_value: float + noise_amplitude: float + drift_rate: float + outlier_probability: float = 0.01 + outlier_multiplier: float = 10.0 + + # Start time (computed at runtime if None) + start_time: datetime | None = None + + probe_id: str | None = None + probe_ip: str | None = None + + @classmethod + def _generate_random_ip(cls) -> str: + """Generate a random IP address.""" + ip_parts = [random.randint(1, 254) for _ in range(4)] + return ".".join(map(str, ip_parts)) + + @model_validator(mode="after") + def define_start_time(self): + """If start_time is None at the end of validation,""" + if self.start_time is None: + self.start_time = datetime.now(tz=timezone.utc) - timedelta(hours=self.duration_hours) + return self + + @field_validator("*", mode="before") + @classmethod + def replace_none_with_default(cls, v: Any, info: ValidationInfo) -> Any: + """If field provided with None replace with default""" + if v is None and info.field_name != "start_time": + field_info = cls.model_fields.get(info.field_name) + # fall back to the field default + return field_info.default_factory() if field_info.default_factory else field_info.default + return v + + @field_serializer("start_time") + def start_time_to_str(self, start_time: datetime) -> str: + """Convert start_time to string when dumping the model""" + return start_time.strftime("%Y/%m/%d %H:%M:%S") + + def generate_time_series(self): + """Generate a realistic time series with drift, noise, and occasional outliers.""" + total_seconds = self.duration_hours * 3600 + num_samples = int(total_seconds / self.sample_interval) + + time_points = [] + values = [] + for i in range(num_samples): + sample_time = self.start_time + timedelta(seconds=i * self.sample_interval) + time_points.append(sample_time) + + # Generate value with drift and noise + time_offset = i * self.sample_interval + drift_component = self.drift_rate * time_offset + noise_component = np.random.normal(0, self.noise_amplitude) + value = self.base_value + drift_component + noise_component + + # Add occasional outliers for realism + if random.random() < self.outlier_probability: + value += np.random.normal(0, self.noise_amplitude * self.outlier_multiplier) + + values.append(value) + + return pd.DataFrame({"time": time_points, "value": values}) + + @classmethod + def get_random_data_cli_options(cls) -> list[Callable]: + """Return the click options for random data generation.""" + return [ + click.option( + "--config", + "-c", + type=click.Path(exists=True, path_type=Path), + help="YAML configuration file for random data generation settings", + ), + click.option( + "--num-probes", + type=int, + default=cls.RandomDataConfig.model_fields.get("num_probes").default, + show_default=True, + help="Number of probes to generate data for", + ), + click.option( + "--duration", + type=float, + default=cls.RandomDataConfig.model_fields.get("duration_hours").default, + show_default=True, + help="Duration of data in hours", + ), + click.option( + "--seed", + show_default=True, + default=cls.RandomDataConfig.model_fields.get("seed").default, + type=int, # type: ignore[attr-defined] + help="Random seed for reproducible results", + ), + click.option( + "--sample-interval", + type=float, + show_default=True, + default=cls.RandomDataConfig.model_fields.get("sample_interval").default, + help="Sample interval in seconds ", + ), + click.option( + "--base-value", + type=float, + show_default=True, + default=cls.RandomDataConfig.model_fields.get("base_value").description, + help="Base value for time offset measurements", + ), + click.option( + "--noise-amplitude", + type=float, + show_default=True, + default=cls.RandomDataConfig.model_fields.get("noise_amplitude").description, + help=("Noise amplitude/standard deviation for time offset measurements "), + ), + click.option( + "--drift-rate", + type=float, + show_default=True, + default=cls.RandomDataConfig.model_fields.get("drift_rate").description, + help=("Linear drift rate per second for time offset measurements "), + ), + click.option( + "--outlier-probability", + type=float, + show_default=True, + default=cls.RandomDataConfig.model_fields.get("outlier_probability").default, + help=("Probability of outliers per sample "), + ), + click.option( + "--outlier-multiplier", + type=float, + default=cls.RandomDataConfig.model_fields.get("outlier_multiplier").default, + show_default=True, + help=("Multiplier for outlier noise amplitude "), + ), + click.option( + "--probe-ip", + type=str, + help=( + "The ip_address you want the random data to show up under. " + "Randomly generated for each probe if left empty" + ), + ), + click.pass_context, + ] + + @classmethod + def get_random_data_cli_command(cls) -> Callable: + """ + Create a click command that generates random test data. + + Returns + ------- + A click CLI command that generates random test data for this probe type. + + """ + + def make_command(f: Callable) -> Callable: + # Add vendor-specific options first, then base options + options = cls.get_random_data_cli_options() + + for option in reversed(options): + f = option(f) + return click.command(name=cls.vendor.name.lower(), help=f"Generate random test data for {cls.__name__}")(f) + + def random_data_callback(ctx: click.Context, **kwargs: dict) -> None: # noqa: ARG001 + """Generate random test data for this probe type.""" + try: + gen_config = cls._extract_random_data_config(kwargs) + probe_keys = [] + for i in range(gen_config.num_probes): + # Use different seeds for each probe if seed is provided + probe_config = gen_config.model_copy(deep=True) + if probe_config.seed is not None: + probe_config.seed += i + + probe_key = cls._generate_random_probe_key(probe_config, i) + + logger.info(f"Generating data for {cls.__name__} probe {i + 1}/{gen_config.num_probes}") + probe_key = cls.generate_random_data(probe_config, probe_key=probe_key) + probe_keys.append(probe_key) + + # Print summary + click.echo(f"\n=== Generated {len(probe_keys)} {cls.__name__} probes ===") + for probe_key in probe_keys: + click.echo(f" - {probe_key}") + + logger.info("Random test data generation completed successfully") + + except Exception as e: + logger.exception(f"Failed to generate test data: {e}") + raise click.Abort(f"Failed to generate test data: {e}") from e + + return make_command(random_data_callback) + + @classmethod + def _extract_random_data_config(cls, kwargs: dict) -> RandomDataConfig: + """ + Extract and normalize CLI keyword arguments into a RandomDataConfig object. + + Args: + ---- + kwargs: Dictionary of keyword arguments passed to the CLI command + + Returns: + ------- + A RandomDataConfig object with all relevant parameters + + """ + # Load configuration from YAML file if provided + config_file = kwargs.pop("config", None) + if config_file: + config_data = cls._load_yaml_config(config_file) + # Merge config file data with CLI arguments (CLI args take precedence) + for key, value in config_data.items(): + if kwargs.get(key) is None: # Only use config value if CLI arg not provided + kwargs[key] = value + logger.info(f"Loaded configuration from {config_file}") + + return cls.RandomDataConfig(**kwargs) + + @classmethod + def _setup_random_seed(cls, seed: int | None) -> None: + """Set up random seed for reproducible data generation.""" + if seed is not None: + random.seed(seed) + np.random.seed(seed) + + @classmethod + def _generate_random_ip(cls) -> str: + """Generate a random IP address.""" + ip_parts = [random.randint(1, 254) for _ in range(4)] + return ".".join(map(str, ip_parts)) + + @classmethod + def _generate_time_series( + cls, + start_time: datetime, + duration_hours: float, + sample_interval_seconds: float, + base_value: float, + noise_amplitude: float, + drift_rate: float = 0.0, + outlier_probability: float = 0.01, + outlier_multiplier: float = 10.0, + ) -> pd.DataFrame: + """ + Generate a realistic time series with drift, noise, and occasional outliers. + + Args: + start_time: Start timestamp for the data + duration_hours: Duration of data in hours + sample_interval_seconds: Time between samples in seconds + base_value: Base value around which to generate data + noise_amplitude: Standard deviation of random noise + drift_rate: Linear drift rate per second + outlier_probability: Probability of outliers per sample + outlier_multiplier: Multiplier for outlier noise amplitude + + Returns: + DataFrame with 'time' and 'value' columns + + """ + total_seconds = duration_hours * 3600 + num_samples = int(total_seconds / sample_interval_seconds) + + time_points = [] + values = [] + + for i in range(num_samples): + sample_time = start_time + timedelta(seconds=i * sample_interval_seconds) + time_points.append(sample_time) + + # Generate value with drift and noise + time_offset = i * sample_interval_seconds + drift_component = drift_rate * time_offset + noise_component = np.random.normal(0, noise_amplitude) + value = base_value + drift_component + noise_component + + # Add occasional outliers for realism + if random.random() < outlier_probability: + value += np.random.normal(0, noise_amplitude * outlier_multiplier) + + values.append(value) + + return pd.DataFrame({"time": time_points, "value": values}) + + @classmethod + def _load_yaml_config(cls, config_path: Path) -> dict[str, Any]: + """ + Load YAML configuration file for random data generation. + + Args: + config_path: Path to the YAML configuration file + + Returns: + Dictionary containing configuration parameters + + """ + try: + with config_path.open() as f: + config_data = yaml.safe_load(f) + except FileNotFoundError as e: + raise ValueError(f"Configuration file not found: {config_path}") from e + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML configuration file {config_path}: {e}") from e + except Exception as e: + raise ValueError(f"Error loading configuration file {config_path}: {e}") from e + else: + # Validate that it's a dictionary + if not isinstance(config_data, dict): + raise TypeError(f"Configuration file {config_path} must contain a YAML dictionary") + + logger.debug(f"Loaded YAML config from {config_path}: {config_data}") + return config_data + + @classmethod + @abstractmethod + def generate_random_data( + cls, + config: RandomDataConfig, + probe_key: ProbeKey, + ) -> ProbeKey: + """ + Generate random test data and send it directly to the database. + + Args: + probe_key: Probe key to use (generated if None) + config: RandomDataConfig with parameters specifying how to generate data + + Returns: + ProbeKey: The probe key used for the generated data + + """ + + @classmethod + def _generate_random_probe_key(cls, gen_config: RandomDataConfig, probe_index: int) -> ProbeKey: + ip_address = str(gen_config.probe_ip) if gen_config.probe_ip is not None else cls._generate_random_ip() + + if gen_config.probe_id is None: + probe_id = f"{1 + probe_index}" + elif isinstance(gen_config.probe_id, str): + probe_suffix = f"-{probe_index}" if probe_index > 0 else "" + probe_id = f"{gen_config.probe_id}{probe_suffix}" + + return ProbeKey(probe_id=probe_id, ip_address=ip_address) diff --git a/opensampl/server/__init__.py b/opensampl/server/__init__.py index 9afc2c0..8568dcd 100644 --- a/opensampl/server/__init__.py +++ b/opensampl/server/__init__.py @@ -12,10 +12,14 @@ def check_command(command: list[str]) -> bool: return False -if not check_command(["docker", "--version"]): - raise ImportError("Docker is not installed or not found in PATH. Please install Docker.") +def ensure_docker(): + """Ensure Docker and Docker Compose are installed, error if not""" + if not check_command(["docker", "--version"]): + raise RuntimeError("Docker is not installed or not found in PATH. Please install Docker.") -compose_installed = check_command(["docker", "compose", "version"]) or check_command(["docker-compose", "--version"]) + compose_installed = check_command(["docker", "compose", "version"]) or check_command( + ["docker-compose", "--version"] + ) -if not compose_installed: - raise ImportError("Neither 'docker compose' nor 'docker-compose' is installed. Please install Docker Compose.") + if not compose_installed: + raise RuntimeError("Neither 'docker compose' nor 'docker-compose' is installed. Please install Docker Compose.") diff --git a/opensampl/server/backend/Dockerfile b/opensampl/server/backend/Dockerfile new file mode 100755 index 0000000..31b6875 --- /dev/null +++ b/opensampl/server/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12 AS base +ARG OPENSAMPL_VERSION=1.1.5 + +WORKDIR /tmp +ENV ROUTE_TO_BACKEND=false + +FROM base AS prod + +RUN pip install --no-cache-dir "opensampl[backend]==${OPENSAMPL_VERSION}" + +CMD ["uvicorn", "opensampl.server.backend.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000"] + +FROM base AS dev + +CMD ["sh", "-c", "pip install -e \"./opensampl[backend]\" && uvicorn opensampl.server.backend.main:app --proxy-headers --host 0.0.0.0 --port 8000 --log-level debug --reload"] \ No newline at end of file diff --git a/opensampl/server/backend/__init__.py b/opensampl/server/backend/__init__.py new file mode 100644 index 0000000..478ae3a --- /dev/null +++ b/opensampl/server/backend/__init__.py @@ -0,0 +1 @@ +"""Backend API tooling""" diff --git a/opensampl/server/backend/main.py b/opensampl/server/backend/main.py new file mode 100644 index 0000000..87d3783 --- /dev/null +++ b/opensampl/server/backend/main.py @@ -0,0 +1,387 @@ +"""API Configuration to Indirectly interact with the database""" + +import io +import json +import os +import sys +import time +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +import pandas as pd +import psycopg2 +from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.security.api_key import APIKeyHeader +from loguru import logger +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest +from pydantic import BaseModel +from sqlalchemy import create_engine, or_, select, text +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from opensampl import load_data +from opensampl.db.access_orm import APIAccessKey +from opensampl.db.orm import ProbeMetadata +from opensampl.metrics import METRICS, MetricType +from opensampl.references import REF_TYPES, CompoundReferenceType, ReferenceType +from opensampl.vendors.constants import ProbeKey, VendorType + + +class TimeDataPoint(BaseModel): + """Time Data Model""" + + time: str + value: float + + +class WriteTablePayload(BaseModel): + """Write Table Payload Model""" + + table: str + data: dict[str, Any] + if_exists: load_data.conflict_actions = "update" + + +class ProbeMetadataPayload(BaseModel): + """Probe Metadata Payload Model""" + + vendor: VendorType + probe_key: ProbeKey + data: dict[str, Any] + + +DATABASE_URI = os.getenv("DATABASE_URL") +engine = create_engine(DATABASE_URI) + +loglevel = os.getenv("BACKEND_LOG_LEVEL", "INFO") +app = FastAPI( + title="openSAMPL Backend", + description=""" + The backend for interacting with openSAMPL server + + Provides additional security and durability for loading data and interacting with database. + """, +) + + +REQUEST_COUNT = Counter("http_requests_total", "Total number of HTTP requests", ["method", "endpoint", "http_status"]) + +REQUEST_LATENCY = Histogram( + "http_request_duration_seconds", "Duration of HTTP requests in seconds", ["method", "endpoint"] +) + +EXCLUDED_PATHS = {"/metrics", "/healthcheck", "/healthcheck_database", "/healthcheck_metadata"} + +logger.configure(handlers=[{"sink": sys.stderr, "level": loglevel}]) + +USE_API_KEY = os.getenv("USE_API_KEY", "false").lower() == "true" +API_KEY_NAME = "access-key" + +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + + +def get_keys(): + """Get active API keys""" + env_keys = os.getenv("API_KEYS", "").strip() + keys = [k.strip() for k in env_keys.split(",") if k.strip()] + if keys: + logger.debug("api access keys loaded from env") + return keys + try: + Session = sessionmaker(bind=engine) # noqa: N806 + with Session() as session: + now = datetime.now(tz=UTC) + stmt = select(APIAccessKey.key).where(or_(APIAccessKey.expires_at is None, APIAccessKey.expires_at > now)) + result = session.execute(stmt) + keys = [row[0] for row in result.all()] + logger.debug("api access keys loaded from db") + return keys + except Exception as e: + logger.debug(f"exception attempting to load api access keys from db: {e}") + return [] + + +def require_api_key(bootstrap: bool = False): + """Return function to validate api key with or without bootstrap.""" + + def validate_api_key(api_key: str = Security(api_key_header)) -> str | None: + """Validate provided API key""" + if not USE_API_KEY: + return None # Security is disabled + + keys = get_keys() + if not keys and bootstrap: + logger.warning("No API keys configured; allowing bootstrap API key generation") + return None + if api_key not in keys: + raise HTTPException(status_code=403, detail="Invalid or missing API key") + return api_key + + return validate_api_key + + +def get_db(): + """Get database session""" + Session = sessionmaker(bind=engine) # noqa: N806 + try: + session = Session() + yield session + finally: + session.close() + + +@app.middleware("http") +async def metrics_middleware(request: Request, call_next: Callable) -> Response: + """Middleware to track request metrics.""" + if request.url.path in EXCLUDED_PATHS: + return await call_next(request) + start_time = time.time() + response: Response = await call_next(request) + duration = time.time() - start_time + + REQUEST_COUNT.labels(method=request.method, endpoint=request.url.path, http_status=response.status_code).inc() + + REQUEST_LATENCY.labels(method=request.method, endpoint=request.url.path).observe(duration) + + return response + + +# add route to docs from / to /docs +@app.get("/", include_in_schema=False) +async def docs_redirect(): + """Redirect bare url to docs""" + return RedirectResponse(url="/docs") + + +@app.get("/setloglevel") +def set_log_level(newloglevel: str, api_key: str = Depends(require_api_key())): + """Change visible log level in backend container""" + newloglevel = newloglevel.upper() + logger.configure(handlers=[{"sink": sys.stderr, "level": newloglevel}]) + return {"loglevel": newloglevel} + + +@app.get("/checkloglevel") +def check_log_level(api_key: str = Depends(require_api_key())): + """Check which log levels are visible in backend container""" + logger.debug("Debug test") + logger.info("Info test") + logger.warning("Warning test") + logger.error("Error test") + current_level = next(iter(logger._core.handlers.values()))["level"].name # noqa: SLF001 + return {"loglevel": current_level} + + +@app.post("/write_to_table") +def write_to_table( + payload: WriteTablePayload, api_key: str = Depends(require_api_key()), session: Session = Depends(get_db) +): + """Write given data to specified table""" + try: + load_data.write_to_table(table=payload.table, data=payload.data, if_exists=payload.if_exists, session=session) + logger.debug(f"Successfully wrote to {payload.table} using: {payload.data}") + return JSONResponse(content={"message": f"Succeeded loading data into {payload.table}"}, status_code=200) + except IntegrityError as e: + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f"SQLAlchemy error: {e}") + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {e}") + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.error(f"Unexpected error: {e}") + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.post("/load_time_data") +async def load_time_data( # noqa: PLR0912, C901 + probe_key_str: str = Form(...), + metric_type_str: str | None = Form(None), + reference_type_str: str | None = Form(None), + compound_key_str: str | None = Form(None), + file: UploadFile = File(...), + api_key: str = Depends(require_api_key()), + session: Session = Depends(get_db), +): + """Load provided data for given probe""" + try: + probe_key = ProbeKey(**json.loads(probe_key_str)) + + if metric_type_str is not None: + metric_type_dict = json.loads(metric_type_str) + metric_type = MetricType(**metric_type_dict) + else: + metric_type = METRICS.UNKNOWN + + if reference_type_str is not None: + reference_type_dict = json.loads(reference_type_str) + if "reference_table" in reference_type_dict: + reference_type = CompoundReferenceType(**reference_type_dict) + else: + reference_type = ReferenceType(**reference_type_dict) + else: + reference_type = REF_TYPES.UNKNOWN + + compound_key = None if compound_key_str is None else json.loads(compound_key_str) + + content = await file.read() + df = pd.read_csv(io.BytesIO(content)) + logger.info(df.head()) + # Convert time strings back to datetime + df["time"] = pd.to_datetime(df["time"]) + + # Use the same load_time_data function as before + load_data.load_time_data( + probe_key=probe_key, + metric_type=metric_type, + reference_type=reference_type, + compound_key=compound_key, + data=df, + session=session, + ) + + return JSONResponse(content={"message": f"Successfully loaded {len(df)} data points"}, status_code=200) + except IntegrityError as e: + if session: + session.rollback() + session.close() + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f"Database error: {e}") + if session: + session.rollback() + session.close() + raise HTTPException(status_code=500, detail=f"Database error: {e!s}") from e + except Exception as e: + logger.error(f"Unexpected error: {e}") + if session: + session.rollback() + session.close() + raise HTTPException(status_code=500, detail=f"Error processing time series data: {e!s}") from e + + +@app.post("/load_probe_metadata") +def load_probe_metadata( + payload: ProbeMetadataPayload, api_key: str = Depends(require_api_key()), session: Session = Depends(get_db) +): + """Load metadata for given probe""" + logger.debug(f"Received payload: {payload.model_dump()}") + + try: + load_data.load_probe_metadata( + vendor=payload.vendor, probe_key=payload.probe_key, data=payload.data, session=session + ) + logger.debug( + f"Successfully wrote to {ProbeMetadata.__tablename__} and {payload.vendor.metadata_table}: {payload.data}" + ) + return JSONResponse(content={"message": f"Succeeded loaded metadata for {payload.probe_key}"}, status_code=200) + except IntegrityError as e: + session.rollback() + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f"SQLAlchemy error: {e}") + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {e}") + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.exception(f"Unexpected error: {e}") + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.get("/create_new_tables") +def create_new_tables( + create_schema: bool = True, api_key: str = Depends(require_api_key()), session: Session = Depends(get_db) +): + """Update DB based on ORM Tables""" + try: + load_data.create_new_tables(create_schema=create_schema, session=session) + return JSONResponse(content={"message": "Succeeded in creating any new tables"}, status_code=200) + except SQLAlchemyError as e: + logger.error(f"SQLAlchemy error: {e}") + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {e}") + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.error(f"Unexpected error: {e}") + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.get("/gen_api_key") +def generate_api_key( + expire_after: int | None = None, + api_key: str | None = Depends(require_api_key(bootstrap=True)), + session: Session = Depends(get_db), +): + """Generate new API key in the database""" + try: + new_key = APIAccessKey() + new_key.generate_key() + if expire_after: + new_key.expires_at = datetime.now(tz=UTC) + timedelta(days=expire_after) + + session.add(new_key) + + session.commit() + return JSONResponse(content={"message": "Succeeded in creating new access key"}, status_code=200) + except SQLAlchemyError as e: + logger.error(f"SQLAlchemy error: {e}") + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except Exception as e: + logger.error(f"Unexpected error: {e}") + return JSONResponse(content={"message": f"Failed to create new access key: {e}"}, status_code=500) + + +@app.get("/healthcheck") +def healthcheck(): + """Ensure the api is accepting queries""" + return {"status": "OK"} + + +@app.get("/healthcheck_database") +def healthcheck_db(): + """Ensure the db is accepting connections""" + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except SQLAlchemyError as e: + return JSONResponse(content={"message": f"Database connection error: {e!s}"}, status_code=503) + else: + return {"status": "OK"} + + +@app.get("/healthcheck_metadata") +def healthcheck_metadata(): + """Ensure that the database exists AND the expected format is present""" + # eventually, we want to make the schema configurable through environment variables + # for now, we have it hard coded too many places. So this is a small step towards that goal + SCHEMA = "castdb" # noqa: N806 + + try: + with engine.connect() as connection: + result = connection.execute( + text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = :schema;"), + {"schema": SCHEMA}, + ) + schema_exists = result.fetchone() is not None + if schema_exists: + return {"status": "OK"} + return JSONResponse(status_code=500, content={"message": f"Expected schema '{SCHEMA}' does not exist"}) + except SQLAlchemyError as e: + return JSONResponse(content={"message": f"Database connection error: {e!s}"}, status_code=503) + + +@app.get("/metrics", include_in_schema=False) +def metrics(): + """Expose Prometheus metrics.""" + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/opensampl/server/cli.py b/opensampl/server/cli.py index d78ceeb..9815149 100644 --- a/opensampl/server/cli.py +++ b/opensampl/server/cli.py @@ -13,6 +13,9 @@ from loguru import logger from opensampl.config.server import ServerConfig +from opensampl.server import ensure_docker + +ensure_docker() def load_config(env_file: str | None = None) -> ServerConfig: diff --git a/opensampl/server/cli2.py b/opensampl/server/cli2.py index 14d9d54..6b15011 100644 --- a/opensampl/server/cli2.py +++ b/opensampl/server/cli2.py @@ -19,6 +19,9 @@ from loguru import logger from opensampl.config.server import ServerConfig +from opensampl.server import ensure_docker + +ensure_docker() def load_config(env_file: str | None = None) -> ServerConfig: diff --git a/opensampl/server/docker-compose.dev.yaml b/opensampl/server/docker-compose.dev.yaml new file mode 100644 index 0000000..c1cc204 --- /dev/null +++ b/opensampl/server/docker-compose.dev.yaml @@ -0,0 +1,75 @@ +services: + db: + image: savannah.ornl.gov/opensampl/db:latest + ports: + - "5415:5432" + volumes: + - castdb:/home/postgres/pgdata/data + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_DB=${POSTGRES_DB} + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} + restart: unless-stopped + healthcheck: + test: [ "CMD", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "${POSTGRES_DB}" ] + interval: 5s + retries: 5 + start_period: 10s + timeout: 3s + command: > + postgres + -c shared_preload_libraries=timescaledb,pg_cron + -c cron.database_name=${POSTGRES_DB} + + grafana: + image: savannah.ornl.gov/opensampl/grafana:latest + build: + context: ./grafana + restart: unless-stopped + ports: + - "3015:3000" + env_file: + - grafana/grafana.env + environment: + - POSTGRES_DB=${POSTGRES_DB} + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} + volumes: + - grafana-data:/var/lib/grafana + + + migrations: + image: savannah.ornl.gov/opensampl/migrations:latest + build: + context: migrations + restart: "no" + environment: + - DB_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + depends_on: + db: + condition: service_healthy + + + backend: + image: savannah.ornl.gov/opensampl/backend:latest + build: + context: backend + target: dev + ports: + - "8015:8000" + restart: unless-stopped + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - ROUTE_TO_BACKEND=false + - BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL} + - USE_API_KEY=${USE_API_KEY} + - API_KEYS=${API_KEYS} + volumes: + - ../..:/tmp/opensampl + depends_on: + db: + condition: service_healthy + +volumes: + castdb: + grafana-data: \ No newline at end of file diff --git a/opensampl/server/docker-compose.yaml b/opensampl/server/docker-compose.yaml index 931bf8a..48fa367 100644 --- a/opensampl/server/docker-compose.yaml +++ b/opensampl/server/docker-compose.yaml @@ -26,6 +26,7 @@ services: image: savannah.ornl.gov/opensampl/grafana:latest build: context: ./grafana + restart: "always" ports: - "3015:3000" environment: @@ -35,6 +36,8 @@ services: migrations: image: savannah.ornl.gov/opensampl/migrations:latest + build: + context: ./migrations restart: "no" environment: - DB_URI=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} @@ -46,6 +49,9 @@ services: backend: image: savannah.ornl.gov/opensampl/backend:latest + build: + context: ./backend + target: prod ports: - "8015:8000" restart: always diff --git a/opensampl/server/grafana/grafana-dashboards/ntp_dash.json b/opensampl/server/grafana/grafana-dashboards/ntp_dash.json new file mode 100644 index 0000000..345fd8c --- /dev/null +++ b/opensampl/server/grafana/grafana-dashboards/ntp_dash.json @@ -0,0 +1,1412 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "NTP reference path: measurements are relative to OpenSAMPL’s configured default reference (UNKNOWN type) unless you add GNSS-backed probes; timing vs GNSS is not implied for these series.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 52, + "panels": [], + "title": "All Probes", + "type": "row" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP phase offset (Phase Offset metric)", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "metric_type_uuid": true, + "probe_uuid": true, + "reference_uuid": true, + "stratum": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": { + "metric_type_uuid": 5, + "probe_name": 0, + "probe_uuid": 3, + "reference_uuid": 4, + "time": 1, + "value": 2 + }, + "renameByName": { + "probe_name": "" + } + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "phase_offset" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Remote single-packet paths use a conservative jitter estimate from delay and root dispersion when peer RMS jitter is unavailable; local chrony/ntpq snapshots may supply measured jitter.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP jitter (delay/dispersion estimate or measured)", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "metric_type_uuid": true, + "phase_offset": true, + "probe_uuid": true, + "reference_uuid": true, + "stratum": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": { + "metric_type_uuid": 5, + "probe_name": 0, + "probe_uuid": 3, + "reference_uuid": 4, + "time": 1, + "value": 2 + }, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "emptyValue": "null", + "rowField": "time", + "valueField": "jitter" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP stratum", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "phase_offset": true, + "probe_uuid": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "stratum" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP sync health (1=healthy)", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "phase_offset": true, + "probe_name": false, + "probe_uuid": true, + "stratum": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "sync_health" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 53, + "panels": [], + "repeat": "ntp_reference", + "title": "Reference: $ntp_reference", + "type": "row" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 19 + }, + "id": 54, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP phase offset (Phase Offset metric)", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "${ntp_reference}" + } + }, + "fieldName": "reference_uuid" + } + ], + "match": "all", + "type": "include" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "metric_type_uuid": true, + "probe_uuid": true, + "reference_uuid": true, + "stratum": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": { + "metric_type_uuid": 5, + "probe_name": 0, + "probe_uuid": 3, + "reference_uuid": 4, + "time": 1, + "value": 2 + }, + "renameByName": { + "probe_name": "" + } + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "phase_offset" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Remote single-packet paths use a conservative jitter estimate from delay and root dispersion when peer RMS jitter is unavailable; local chrony/ntpq snapshots may supply measured jitter.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 19 + }, + "id": 55, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP jitter (delay/dispersion estimate or measured)", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "${ntp_reference}" + } + }, + "fieldName": "reference_uuid" + } + ], + "match": "all", + "type": "include" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "metric_type_uuid": true, + "phase_offset": true, + "probe_uuid": true, + "reference_uuid": true, + "stratum": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": { + "metric_type_uuid": 5, + "probe_name": 0, + "probe_uuid": 3, + "reference_uuid": 4, + "time": 1, + "value": 2 + }, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "emptyValue": "null", + "rowField": "time", + "valueField": "jitter" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 56, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP stratum", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "${ntp_reference}" + } + }, + "fieldName": "reference_uuid" + } + ], + "match": "all", + "type": "include" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "phase_offset": true, + "probe_uuid": true, + "sync_health": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "stratum" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 57, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 51, + "refId": "A" + } + ], + "title": "NTP sync health (1=healthy)", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "${ntp_reference}" + } + }, + "fieldName": "reference_uuid" + } + ], + "match": "all", + "type": "include" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "jitter": true, + "phase_offset": true, + "probe_name": false, + "probe_uuid": true, + "stratum": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + }, + { + "id": "groupingToMatrix", + "options": { + "columnField": "probe_name", + "rowField": "time", + "valueField": "sync_health" + } + }, + { + "id": "prepareTimeSeries", + "options": { + "format": "multi" + } + } + ], + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 52 + }, + "id": 50, + "panels": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "castdb-datasource" + }, + "description": "Phase metrics use OpenSAMPL’s default reference row (UNKNOWN reference type). NTP **observation** context is the configured server in `ntp_metadata` (not GNSS unless a GNSS-backed probe is present).", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 87 + }, + "id": 5, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "probe" + } + ] + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "castdb-datasource" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS probe,\n pm.vendor,\n COALESCE(rt.name, '') AS reference_type,\n COALESCE(nm.target_host::text, '') AS ntp_server,\n COALESCE(nm.mode::text, '') AS ntp_mode,\n COALESCE(nm.reference_id::text, '') AS ntp_ref_id,\n COALESCE(l.name, '') AS location,\n COALESCE(pm.public::text, '') AS public\nFROM castdb.probe_metadata pm\nLEFT JOIN castdb.ntp_metadata nm ON nm.probe_uuid = pm.uuid\nLEFT JOIN castdb.locations l ON l.uuid = pm.location_uuid\nLEFT JOIN LATERAL (\n SELECT pd.reference_uuid FROM castdb.probe_data pd WHERE pd.probe_uuid = pm.uuid LIMIT 1\n) rp ON true\nLEFT JOIN castdb.reference r ON r.uuid = rp.reference_uuid\nLEFT JOIN castdb.reference_type rt ON rt.uuid = r.reference_type_uuid\nWHERE pm.vendor = 'NTP'\n AND (trim('${ntp_probe:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${ntp_probe:csv}'), ',')))\nORDER BY 1;", + "refId": "A" + } + ], + "title": "Probe reference & source (stored metadata)", + "type": "table" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "P55EB97F79F5EB88E" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "hideFrom": { + "viz": false + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 96 + }, + "id": 51, + "options": { + "cellHeight": "sm", + "frameIndex": 1, + "showHeader": true + }, + "pluginVersion": "12.2.1", + "targets": [ + { + "dataset": "castdb", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "WITH probe_ref AS (\n SELECT\n uuid,\n COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS probe_name\n FROM castdb.probe_metadata pm\n)\nSELECT\n time_bucket('1 minute'::interval, pd.time AT TIME ZONE 'UTC') AS time,\n pd.probe_uuid,\n pr.probe_name,\n pd.reference_uuid,\n AVG(pd.value::float * 1e9) FILTER (WHERE lower(m.name) = 'phase offset') AS phase_offset,\n AVG(pd.value::float * 1e9) FILTER (WHERE lower(m.name) = 'jitter') AS jitter,\n AVG((pd.value)::float) FILTER (WHERE lower(m.name) = 'stratum') AS stratum,\n AVG((pd.value)::float) FILTER (WHERE lower(m.name) = 'sync health') AS sync_health\nFROM castdb.probe_data pd\nJOIN probe_ref pr\n ON pd.probe_uuid = pr.uuid\nJOIN castdb.metric_type m\n ON pd.metric_type_uuid = m.uuid\nWHERE pd.probe_uuid = ANY(ARRAY[${ntp_probe:sqlstring}]::text[]) AND $__timeFilter(pd.time)\nGROUP BY 1, 2, 3, 4\nORDER BY\n 1, 3;", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "source_panel", + "type": "table" + } + ], + "title": "Reference & source metadata", + "type": "row" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 42, + "tags": [ + "ntp", + "opensampl", + "reference" + ], + "templating": { + "list": [ + { + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "castdb-datasource" + }, + "definition": "SELECT pm.uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS __text FROM castdb.probe_metadata pm WHERE pm.vendor = 'NTP' ORDER BY 2", + "includeAll": true, + "multi": true, + "name": "ntp_probe", + "options": [], + "query": "SELECT pm.uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS __text FROM castdb.probe_metadata pm WHERE pm.vendor = 'NTP' ORDER BY 2", + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + }, + { + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "castdb-datasource" + }, + "definition": "SELECT pm.reference_uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS __text FROM castdb.reference_probe_metadata pm WHERE pm.vendor = 'NTP' \nUNION ALL\nSELECT r.uuid::text AS __value, rt.\"name\" AS __text FROM castdb.reference r JOIN castdb.reference_type rt ON r.reference_type_uuid = rt.\"uuid\" WHERE rt.\"name\" = 'UNKNOWN';", + "includeAll": true, + "multi": true, + "name": "ntp_reference", + "options": [], + "query": "SELECT pm.reference_uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS __text FROM castdb.reference_probe_metadata pm WHERE pm.vendor = 'NTP' \nUNION ALL\nSELECT r.uuid::text AS __value, rt.\"name\" AS __text FROM castdb.reference r JOIN castdb.reference_type rt ON r.reference_type_uuid = rt.\"uuid\" WHERE rt.\"name\" = 'UNKNOWN';", + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "NTP probes (NTP server reference path)", + "uid": "ntp-opensampl", + "version": 17 +} \ No newline at end of file diff --git a/opensampl/server/grafana/grafana-dashboards/public-timing-dashboard.json b/opensampl/server/grafana/grafana-dashboards/public-timing-dashboard.json index 687ceae..24b7a6f 100644 --- a/opensampl/server/grafana/grafana-dashboards/public-timing-dashboard.json +++ b/opensampl/server/grafana/grafana-dashboards/public-timing-dashboard.json @@ -338,7 +338,7 @@ "group": [], "metricColumn": "none", "rawQuery": true, - "rawSql": "select\n pm.name as \"Clock\",\n l.name as \"Location Name\",\n l.latitude,\n l.longitude,\n l.campus\nfrom castdb.campus_locations l left join castdb.probe_metadata pm on l.uuid = pm.location_uuid\nwhere pm.uuid in (${clock_name:sqlstring});", + "rawSql": "select\n pm.name as \"Clock\",\n l.name as \"Location Name\",\n l.latitude,\n l.longitude,\n l.campus\nfrom castdb.campus_locations l left join castdb.probe_metadata pm on l.uuid = pm.location_uuid\nwhere (trim('${clock_name:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')));", "refId": "ClockProbes", "select": [ [ @@ -379,13 +379,13 @@ { "datasource": { "type": "grafana-postgresql-datasource", - "uid": "P55EB97F79F5EB88E" + "uid": "castdb-datasource" }, "editorMode": "code", "format": "table", "hide": false, "rawQuery": true, - "rawSql": "SELECT\n l.latitude,\n l.longitude,\n l.campus,\n sum(\n CASE\n when pm.public = True and pm.vendor in ('ADVA', 'MicrochipTP4100') then 1 else 0\n end \n ) as visible_clocks,\n sum(\n CASE\n when pm.uuid in (${clock_name:sqlstring}) then 1 else 0\n end \n ) as selected_clocks\n from castdb.campus_locations l left join castdb.probe_metadata pm on l.uuid = pm.location_uuid\n where l.public = True\n group by\n l.latitude, l.longitude, l.campus;", + "rawSql": "SELECT\n l.latitude,\n l.longitude,\n l.campus,\n sum(\n CASE\n when pm.public = True and pm.vendor in ('ADVA', 'MicrochipTP4100', 'NTP') then 1 else 0\n end \n ) as visible_clocks,\n sum(\n CASE\n when (trim('${clock_name:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${clock_name:csv}'), ','))) then 1 else 0\n end \n ) as selected_clocks\n from castdb.campus_locations l left join castdb.probe_metadata pm on l.uuid = pm.location_uuid\n where l.public = True\n group by\n l.latitude, l.longitude, l.campus;", "refId": "A", "sql": { "columns": [ @@ -465,7 +465,7 @@ }, "format": "table", "rawQuery": true, - "rawSql": "SELECT COUNT(*) as \"Total Clock Probes\" FROM castdb.probe_metadata where uuid in ($clock_name)", + "rawSql": "SELECT COUNT(*)::bigint AS \"Total Clock Probes\" FROM castdb.probe_metadata pm WHERE pm.vendor IN ('ADVA', 'MicrochipTP4100', 'NTP') AND coalesce(pm.public, true) AND (trim('${clock_name:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')))", "refId": "A" } ], @@ -539,7 +539,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT \n coalesce(pm.name, concat(pm.ip_address, 'Inteface', pm.probe_id)) as \"Clock Probe\", \n COUNT(*) as \"Total Records\" \nFROM castdb.probe_data pd\njoin castdb.probe_metadata pm on pd.probe_uuid = pm.uuid \nwhere pm.uuid in (${clock_name:sqlstring})\nAND pd.\"time\" >= $__timeFrom()\nAND pd.\"time\" <= $__timeTo()\ngroup by pm.uuid, pm.name, pm.ip_address, pm.probe_id;", + "rawSql": "SELECT \n coalesce(pm.name, concat(pm.ip_address, ' Interface ', pm.probe_id)) AS \"Clock Probe\", \n COUNT(*)::bigint AS \"Total Records\" \nFROM castdb.probe_data pd\nJOIN castdb.probe_metadata pm ON pd.probe_uuid = pm.uuid \nWHERE pm.vendor IN ('ADVA', 'MicrochipTP4100', 'NTP')\n AND coalesce(pm.public, true)\n AND (trim('${clock_name:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')))\n AND pd.\"time\" >= $__timeFrom()\n AND pd.\"time\" <= $__timeTo()\nGROUP BY pm.uuid, pm.name, pm.ip_address, pm.probe_id;", "refId": "A", "sql": { "columns": [ @@ -568,7 +568,7 @@ "type": "grafana-postgresql-datasource", "uid": "castdb-datasource" }, - "description": "Average time error \n(averaged on selected resolution)", + "description": "Average time error vs stored reference (resolution as selected). GNSS-specific labeling applies only when the probe/reference model is GNSS-backed.", "fieldConfig": { "defaults": { "color": { @@ -650,7 +650,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC') AS time,\n coalesce(pm.name, concat(pm.ip_address, ' Interface ', pm.probe_id)),\n AVG(pd.value::FLOAT) * 1e9 AS value\nFROM castdb.probe_data pd join castdb.probe_metadata pm on pd.probe_uuid = pm.uuid\nWHERE\n $__timeFilter(pd.time)\n AND pd.probe_uuid IN (${clock_name:sqlstring})\nGROUP BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC'),\n pd.probe_uuid,\n pm.name,\n pm.ip_address,\n pm.probe_id\nORDER BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC')\n", + "rawSql": "SELECT \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC') AS time,\n coalesce(pm.name, concat(pm.ip_address, ' Interface ', pm.probe_id)),\n AVG(pd.value::FLOAT) * 1e9 AS value\nFROM castdb.probe_data pd JOIN castdb.probe_metadata pm ON pd.probe_uuid = pm.uuid\nWHERE\n $__timeFilter(pd.time)\n AND pm.vendor IN ('ADVA', 'MicrochipTP4100', 'NTP')\n AND coalesce(pm.public, true)\n AND (trim('${clock_name:csv}') = '' OR pd.probe_uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')))\nGROUP BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC'),\n pd.probe_uuid,\n pm.name,\n pm.ip_address,\n pm.probe_id\nORDER BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC')\n", "refId": "A", "sql": { "columns": [ @@ -671,7 +671,7 @@ } } ], - "title": "Time Error - Clock Time vs GNSS", + "title": "Time Error - Clock Time vs Reference", "transformations": [ { "id": "prepareTimeSeries", @@ -776,7 +776,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC') AS time,\n COALESCE(name, CONCAT(ip_address, ' Interface ', probe_id)),\n (MAX(pd.value::FLOAT) - MIN(pd.value::FLOAT)) * 1e9 AS value\nFROM castdb.probe_data pd join castdb.probe_metadata pm on pd.probe_uuid = pm.uuid\nWHERE\n $__timeFilter(pd.time)\n AND pd.probe_uuid in (${clock_name:sqlstring})\nGROUP BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC'),\n pd.probe_uuid,\n pm.name,\n pm.ip_address,\n pm.probe_id\nORDER BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC')\n", + "rawSql": "SELECT \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC') AS time,\n COALESCE(pm.name, CONCAT(pm.ip_address, ' Interface ', pm.probe_id)),\n (MAX(pd.value::FLOAT) - MIN(pd.value::FLOAT)) * 1e9 AS value\nFROM castdb.probe_data pd JOIN castdb.probe_metadata pm ON pd.probe_uuid = pm.uuid\nWHERE\n $__timeFilter(pd.time)\n AND pm.vendor IN ('ADVA', 'MicrochipTP4100', 'NTP')\n AND coalesce(pm.public, true)\n AND (trim('${clock_name:csv}') = '' OR pd.probe_uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')))\nGROUP BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC'),\n pd.probe_uuid,\n pm.name,\n pm.ip_address,\n pm.probe_id\nORDER BY \n time_bucket(${resolution:sqlstring}, pd.time AT TIME ZONE 'UTC')\n", "refId": "A", "sql": { "columns": [ @@ -797,7 +797,7 @@ } } ], - "title": "Maximum Time Interval Error VS GNSS", + "title": "Maximum Time Interval Error vs Reference", "transformations": [ { "id": "prepareTimeSeries", @@ -834,7 +834,7 @@ "type": "grafana-postgresql-datasource", "uid": "castdb-datasource" }, - "description": "Average time error \n(averaged on selected resolution)", + "description": "Average time error vs stored reference (resolution as selected). GNSS-specific labeling applies only when the probe/reference model is GNSS-backed.", "fieldConfig": { "defaults": { "color": { @@ -937,7 +937,7 @@ } } ], - "title": "Time Error - Clock Time vs GNSS", + "title": "Time Error - Clock Time vs Reference", "type": "timeseries" }, { @@ -1048,14 +1048,44 @@ } } ], - "title": "Maximum Time Interval Error", + "title": "Maximum Time Interval Error vs Reference", "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 29}, + "id": 101, + "panels": [ + { + "datasource": {"type": "grafana-postgresql-datasource", "uid": "castdb-datasource"}, + "description": "Rows reflect stored `probe_metadata`, `ntp_metadata` (when vendor is NTP), `locations`, and one sample `reference`/`reference_type` from `probe_data` per probe.", + "fieldConfig": {"defaults": {}, "overrides": []}, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 0}, + "id": 100, + "options": {"cellHeight": "sm", "showHeader": true}, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": {"type": "grafana-postgresql-datasource", "uid": "castdb-datasource"}, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(pm.name, CONCAT(pm.ip_address, ' ', pm.probe_id)) AS probe,\n pm.vendor,\n COALESCE(rt.name, '') AS reference_type,\n COALESCE(nm.target_host::text, '') AS ntp_server,\n COALESCE(nm.mode::text, '') AS ntp_mode,\n COALESCE(nm.reference_id::text, '') AS ntp_ref_id,\n COALESCE(l.name, '') AS location,\n COALESCE(pm.public::text, '') AS public\nFROM castdb.probe_metadata pm\nLEFT JOIN castdb.ntp_metadata nm ON nm.probe_uuid = pm.uuid\nLEFT JOIN castdb.locations l ON l.uuid = pm.location_uuid\nLEFT JOIN LATERAL (\n SELECT pd.reference_uuid FROM castdb.probe_data pd WHERE pd.probe_uuid = pm.uuid LIMIT 1\n) rp ON true\nLEFT JOIN castdb.reference r ON r.uuid = rp.reference_uuid\nLEFT JOIN castdb.reference_type rt ON rt.uuid = r.reference_type_uuid\nWHERE pm.vendor IN ('ADVA', 'MicrochipTP4100', 'NTP')\n AND coalesce(pm.public, true)\n AND (trim('${clock_name:csv}') = '' OR pm.uuid = ANY(string_to_array(trim('${clock_name:csv}'), ',')))\nORDER BY 1;", + "refId": "A" + } + ], + "title": "Probe reference & source (stored metadata)", + "type": "table" + } + ], + "title": "Reference & source metadata", + "type": "row" } ], "preload": false, "refresh": "", "schemaVersion": 41, - "tags": [], + "tags": ["opensampl", "reference", "geospatial"], "templating": { "list": [ { @@ -1067,12 +1097,12 @@ "type": "grafana-postgresql-datasource", "uid": "castdb-datasource" }, - "definition": "SELECT uuid AS __value, COALESCE(name, CONCAT(ip_address, ' Interface ', probe_id)) AS __text FROM castdb.probe_metadata WHERE vendor in ('ADVA', 'MicrochipTP4100') and public;", + "definition": "SELECT pm.uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' Interface ', pm.probe_id)) AS __text FROM castdb.probe_metadata pm WHERE pm.vendor in ('ADVA', 'MicrochipTP4100', 'NTP') AND coalesce(pm.public, true) ORDER BY 2;", "includeAll": true, "multi": true, "name": "clock_name", "options": [], - "query": "SELECT uuid AS __value, COALESCE(name, CONCAT(ip_address, ' Interface ', probe_id)) AS __text FROM castdb.probe_metadata WHERE vendor in ('ADVA', 'MicrochipTP4100') and public;", + "query": "SELECT pm.uuid::text AS __value, COALESCE(pm.name, CONCAT(pm.ip_address, ' Interface ', pm.probe_id)) AS __text FROM castdb.probe_metadata pm WHERE pm.vendor in ('ADVA', 'MicrochipTP4100', 'NTP') AND coalesce(pm.public, true) ORDER BY 2;", "refresh": 1, "regex": "", "type": "query" @@ -1130,7 +1160,8 @@ }, "timepicker": {}, "timezone": "utc", - "title": "Public Geospatial and Timing Combined Dashboard", + "description": "Geospatial views use stored `locations` geometry. Timing series are relative to each probe\u2019s stored reference (OpenSAMPL `reference` / `reference_type`) and are **not** GNSS-truth unless a GNSS-backed probe supplies that semantics.", + "title": "Public Geospatial and Timing (Reference)", "uid": "public-geospatial-dashboard", "version": 10 } \ No newline at end of file diff --git a/opensampl/server/migrations/Dockerfile b/opensampl/server/migrations/Dockerfile new file mode 100644 index 0000000..d3c7ae1 --- /dev/null +++ b/opensampl/server/migrations/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12 + +ARG OPENSAMPL_VERSION=1.1.5 + +WORKDIR / +RUN pip install --no-cache-dir "opensampl[migrations]==${OPENSAMPL_VERSION}" alembic + +RUN useradd -m alembic +USER alembic +WORKDIR /app +COPY _migrations/ /app/_migrations/ +COPY alembic.ini . + +# needs 15 seconds for the db to finish initializing when run locally +CMD ["sh", "-c", "sleep 15 && alembic upgrade head"] +# uncomment below to reset datbase before any migration +#CMD ["alembic", "downgrade", "base"] +# or uncomment below to allow container to just stay awake +#CMD ["tail", "-f", "/dev/null"] diff --git a/opensampl/server/migrations/_migrations/README b/opensampl/server/migrations/_migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/opensampl/server/migrations/_migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/env.py b/opensampl/server/migrations/_migrations/env.py new file mode 100644 index 0000000..a44ba86 --- /dev/null +++ b/opensampl/server/migrations/_migrations/env.py @@ -0,0 +1,81 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +sqlalchemy_url = os.environ.get('DB_URI') +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. +config.set_main_option('sqlalchemy.url', sqlalchemy_url) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/opensampl/server/migrations/_migrations/script.py.mako b/opensampl/server/migrations/_migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/opensampl/server/migrations/_migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/opensampl/server/migrations/_migrations/versions/2024_03_26_1145_create_schema_initialize_orm.py b/opensampl/server/migrations/_migrations/versions/2024_03_26_1145_create_schema_initialize_orm.py new file mode 100644 index 0000000..f3f97c6 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2024_03_26_1145_create_schema_initialize_orm.py @@ -0,0 +1,101 @@ +"""create schema & initialize orm + +Revision ID: fe18404ea614 +Revises: +Create Date: 2024-03-26 11:45:04.612673 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import geoalchemy2 + + +# revision identifiers, used by Alembic. +revision: str = 'fe18404ea614' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' +def upgrade() -> None: + # starting postgis + op.execute("CREATE EXTENSION IF NOT EXISTS postgis;") + + # Create our schema + op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA};") + op.execute(f"CREATE SCHEMA IF NOT EXISTS access;") + + # Create locations table + op.create_table('locations', + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column('name', sa.Text(), nullable=False, unique=True), + sa.Column('geom', geoalchemy2.Geometry(geometry_type='GEOMETRY', srid=4326)), + sa.Column('public', sa.Boolean(), nullable=True), + schema=SCHEMA, + if_not_exists=True + ) + + # Create test_metadata table + op.create_table('test_metadata', + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column('name', sa.Text(), unique=True, nullable=False), + sa.Column('start_date', sa.TIMESTAMP()), + sa.Column('end_date', sa.TIMESTAMP()), + schema=SCHEMA, + if_not_exists=True + ) + + # Create probe_metadata table + op.create_table('probe_metadata', + sa.Column('uuid', sa.String(36), primary_key=True), + sa.Column('probe_id', sa.Text()), + sa.Column('ip_address', sa.Text()), + sa.Column('vendor', sa.Text()), + sa.Column('model', sa.Text()), + sa.Column('name', sa.Text(), unique=True), + sa.Column('public', sa.Boolean(), nullable=True), + sa.Column('location_uuid', sa.String(36), sa.ForeignKey('castdb.locations.uuid')), + sa.Column('test_uuid', sa.String(36), sa.ForeignKey('castdb.test_metadata.uuid')), + sa.UniqueConstraint('probe_id', 'ip_address', name='uq_probe_metadata_ipaddress_probeid'), + schema=SCHEMA, + if_not_exists=True + ) + + # Create probe_data table + op.create_table('probe_data', + sa.Column('time', sa.TIMESTAMP(), primary_key=True), + sa.Column('probe_uuid', sa.String(36), sa.ForeignKey('castdb.probe_metadata.uuid'), + primary_key=True), + sa.Column('value', sa.NUMERIC()), + schema=SCHEMA, + if_not_exists=True + ) + + # Create adva_metadata table + op.create_table('adva_metadata', + sa.Column('probe_uuid', sa.String(36), sa.ForeignKey('castdb.probe_metadata.uuid'), + primary_key=True), + sa.Column('type', sa.Text()), + sa.Column('start', sa.TIMESTAMP()), + sa.Column('frequency', sa.Integer()), + sa.Column('timemultiplier', sa.Integer()), + sa.Column('multiplier', sa.Integer()), + sa.Column('title', sa.Text()), + sa.Column('adva_probe', sa.Text()), + sa.Column('adva_reference', sa.Text()), + sa.Column('adva_reference_expected_ql', sa.Text()), + sa.Column('adva_source', sa.Text()), + sa.Column('adva_direction', sa.Text()), + sa.Column('adva_version', sa.Float()), + sa.Column('adva_status', sa.Text()), + sa.Column('adva_mtie_mask', sa.Text()), + sa.Column('adva_mask_margin', sa.Integer()), + schema=SCHEMA, + if_not_exists=True + ) + + +def downgrade() -> None: + op.execute(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE;") diff --git a/opensampl/server/migrations/_migrations/versions/2024_12_04_1155_update_db_tables.py b/opensampl/server/migrations/_migrations/versions/2024_12_04_1155_update_db_tables.py new file mode 100644 index 0000000..a6023ea --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2024_12_04_1155_update_db_tables.py @@ -0,0 +1,195 @@ +"""update db tables + +Revision ID: 7f8adc06bb6b +Revises: fe18404ea614 +Create Date: 2024-12-04 11:55:12.955284 + +""" +from typing import Sequence, Union, Dict + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import reflection +from loguru import logger +import uuid + +# revision identifiers, used by Alembic. +revision: str = '7f8adc06bb6b' +down_revision: Union[str, None] = 'fe18404ea614' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +def create_uuid_mapping(connection, table_name: str, id_columns: list) -> Dict[tuple, str]: + """Create mapping of old composite keys to new UUIDs""" + # Query all existing records + select_stmt = sa.text(f""" + SELECT {', '.join(id_columns)} + FROM {SCHEMA}.{table_name} + """) + records = connection.execute(select_stmt).fetchall() + + # Create mapping + return {tuple(record): str(uuid.uuid4()) for record in records} + + +def upgrade(): + # Create connection for executing raw SQL + connection = op.get_bind() + inspector = reflection.Inspector.from_engine(connection) + existing_columns = [col['name'] for col in inspector.get_columns('probe_metadata', schema=SCHEMA)] + + # Step 1: Add new UUID column to probe_metadata (nullable initially) + if 'uuid' not in existing_columns: + op.add_column('probe_metadata', sa.Column('uuid', sa.String(36), nullable=True), schema=SCHEMA) + # Generate and store UUIDs for existing probe_metadata records + probe_uuid_map = create_uuid_mapping( + connection, + 'probe_metadata', + ['probe_id', 'ip_address'] + ) + + # Update probe_metadata with UUIDs + for (probe_id, ip_address), new_uuid in probe_uuid_map.items(): + op.execute(f""" + UPDATE {SCHEMA}.probe_metadata + SET uuid = '{new_uuid}', vendor = 'ADVA' + WHERE probe_id = '{probe_id}' + AND ip_address = '{ip_address}' + """) + + # Make UUID column non-nullable and make it the primary key + op.alter_column('probe_metadata', 'uuid', + existing_type=sa.String(36), + nullable=False, + schema=SCHEMA + ) + + if 'public' not in existing_columns: + op.add_column('probe_metadata', sa.Column('public', sa.Boolean, nullable=True), schema=SCHEMA) + + + + def safe_drop_constraint(constraint, table): + if table in inspector.get_table_names(schema=SCHEMA): + constraints = [fk['name'] for fk in inspector.get_foreign_keys(table, schema=SCHEMA) if fk['name']] + if constraint in constraints: + op.drop_constraint(constraint, table, type_='foreignkey', schema=SCHEMA) + + # First drop foreign key constraints from dependent tables + safe_drop_constraint('ad_data_probe_id_ip_address_fkey', 'ad_data') + safe_drop_constraint('mtie_data_probe_id_ip_address_fkey', 'mtie_data') + safe_drop_constraint('avg_phase_err_data_probe_id_ip_address_fkey', 'avg_phase_err_data') + safe_drop_constraint('raw_data_probe_id_ip_address_fkey', 'raw_data') + safe_drop_constraint('headers_probe_id_ip_address_fkey', 'headers') + + # Drop old primary key and create new one with UUID + pk_info = inspector.get_pk_constraint('probe_metadata', schema=SCHEMA) + existing_pk_name = pk_info.get('name') + existing_pk_cols = pk_info.get('constrained_columns', []) + + # Replace only if it's not already set to 'uuid' as the sole primary key + if existing_pk_cols != ['uuid']: + if existing_pk_name: + op.drop_constraint(existing_pk_name, 'probe_metadata', type_='primary', schema=SCHEMA) + + op.create_primary_key( + 'probe_metadata_pkey', + 'probe_metadata', + ['uuid'], + schema=SCHEMA + ) + def safe_create_unique_constraint(name, table, columns): + existing = [uc['name'] for uc in inspector.get_unique_constraints(table, schema=SCHEMA)] + if name not in existing: + op.create_unique_constraint(name, table, columns, schema=SCHEMA) + + safe_create_unique_constraint('uq_probe_metadata_uuid', 'probe_metadata', ['uuid']) + safe_create_unique_constraint('uq_probe_metadata_name', 'probe_metadata', ['name']) + safe_create_unique_constraint('uq_probe_metadata_ipaddress_probeid', 'probe_metadata', ['ip_address', 'probe_id']) + + + # Now create adva_metadata table (after uuid is unique) + op.create_table('adva_metadata', + sa.Column('probe_uuid', sa.String(36), + sa.ForeignKey(f'{SCHEMA}.probe_metadata.uuid'), + primary_key=True), + sa.Column('type', sa.Text), + sa.Column('start', sa.TIMESTAMP), + sa.Column('frequency', sa.Integer), + sa.Column('timemultiplier', sa.Integer), + sa.Column('multiplier', sa.Integer), + sa.Column('title', sa.Text), + sa.Column('adva_probe', sa.Text), + sa.Column('adva_reference', sa.Text), + sa.Column('adva_reference_expected_ql', sa.Text), + sa.Column('adva_source', sa.Text), + sa.Column('adva_direction', sa.Text), + sa.Column('adva_version', sa.Float), + sa.Column('adva_status', sa.Text), + sa.Column('adva_mtie_mask', sa.Text), + sa.Column('adva_mask_margin', sa.Integer), + schema=SCHEMA, + if_not_exists=True + ) + + # Migrate data from adva_headers to adva_metadata + if 'adva_headers' in inspector.get_table_names(schema=SCHEMA): + op.execute(f""" + INSERT INTO {SCHEMA}.adva_metadata ( + probe_uuid, type, start, frequency, multiplier, + adva_probe, adva_reference, + adva_source, adva_direction, adva_version, adva_status, + adva_mtie_mask, adva_mask_margin + ) + SELECT + pm.uuid, + ah.type, + ah.start, + ah.frequency, + CAST(ah.multiplier as INTEGER), + ah.adva_probe, + ah.adva_ref as adva_reference, + ah.adva_src as adva_source, + ah.adva_direction, + CAST(ah.adva_version as FLOAT), + ah.adva_status, + ah.adva_mtie_mask, + CAST(ah.adva_mask_margin as INTEGER) + FROM {SCHEMA}.adva_headers ah + JOIN {SCHEMA}.headers h ON h.adva_id = ah.id + JOIN {SCHEMA}.probe_metadata pm + ON pm.probe_id = h.probe_id + AND pm.ip_address = h.ip_address + """) + + # Create new probe_data table + op.create_table('probe_data', + sa.Column('time', sa.TIMESTAMP, primary_key=True), + sa.Column('probe_uuid', sa.String(36), + sa.ForeignKey(f'{SCHEMA}.probe_metadata.uuid'), + primary_key=True), + sa.Column('value', sa.NUMERIC), + schema=SCHEMA, + if_not_exists=True + ) + + # Convert probe_data to hypertable + op.execute(""" + SELECT create_hypertable('castdb.probe_data', 'time', + chunk_time_interval => INTERVAL '1 hour', + if_not_exists => TRUE, + migrate_data => TRUE); + """) + + # Drop old header tables + op.drop_table('adva_headers', schema=SCHEMA, if_exists=True) + op.drop_table('headers', schema=SCHEMA, if_exists=True) + + + +def downgrade(): + # This migration is not reversible due to potential data loss + # and the complexity of regenerating composite keys + logger.info("Downgrade is not supported for this migration.") diff --git a/opensampl/server/migrations/_migrations/versions/2025_01_28_2212_create_time_buckets.py b/opensampl/server/migrations/_migrations/versions/2025_01_28_2212_create_time_buckets.py new file mode 100644 index 0000000..a6a7c80 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_01_28_2212_create_time_buckets.py @@ -0,0 +1,109 @@ +"""create time buckets + +Revision ID: c464878dac7b +Revises: 7f8adc06bb6b +Create Date: 2025-01-28 22:12:48.387383 + +""" +from typing import Sequence, Union, Tuple + +from alembic import op +import sqlalchemy as sa +from loguru import logger + + +# revision identifiers, used by Alembic. +revision: str = 'c464878dac7b' +down_revision: Union[str, None] = '7f8adc06bb6b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +time_buckets = [ + #suffix interval cron schedule (min hr * * *) + ('1min', '1 minute', '*/5 * * * *'), # Every 5 minutes + ('5min', '5 minutes', '*/5 * * * *'), # Every 5 minutes + ('15min', '15 minutes', '*/15 * * * *'), # Every 15 minutes + ('1hour', '1 hour', '0 * * * *'), # Start of every hour + ('6hour', '6 hours', '0 */6 * * *'), # Every 6 hours + ('1day', '1 day', '0 0 * * *') # Midnight every day +] + +# def execute_out_of_transaction(statement: str): +# """Execute a statement outside of a transaction block""" +# # Get connection from alembic +# connection = op.get_bind() +# +# # Close existing transaction +# connection.execution_options(isolation_level="AUTOCOMMIT") +# +# # Execute statement +# connection.execute(statement) + + +def upgrade(): + # Install pg_cron extension if not exists + op.execute(""" + CREATE EXTENSION IF NOT EXISTS pg_cron; + """) + + for suffix, interval, schedule in time_buckets: + # Create materialized views with indexes + try: + op.execute(f""" + CREATE MATERIALIZED VIEW castdb.avg_phase_err_{suffix} AS + SELECT + time_bucket('{interval}', pd.time) as "time", + pd.probe_uuid as uuid, + AVG(pd.value) * 1e9 as value + FROM castdb.probe_data pd + GROUP BY + time_bucket('{interval}', pd.time), + pd.probe_uuid; + + CREATE INDEX ON castdb.avg_phase_err_{suffix} ("time" DESC); + CREATE INDEX ON castdb.avg_phase_err_{suffix} (uuid); + + CREATE UNIQUE INDEX IF NOT EXISTS avg_phase_err_{suffix}_unique_idx ON castdb.avg_phase_err_{suffix} ("time", uuid); + + CREATE MATERIALIZED VIEW castdb.mtie_{suffix} AS + SELECT + time_bucket('{interval}', pd.time) as "time", + pd.probe_uuid as uuid, + (MAX(pd.value) - MIN(pd.value)) * 1e9 as value + FROM castdb.probe_data pd + GROUP BY + time_bucket('{interval}', pd.time), + pd.probe_uuid; + + CREATE INDEX ON castdb.mtie_{suffix} ("time" DESC); + CREATE INDEX ON castdb.mtie_{suffix} (uuid); + + CREATE UNIQUE INDEX IF NOT EXISTS mtie_{suffix}_unique_idx ON castdb.mtie_{suffix} ("time", uuid); + -- Schedule refresh using pg_cron + SELECT cron.schedule( + 'refresh_{suffix}', + '{schedule}', + $$ + REFRESH MATERIALIZED VIEW CONCURRENTLY castdb.avg_phase_err_{suffix}; + REFRESH MATERIALIZED VIEW CONCURRENTLY castdb.mtie_{suffix}; + $$ + ); + """) + except Exception as e: + logger.warning(f"Error creating materialized view for {suffix}: {e}") + + +def downgrade(): + for suffix, _, _ in time_buckets: + # Remove cron jobs first + op.execute(f""" + SELECT cron.unschedule('refresh_{suffix}'); + """) + + op.execute(f"DROP MATERIALIZED VIEW IF EXISTS castdb.avg_phase_err_{suffix} CASCADE;") + op.execute(f"DROP MATERIALIZED VIEW IF EXISTS castdb.mtie_{suffix} CASCADE;") + + # Remove hypertable (this will keep the table but remove timescale functionality) + # op.execute(""" + # SELECT drop_chunks('castdb.probe_data', older_than => '-infinity'::timestamp); + # """) \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/versions/2025_01_29_0909_updating_location_and_test_tables.py b/opensampl/server/migrations/_migrations/versions/2025_01_29_0909_updating_location_and_test_tables.py new file mode 100644 index 0000000..7d57f84 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_01_29_0909_updating_location_and_test_tables.py @@ -0,0 +1,234 @@ +"""updating location and test tables + +Revision ID: bd1322d0b00f +Revises: c464878dac7b +Create Date: 2025-01-29 09:09:01.383919 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import reflection + +from loguru import logger + +import uuid +from typing import Dict + + +# revision identifiers, used by Alembic. +revision: str = 'bd1322d0b00f' +down_revision: Union[str, None] = 'c464878dac7b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + + +def create_uuid_mapping(connection, table_name: str, id_columns: list) -> Dict[tuple, str]: + """Create mapping of old composite keys to new UUIDs""" + # Query all existing records + select_stmt = sa.text(f""" + SELECT {', '.join(id_columns)} + FROM {SCHEMA}.{table_name} + """) + records = connection.execute(select_stmt).fetchall() + + # Create mapping + return {tuple(record): str(uuid.uuid4()) for record in records} + + +def upgrade(): + connection = op.get_bind() + inspector = reflection.Inspector.from_engine(connection) + + def safe_create_unique_constraint(name, table, columns): + existing = [uc['name'] for uc in inspector.get_unique_constraints(table, schema=SCHEMA)] + if name not in existing: + op.create_unique_constraint(name, table, columns, schema=SCHEMA) + + def safe_drop_constraint(constraint, table, type_='foreignkey'): + constraints = [fk['name'] for fk in inspector.get_foreign_keys(table, schema=SCHEMA) if fk['name']] + if constraint in constraints: + op.drop_constraint(constraint, table, type_=type_, schema=SCHEMA) + + def safe_create_foreign_key( + constraint: str, + source_table: str, + referent_table: str, + local_cols: list[str], + remote_cols: list[str] + ): + existing_fks = [fk["name"] for fk in inspector.get_foreign_keys(source_table, schema=SCHEMA)] + if constraint not in existing_fks: + op.create_foreign_key( + constraint, + source_table, + referent_table, + local_cols, + remote_cols, + source_schema=SCHEMA, + referent_schema=SCHEMA + ) + + location_columns = [col["name"] for col in inspector.get_columns("locations", schema=SCHEMA)] + probe_md_columns = [col['name'] for col in inspector.get_columns('probe_metadata', schema=SCHEMA)] + if "uuid" not in location_columns: + op.add_column("locations", sa.Column("uuid", sa.String(36), nullable=True), schema=SCHEMA) + location_uuid_map = create_uuid_mapping( + connection, + 'locations', + ['location_id'] + ) + + # Update locations with UUIDs + for (location_id,), new_uuid in location_uuid_map.items(): + op.execute(f""" + UPDATE {SCHEMA}.locations + SET uuid = '{new_uuid}' + WHERE location_id = '{location_id}' + """) + + # Make locations UUID and name columns non-nullable + op.alter_column('locations', 'uuid', + existing_type=sa.String(36), + nullable=False, + schema=SCHEMA + ) + + safe_create_unique_constraint( + 'uq_locations_uuid', + 'locations', + ['uuid'] + ) + safe_drop_constraint('probe_metadata_location_id_fkey', 'probe_metadata') + if 'location_uuid' not in probe_md_columns: + op.add_column('probe_metadata', + sa.Column('location_uuid', sa.String(36), nullable=True), + schema=SCHEMA + ) + if 'location_id' in probe_md_columns: + op.execute(f""" + UPDATE {SCHEMA}.probe_metadata pm + SET location_uuid = l.uuid + FROM {SCHEMA}.locations l + WHERE pm.location_id = l.location_id + """) + safe_drop_constraint('locations_pkey', 'locations', type_='primary') + op.create_primary_key( + 'locations_pkey', + 'locations', + ['uuid'], + schema=SCHEMA + ) + safe_create_foreign_key( + 'probe_metadata_location_uuid_fkey', + 'probe_metadata', + 'locations', + ['location_uuid'], + ['uuid'], + ) + + if "public" not in location_columns: + op.add_column("locations", sa.Column("public", sa.Boolean, nullable=True), schema=SCHEMA) + + op.alter_column('locations', 'name', + existing_type=sa.Text, + nullable=False, + schema=SCHEMA + ) + + safe_create_unique_constraint( + 'uq_locations_name', + 'locations', + ['name'] + ) + + # Step 2: Handle test_metadata table + test_columns = [col["name"] for col in inspector.get_columns("test_metadata", schema=SCHEMA)] + + if 'uuid' not in test_columns: + op.add_column('test_metadata', + sa.Column('uuid', sa.String(36), nullable=True), + schema=SCHEMA + ) + + # Generate UUIDs for test_metadata + test_uuid_map = create_uuid_mapping( + connection, + 'test_metadata', + ['test_id'] + ) + + # Update test_metadata with UUIDs + for (test_id,), new_uuid in test_uuid_map.items(): + op.execute(f""" + UPDATE {SCHEMA}.test_metadata + SET uuid = '{new_uuid}' + WHERE test_id = '{test_id}' + """) + + # Make test_metadata UUID and name columns non-nullable + op.alter_column('test_metadata', 'uuid', + existing_type=sa.String(36), + nullable=False, + schema=SCHEMA + ) + + safe_drop_constraint('probe_metadata_test_id_fkey', 'probe_metadata') + + if 'test_uuid' not in probe_md_columns: + op.add_column('probe_metadata', + sa.Column('test_uuid', sa.String(36), nullable=True), + schema=SCHEMA + ) + + if 'test_id' in probe_md_columns: + op.execute(f""" + UPDATE {SCHEMA}.probe_metadata pm + SET test_uuid = t.uuid + FROM {SCHEMA}.test_metadata t + WHERE pm.test_id = t.test_id + """) + + safe_drop_constraint('test_metadata_pkey', 'test_metadata', type_='primary') + op.create_primary_key( + 'test_metadata_pkey', + 'test_metadata', + ['uuid'], + schema=SCHEMA + ) + safe_create_foreign_key( + 'probe_metadata_test_uuid_fkey', + 'probe_metadata', + 'test_metadata', + ['test_uuid'], + ['uuid'] + ) + + op.alter_column('test_metadata', 'name', + existing_type=sa.Text, + nullable=False, + schema=SCHEMA + ) + + safe_create_unique_constraint( + 'uq_test_metadata_uuid', + 'test_metadata', + ['uuid'] + ) + safe_create_unique_constraint( + 'uq_test_metadata_name', + 'test_metadata', + ['name'] + ) + + + op.drop_column('locations', 'location_id', schema=SCHEMA, if_exists=True) + op.drop_column('test_metadata', 'test_id', schema=SCHEMA, if_exists=True) + op.drop_column('probe_metadata', 'location_id', schema=SCHEMA, if_exists=True) + op.drop_column('probe_metadata', 'test_id', schema=SCHEMA, if_exists=True) + +def downgrade(): + logger.info("Downgrade is not supported for this migration.") \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/versions/2025_03_05_0958_add_grafana_user_access.py b/opensampl/server/migrations/_migrations/versions/2025_03_05_0958_add_grafana_user_access.py new file mode 100644 index 0000000..8850392 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_03_05_0958_add_grafana_user_access.py @@ -0,0 +1,28 @@ +"""add grafana user access + +Revision ID: ba4a99e5f745 +Revises: bd1322d0b00f +Create Date: 2025-03-05 09:58:44.110655 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ba4a99e5f745' +down_revision: Union[str, None] = 'bd1322d0b00f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("GRANT ALL ON SCHEMA castdb TO grafana;") + op.execute("GRANT SELECT ON ALL TABLES IN SCHEMA castdb TO grafana;") + op.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA castdb GRANT SELECT ON TABLES TO grafana;") + + +def downgrade() -> None: + pass diff --git a/opensampl/server/migrations/_migrations/versions/2025_03_26_0743_create_campus_view.py b/opensampl/server/migrations/_migrations/versions/2025_03_26_0743_create_campus_view.py new file mode 100644 index 0000000..eafb2f6 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_03_26_0743_create_campus_view.py @@ -0,0 +1,61 @@ +"""create campus view + +Revision ID: e881512e7a10 +Revises: ba4a99e5f745 +Create Date: 2025-03-26 07:43:04.981724 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e881512e7a10' +down_revision: Union[str, None] = 'ba4a99e5f745' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +""" +This particular migration is not needed in our public version, just for the ORNL cast system +""" + +def upgrade() -> None: + op.execute(""" +CREATE VIEW castdb.campus_locations AS +WITH ornl AS ( + SELECT * FROM castdb.locations l + WHERE l.name = 'Oak Ridge National Laboratory' +), + hvc AS ( + SELECT * FROM castdb.locations l + WHERE l.name = 'Hardin Valley Campus' + ) +SELECT + l.uuid, + l.name, + l.public, + CASE + WHEN l.name = hvc.name THEN ST_Y(ornl.geom :: geometry) + ELSE ST_Y(l.geom :: geometry) + END AS latitude, + CASE + WHEN l.name = hvc.name THEN ST_X(ornl.geom :: geometry) + ELSE ST_X(l.geom :: geometry) + END AS longitude, + CASE + WHEN l.name = hvc.name THEN ornl.name + ELSE l.name + END AS campus, + CASE + WHEN l.name = hvc.name THEN ornl.geom + ELSE l.geom + END AS geom +FROM castdb.locations l, hvc, ornl; + """) + op.execute('GRANT SELECT ON ALL TABLES IN SCHEMA castdb TO "grafana";') + + +def downgrade() -> None: + op.execute(sa.text("DROP VIEW IF EXISTS castdb.campus_locations CASCADE;")) diff --git a/opensampl/server/migrations/_migrations/versions/2025_04_14_1231_update_retention_policy.py b/opensampl/server/migrations/_migrations/versions/2025_04_14_1231_update_retention_policy.py new file mode 100644 index 0000000..d381f5c --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_04_14_1231_update_retention_policy.py @@ -0,0 +1,43 @@ +"""update retention policy + +Revision ID: 89ca5e16c662 +Revises: e881512e7a10 +Create Date: 2025-04-14 12:31:26.335799 + +""" +from typing import Sequence, Union +import os +from alembic import op +import sqlalchemy as sa +import re + +# revision identifiers, used by Alembic. +revision: str = '89ca5e16c662' +down_revision: Union[str, None] = 'e881512e7a10' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def sanitize_interval(value: str, fallback: str) -> str: + """ + Validate that the input string is a safe Postgres INTERVAL. + Fallback to a default if not valid. + """ + # Very basic pattern: number + space + unit (e.g., '7 days', '1 hour', etc.) + pattern = r"^\s*\d+\s+(second|minute|hour|day|week|month|year)s?\s*$" + if re.match(pattern, value.strip(), re.IGNORECASE): + return value.strip() + return fallback + +def upgrade() -> None: + chunk_interval = sanitize_interval(os.getenv("CHUNK_INTERVAL", ""), "1 day") + + # set chunks interval (1 hour was too small). Can be configured in ENV + op.execute(f"SELECT set_chunk_time_interval('castdb.probe_data', INTERVAL '{chunk_interval}');") + + + + +def downgrade(): + # Reset chunk interval to 1 hour + op.execute("SELECT set_chunk_time_interval('castdb.probe_data', INTERVAL '1 hour');") + diff --git a/opensampl/server/migrations/_migrations/versions/2025_04_22_1531_add_access_tokens.py b/opensampl/server/migrations/_migrations/versions/2025_04_22_1531_add_access_tokens.py new file mode 100644 index 0000000..41c3795 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_04_22_1531_add_access_tokens.py @@ -0,0 +1,33 @@ +"""add access tokens + +Revision ID: 4435cf3ed8eb +Revises: 89ca5e16c662 +Create Date: 2025-04-22 15:31:20.546256 + +""" +from typing import Sequence, Union +from datetime import datetime +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4435cf3ed8eb' +down_revision: Union[str, None] = '89ca5e16c662' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.create_table( + 'api_access_keys', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('key', sa.String(length=64), unique=True, nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, default=datetime.utcnow), + sa.Column('expires_at', sa.DateTime, nullable=True), + schema='access', + if_not_exists=True, + ) + +def downgrade(): + op.drop_table('api_access_keys', schema='access', if_exists=True) diff --git a/opensampl/server/migrations/_migrations/versions/2025_04_22_1650_turn_off_matviews.py b/opensampl/server/migrations/_migrations/versions/2025_04_22_1650_turn_off_matviews.py new file mode 100644 index 0000000..15f683f --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_04_22_1650_turn_off_matviews.py @@ -0,0 +1,45 @@ +"""turn off matviews + +Revision ID: 74df2bd60bb8 +Revises: 4435cf3ed8eb +Create Date: 2025-04-22 16:50:04.899162 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = '74df2bd60bb8' +down_revision: Union[str, None] = '4435cf3ed8eb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def has_update(): + conn = op.get_bind() + has_privilege = conn.execute(sa.text(""" + SELECT has_table_privilege(current_user, 'cron.job', 'UPDATE') AS has_update + """)).scalar() + return has_privilege + +def upgrade() -> None: + """ + It ends up being more than adequate to simply run the queries via grafana to generate time buckets. Their caching is good enough. + """ + + if not has_update(): + logger.warning("current user cannot update cron.job table to turn off.") + return + + op.execute("UPDATE cron.job SET active = false;") + + +def downgrade() -> None: + if not has_update(): + logger.warning("current user cannot update cron.job table to turn back on.") + return + + op.execute("UPDATE cron.job SET active = true;") diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1223_add_column_comments.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1223_add_column_comments.py new file mode 100644 index 0000000..040dc8b --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1223_add_column_comments.py @@ -0,0 +1,147 @@ +"""add column comments + +Revision ID: 07cf92bf4aa0 +Revises: 74df2bd60bb8 +Create Date: 2025-06-03 12:23:43.611971 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = '07cf92bf4aa0' +down_revision: Union[str, None] = '74df2bd60bb8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +def upgrade(): + """Add comments to all existing columns""" + + # Add comments to locations table + op.alter_column('locations', 'uuid', + comment="Auto generated primary key UUID for the location", + schema=SCHEMA) + op.alter_column('locations', 'name', + comment="Unique name identifying the location", + schema=SCHEMA) + op.alter_column('locations', 'geom', + comment="Geospatial point geometry (lat, lon, z)", + schema=SCHEMA) + op.alter_column('locations', 'public', + comment="Whether this location is publicly visible", + schema=SCHEMA) + + # Add comments to test_metadata table + op.alter_column('test_metadata', 'uuid', + comment="Auto generated primary key UUID for the test", + schema=SCHEMA) + op.alter_column('test_metadata', 'name', + comment="Unique name of the test", + schema=SCHEMA) + op.alter_column('test_metadata', 'start_date', + comment="Start timestamp of the test", + schema=SCHEMA) + op.alter_column('test_metadata', 'end_date', + comment="End timestamp of the test", + schema=SCHEMA) + + # Add comments to probe_metadata table + op.alter_column('probe_metadata', 'uuid', + comment="Auto generated primary key UUID for the probe metadata entry", + schema=SCHEMA) + op.alter_column('probe_metadata', 'probe_id', + comment="Interface ID of the probe device; can be multiple probes from the same ip_address", + schema=SCHEMA) + op.alter_column('probe_metadata', 'ip_address', + comment="IP address of the probe", + schema=SCHEMA) + op.alter_column('probe_metadata', 'vendor', + comment="Manufacturer/vendor of the probe", + schema=SCHEMA) + op.alter_column('probe_metadata', 'model', + comment="Model name/number of the probe", + schema=SCHEMA) + op.alter_column('probe_metadata', 'name', + comment="Human-readable name for the probe", + schema=SCHEMA) + op.alter_column('probe_metadata', 'public', + comment="Whether this probe is publicly visible", + schema=SCHEMA) + op.alter_column('probe_metadata', 'location_uuid', + comment="Foreign key to the associated location", + schema=SCHEMA) + op.alter_column('probe_metadata', 'test_uuid', + comment="Foreign key to the associated test", + schema=SCHEMA) + + # Add comments to probe_data table + op.alter_column('probe_data', 'time', + comment="Timestamp of the measurement", + schema=SCHEMA) + op.alter_column('probe_data', 'probe_uuid', + comment="Foreign key to the probe that collected the data", + schema=SCHEMA) + + # Add comments to adva_metadata table + op.alter_column('adva_metadata', 'probe_uuid', + comment="Foreign key to the associated probe", + schema=SCHEMA) + op.alter_column('adva_metadata', 'type', + comment="ADVA measurement type (eg Phase)", + schema=SCHEMA) + op.alter_column('adva_metadata', 'start', + comment="Start time for the current measurement series", + schema=SCHEMA) + op.alter_column('adva_metadata', 'frequency', + comment="Sampling frequency of the ADVA probe, in rate per second", + schema=SCHEMA) + op.alter_column('adva_metadata', 'timemultiplier', + comment="Time multiplier used by the ADVA tool", + schema=SCHEMA) + op.alter_column('adva_metadata', 'multiplier', + comment="Data scaling multiplier", + schema=SCHEMA) + + +def downgrade(): + """Remove comments from all columns""" + # Remove comments from locations table + op.alter_column('locations', 'uuid', comment=None, schema=SCHEMA) + op.alter_column('locations', 'name', comment=None, schema=SCHEMA) + op.alter_column('locations', 'geom', comment=None, schema=SCHEMA) + op.alter_column('locations', 'public', comment=None, schema=SCHEMA) + + # Remove comments from test_metadata table + op.alter_column('test_metadata', 'uuid', comment=None, schema=SCHEMA) + op.alter_column('test_metadata', 'name', comment=None, schema=SCHEMA) + op.alter_column('test_metadata', 'start_date', comment=None, schema=SCHEMA) + op.alter_column('test_metadata', 'end_date', comment=None, schema=SCHEMA) + + # Remove comments from probe_metadata table + op.alter_column('probe_metadata', 'uuid', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'probe_id', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'ip_address', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'vendor', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'model', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'name', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'public', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'location_uuid', comment=None, schema=SCHEMA) + op.alter_column('probe_metadata', 'test_uuid', comment=None, schema=SCHEMA) + + # Remove comments from probe_data table + op.alter_column('probe_data', 'time', comment=None, schema=SCHEMA) + op.alter_column('probe_data', 'probe_uuid', comment=None, schema=SCHEMA) + + # Remove comments from adva_metadata table + op.alter_column('adva_metadata', 'probe_uuid', comment=None, schema=SCHEMA) + op.alter_column('adva_metadata', 'type', comment=None, schema=SCHEMA) + op.alter_column('adva_metadata', 'start', comment=None, schema=SCHEMA) + op.alter_column('adva_metadata', 'frequency', comment=None, schema=SCHEMA) + op.alter_column('adva_metadata', 'timemultiplier', comment=None, schema=SCHEMA) + op.alter_column('adva_metadata', 'multiplier', comment=None, schema=SCHEMA) \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1235_create_reference_and_metric_tables.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1235_create_reference_and_metric_tables.py new file mode 100644 index 0000000..dce1cad --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1235_create_reference_and_metric_tables.py @@ -0,0 +1,162 @@ +"""create reference and metric tables + +Revision ID: d1546c1ecf9b +Revises: 07cf92bf4aa0 +Create Date: 2025-06-03 12:35:20.987981 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import uuid + +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = 'd1546c1ecf9b' +down_revision: Union[str, None] = '07cf92bf4aa0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +def upgrade(): + """Create reference system tables and populate with initial data""" + + # Create reference_type table + op.create_table('reference_type', + sa.Column('uuid', sa.String(length=36), nullable=False, primary_key=True, + comment="Auto generated primary key UUID for the reference type"), + sa.Column('name', sa.String(), nullable=False, unique=True, + comment="Unique name of the reference type (e.g., GPS, GNSS, Unknown)"), + sa.Column('description', sa.Text(), nullable=True, + comment="Optional human-readable description of the reference type"), + sa.Column('reference_table', sa.String(), nullable=True, + comment="Optional table name if the reference type is a compound type"), + schema=SCHEMA, + if_not_exists=True + ) + + # Create metric_type table + op.create_table('metric_type', + sa.Column('uuid', sa.String(length=36), nullable=False, primary_key=True, + comment="Auto generated primary key UUID for the metric type"), + sa.Column('name', sa.String(), nullable=True, unique=True, + comment="Unique name for the metric type (e.g., phase offset, delay, quality)"), + sa.Column('description', sa.Text(), nullable=True, + comment="Optional human-readable description of the metric"), + sa.Column('unit', sa.String(), nullable=False, + comment="Measurement unit (e.g., ns, s, ppm)"), + sa.Column('value_type', sa.String(), nullable=False, server_default='string', + comment="Data type of the value (e.g., float, int, string)"), + schema=SCHEMA, + if_not_exists=True + ) + + # Create reference table + op.create_table('reference', + sa.Column('uuid', sa.String(length=36), nullable=False, primary_key=True, + comment="Auto generated primary key UUID for the reference entry"), + sa.Column('reference_type_uuid', sa.String(length=36), sa.ForeignKey(f'{SCHEMA}.reference_type.uuid'), + comment="Foreign key to the reference type (e.g., GPS, GNSS, Probe)"), + sa.Column('compound_reference_uuid', sa.String(length=36), sa.ForeignKey(f'{SCHEMA}.probe_metadata.uuid'), nullable=True, + comment="Optional foreign key if the reference type is Compound. Which table it references is determined via reference_table field in reference_type table"), + schema=SCHEMA, + if_not_exists=True + ) + + # Populate reference_type table with initial values + reference_type_table = sa.table('reference_type', + sa.column('uuid', sa.String), + sa.column('name', sa.String), + sa.column('description', sa.Text), + sa.column('reference_table', sa.Text), + schema=SCHEMA + ) + + # Generate UUIDs for reference types + gps_ref_type_uuid = str(uuid.uuid4()) + gnss_ref_type_uuid = str(uuid.uuid4()) + probe_ref_type_uuid = str(uuid.uuid4()) + unknown_ref_type_uuid = str(uuid.uuid4()) + + op.bulk_insert(reference_type_table, [ + { + 'uuid': gps_ref_type_uuid, + 'name': 'GPS', + 'description': 'Global Positioning System time reference' + }, + { + 'uuid': gnss_ref_type_uuid, + 'name': 'GNSS', + 'description': 'Global Navigation Satellite System time reference' + }, + { + 'uuid': probe_ref_type_uuid, + 'name': 'PROBE', + 'description': 'Another probe device used as time reference', + 'reference_table': 'probe_metadata' + }, + { + 'uuid': unknown_ref_type_uuid, + 'name': 'UNKNOWN', + 'description': 'Unknown or unspecified reference type' + } + ]) + + reference_table = sa.table('reference', + sa.column('uuid', sa.String), + sa.column('reference_type_uuid', sa.String), + sa.column('compound_reference_uuid', sa.String), + schema=SCHEMA + ) + + unknown_ref_uuid = str(uuid.uuid4()) + + op.bulk_insert(reference_table, [ + { + 'uuid': unknown_ref_uuid, + 'reference_type_uuid': unknown_ref_type_uuid, + 'reference_probe_uuid': None + } + ]) + + # Populate metric_type table with initial values + metric_type_table = sa.table('metric_type', + sa.column('uuid', sa.String), + sa.column('name', sa.String), + sa.column('description', sa.Text), + sa.column('unit', sa.String), + sa.column('value_type', sa.String), + schema=SCHEMA + ) + + # Generate UUIDs for metric types + phase_offset_uuid = str(uuid.uuid4()) + unknown_metric_uuid = str(uuid.uuid4()) + + op.bulk_insert(metric_type_table, [ + { + 'uuid': phase_offset_uuid, + 'name': 'Phase Offset', + 'description': 'Difference in seconds between the probe\'s time reading and the reference time reading', + 'unit': 's', + 'value_type': 'float' + }, + { + 'uuid': unknown_metric_uuid, + 'name': 'UNKNOWN', + 'description': 'Unknown or unspecified metric type, with value_type of jsonb due to flexibility', + 'unit': 'unknown', + 'value_type': 'jsonb' + } + ]) + + +def downgrade(): + """Drop reference system tables""" + # Drop tables in reverse order due to foreign key constraints + op.drop_table('reference', schema=SCHEMA, if_exists=True) + op.drop_table('metric_type', schema=SCHEMA, if_exists=True) + op.drop_table('reference_type', schema=SCHEMA, if_exists=True) \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1254_update_probe_data_to_take_reference_and_.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1254_update_probe_data_to_take_reference_and_.py new file mode 100644 index 0000000..4c370b0 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1254_update_probe_data_to_take_reference_and_.py @@ -0,0 +1,167 @@ +"""update probe data to take reference and metric + +Revision ID: 519588f63e5c +Revises: d1546c1ecf9b +Create Date: 2025-06-03 12:54:47.183309 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = '519588f63e5c' +down_revision: Union[str, None] = 'd1546c1ecf9b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +time_buckets = [ + #suffix interval cron schedule (min hr * * *) + ('1min', '1 minute', '*/5 * * * *'), # Every 5 minutes + ('5min', '5 minutes', '*/5 * * * *'), # Every 5 minutes + ('15min', '15 minutes', '*/15 * * * *'), # Every 15 minutes + ('1hour', '1 hour', '0 * * * *'), # Start of every hour + ('6hour', '6 hours', '0 */6 * * *'), # Every 6 hours + ('1day', '1 day', '0 0 * * *') # Midnight every day +] + +def upgrade(): + """Update probe_data table structure""" + # Add new columns to probe_data table + op.add_column('probe_data', + sa.Column('reference_uuid', sa.String(length=36), nullable=True, + comment="Foreign key to the reference point for the reading"), + schema=SCHEMA, if_not_exists=True) + + op.add_column('probe_data', + sa.Column('metric_type_uuid', sa.String(length=36), nullable=True, + comment="Foreign key to the metric type being measured"), + schema=SCHEMA, if_not_exists=True) + + connection = op.get_bind() + + unknown_reference_uuid = connection.execute( + sa.text(""" + SELECT r.uuid FROM castdb.reference r + JOIN castdb.reference_type rt ON r.reference_type_uuid = rt.uuid + WHERE lower(rt.name) = lower('UNKNOWN') + """) + ).scalar() + + phase_offset_metric_uuid = connection.execute( + sa.text("SELECT uuid FROM castdb.metric_type WHERE lower(name) = lower('Phase Offset')") + ).scalar() + + # Populate new columns with UNKNOWN reference and phase offset metric for existing records + op.execute( + sa.text(""" + UPDATE castdb.probe_data + SET reference_uuid = :ref_uuid, + metric_type_uuid = :metric_uuid + WHERE reference_uuid IS NULL + OR metric_type_uuid IS NULL + """).bindparams(ref_uuid=unknown_reference_uuid, metric_uuid=phase_offset_metric_uuid) + ) + + # Make the new columns non-nullable now that they have values + op.alter_column('probe_data', 'reference_uuid', nullable=False, schema=SCHEMA) + op.alter_column('probe_data', 'metric_type_uuid', nullable=False, schema=SCHEMA) + + # Add foreign key constraints + op.create_foreign_key( + 'fk_probe_data_reference_uuid', 'probe_data', 'reference', + ['reference_uuid'], ['uuid'], source_schema=SCHEMA, referent_schema=SCHEMA + ) + + op.create_foreign_key( + 'fk_probe_data_metric_type_uuid', 'probe_data', 'metric_type', + ['metric_type_uuid'], ['uuid'], source_schema=SCHEMA, referent_schema=SCHEMA + ) + + # Before we can change the type of "value" we need to drop the mat views + for suffix, _, _ in time_buckets: + op.execute(f"DROP MATERIALIZED VIEW IF EXISTS castdb.avg_phase_err_{suffix} CASCADE;") + op.execute(f"DROP MATERIALIZED VIEW IF EXISTS castdb.mtie_{suffix} CASCADE;") + + pk_name = connection.execute( + sa.text(""" + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = :schema_name + AND table_name = 'probe_data' + AND constraint_type = 'PRIMARY KEY' + """).bindparams(schema_name=SCHEMA) + ).scalar() + + # Drop the old primary key constraint (whatever it's named) + if pk_name: + op.drop_constraint(pk_name, 'probe_data', type_='primary', schema=SCHEMA) + + # Create new primary key with all required columns + op.create_primary_key( + 'probe_data_pkey', 'probe_data', + ['time', 'probe_uuid', 'reference_uuid', 'metric_type_uuid'], + schema=SCHEMA + ) + + # Change value column from NUMERIC to JSONB (containing numeric value) + op.alter_column('probe_data', 'value', + type_=sa.dialects.postgresql.JSONB(), + postgresql_using='to_jsonb(value)', + comment="Measurement value stored as JSON; value's expected type defined via metric", + schema=SCHEMA) + +def downgrade(): + """Revert probe_data table structure changes""" + connection = op.get_bind() + + conflict_count = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM (SELECT TIME, probe_uuid + FROM castdb.probe_data + GROUP BY TIME, probe_uuid + HAVING COUNT (*) > 1) dupes + """)).scalar() + + if conflict_count > 0: + raise Exception("Unsafe downgrade: would violate original primary key due to duplicated time/probe_uuid") + + # Add back the old NUMERIC value column + op.add_column('probe_data', + sa.Column('value_old', sa.NUMERIC(), nullable=True), + schema=SCHEMA) + + # Copy JSONB values back to NUMERIC (this will lose non-numeric data!) + op.execute(""" + UPDATE castdb.probe_data + SET value_old = (value::text)::numeric + WHERE value IS NOT NULL AND jsonb_typeof(value) = 'number' + """) + + # Drop the JSONB value column + op.drop_column('probe_data', 'value', schema=SCHEMA) + + # Rename the old column back to 'value' + op.alter_column('probe_data', 'value_old', new_column_name='value', schema=SCHEMA) + + # Drop the new primary key + op.drop_constraint('probe_data_pkey', 'probe_data', type_='primary', schema=SCHEMA) + + # Recreate the old primary key + op.create_primary_key( + 'probe_data_pkey', 'probe_data', + ['time', 'probe_uuid'], + schema=SCHEMA + ) + + # Drop foreign key constraints + op.drop_constraint('fk_probe_data_reference_uuid', 'probe_data', type_='foreignkey', schema=SCHEMA) + op.drop_constraint('fk_probe_data_metric_type_uuid', 'probe_data', type_='foreignkey', schema=SCHEMA) + + # Drop the new columns + op.drop_column('probe_data', 'reference_uuid', schema=SCHEMA) + op.drop_column('probe_data', 'metric_type_uuid', schema=SCHEMA) \ No newline at end of file diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1318_adding_freeform_metadata_to_adva.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1318_adding_freeform_metadata_to_adva.py new file mode 100644 index 0000000..6b19e32 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1318_adding_freeform_metadata_to_adva.py @@ -0,0 +1,30 @@ +"""adding freeform metadata to adva + +Revision ID: 4b47485da562 +Revises: 519588f63e5c +Create Date: 2025-06-03 13:18:34.256294 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = '4b47485da562' +down_revision: Union[str, None] = '519588f63e5c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA='castdb' + +def upgrade() -> None: + op.add_column('adva_metadata', + sa.Column('additional_metadata', sa.dialects.postgresql.JSONB(), nullable=True, + comment="Additional metadata found in the file headers that did not match existing columns"), + schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column('adva_metadata', 'additional_metadata', schema=SCHEMA, if_exists=True) diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1358_setting_default_reference_and_metric.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1358_setting_default_reference_and_metric.py new file mode 100644 index 0000000..6b63230 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1358_setting_default_reference_and_metric.py @@ -0,0 +1,97 @@ +"""setting default reference and metric + +Revision ID: 90e87a6293f7 +Revises: 4b47485da562 +Create Date: 2025-06-03 13:58:13.297062 + +""" +from typing import Sequence, Union +import os +from alembic import op +import sqlalchemy as sa + +from loguru import logger + +# revision identifiers, used by Alembic. +revision: str = '90e87a6293f7' +down_revision: Union[str, None] = '4b47485da562' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +def upgrade() -> None: + # Create the default_table + op.create_table( + 'defaults', + sa.Column('table_name', sa.Text, primary_key=True, comment='Name of the table/category this entry belongs to'), + sa.Column('uuid', sa.String(36), nullable=False, comment='UUID reference resolved from name_value'), + schema=SCHEMA, + if_not_exists=True, + ) + + # 2. Function to get default UUID + op.execute(sa.text(f""" + CREATE OR REPLACE FUNCTION get_default_uuid_for(table_arg TEXT) + RETURNS UUID AS $$ + DECLARE + result UUID; + BEGIN + SELECT uuid INTO result + FROM {SCHEMA}.defaults + WHERE table_name = table_arg; + + IF result IS NULL THEN + RAISE EXCEPTION 'No default UUID found for table: %', table_arg; + END IF; + + RETURN result; + END; + $$ LANGUAGE plpgsql; + """)) + + # 3. Function to set default UUID by name + op.execute(sa.text(f""" + CREATE OR REPLACE FUNCTION set_default_by_name( + table_arg TEXT, + name_value TEXT + ) + RETURNS VOID AS $$ + DECLARE + id UUID; + schema_name TEXT := '{SCHEMA}'; + sql TEXT; + BEGIN + -- Use format with two %I to quote both schema and table names + sql := format('SELECT uuid FROM %I.%I WHERE lower(name) = lower($1) LIMIT 1', + schema_name, table_arg); + EXECUTE sql INTO id USING name_value; + + IF id IS NULL THEN + RAISE EXCEPTION 'No row found in %.% with name = %', schema_name, table_arg, name_value; + END IF; + + INSERT INTO "{SCHEMA}"."defaults" (table_name, uuid) + VALUES (table_arg, id) + ON CONFLICT (table_name) DO UPDATE + SET uuid = EXCLUDED.uuid; + END; + $$ LANGUAGE plpgsql; + """)) + + # Set the defaults + op.execute(sa.text(f"""SELECT set_default_by_name('metric_type', 'Phase Offset')""")) + op.execute(sa.text(f"""SELECT set_default_by_name('reference_type', 'UNKNOWN')""")) + + op.execute(sa.text(f""" + INSERT INTO "{SCHEMA}"."defaults" (table_name, uuid) + VALUES ('reference', get_default_uuid_for('reference_type')) + """)) + + +def downgrade() -> None: + op.execute(sa.text(""" + DROP FUNCTION IF EXISTS set_default_by_name CASCADE; + DROP FUNCTION IF EXISTS get_default_uuid_for CASCADE; + """)) + op.drop_table('defaults', schema=SCHEMA, if_exists=True) diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_03_1539_making_default_trigger_functions.py b/opensampl/server/migrations/_migrations/versions/2025_06_03_1539_making_default_trigger_functions.py new file mode 100644 index 0000000..4578b58 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_03_1539_making_default_trigger_functions.py @@ -0,0 +1,55 @@ +"""making default trigger functions + +Revision ID: 94f32a76726e +Revises: 90e87a6293f7 +Create Date: 2025-06-03 15:39:41.048401 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '94f32a76726e' +down_revision: Union[str, None] = '90e87a6293f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(sa.text(""" + -- Trigger function to set default values for probe_data table + CREATE OR REPLACE FUNCTION set_probe_data_defaults() + RETURNS TRIGGER AS $$ + BEGIN + -- Set default reference_uuid if not provided + IF NEW.reference_uuid IS NULL THEN + NEW.reference_uuid := get_default_uuid_for('reference'); + END IF; + + -- Set default metric_type_uuid if not provided + IF NEW.metric_type_uuid IS NULL THEN + NEW.metric_type_uuid := get_default_uuid_for('metric_type'); + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- Create the trigger that fires before INSERT or UPDATE + CREATE TRIGGER probe_data_set_defaults + BEFORE INSERT ON castdb.probe_data + FOR EACH ROW + EXECUTE FUNCTION set_probe_data_defaults(); + """)) + + +def downgrade() -> None: + op.execute(sa.text( + """ + DROP TRIGGER IF EXISTS probe_data_set_defaults ON castdb.probe_data; + DROP FUNCTION IF EXISTS set_probe_data_defaults; + """ + )) diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_23_0825_data_filtering_functions.py b/opensampl/server/migrations/_migrations/versions/2025_06_23_0825_data_filtering_functions.py new file mode 100644 index 0000000..6aa70a9 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_23_0825_data_filtering_functions.py @@ -0,0 +1,103 @@ +"""data filtering functions + +Revision ID: c73212f2c0dd +Revises: 94f32a76726e +Create Date: 2025-06-23 08:25:44.638142 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c73212f2c0dd' +down_revision: Union[str, None] = '94f32a76726e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(sa.text(""" + -- Function to filter probe data by probe UUID + CREATE OR REPLACE FUNCTION get_probe_data_by_probe(probe_uuid_param TEXT) + RETURNS TABLE( + "time" TIMESTAMP, + probe_uuid VARCHAR(36), + reference_uuid VARCHAR(36), + metric_type_uuid VARCHAR(36), + value JSONB + ) AS $$ + BEGIN + RETURN QUERY + SELECT + pd.time, + pd.probe_uuid, + pd.reference_uuid, + pd.metric_type_uuid, + pd.value + FROM castdb.probe_data pd + WHERE pd.probe_uuid = probe_uuid_param + ORDER BY pd.time; + END; + $$ LANGUAGE plpgsql; + + -- Function to filter probe data by metric type UUID + CREATE OR REPLACE FUNCTION get_probe_data_by_metric(metric_type_uuid_param TEXT) + RETURNS TABLE( + "time" TIMESTAMP, + probe_uuid VARCHAR(36), + reference_uuid VARCHAR(36), + metric_type_uuid VARCHAR(36), + value JSONB + ) AS $$ + BEGIN + RETURN QUERY + SELECT + pd.time, + pd.probe_uuid, + pd.reference_uuid, + pd.metric_type_uuid, + pd.value + FROM castdb.probe_data pd + WHERE pd.metric_type_uuid = metric_type_uuid_param + ORDER BY pd.time; + END; + $$ LANGUAGE plpgsql; + + -- Function to filter probe data by both probe UUID and metric type UUID + CREATE OR REPLACE FUNCTION get_probe_data_by_probe_and_metric( + probe_uuid_param TEXT, + metric_type_uuid_param TEXT + ) + RETURNS TABLE( + "time" TIMESTAMP, + probe_uuid VARCHAR(36), + reference_uuid VARCHAR(36), + metric_type_uuid VARCHAR(36), + value JSONB + ) AS $$ + BEGIN + RETURN QUERY + SELECT + pd.time, + pd.probe_uuid, + pd.reference_uuid, + pd.metric_type_uuid, + pd.value + FROM castdb.probe_data pd + WHERE pd.probe_uuid = probe_uuid_param + AND pd.metric_type_uuid = metric_type_uuid_param + ORDER BY pd.time; + END; + $$ LANGUAGE plpgsql; + """)) + + +def downgrade() -> None: + op.execute(sa.text(""" + DROP FUNCTION IF EXISTS get_probe_data_by_probe; + DROP FUNCTION IF EXISTS get_probe_data_by_metric; + DROP FUNCTION IF EXISTS get_probe_data_by_probe_and_metric; + """)) diff --git a/opensampl/server/migrations/_migrations/versions/2025_06_23_1654_making_microsemi_table.py b/opensampl/server/migrations/_migrations/versions/2025_06_23_1654_making_microsemi_table.py new file mode 100644 index 0000000..169c404 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_06_23_1654_making_microsemi_table.py @@ -0,0 +1,73 @@ +"""making microsemi table + +Revision ID: c45e2dbdf900 +Revises: c73212f2c0dd +Create Date: 2025-06-23 16:54:41.381184 + +""" +from typing import Sequence, Union +from sqlalchemy.dialects import postgresql + +from alembic import op +import sqlalchemy as sa +import uuid + +# revision identifiers, used by Alembic. +revision: str = 'c45e2dbdf900' +down_revision: Union[str, None] = 'c73212f2c0dd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA='castdb' + +def upgrade(): + op.create_table( + 'microchip_twst_metadata', + sa.Column( + 'probe_uuid', + sa.String(), + sa.ForeignKey('castdb.probe_metadata.uuid', ondelete='CASCADE'), + primary_key=True, + comment='Foreign key to the associated probe' + ), + sa.Column( + 'additional_metadata', + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment='Additional metadata found in the file headers that did not match existing columns' + ), + comment='Microchip TWST Clock Probe specific metadata provided by probe text file exports.', + schema='castdb', + if_not_exists=True, + ) + + metric_type_table = sa.table('metric_type', + sa.column('uuid', sa.String), + sa.column('name', sa.String), + sa.column('description', sa.Text), + sa.column('unit', sa.String), + sa.column('value_type', sa.String), + schema=SCHEMA + ) + + # Generate UUIDs for metric types + ebno_uuid = str(uuid.uuid4()) + + op.bulk_insert(metric_type_table, [ + { + 'uuid': ebno_uuid, + 'name': 'Eb/No', + 'description': ( + "Energy per bit to noise power spectral density ratio measured at the clock probe. " + "Indicates the quality of the received signal relative to noise."), + 'unit': 'dB', + 'value_type': 'float' + } + ]) + + +def downgrade(): + op.drop_table('microsemi_twst_metadata', schema='castdb', if_exists=True) + + op.execute(sa.text("DELETE FROM castdb.metric_type WHERE name = 'Eb/No'")) + diff --git a/opensampl/server/migrations/_migrations/versions/2025_08_15_0840_create_microchip_tp400_metadata.py b/opensampl/server/migrations/_migrations/versions/2025_08_15_0840_create_microchip_tp400_metadata.py new file mode 100644 index 0000000..2b16d7d --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_08_15_0840_create_microchip_tp400_metadata.py @@ -0,0 +1,44 @@ +"""create microchip tp400 metadata + +Revision ID: 2e2b5c419a9b +Revises: c45e2dbdf900 +Create Date: 2025-08-15 08:40:34.520515 + +""" +from typing import Sequence, Union +from sqlalchemy.dialects import postgresql + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '2e2b5c419a9b' +down_revision: Union[str, None] = 'c45e2dbdf900' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'microchip_tp4100_metadata', + sa.Column( + 'probe_uuid', + sa.String(), + sa.ForeignKey('castdb.probe_metadata.uuid', ondelete='CASCADE'), + primary_key=True, + comment='Foreign key to the associated probe' + ), + sa.Column( + 'additional_metadata', + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment='Additional metadata found in the file headers that did not match existing columns' + ), + comment='Microchip TP4100 Clock Probe specific metadata provided by probe text file exports.', + schema='castdb', + if_not_exists=True, + ) + +def downgrade() -> None: + op.drop_table('microchip_tp4100_metadata', schema='castdb', if_exists=True) + diff --git a/opensampl/server/migrations/_migrations/versions/2025_09_22_0915_campus_view_not_forced.py b/opensampl/server/migrations/_migrations/versions/2025_09_22_0915_campus_view_not_forced.py new file mode 100644 index 0000000..13d5d8e --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2025_09_22_0915_campus_view_not_forced.py @@ -0,0 +1,65 @@ +"""campus view not forced + +Revision ID: d419cac01df2 +Revises: 2e2b5c419a9b +Create Date: 2025-09-22 09:15:53.973961 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd419cac01df2' +down_revision: Union[str, None] = '2e2b5c419a9b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" +CREATE OR REPLACE VIEW castdb.campus_locations AS +WITH ornl AS ( + SELECT l_1.uuid, l_1.name, l_1.geom, l_1.public + FROM castdb.locations l_1 + WHERE l_1.name = 'Oak Ridge National Laboratory' +), +hvc AS ( + SELECT l_1.uuid, l_1.name, l_1.geom, l_1.public + FROM castdb.locations l_1 + WHERE l_1.name = 'Hardin Valley Campus' +) +SELECT + l.uuid, + l.name, + l.public, + CASE + WHEN l.name = hvc.name AND ornl.geom IS NOT NULL + THEN ST_Y(ornl.geom::geometry) + ELSE ST_Y(l.geom::geometry) + END AS latitude, + CASE + WHEN l.name = hvc.name AND ornl.geom IS NOT NULL + THEN ST_X(ornl.geom::geometry) + ELSE ST_X(l.geom::geometry) + END AS longitude, + CASE + WHEN l.name = hvc.name AND ornl.name IS NOT NULL + THEN ornl.name + ELSE l.name + END AS campus, + CASE + WHEN l.name = hvc.name AND ornl.geom IS NOT NULL + THEN ornl.geom + ELSE l.geom + END AS geom +FROM castdb.locations l +LEFT JOIN hvc ON TRUE +LEFT JOIN ornl ON TRUE; +""") + + +def downgrade() -> None: + pass diff --git a/opensampl/server/migrations/_migrations/versions/2026_04_17_1243_add_ntp_values.py b/opensampl/server/migrations/_migrations/versions/2026_04_17_1243_add_ntp_values.py new file mode 100644 index 0000000..4cd6b14 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2026_04_17_1243_add_ntp_values.py @@ -0,0 +1,159 @@ +"""add ntp values + +Revision ID: 5665e5902905 +Revises: d419cac01df2 +Create Date: 2026-04-17 12:43:23.711453 + +""" +from typing import Sequence, Union +import uuid +from sqlalchemy.dialects import postgresql +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5665e5902905' +down_revision: Union[str, None] = 'd419cac01df2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +def upgrade() -> None: + op.create_table( + "ntp_metadata", + sa.Column( + "probe_uuid", + sa.String(), + sa.ForeignKey(f"{SCHEMA}.probe_metadata.uuid"), + primary_key=True, + nullable=False, + ), + sa.Column("mode", sa.Text(), nullable=True), + sa.Column( + "reference", + sa.Boolean(), + nullable=True, + comment="Is used as a reference for other probes", + ), + sa.Column("target_host", sa.Text(), nullable=True), + sa.Column("target_port", sa.Integer(), nullable=True), + sa.Column("sync_status", sa.Text(), nullable=True), + sa.Column("leap_status", sa.Text(), nullable=True), + sa.Column("reference_id", sa.Text(), nullable=True), + sa.Column("observation_sources", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("collection_id", sa.Text(), nullable=True), + sa.Column("collection_ip", sa.Text(), nullable=True), + sa.Column("timeout", sa.Float(), nullable=True), + sa.Column("additional_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + schema=SCHEMA, + if_not_exists=True, + comment="NTP Clock Probe specific metadata" + ) + + metric_type_table = sa.table('metric_type', + sa.column('uuid', sa.String), + sa.column('name', sa.String), + sa.column('description', sa.Text), + sa.column('unit', sa.String), + sa.column('value_type', sa.String), + schema=SCHEMA + ) + new_metrics = [ + dict(uuid=str(uuid.uuid4()), + name="Delay", + description=( + "Round-trip delay (RTD) or Round-Trip Time (RTT). The time in seconds it takes for a data signal to " + "travel from a source to a destination and back, including acknowledgement." + ), + unit="s", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="Jitter", + description=("Jitter or offset variation in delay in seconds. Represents inconsistent response times."), + unit="s", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="Stratum", + description=( + 'Stratum level. Hierarchical layer defining the distance (or "hops") between device and reference.' + ), + unit="level", + value_type='int', + ), + dict(uuid=str(uuid.uuid4()), + name="Reachability", + description=( + "Reachability register (0-255) as a scalar for plotting. Ability of a source node to communicate " + "with a target node." + ), + unit="count", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="Dispersion", + description="Uncertainty in a clock's time relative to its reference source in seconds", + unit="s", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="NTP Root Delay", + description=( + "Total round-trip network delay from the local system" + " all the way to the primary reference clock (stratum 0)" + ), + unit="s", + value_type='float' + ), + dict(uuid=str(uuid.uuid4()), + name="NTP Root Dispersion", + description="The total accumulated clock uncertainty from the local system back to the primary reference clock", + unit="s", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="Poll Interval", + description="Time between requests sent to a time server in seconds", + unit="s", + value_type='float', + ), + dict(uuid=str(uuid.uuid4()), + name="Sync Health", + description="1.0 if synchronized/healthy, 0.0 otherwise (probe-defined)", + unit="ratio", + value_type='float', + ) + ] + op.bulk_insert(metric_type_table, new_metrics) + + + + +def downgrade() -> None: + op.drop_table('ntp_metadata', schema=SCHEMA, if_exists=True) + metric_type = sa.sql.table( + "metric_type", + sa.column("name", sa.String), + schema=SCHEMA, + ) + + op.execute( + metric_type.delete().where( + metric_type.c.name.in_( + [ + "Delay", + "Jitter", + "Stratum", + "Reachability", + "Dispersion", + "NTP Root Delay", + "NTP Root Dispersion", + "Poll Interval", + "Sync Health", + ] + ) + ) + ) diff --git a/opensampl/server/migrations/_migrations/versions/2026_04_17_1254_add_reference_view.py b/opensampl/server/migrations/_migrations/versions/2026_04_17_1254_add_reference_view.py new file mode 100644 index 0000000..8cbb326 --- /dev/null +++ b/opensampl/server/migrations/_migrations/versions/2026_04_17_1254_add_reference_view.py @@ -0,0 +1,56 @@ +"""add reference view + +Revision ID: c95e49e551be +Revises: 5665e5902905 +Create Date: 2026-04-17 12:54:27.037125 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c95e49e551be' +down_revision: Union[str, None] = '5665e5902905' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = 'castdb' + +CREATE_VIEW_SQL = f""" +CREATE VIEW {SCHEMA}.reference_probe_metadata +AS WITH probe_references AS ( + SELECT r.uuid, + r.reference_type_uuid, + r.compound_reference_uuid + FROM {SCHEMA}.reference r + JOIN {SCHEMA}.reference_type rt ON r.reference_type_uuid::text = rt.uuid::text + WHERE rt.name::text = 'PROBE'::text + ) + SELECT pm.uuid, + pm.probe_id, + pm.ip_address, + pm.vendor, + pm.model, + pm.name, + pm.public, + pm.location_uuid, + pm.test_uuid, + pr.uuid AS reference_uuid + FROM probe_references pr + JOIN {SCHEMA}.probe_metadata pm ON pr.compound_reference_uuid::text = pm.uuid::text; +""" + +DROP_VIEW_SQL = f""" +DROP VIEW IF EXISTS {SCHEMA}.reference_probe_metadata""" + +def upgrade() -> None: + # Drop the view first, just to be extra safe. + op.execute(DROP_VIEW_SQL) + op.execute(CREATE_VIEW_SQL) + + +def downgrade() -> None: + op.execute(DROP_VIEW_SQL) diff --git a/opensampl/server/migrations/alembic.ini b/opensampl/server/migrations/alembic.ini new file mode 100644 index 0000000..891ca1d --- /dev/null +++ b/opensampl/server/migrations/alembic.ini @@ -0,0 +1,114 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = _migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/opensampl/vendors/adva.py b/opensampl/vendors/adva.py index 26a6f8a..1d6ed36 100644 --- a/opensampl/vendors/adva.py +++ b/opensampl/vendors/adva.py @@ -5,7 +5,7 @@ import re from datetime import datetime, timezone from pathlib import Path -from typing import ClassVar, TextIO, Union +from typing import ClassVar, TextIO import click import pandas as pd @@ -13,12 +13,13 @@ from pydantic import Field from opensampl.metrics import METRICS +from opensampl.mixins.random_data import RandomDataMixin from opensampl.references import REF_TYPES from opensampl.vendors.base_probe import BaseProbe from opensampl.vendors.constants import VENDORS, ProbeKey -class AdvaProbe(BaseProbe): +class AdvaProbe(BaseProbe, RandomDataMixin): """ADVA Probe Object""" timestamp: datetime @@ -32,7 +33,7 @@ class AdvaProbe(BaseProbe): r"(?P\d+)-(?P\d+)-(?P\d+)\.txt(?:\.gz)?" ) - class RandomDataConfig(BaseProbe.RandomDataConfig): + class RandomDataConfig(RandomDataMixin.RandomDataConfig): """Model for storing random data generation configurations as provided by CLI or YAML""" # Time series parameters @@ -62,9 +63,9 @@ def get_random_data_cli_options(cls) -> list: ] return base_options + vendor_options - def __init__(self, input_file: Union[str, Path]): + def __init__(self, input_file: str | Path, **kwargs: dict): """Initialize AdvaProbe object give input_file and determines probe identity from filename""" - super().__init__(input_file=input_file) + super().__init__(input_file=input_file, **kwargs) self.probe_key, self.timestamp = self.parse_file_name(self.input_file) @classmethod @@ -94,7 +95,7 @@ def parse_file_name(cls, file_name: Path) -> tuple[ProbeKey, datetime]: return ProbeKey(probe_id=probe_id, ip_address=ip_address), timestamp raise ValueError(f"Could not parse file name {file_name} into probe key and timestamp for ADVA probe") - def _open_file(self) -> Union[TextIO, gzip.GzipFile]: + def _open_file(self) -> TextIO | gzip.GzipFile: """Open the input file, handling both .txt and .txt.gz formats""" if self.input_file.name.endswith(".gz"): return gzip.open(self.input_file, "rt") diff --git a/opensampl/vendors/base_probe.py b/opensampl/vendors/base_probe.py index eaa8948..320af96 100644 --- a/opensampl/vendors/base_probe.py +++ b/opensampl/vendors/base_probe.py @@ -1,31 +1,35 @@ """Abstract probe Base which provides scaffolding for vendor specific implementation""" -import random +from __future__ import annotations + import shutil from abc import ABC, abstractmethod -from collections.abc import Generator +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable, ClassVar, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar import click -import numpy as np -import pandas as pd import psycopg2.errors import requests import requests.exceptions -import yaml from loguru import logger -from pydantic import BaseModel, ValidationInfo, field_serializer, field_validator, model_validator +from pydantic import BaseModel from sqlalchemy.exc import IntegrityError from tqdm import tqdm from opensampl.load_data import load_probe_metadata, load_time_data from opensampl.metrics import METRICS, MetricType -from opensampl.references import ReferenceType -from opensampl.vendors.constants import ProbeKey, VendorType + +if TYPE_CHECKING: + from collections.abc import Generator + + import pandas as pd + + from opensampl.references import ReferenceType + from opensampl.vendors.constants import ProbeKey, VendorType T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) @@ -68,12 +72,12 @@ def __init__(self, func: F) -> None: """ self.func: F = func - self.__doc__: Optional[str] = func.__doc__ + self.__doc__: str | None = func.__doc__ fname = getattr(func, "__name__", None) self.__name__: str = fname self.__qualname__: str = getattr(func, "__qualname__", fname) - def __get__(self, obj: Optional[T], cls: type[T]) -> Callable[..., Any]: + def __get__(self, obj: T | None, cls: type[T]) -> Callable[..., Any]: """ Descriptor protocol method that returns the appropriate bound method. @@ -130,99 +134,33 @@ class LoadConfig(BaseModel): metadata: bool = False time_data: bool = False max_workers: int = 4 - chunk_size: Optional[int] = None + chunk_size: int | None = None show_progress: bool = False class BaseProbe(ABC): """BaseProbe abstract object""" - input_file: Path - probe_key: ProbeKey vendor: ClassVar[VendorType] - chunk_size: Optional[int] = None - metadata_parsed: bool = False - - class RandomDataConfig(BaseModel): - """Model for storing random data generation configurations as provided by CLI or YAML""" - - # General configuration - num_probes: int = 1 - duration_hours: float = 1.0 - seed: Optional[int] = None - - # Time series parameters - sample_interval: float = 1 - - base_value: float - noise_amplitude: float - drift_rate: float - outlier_probability: float = 0.01 - outlier_multiplier: float = 10.0 - - # Start time (computed at runtime if None) - start_time: Optional[datetime] = None - - probe_id: Union[str, None] = None - probe_ip: Optional[str] = None - - @classmethod - def _generate_random_ip(cls) -> str: - """Generate a random IP address.""" - ip_parts = [random.randint(1, 254) for _ in range(4)] - return ".".join(map(str, ip_parts)) - - @model_validator(mode="after") - def define_start_time(self): - """If start_time is None at the end of validation,""" - if self.start_time is None: - self.start_time = datetime.now(tz=timezone.utc) - timedelta(hours=self.duration_hours) - return self - - @field_validator("*", mode="before") - @classmethod - def replace_none_with_default(cls, v: Any, info: ValidationInfo) -> Any: - """If field provided with None replace with default""" - if v is None and info.field_name != "start_time": - field_info = cls.model_fields.get(info.field_name) - # fall back to the field default - return field_info.default_factory() if field_info.default_factory else field_info.default - return v - - @field_serializer("start_time") - def start_time_to_str(self, start_time: datetime) -> str: - """Convert start_time to string when dumping the model""" - return start_time.strftime("%Y/%m/%d %H:%M:%S") - - def generate_time_series(self): - """Generate a realistic time series with drift, noise, and occasional outliers.""" - total_seconds = self.duration_hours * 3600 - num_samples = int(total_seconds / self.sample_interval) - - time_points = [] - values = [] - for i in range(num_samples): - sample_time = self.start_time + timedelta(seconds=i * self.sample_interval) - time_points.append(sample_time) - - # Generate value with drift and noise - time_offset = i * self.sample_interval - drift_component = self.drift_rate * time_offset - noise_component = np.random.normal(0, self.noise_amplitude) - value = self.base_value + drift_component + noise_component - - # Add occasional outliers for realism - if random.random() < self.outlier_probability: - value += np.random.normal(0, self.noise_amplitude * self.outlier_multiplier) - - values.append(value) - - return pd.DataFrame({"time": time_points, "value": values}) + + def __init__( + self, + input_file: str | Path | None = None, + probe_key: ProbeKey | None = None, + chunk_size: int | None = None, + **kwargs: dict, + ): + """Initialize probe given input file""" + self.input_file: Path | None = Path(input_file) if input_file else None + self.probe_key: ProbeKey = probe_key + self.chunk_size: int | None = chunk_size + self.metadata: dict = {} | kwargs + + self.metadata_parsed: bool = False @classmethod - @property def help_str(cls) -> str: - """Defines the help string for use in the CLI.""" + """Help string for use in the CLI.""" return ( f"Processes a file or directory to load {cls.__name__} metadata and/or time series data.\n\n" "By default, both metadata and time series data are processed. " @@ -285,102 +223,6 @@ def get_cli_options(cls) -> list[Callable]: click.pass_context, ] - @classmethod - def get_random_data_cli_options(cls) -> list[Callable]: - """Return the click options for random data generation.""" - return [ - click.option( - "--config", - "-c", - type=click.Path(exists=True, path_type=Path), - help="YAML configuration file for random data generation settings", - ), - click.option( - "--num-probes", - type=int, - default=1, - help=( - f"Number of probes to generate data for " - f"(default={cls.RandomDataConfig.model_fields.get('num_probes').default})" - ), - ), - click.option( - "--duration", - type=float, - help=( - f"Duration of data in hours " - f"(default={cls.RandomDataConfig.model_fields.get('duration_hours').default})" - ), - ), - click.option( - "--seed", - type=int, # type: ignore[attr-defined] - help=( - f"Random seed for reproducible results " - f"(default={cls.RandomDataConfig.model_fields.get('seed').default})" - ), - ), - click.option( - "--sample-interval", - type=float, - help=( - f"Sample interval in seconds " - f"(default={cls.RandomDataConfig.model_fields.get('sample_interval').default})" - ), - ), - click.option( - "--base-value", - type=float, - help=( - f"Base value for time offset measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('base_value').description!s})" - ), - ), - click.option( - "--noise-amplitude", - type=float, - help=( - f"Noise amplitude/standard deviation for time offset measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('noise_amplitude').description!s})" - ), - ), - click.option( - "--drift-rate", - type=float, - help=( - f"Linear drift rate per second for time offset measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('drift_rate').description!s})" - ), - ), - click.option( - "--outlier-probability", - type=float, - default=0.01, - help=( - f"Probability of outliers per sample " - f"(default = {cls.RandomDataConfig.model_fields.get('outlier_probability').default!s})" - ), - ), - click.option( - "--outlier-multiplier", - type=float, - default=10.0, - help=( - f"Multiplier for outlier noise amplitude " - f"(default = {cls.RandomDataConfig.model_fields.get('outlier_multiplier').default!s})" - ), - ), - click.option( - "--probe-ip", - type=str, - help=( - "The ip_address you want the random data to show up under. " - "Randomly generated for each probe if left empty" - ), - ), - click.pass_context, - ] - @classmethod def process_single_file( # noqa: PLR0912, C901 cls, @@ -389,14 +231,13 @@ def process_single_file( # noqa: PLR0912, C901 time_data: bool, archive_dir: Path, no_archive: bool, - chunk_size: Optional[int] = None, - pbar: Optional[Union[tqdm, DummyTqdm]] = None, + chunk_size: int | None = None, + pbar: tqdm | DummyTqdm | None = None, **kwargs: dict, ) -> None: """Process a single file with the given options.""" try: - probe = cls(filepath, **kwargs) - probe.chunk_size = chunk_size + probe = cls(input_file=filepath, chunk_size=chunk_size, **kwargs) try: if metadata: logger.debug(f"Loading {cls.__name__} metadata from {filepath}") @@ -475,7 +316,7 @@ def get_cli_command(cls) -> Callable: def make_command(f: Callable) -> Callable: for option in reversed(cls.get_cli_options()): f = option(f) - return click.command(name=cls.vendor.name.lower(), help=cls.help_str)(f) + return click.command(name=cls.vendor.name.lower(), help=cls.help_str())(f) def load_callback(ctx: click.Context, **kwargs: dict) -> None: """Load probe data from file or directory.""" @@ -494,55 +335,6 @@ def load_callback(ctx: click.Context, **kwargs: dict) -> None: return make_command(load_callback) - @classmethod - def get_random_data_cli_command(cls) -> Callable: - """ - Create a click command that generates random test data. - - Returns - ------- - A click CLI command that generates random test data for this probe type. - - """ - - def make_command(f: Callable) -> Callable: - # Add vendor-specific options first, then base options - options = cls.get_random_data_cli_options() - - for option in reversed(options): - f = option(f) - return click.command(name=cls.vendor.name.lower(), help=f"Generate random test data for {cls.__name__}")(f) - - def random_data_callback(ctx: click.Context, **kwargs: dict) -> None: # noqa: ARG001 - """Generate random test data for this probe type.""" - try: - gen_config = cls._extract_random_data_config(kwargs) - probe_keys = [] - for i in range(gen_config.num_probes): - # Use different seeds for each probe if seed is provided - probe_config = gen_config.model_copy(deep=True) - if probe_config.seed is not None: - probe_config.seed += i - - probe_key = cls._generate_random_probe_key(probe_config, i) - - logger.info(f"Generating data for {cls.__name__} probe {i + 1}/{gen_config.num_probes}") - probe_key = cls.generate_random_data(probe_config, probe_key=probe_key) - probe_keys.append(probe_key) - - # Print summary - click.echo(f"\n=== Generated {len(probe_keys)} {cls.__name__} probes ===") - for probe_key in probe_keys: - click.echo(f" - {probe_key}") - - logger.info("Random test data generation completed successfully") - - except Exception as e: - logger.exception(f"Failed to generate test data: {e}") - raise click.Abort(f"Failed to generate test data: {e}") from e - - return make_command(random_data_callback) - @classmethod def _extract_load_config(cls, ctx: click.Context, kwargs: dict) -> LoadConfig: """ @@ -575,32 +367,6 @@ def _extract_load_config(cls, ctx: click.Context, kwargs: dict) -> LoadConfig: return config - @classmethod - def _extract_random_data_config(cls, kwargs: dict) -> RandomDataConfig: - """ - Extract and normalize CLI keyword arguments into a RandomDataConfig object. - - Args: - ---- - kwargs: Dictionary of keyword arguments passed to the CLI command - - Returns: - ------- - A RandomDataConfig object with all relevant parameters - - """ - # Load configuration from YAML file if provided - config_file = kwargs.pop("config", None) - if config_file: - config_data = cls._load_yaml_config(config_file) - # Merge config file data with CLI arguments (CLI args take precedence) - for key, value in config_data.items(): - if kwargs.get(key) is None: # Only use config value if CLI arg not provided - kwargs[key] = value - logger.info(f"Loaded configuration from {config_file}") - - return cls.RandomDataConfig(**kwargs) - @classmethod def _prepare_archive(cls, archive_dir: Path, no_archive: bool) -> None: """ @@ -694,21 +460,17 @@ def ip_address(self): """Return ip_address of probe""" return self.probe_key.ip_address - def __init__(self, input_file: str): - """Initialize probe given input file""" - self.input_file = Path(input_file) - @abstractmethod - def process_time_data(self) -> pd.DataFrame: + def process_time_data(self) -> None: """ - Process time series data. + Parse and load time series data from self.input_file. - Returns - ------- - pd.DataFrame: DataFrame with columns: + Use either send_time_data (which prefills METRICS.PHASE_OFFSET) + or send_data and provide alternative METRICS type. + Both require a df as follows: + pd.DataFrame with columns: - time (datetime64[ns]): timestamp for each measurement - value (float64): measured value at each timestamp - """ @dualmethod @@ -717,17 +479,17 @@ def send_data( data: pd.DataFrame, metric: MetricType, reference_type: ReferenceType, - compound_reference: Optional[dict[str, Any]] = None, - probe_key: Optional[ProbeKey] = None, + compound_reference: dict[str, Any] | None = None, + probe_key: ProbeKey | None = None, ) -> None: """Ingests data into the database""" - if isinstance(self, BaseProbe): + if isinstance(self, BaseProbe) and probe_key is None: probe_key = self.probe_key if probe_key is None: raise ValueError("send data must be called with probe_key if used as class method") - if self.chunk_size: + if hasattr(self, "chunk_size") and self.chunk_size: for chunk_start in range(0, len(data), self.chunk_size): chunk = data.iloc[chunk_start : chunk_start + self.chunk_size] load_time_data( @@ -747,7 +509,7 @@ def send_data( ) def send_time_data( - self, data: pd.DataFrame, reference_type: ReferenceType, compound_reference: Optional[dict[str, Any]] = None + self, data: pd.DataFrame, reference_type: ReferenceType, compound_reference: dict[str, Any] | None = None ): """ Ingests time data into the database @@ -770,138 +532,12 @@ def process_metadata(self) -> dict: """ - @classmethod - def _setup_random_seed(cls, seed: Optional[int]) -> None: - """Set up random seed for reproducible data generation.""" - if seed is not None: - random.seed(seed) - np.random.seed(seed) - - @classmethod - def _generate_random_ip(cls) -> str: - """Generate a random IP address.""" - ip_parts = [random.randint(1, 254) for _ in range(4)] - return ".".join(map(str, ip_parts)) - - @classmethod - def _generate_time_series( - cls, - start_time: datetime, - duration_hours: float, - sample_interval_seconds: float, - base_value: float, - noise_amplitude: float, - drift_rate: float = 0.0, - outlier_probability: float = 0.01, - outlier_multiplier: float = 10.0, - ) -> pd.DataFrame: - """ - Generate a realistic time series with drift, noise, and occasional outliers. - - Args: - start_time: Start timestamp for the data - duration_hours: Duration of data in hours - sample_interval_seconds: Time between samples in seconds - base_value: Base value around which to generate data - noise_amplitude: Standard deviation of random noise - drift_rate: Linear drift rate per second - outlier_probability: Probability of outliers per sample - outlier_multiplier: Multiplier for outlier noise amplitude - - Returns: - DataFrame with 'time' and 'value' columns - - """ - total_seconds = duration_hours * 3600 - num_samples = int(total_seconds / sample_interval_seconds) - - time_points = [] - values = [] - - for i in range(num_samples): - sample_time = start_time + timedelta(seconds=i * sample_interval_seconds) - time_points.append(sample_time) - - # Generate value with drift and noise - time_offset = i * sample_interval_seconds - drift_component = drift_rate * time_offset - noise_component = np.random.normal(0, noise_amplitude) - value = base_value + drift_component + noise_component - - # Add occasional outliers for realism - if random.random() < outlier_probability: - value += np.random.normal(0, noise_amplitude * outlier_multiplier) - - values.append(value) - - return pd.DataFrame({"time": time_points, "value": values}) - - @classmethod - def _load_yaml_config(cls, config_path: Path) -> dict[str, Any]: - """ - Load YAML configuration file for random data generation. - - Args: - config_path: Path to the YAML configuration file - - Returns: - Dictionary containing configuration parameters - - """ - try: - with config_path.open() as f: - config_data = yaml.safe_load(f) - except FileNotFoundError as e: - raise ValueError(f"Configuration file not found: {config_path}") from e - except yaml.YAMLError as e: - raise ValueError(f"Error parsing YAML configuration file {config_path}: {e}") from e - except Exception as e: - raise ValueError(f"Error loading configuration file {config_path}: {e}") from e - else: - # Validate that it's a dictionary - if not isinstance(config_data, dict): - raise TypeError(f"Configuration file {config_path} must contain a YAML dictionary") - - logger.debug(f"Loaded YAML config from {config_path}: {config_data}") - return config_data - @classmethod def _send_metadata_to_db(cls, probe_key: ProbeKey, metadata: dict) -> None: """Send metadata to the database.""" load_probe_metadata(vendor=cls.vendor, probe_key=probe_key, data=metadata) logger.debug(f"Sent metadata for probe {probe_key}") - @classmethod - @abstractmethod - def generate_random_data( - cls, - config: RandomDataConfig, - probe_key: ProbeKey, - ) -> ProbeKey: - """ - Generate random test data and send it directly to the database. - - Args: - probe_key: Probe key to use (generated if None) - config: RandomDataConfig with parameters specifying how to generate data - - Returns: - ProbeKey: The probe key used for the generated data - - """ - - @classmethod - def _generate_random_probe_key(cls, gen_config: RandomDataConfig, probe_index: int) -> ProbeKey: - ip_address = str(gen_config.probe_ip) if gen_config.probe_ip is not None else cls._generate_random_ip() - - if gen_config.probe_id is None: - probe_id = f"{1 + probe_index}" - elif isinstance(gen_config.probe_id, str): - probe_suffix = f"-{probe_index}" if probe_index > 0 else "" - probe_id = f"{gen_config.probe_id}{probe_suffix}" - - return ProbeKey(probe_id=probe_id, ip_address=ip_address) - def send_metadata(self): """Send metadata to database""" metadata = self.process_metadata() diff --git a/opensampl/vendors/constants.py b/opensampl/vendors/constants.py index 4725b05..b5b7bdd 100644 --- a/opensampl/vendors/constants.py +++ b/opensampl/vendors/constants.py @@ -70,6 +70,14 @@ class VENDORS: metadata_orm="MicrochipTP4100Metadata", ) + NTP = VendorType( + name="NTP", + parser_class="NtpProbe", + parser_module="ntp", + metadata_table="ntp_metadata", + metadata_orm="NtpMetadata", + ) + # --- CUSTOM VENDORS --- !! Do not remove line, used as reference when inserting vendor # --- VENDOR FUNCTIONS --- diff --git a/opensampl/vendors/microchip/tp4100.py b/opensampl/vendors/microchip/tp4100.py index 66a5345..e6d2f31 100644 --- a/opensampl/vendors/microchip/tp4100.py +++ b/opensampl/vendors/microchip/tp4100.py @@ -2,7 +2,7 @@ import random from pathlib import Path -from typing import ClassVar, Optional, Union +from typing import ClassVar import click import pandas as pd @@ -11,12 +11,13 @@ from pydantic import Field from opensampl.metrics import METRICS +from opensampl.mixins.random_data import RandomDataMixin from opensampl.references import REF_TYPES from opensampl.vendors.base_probe import BaseProbe from opensampl.vendors.constants import VENDORS, ProbeKey -class MicrochipTP4100Probe(BaseProbe): +class MicrochipTP4100Probe(BaseProbe, RandomDataMixin): """MicrochipTP4100 Probe Object""" vendor = VENDORS.MICROCHIP_TP4100 @@ -25,17 +26,17 @@ class MicrochipTP4100Probe(BaseProbe): } REFERENCES: ClassVar = {"GNSS": REF_TYPES.GNSS} - class RandomDataConfig(BaseProbe.RandomDataConfig): + class RandomDataConfig(RandomDataMixin.RandomDataConfig): """Model for storing random data generation configurations as provided by CLI or YAML""" # Time series parameters - base_value: Optional[float] = Field( + base_value: float | None = Field( default_factory=lambda: random.uniform(-5e-7, 5e-7), description="random.uniform(-5e-7, 5e-7)" ) - noise_amplitude: Optional[float] = Field( + noise_amplitude: float | None = Field( default_factory=lambda: random.uniform(1e-8, 5e-8), description="random.uniform(1e-8, 5e-8)" ) - drift_rate: Optional[float] = Field( + drift_rate: float | None = Field( default_factory=lambda: random.uniform(-1e-10, 1e-10), description="random.uniform(-1e-10, 1e-10)" ) @@ -58,9 +59,9 @@ def get_random_data_cli_options(cls) -> list: ] return base_options + vendor_options - def __init__(self, input_file: Union[str, Path]): + def __init__(self, input_file: str | Path, **kwargs: dict): """Initialize MicrochipTP4100 object given input_file and determines probe identity from file headers""" - super().__init__(input_file=input_file) + super().__init__(input_file=input_file, **kwargs) self.header = self.get_header() self.probe_key = ProbeKey( ip_address=self.header.get("host"), probe_id=self.header.get("probe_id", None) or "1-1" diff --git a/opensampl/vendors/microchip/twst.py b/opensampl/vendors/microchip/twst.py index e884e70..e9c094b 100644 --- a/opensampl/vendors/microchip/twst.py +++ b/opensampl/vendors/microchip/twst.py @@ -4,7 +4,7 @@ import re from datetime import timedelta from pathlib import Path -from typing import ClassVar, Optional, Union +from typing import ClassVar import click import numpy as np @@ -18,38 +18,39 @@ from opensampl.load_data import load_probe_metadata from opensampl.metrics import METRICS +from opensampl.mixins.random_data import RandomDataMixin from opensampl.references import REF_TYPES from opensampl.vendors.base_probe import BaseProbe from opensampl.vendors.constants import VENDORS, ProbeKey -class MicrochipTWSTProbe(BaseProbe): +class MicrochipTWSTProbe(BaseProbe, RandomDataMixin): """MicrochipTWST Probe Object""" vendor = VENDORS.MICROCHIP_TWST MEASUREMENTS: ClassVar = {"meas:offset": METRICS.PHASE_OFFSET, "tracking:ebno": METRICS.EB_NO} - class RandomDataConfig(BaseProbe.RandomDataConfig): + class RandomDataConfig(RandomDataMixin.RandomDataConfig): """Model for storing random data generation configurations as provided by CLI or YAML""" # Time series parameters - base_value: Optional[float] = Field( + base_value: float | None = Field( default_factory=lambda: random.uniform(-1e-8, 1e-8), description="random.uniform(-1e-8, 1e-8)" ) - noise_amplitude: Optional[float] = Field( + noise_amplitude: float | None = Field( default_factory=lambda: random.uniform(1e-10, 1e-9), description="random.uniform(1e-10, 1e-9)" ) - drift_rate: Optional[float] = Field( + drift_rate: float | None = Field( default_factory=lambda: random.uniform(-1e-12, 1e-12), description="random.uniform(-1e-12, 1e-12)" ) - ebno_base_value: Optional[float] = Field( + ebno_base_value: float | None = Field( default_factory=lambda: random.uniform(10.0, 20.0), description="random.uniform(10.0, 20.0)" ) - ebno_noise_amplitude: Optional[float] = Field( + ebno_noise_amplitude: float | None = Field( default_factory=lambda: random.uniform(0.5, 2.0), description="random.uniform(0.5, 2.0)" ) - ebno_drift_rate: Optional[float] = Field( + ebno_drift_rate: float | None = Field( default_factory=lambda: random.uniform(-0.01, 0.01), description="random.uniform(-0.01, 0.01)" ) @@ -90,41 +91,37 @@ def get_random_data_cli_options(cls) -> list: click.option( "--num-channels", type=int, - help=( - f"Number of remote channels to generate data for " - f"(default: {cls.RandomDataConfig.model_fields.get('num_channels').default})" - ), + default=cls.RandomDataConfig.model_fields.get("num_channels").default, + show_default=True, + help=("Number of remote channels to generate data for "), ), click.option( "--ebno-base-value", type=float, - help=( - f"Base value for Eb/No measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('base_value').description!s})" - ), + default=cls.RandomDataConfig.model_fields.get("base_value").description, + show_default=True, + help=("Base value for Eb/No measurements "), ), click.option( "--ebno-noise-amplitude", type=float, - help=( - f"Noise amplitude/standard deviation for Eb/No measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('noise_amplitude').description!s})" - ), + default=cls.RandomDataConfig.model_fields.get("noise_amplitude").description, + show_default=True, + help=("Noise amplitude/standard deviation for Eb/No measurements "), ), click.option( "--ebno-drift-rate", type=float, - help=( - f"Linear drift rate per second for Eb/No measurements " - f"(default = {cls.RandomDataConfig.model_fields.get('drift_rate').description!s})" - ), + default=cls.RandomDataConfig.model_fields.get("drift_rate").description, + show_default=True, + help=("Linear drift rate per second for Eb/No measurements "), ), ] return vendor_options + base_options - def __init__(self, input_file: Union[str, Path]): + def __init__(self, input_file: str | Path, **kwargs: dict): """Initialize MicrochipTWST object give input_file and determines probe identity from filename""" - super().__init__(input_file=input_file) + super().__init__(input_file=input_file, **kwargs) self.header = self.get_header() self.probe_key = ProbeKey(probe_id="modem", ip_address=self.header["local"]["ip"]) diff --git a/opensampl/vendors/ntp.py b/opensampl/vendors/ntp.py new file mode 100644 index 0000000..c5e634a --- /dev/null +++ b/opensampl/vendors/ntp.py @@ -0,0 +1,773 @@ +"""Probe implementation for NTP vendor""" + +from __future__ import annotations + +import contextlib +import random +import re +import shutil +import socket +import subprocess +import textwrap +import time +from datetime import datetime, timedelta, timezone +from io import StringIO +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar + +import click +import numpy as np +import pandas as pd +import psycopg2.errors +import requests +import yaml +from loguru import logger +from pydanclick import from_pydantic +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError + +from opensampl.load_data import load_probe_metadata +from opensampl.metrics import METRICS, MetricType +from opensampl.mixins.collect import CollectMixin +from opensampl.mixins.random_data import RandomDataMixin +from opensampl.references import REF_TYPES, ReferenceType +from opensampl.vendors.base_probe import BaseProbe +from opensampl.vendors.constants import VENDORS, ProbeKey + +T = TypeVar("T") + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +def _merge(a: T | None, b: T | None) -> T | None: + return a if a is not None else b + + +class NTPCollector(BaseModel): + """Base class for NTP Collector, for specific implementations to inherit.""" + + mode: ClassVar[Literal["remote", "local"]] + metric_map: ClassVar[dict[str, MetricType]] = { + "phase_offset_s": METRICS.PHASE_OFFSET, + "delay_s": METRICS.DELAY, + "jitter_s": METRICS.JITTER, + "stratum": METRICS.STRATUM, + "reachability": METRICS.REACHABILITY, + "dispersion_s": METRICS.DISPERSION, + "root_delay_s": METRICS.NTP_ROOT_DELAY, + "root_dispersion_s": METRICS.NTP_ROOT_DISPERSION, + "poll_interval_s": METRICS.POLL_INTERVAL, + "sync_health": METRICS.SYNC_HEALTH, + } + + target_host: str + + sync_status: str = Field("unknown") + sync_health: float | None = Field(None, json_schema_extra={"metric": True}) + + stratum: int | None = Field(None, json_schema_extra={"metric": True}) + reachability: int | None = Field(None, json_schema_extra={"metric": True}) + offset_s: float | None = Field(None, serialization_alias="phase_offset_s", json_schema_extra={"metric": True}) + delay_s: float | None = Field(None, json_schema_extra={"metric": True}) + jitter_s: float | None = Field(None, json_schema_extra={"metric": True}) + reference_id: str | None = None + observation_sources: list[str] = Field(default_factory=list) + collection_id: str + collection_ip: str + probe_id: str | None = None + + extras: dict = Field(default_factory=dict, serialization_alias="additional_metadata") + model_config = ConfigDict(serialize_by_alias=True) + + def collect(self): + """Collect a single NTP Reading""" + raise NotImplementedError + + def export_data(self) -> list[CollectMixin.DataArtifact]: + """ + Export the data from the NTP Collection to a list of DataArtifacts + + Each distinct metric type will get it's own data artifact + """ + now = datetime.now(tz=timezone.utc) + include_list = { + f + for f, field_info in type(self).model_fields.items() + if field_info.json_schema_extra and field_info.json_schema_extra.get("metric", False) + } + reference_type, compound_reference = self.determine_reference() + metric_values = self.model_dump(include=include_list, exclude_none=True) + + artifacts: list[CollectMixin.DataArtifact] = [] + for m, v in metric_values.items(): + metric = self.metric_map.get(m, None) + if metric is None: + metric = MetricType( + name=m, + description=f"Automatically generated metric type for {m}", + value_type=object, + unit="unknown", + ) + logger.warning(f"Generated new metric type for {m}") + value = pd.DataFrame([(now, v)], columns=["time", "value"]) + value["time"] = pd.to_datetime(value["time"]) + + artifacts.append( + CollectMixin.DataArtifact( + metric=metric, reference_type=reference_type, compound_reference=compound_reference, value=value + ) + ) + return artifacts + + def export_metadata(self) -> dict[str, Any]: + """Export the metadata from the NTP Collection to a dict""" + include_list = { + f + for f, field_info in type(self).model_fields.items() + if not field_info.json_schema_extra or not field_info.json_schema_extra.get("metric", False) + } + meta = self.model_dump(include=include_list, exclude_none=True) + meta["mode"] = self.mode + return meta + + def export(self) -> CollectMixin.CollectArtifact: + """Export the data + metadata for the NTP Collection to a CollectArtifact""" + meta = self.export_metadata() + + artifacts: list[CollectMixin.DataArtifact] = self.export_data() + + return CollectMixin.CollectArtifact(data=artifacts, metadata=meta) + + @classmethod + def invert_metric_map(cls) -> dict[str, str]: + """Invert metric map to go from MetricType.name to string""" + return {v.name: k for k, v in cls.metric_map.items()} + + def determine_reference(self) -> tuple[ReferenceType, None | dict[str, Any]]: + """Get the reference type and compound reference details""" + return REF_TYPES.PROBE, {"ip_address": self.collection_ip, "probe_id": self.collection_id} + + +class NTPLocalCollector(NTPCollector): + """Collector model for taking NTP readings from local device""" + + mode: ClassVar[Literal["remote", "local"]] = "local" + + @staticmethod + def _run(cmd: list[str], timeout: float = 8.0) -> str | None: + """Run command; return stdout or None if missing/failed.""" + bin0 = cmd[0] + if shutil.which(bin0) is None: + logger.debug(f"ntp local: command {bin0!r} not found") + return None + try: + proc = subprocess.run( # noqa: S603 + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.SubprocessError) as e: + logger.debug(f"ntp local: command {cmd!r} failed: {e}") + return None + if proc.returncode != 0: + logger.debug(f"ntp local: {cmd!r} exit {proc.returncode}: {proc.stderr!r}") + return None + logger.debug(f"ntp local: {cmd!r} exit {proc.stdout}") + return proc.stdout or "" + + def _parse_chronyc_tracking(self, text: str) -> None: + """Parse `chronyc tracking` key: value output.""" + out: dict[str, Any] = {} + for l in text.splitlines(): + line = l.strip() + if not line or ":" not in line: + continue + key, _, rest = line.partition(":") + key = key.strip().lower().replace(" ", "_") + val = rest.strip() + out[key] = val + + # Last offset : +0.000000123 seconds + m = re.search(r"last offset\s*:\s*([+-]?[\d.eE+-]+)\s*seconds?", text, re.IGNORECASE) + if m: + with contextlib.suppress(ValueError): + self.offset_s = _merge(self.offset_s, float(m.group(1))) + + m = re.search(r"rms offset\s*:\s*([+-]?[\d.eE+-]+)\s*seconds?", text, re.IGNORECASE) + if m: + with contextlib.suppress(ValueError): + self.jitter_s = _merge(self.jitter_s, float(m.group(1))) + + m = re.search(r"stratum\s*:\s*(\d+)", text, re.IGNORECASE) + if m: + with contextlib.suppress(ValueError): + self.stratum = _merge(self.stratum, int(m.group(1))) + + m = re.search(r"reference id\s*:\s*(\S+)(?:\s*\(([^)]+)\))?", text, re.IGNORECASE) + if m: + self.reference_id = (m.group(2) or m.group(1)) or self.reference_id + + self.sync_status = "unsynchronized" + if "normal" in text.lower() or self.offset_s is not None: + self.sync_status = "tracking" + self.extras["chronyc_raw_tracking"] = out + self.observation_sources.append("chronyc_tracking") + + def _parse_chronyc_sources(self, text: str) -> None: + """Parse `chronyc sources` for reach and selected source.""" + reach: int | None = None + selected: str | None = None + for l in text.splitlines(): + line = l.strip() + if not line or line.startswith(("MS", "=")): + continue + # ^* or ^+ prefix indicates selected/accepted + if line.startswith(("*", "+")): + parts = line.split() + if len(parts) >= 7: + try: + reach = int(parts[5], 8) if parts[5].startswith("0") else int(parts[5]) + except ValueError: + with contextlib.suppress(ValueError): + reach = int(parts[5]) + selected = parts[1] + break + # Fallback: last column often reach (octal) + parts = line.split() + if len(parts) >= 7 and parts[0] in ("^*", "^+", "*", "+"): + # already handled + pass + if reach is None: + # Try any line with 377 octal style + m = re.search(r"\b([0-7]{3})\b", text) + if m: + with contextlib.suppress(ValueError): + reach = int(m.group(1), 8) + + self.reachability = self.reachability or reach + self.reference_id = self.reference_id or selected + self.observation_sources.append("chronyc_sources") + + def _parse_ntpq(self, text: str) -> None: + """Parse `ntpq -p` / `ntpq -pn` output.""" + offset_s: float | None = None + delay_s: float | None = None + jitter_s: float | None = None + stratum: int | None = None + reach: int | None = None + ref = None + for l in text.splitlines(): + line = l.strip() + if not line or line.startswith(("remote", "=")): + continue + if line.startswith(("*", "+", "-")): + parts = line.split() + # remote refid st t when poll reach delay offset jitter + if len(parts) >= 10: + with contextlib.suppress(ValueError): + stratum = int(parts[2]) + + try: + delay_s = float(parts[7]) / 1000.0 # ms -> s + offset_s = float(parts[8]) / 1000.0 + jitter_s = float(parts[9]) / 1000.0 + except (ValueError, IndexError): + pass + try: + reach = int(parts[6], 8) if parts[6].startswith("0") else int(parts[6]) + except ValueError: + with contextlib.suppress(ValueError): + reach = int(parts[6]) + + ref = parts[1] + break + sync_status = "synced" if offset_s is not None else "unknown" + + self.offset_s = self.offset_s or offset_s + self.delay_s = self.delay_s or delay_s + self.jitter_s = self.jitter_s or jitter_s + self.stratum = self.stratum or stratum + self.reachability = self.reachability or reach + self.reference_id = self.reference_id or ref + self.sync_status = sync_status or self.sync_status + self.observation_sources.append("ntpq") + + def _parse_timedatectl(self, text: str) -> None: + """Parse `timedatectl status` / `show-timesync --all`.""" + sync = None + for line in text.splitlines(): + low = line.lower() + if "system clock synchronized" in low or "ntp synchronized" in low: + if "yes" in low: + sync = True + elif "no" in low: + sync = False + sync_status = "unknown" + if sync is True: + sync_status = "synchronized" + elif sync is False: + sync_status = "unsynchronized" + + if self.sync_status == "unknown": + self.sync_status = sync_status or self.sync_status + self.observation_sources.append("timedatectl") + self.extras["timedatectl"] = text[:2000] + + def _parse_systemctl_show(self, text: str) -> None: + """Parse `systemctl show` / `systemctl status` for systemd-timesyncd.""" + active = None + for line in text.splitlines(): + if line.strip().lower().startswith("activestate="): + active = line.split("=", 1)[1].strip().lower() == "active" + break + if active is None and "active (running)" in text.lower(): + active = True + sync_status = "unknown" + if active is True: + sync_status = "service_active" + elif active is False: + sync_status = "service_inactive" + + if self.sync_status == "unknown": + self.sync_status = sync_status or self.sync_status + self.extras["systemctl"] = text[:2000] + self.observation_sources.append("systemctl_timesyncd") + + def collect(self): + """Collect local NTP readings using various tools""" + t = self._run(["chronyc", "tracking"]) + if t: + self._parse_chronyc_tracking(t) + + t = self._run(["chronyc", "sources", "-v"]) or self._run(["chronyc", "sources"]) + if t: + self._parse_chronyc_sources(t) + + if self.offset_s is None and self.stratum is None: + t = self._run(["ntpq", "-pn"]) or self._run(["ntpq", "-p"]) + if t: + self._parse_ntpq(t) + + t = self._run(["timedatectl", "show-timesync", "--all"]) or self._run(["timedatectl", "status"]) + if t: + self._parse_timedatectl(t) + + t = self._run(["systemctl", "show", "systemd-timesyncd", "--property=ActiveState"]) + if not t: + t = self._run(["systemctl", "status", "systemd-timesyncd", "--no-pager"]) + + if t: + self._parse_systemctl_show(t) + + if not self.observation_sources: + self.observation_sources = ["none"] + + self.sync_health = 1.0 if self.sync_status in ("tracking", "synchronized", "synced") else 0.0 + + if self.probe_id is None: + self.probe_id = "ntp-local" + + +class NTPRemoteCollector(NTPCollector): + """Collector model for taking readings from remote NTP Server.""" + + mode: ClassVar[Literal["remote", "local"]] = "remote" + + target_port: int + timeout: float = 3.0 + + root_delay_s: float | None = Field(None, json_schema_extra={"metric": True}) + root_dispersion_s: float | None = Field(None, json_schema_extra={"metric": True}) + poll_interval_s: float | None = Field(None, json_schema_extra={"metric": True}) + leap_status: str = "unknown" + + def configure_failure(self, e: Exception) -> None: + """Set all metric and metadata values to reflect failure to connect""" + self.sync_status = "unreachable" + self.sync_health = 0 + self.extras["error"] = str(e) + self.observation_sources.append("ntplib") + self.observation_sources.append("error") + + def _estimate_jitter_s(self) -> None: + """ + Single NTP client response does not include RFC5905 peer jitter (that needs multiple samples). + + Emit a conservative positive bound from round-trip delay and root dispersion so downstream + ``NTP Jitter`` metrics and dashboards have a value; chrony/ntpq local paths still supply + true jitter when available. + """ + if self.delay_s is None and self.root_dispersion_s is None: + return + d = float(self.delay_s) if self.delay_s is not None else 0.0 + r = float(self.root_dispersion_s) if self.root_dispersion_s is not None else 0.0 + est = 0.05 * d + 0.25 * r + if est > 0: + self.jitter_s = est + return + + def collect(self): + """Collect readings from a single ping against a remote NTP server.""" + try: + import ntplib # type: ignore[import-untyped] + except ImportError as e: + raise ImportError( + "Remote NTP collection requires the 'ntplib' package (install opensampl[collect])." + ) from e + client = ntplib.NTPClient() + try: + resp = client.request(self.target_host, port=self.target_port, version=3, timeout=self.timeout) + except Exception as e: + logger.warning(f"NTP request to {self.target_host}:{self.target_port} failed: {e}") + self.configure_failure(e) + return + leap = int(resp.leap) + leap_map = {0: "no_warning", 1: "add_second", 2: "del_second", 3: "alarm"} + self.leap_status = leap_map.get(leap, str(leap)) + + stratum = int(resp.stratum) + self.stratum = stratum + + try: + self.poll_interval_s = float(2 ** int(resp.poll)) + except (TypeError, ValueError, OverflowError): + logger.debug("No poll interval determined") + + self.root_delay_s = float(resp.root_delay) if resp.root_delay is not None else None + self.root_dispersion_s = float(resp.root_dispersion) if resp.root_dispersion is not None else None + self.delay_s = float(resp.delay) if resp.delay is not None else None + self.offset_s = float(resp.offset) if resp.offset is not None else None + + ref_id = getattr(resp, "ref_id", None) + if hasattr(ref_id, "decode"): + try: + ref_id = ref_id.decode("ascii", errors="replace") + except Exception: + ref_id = str(ref_id) + self.reference_id = str(ref_id) if ref_id is not None else None + + sync_ok = stratum < 16 and self.offset_s is not None + self.observation_sources.append("ntplib") + self.sync_status = "synchronized" if sync_ok else "unsynchronized" + self.sync_health = 1.0 if sync_ok else 0.0 + self._estimate_jitter_s() + + self.extras["version"] = getattr(resp, "version", None) + + if self.probe_id is None: + self.probe_id = f"remote:{self.target_port}" + + +def collect_ip_factory() -> str: + """Get ip address for collection host using socket (default to 127.0.0.1)""" + s = None + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) # doesn't actually send data + v = s.getsockname()[0] + except Exception: + v = "127.0.0.1" + finally: + if s: + s.close() + return v + + +def collect_id_factory() -> str: + """Get humanreadable host name for collection host using socket (default to collection-host)""" + try: + return socket.gethostname() or "collection-host" + except Exception: + return "collection-host" + + +class NtpProbe(BaseProbe, CollectMixin, RandomDataMixin): + """Probe parser for NTP vendor data files""" + + vendor = VENDORS.NTP + + class CollectConfig(CollectMixin.CollectConfig): + """ + Configuration for Collecting NTP Readings + + Attributes: + probe_id: stable probe_id slug (e.g. local-chrony) + ip_address: Host or IP address for Probe (default '127.0.0.1') + port: UDP port for remote mode (use high ports for lab mocks) + output_dir: When provided, will save collected data as a file to provided directory. Filename will be + automatically generated as NTP_{ip_address}_{probe_id}_{vendor}_{timestamp}.txt + load: Whether to load collected data directly to the database + duration: Number of seconds to collect data for + mode: Collect remote or local NTP. Default is 'local'. + interval: Seconds between samples; 0 = single sample and exit + duration: Samples to collect when interval > 0 + timeout: UDP request timeout for remote mode(seconds) default: 3.0 + collection_ip: Override for the IP address of device collecting readings. Will attempt to resolve a local + network IP using socket and fall back to '127.0.0.1' + collection_id: Override for the Probe ID of the device collecting readings. Will attempt to resolve using + socket.gethostname and fall back to 'collection-host' + + """ + + ip_address: str = "127.0.0.1" + port: int = 123 + mode: Literal["remote", "local"] = "local" + interval: float = Field(0.0, ge=0.0) + duration: int = Field(1, ge=1) + timeout: float = 3.0 + collection_ip: str = Field(default_factory=collect_ip_factory) + collection_id: str = Field(default_factory=collect_id_factory) + + @classmethod + def get_collect_cli_options(cls) -> list[Callable]: + """Get the decorators to generate collection options for CLI""" + return [ + from_pydantic(cls.CollectConfig, rename={"ip_address": "host", "duration": "count"}), + click.pass_context, + ] + + class RandomDataConfig(RandomDataMixin.RandomDataConfig): + """Random NTP-like test data.""" + + base_value: float = Field( + default_factory=lambda: random.uniform(-1e-4, 1e-4), + description="random.uniform(-1e-4, 1e-4)", + ) + noise_amplitude: float = Field( + default_factory=lambda: random.uniform(1e-9, 1e-7), + description="random.uniform(1e-9, 1e-7)", + ) + drift_rate: float = Field( + default_factory=lambda: random.uniform(-1e-12, 1e-12), + description="random.uniform(-1e-12, 1e-12)", + ) + + def __init__(self, input_file: str | Path, **kwargs: dict): + """Initialize NtpProbe from input file""" + super().__init__(input_file=input_file, **kwargs) + self.collection_probe = None + + def process_metadata(self) -> dict: + """ + Parse and return probe metadata from input file. + + Returns: + dict with metadata field names as keys + + """ + if not self.metadata_parsed: + header_lines = [] + with self.input_file.open() as f: + for line in f: + if line.startswith("#"): + header_lines.append(line[2:]) + else: + break + + header_str = "".join(header_lines) + self.metadata = yaml.safe_load(header_str) + self.collection_probe = ProbeKey( + ip_address=self.metadata.get("collection_ip"), probe_id=self.metadata.get("collection_id") + ) + load_probe_metadata(vendor=self.vendor, probe_key=self.collection_probe, data={"reference": True}) + self.probe_key = ProbeKey( + ip_address=self.metadata.get("target_host"), probe_id=self.metadata.get("probe_id") + ) + self.metadata_parsed = True + + return self.metadata + + @classmethod + def load_metadata(cls, probe_key: ProbeKey, metadata: dict) -> None: + """ + Parse and return probe metadata from input file. + + Returns: + dict with metadata field names as keys + + """ + collection_probe = ProbeKey(ip_address=metadata.get("collection_ip"), probe_id=metadata.get("collection_id")) + load_probe_metadata(vendor=cls.vendor, probe_key=collection_probe, data={"reference": True}) + load_probe_metadata(vendor=cls.vendor, probe_key=probe_key, data=metadata) + + def process_time_data(self) -> None: + """ + Parse and load time series data from self.input_file. + + Use either send_time_data (which prefills METRICS.PHASE_OFFSET) + or send_data and provide alternative METRICS type. + Both require a df as follows: + pd.DataFrame with columns: + - time (datetime64[ns]): timestamp for each measurement + - value (float64): measured value at each timestamp + + """ + raw_df = pd.read_csv( + self.input_file, + comment="#", + ) + self.process_metadata() + + reference_type = REF_TYPES.PROBE + grouped_dfs: dict[str, pd.DataFrame] = { + str(metric): group.reset_index(drop=True) for metric, group in raw_df.groupby("metric") + } + for metr, df in grouped_dfs.items(): + metric = NTPCollector.metric_map.get(metr) + if not metric: + logger.warning(f"Metric {metr} is not supported for NTP. Will not ingest {len(df)} rows") + continue + try: + self.send_data( + data=df, + metric=metric, + reference_type=reference_type, + compound_reference=self.collection_probe.model_dump(), + ) + except requests.HTTPError as e: + resp = e.response + if resp is None: + raise + status_code = resp.status_code + if status_code == 409: + logger.info(f"{metr} against {self.collection_probe} already loaded for time frame, continuing..") + continue + raise + except IntegrityError as e: + if isinstance(e.orig, psycopg2.errors.UniqueViolation): # ty: ignore[unresolved-attribute] + logger.info( + f"{metr} against {self.collection_probe} already loaded for time " + f"frame already loaded for time frame, continuing.." + ) + + @classmethod + def collect(cls, collect_config: CollectConfig) -> CollectMixin.CollectArtifact: + """Collect readings for an NTP probe according to collect_config.""" + collector_overrides = collect_config.model_dump( + include=["collection_ip", "collection_id", "probe_id"], exclude_none=True + ) + + def collect_once() -> CollectMixin.CollectArtifact: + collector = None + if collect_config.mode == "local": + collector = NTPLocalCollector(target_host=collect_config.ip_address, **collector_overrides) + elif collect_config.mode == "remote": + collector = NTPRemoteCollector( + target_host=collect_config.ip_address, + target_port=collect_config.port, + timeout=collect_config.timeout, + **collector_overrides, + ) + if collector is None: + raise ValueError("Could not determine mode from collect_config") + collector.collect() + + return collector.export() + + if collect_config.interval <= 0: + return collect_once() + + artifact = None + sample_count = max(collect_config.duration, 1) + for sample_idx in range(sample_count): + newer = collect_once() + if artifact is None: + artifact = newer + else: + artifact.data.extend(newer.data) + artifact.metadata |= newer.metadata + + if sample_idx < sample_count - 1: + time.sleep(collect_config.interval) + + return artifact + + @classmethod + def create_file_content(cls, collected: CollectMixin.CollectArtifact) -> str: + """Create the content of a file from the CollectArtifacts""" + metric_names = NTPCollector.invert_metric_map() + dfs = [] + for d in collected.data or []: + df = d.value + df["metric"] = metric_names.get(d.metric.name, d.metric.name.lower().replace(" ", "_")) + dfs.append(df) + value_df = pd.concat(dfs) if dfs else None + + header = yaml.dump(collected.metadata, sort_keys=False) + header = textwrap.indent(header, prefix="# ") + buffer = StringIO() + buffer.write(header) + buffer.write("\n") + + if value_df is not None: + # write dataframe + value_df.to_csv(buffer, index=False) + + return buffer.getvalue() + + @classmethod + def generate_random_data( + cls, + config: RandomDataConfig, + probe_key: ProbeKey, + ) -> ProbeKey: + """Generate synthetic NTP-like metrics for testing.""" + cls._setup_random_seed(config.seed) + logger.info(f"Generating random NTP data for {probe_key}") + + meta = { + "mode": "random", + "name": f"Random NTP {probe_key}", + "target_host": "", + "target_port": 0, + "sync_status": "tracking", + "leap_status": "no_warning", + "observation_sources": ["random"], + "additional_metadata": {"test_data": True}, + } + cls._send_metadata_to_db(probe_key, meta) + + total_seconds = config.duration_hours * 3600 + num_samples = int(total_seconds / config.sample_interval) + times = [] + metric_maps = { + "offset": {"metric": METRICS.PHASE_OFFSET, "values": []}, + "delay_s": {"metric": METRICS.DELAY, "values": []}, + "jitter_s": {"metric": METRICS.JITTER, "values": []}, + "stratum": {"metric": METRICS.STRATUM, "values": []}, + "sync_health": {"metric": METRICS.SYNC_HEALTH, "values": []}, + } + + for i in range(num_samples): + sample_time = config.start_time + timedelta(seconds=i * config.sample_interval) + times.append(sample_time) + time_offset = i * config.sample_interval + drift_component = config.drift_rate * time_offset + noise = float(np.random.normal(0, config.noise_amplitude)) + offset = config.base_value + drift_component + noise + if random.random() < config.outlier_probability: + offset += float(np.random.normal(0, config.noise_amplitude * config.outlier_multiplier)) + + delay_s = 0.02 + abs(0.0001 * random.random()) + jitter_s = abs(float(config.noise_amplitude * 5)) + stratum = 2 + (1 if random.random() < 0.05 else 0) + sync_health = 1.0 + metric_maps["offset"]["values"].append(offset) + metric_maps["delay_s"]["values"].append(delay_s) + metric_maps["jitter_s"]["values"].append(jitter_s) + metric_maps["stratum"]["values"].append(stratum) + metric_maps["sync_health"]["values"].append(sync_health) + + for metric in metric_maps.values(): + cls.send_data( + probe_key=probe_key, + metric=metric.get("metric"), + reference_type=REF_TYPES.UNKNOWN, + data=pd.DataFrame({"time": times, "value": metric.get("values")}), + ) + + logger.info(f"Finished random NTP generation for {probe_key}") + return probe_key diff --git a/pyproject.toml b/pyproject.toml index 0925101..a5f36e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opensampl" -version = "1.1.5" +version = "1.2.0" description = "Python tools for adding clock data to a timescale db." license = {file = "LICENSE"} authors = [ @@ -18,10 +18,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Physics", @@ -37,7 +38,7 @@ classifiers = [ "Framework :: Pydantic", "Framework :: Pydantic :: 2" ] -requires-python = ">=3.9,<3.13" +requires-python = ">=3.10,<3.15" dependencies = [ "pydantic>=2.10.3,<3", "pydantic-settings>=2.9.0", @@ -54,11 +55,12 @@ dependencies = [ "loguru>=0.7.0,<0.8", "psycopg2-binary>=2.9.0,<3", "python-dotenv", - "python-multipart>=0.0.20,<0.0.21", + "python-multipart>=0.0.26,<0.0.27", "astor", "libcst", "jinja2>=3.1.6", - "tabulate" + "tabulate", + "pydanclick", ] [project.urls] @@ -68,8 +70,18 @@ Documentation = "https://ornl.github.io/OpenSAMPL" Changelog = "https://github.com/ORNL/OpenSAMPL/blob/main/CHANGELOG.md" [project.optional-dependencies] -server = [] -collect = ["telnetlib3==2.0.4"] +migrations = [ + "alembic", +] +backend = [ + "fastapi", + "uvicorn", + "prometheus-client", +] +collect = [ + "telnetlib3==2.0.4", + "ntplib>=0.4.0,<0.5" +] [project.scripts] opensampl = "opensampl.cli:cli" @@ -89,13 +101,19 @@ dev = [ "mkdocs-gen-files", "mkdocs-material", "mkdocs-click", + "psycopg[binary]", + "pytest-postgresql" ] [tool.hatch.build.targets.sdist] include = [ "opensampl/server/docker-compose.yaml", + "opensampl/server/docker-compose.dev.yaml", "opensampl/server/default.env", + "opensampl/server/migrations/*", "opensampl/server/grafana/*", + "opensampl/server/backend/*", + "opensampl/create/templates/*", "opensampl/**/*.py", ] exclude = [".env"] @@ -103,8 +121,12 @@ exclude = [".env"] [tool.hatch.build.targets.wheel] include = [ "opensampl/server/docker-compose.yaml", + "opensampl/server/docker-compose.dev.yaml", "opensampl/server/default.env", + "opensampl/server/migrations/*", "opensampl/server/grafana/*", + "opensampl/server/backend/*", + "opensampl/create/templates/*", "opensampl/**/*.py", ] exclude = [".env"] @@ -115,7 +137,7 @@ build-backend = "hatchling.build" [tool.ruff] line-length = 120 -exclude = [".git", "__pycache__", "venv", "env", ".venv", ".env", "build", "dist", "docs"] +exclude = [".git", "__pycache__", "venv", "env", ".venv", ".env", "build", "dist", "docs", "opensampl/server/migrations/**/*.py"] include = ["opensampl/**/*.py"] [tool.ruff.lint] @@ -125,10 +147,12 @@ select = ["F", "E", "W", "C", "I", "D", "N", "B", "ERA", "ANN", "S", "A", "COM", "FLY", "PERF", "PL", "UP", "FURB", "RUF", "TRY"] ignore = ["D203", "D212", "D400", "D415", "ANN401", "S101", "PLR2004", "COM812", "ANN201", "B011", "EM102", "TRY003", "ANN204", "FA100", "PIE790", "EM101", - "PLC0415"] + "PLC0415", 'E741'] [tool.ruff.lint.per-file-ignores] "opensampl/vendors/**/*.py" = ['S311'] # we want to ignore the errors about random +"opensampl/server/backend/main.py" = ['B008', 'ARG001'] #ignore complaints about calling functions in args +"opensampl/mixins/random_data.py" = ['S311'] [tool.ruff.lint.pylint] max-args = 10 diff --git a/scripts/gen_api_docs.py b/scripts/gen_api_docs.py index daf6373..84eaec8 100644 --- a/scripts/gen_api_docs.py +++ b/scripts/gen_api_docs.py @@ -11,7 +11,9 @@ click_progname_map = { 'opensampl.cli': 'opensampl', - 'opensampl.server.cli': 'opensampl-server' + 'opensampl.server.cli': 'opensampl-server', + 'opensampl.server.cli2': 'opensampl-server2', + 'opensampl.collect.cli': 'opensampl-collect' } def get_click_command_name(file_path: Path) -> str | None: @@ -94,7 +96,7 @@ def generate_api_docs_and_nav(): nav_structure = [{"index": f"{API_DIR_NAME}/index.md"}] for py_file in sorted(SRC_DIR.rglob("*.py")): - if py_file.stem == "__init__": + if py_file.stem == "__init__" or 'migration' in str(py_file): continue rel_path = py_file.relative_to(SRC_DIR) doc_path = DOCS_API_DIR / rel_path.with_suffix(".md") diff --git a/tests/conftest.py b/tests/conftest.py index 0c8b417..44f756e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,7 @@ def mock_config(): config.ROUTE_TO_BACKEND = False config.DATABASE_URL = "sqlite:///:memory:" config.LOG_LEVEL = "DEBUG" + config.ENABLE_GEOLOCATE = False mock.return_value = config yield mock diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1ccf890 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,332 @@ +""" +tests/conftest.py + +Shared pytest fixtures for openSAMPL integration tests. + +Prerequisites +------------- +- PostgreSQL with PostGIS extension available +- pytest-postgresql installed: + uv add --group dev pytest-postgresql +- On macOS (Homebrew): + brew install postgresql postgis + +pytest-postgresql will locate pg_ctl automatically from your PATH. If you +have multiple Postgres versions installed, point it at the right one via +pytest.ini or pyproject.toml: + + [tool.pytest.ini_options] + postgresql_exec = "/opt/homebrew/opt/postgresql@16/bin/pg_ctl" +""" + +import pytest +from pytest_postgresql import factories as pg_factories +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from opensampl.db.orm import Base +from opensampl.db.orm import Defaults as DBDefaults +from opensampl.db.orm import MetricType as DBMetricType +from opensampl.db.orm import Reference as DBReference +from opensampl.db.orm import ReferenceType as DBReferenceType +from opensampl.metrics import METRICS, MetricType +from opensampl.references import REF_TYPES, ReferenceType + + +# --------------------------------------------------------------------------- +# pytest-postgresql process fixture +# +# postgresql_proc manages the Postgres server lifetime (session-scoped). +# We deliberately avoid the postgresql connection fixture so we have no +# dependency on a specific psycopg version — the project already has +# psycopg2-binary, and SQLAlchemy handles the connection from here. +# --------------------------------------------------------------------------- + +postgresql_proc = pg_factories.postgresql_proc() + + +# --------------------------------------------------------------------------- +# Helpers: introspect METRICS / REF_TYPES the same way VENDORS.all() does +# --------------------------------------------------------------------------- + +def _all_metrics() -> list[MetricType]: + """All MetricType instances defined on the METRICS class.""" + return [v for v in METRICS.__dict__.values() if isinstance(v, MetricType)] + + +def _all_ref_types() -> list[ReferenceType]: + """All ReferenceType instances defined on REF_TYPES (includes CompoundReferenceType).""" + return [v for v in REF_TYPES.__dict__.values() if isinstance(v, ReferenceType)] + + +# --------------------------------------------------------------------------- +# Session-scoped engine +# Schema, tables, seed data, and the get_default_uuid_for stub are all +# created once per test session. +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def db_engine(postgresql_proc): + """ + Session-scoped SQLAlchemy engine pointed at the pytest-postgresql instance. + + Connects using psycopg2-binary (already a project dependency) so there is + no dependency on psycopg3. Creates the opensampl_test database on first + run via a temporary autocommit connection to the default 'postgres' database. + + Lifecycle: + 1. Create the opensampl_test database. + 2. Install PostGIS and create the castdb schema. + 3. Create all ORM tables via Base.metadata.create_all(). + 4. Seed metric_type, reference_type, reference, and defaults tables. + 5. Install the get_default_uuid_for() PL/pgSQL stub. + """ + # postgresql_proc exposes plain attributes — no psycopg version dependency + host = postgresql_proc.host + port = postgresql_proc.port + user = postgresql_proc.user + test_dbname = "opensampl_test" + + # Connect to the default 'postgres' db to create our test database. + # Must use isolation_level=AUTOCOMMIT because CREATE DATABASE cannot run + # inside a transaction block. + bootstrap_url = f"postgresql+psycopg2://{user}@{host}:{port}/postgres" + bootstrap_engine = create_engine(bootstrap_url, isolation_level="AUTOCOMMIT") + with bootstrap_engine.connect() as conn: + exists = conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :dbname"), + {"dbname": test_dbname}, + ).fetchone() + if not exists: + conn.execute(text(f'CREATE DATABASE "{test_dbname}"')) + bootstrap_engine.dispose() + + db_url = f"postgresql+psycopg2://{user}@{host}:{port}/{test_dbname}" + engine = create_engine(db_url, echo=False) + + with engine.begin() as conn: + # PostGIS is required by the Locations.geom column (GeoAlchemy2) + conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {Base.metadata.schema}")) + + Base.metadata.create_all(engine) + _seed_lookup_tables(engine) + _install_default_uuid_stub(engine) + + yield engine + + engine.dispose() + + +# --------------------------------------------------------------------------- +# Seeding helpers (called once from db_engine, not exposed as fixtures) +# --------------------------------------------------------------------------- + +def _seed_lookup_tables(engine) -> None: + """ + Populate metric_type, reference_type, reference, and defaults tables. + + Reads directly from the METRICS and REF_TYPES Python definitions so the + test DB always matches what the application expects — no hardcoded values. + After inserting the lookup rows, seeds the defaults table with the UUIDs + of the UNKNOWN rows, mirroring how production initialises get_default_uuid_for(). + """ + SessionLocal = sessionmaker(bind=engine) + session = SessionLocal() + + try: + # --- metric_type --- + for metric in _all_metrics(): + data = metric.model_dump() # value_type serialised to str by field_serializer + if not session.query(DBMetricType).filter_by(name=data["name"]).first(): + session.add(DBMetricType(**data)) + + # --- reference_type --- + # CompoundReferenceType.model_dump() includes reference_table; the column is nullable so plain + # ReferenceType rows (no reference_table) are stored with NULL, which is correct. + for ref_type in _all_ref_types(): + data = ref_type.model_dump() + if not session.query(DBReferenceType).filter_by(name=data["name"]).first(): + session.add(DBReferenceType(**data)) + + session.flush() + + # --- reference: one default UNKNOWN row -------------------------- + # get_default_uuid_for('reference') needs at least one reference row to + # point at. We use the UNKNOWN reference type with no compound target. + unknown_ref_type = session.query(DBReferenceType).filter_by(name=REF_TYPES.UNKNOWN.name).one() + default_reference = session.query(DBReference).filter_by( + reference_type_uuid=unknown_ref_type.uuid, + compound_reference_uuid=None, + ).first() + if not default_reference: + default_reference = DBReference( + reference_type_uuid=unknown_ref_type.uuid, + compound_reference_uuid=None, + ) + session.add(default_reference) + + session.flush() + + # --- defaults table --------------------------------------------- + # Maps table/category names to the UUID that get_default_uuid_for() + # should return. Mirrors what the production TimescaleDB init does. + unknown_metric = session.query(DBMetricType).filter_by(name=METRICS.UNKNOWN.name).one() + + for table_name, uuid_value in [ + ("metric_type", unknown_metric.uuid), + ("reference", default_reference.uuid), + ]: + if not session.query(DBDefaults).filter_by(table_name=table_name).first(): + session.add(DBDefaults(table_name=table_name, uuid=uuid_value)) + + session.commit() + + except Exception: + session.rollback() + raise + finally: + session.close() + + +def _install_default_uuid_stub(engine) -> None: + """ + Install the get_default_uuid_for() PL/pgSQL stub. + + Rather than hardcoding UUIDs, the stub queries the defaults table — the + same approach the production TimescaleDB function uses. This means it + automatically returns whatever was seeded above. + """ + schema = Base.metadata.schema + + with engine.begin() as conn: + conn.execute(text(f""" + CREATE OR REPLACE FUNCTION get_default_uuid_for(entity_type TEXT) + RETURNS TEXT AS $$ + DECLARE + result_uuid TEXT; + BEGIN + SELECT uuid + INTO result_uuid + FROM {schema}.defaults + WHERE table_name = entity_type; + + RETURN result_uuid; + END; + $$ LANGUAGE plpgsql; + """)) + + +# --------------------------------------------------------------------------- +# Per-test session — savepoint rollback keeps tests isolated +# --------------------------------------------------------------------------- + +@pytest.fixture +def db_session(db_engine) -> Session: + """ + Function-scoped database session backed by a savepoint. + + Every test starts with the full seeded dataset intact. Any rows inserted + or updated during the test are rolled back when the test ends without + disturbing the seeded rows, and without the cost of recreating the schema. + + Usage:: + + def test_something(db_session): + factory = TableFactory("locations", db_session) + factory.write({"name": "test-loc", "lat": 35.9, "lon": -84.3}) + result = db_session.query(Locations).filter_by(name="test-loc").one() + assert result.name == "test-loc" + # row is gone after the test + """ + connection = db_engine.connect() + outer_transaction = connection.begin() + session = Session(bind=connection) + session.begin_nested() # SAVEPOINT — inner rollback target + + yield session + + session.close() + outer_transaction.rollback() # wipes everything written during the test + connection.close() + + +# --------------------------------------------------------------------------- +# Seeded UUIDs — expose the canonical lookup UUIDs tests may need directly +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def seeded_uuids(db_engine) -> dict: + """ + Session-scoped dict of UUIDs inserted during seed, keyed by logical name. + + Useful when a test needs to construct ORM objects by hand (e.g. ProbeData) + and must reference real FK values. + + Keys + ---- + metric_type. — UUID from the metric_type table + reference_type. — UUID from the reference_type table + reference.unknown — UUID of the default UNKNOWN reference row + default.metric_type — what get_default_uuid_for('metric_type') returns + default.reference — what get_default_uuid_for('reference') returns + + Example:: + + def test_probe_data(db_session, seeded_uuids): + phase_offset_uuid = seeded_uuids["metric_type.Phase Offset"] + """ + SessionLocal = sessionmaker(bind=db_engine) + session = SessionLocal() + + try: + uuids: dict = {} + + for row in session.query(DBMetricType).all(): + uuids[f"metric_type.{row.name}"] = row.uuid + + for row in session.query(DBReferenceType).all(): + uuids[f"reference_type.{row.name}"] = row.uuid + + unknown_ref_type = session.query(DBReferenceType).filter_by(name=REF_TYPES.UNKNOWN.name).one() + unknown_ref = session.query(DBReference).filter_by( + reference_type_uuid=unknown_ref_type.uuid, + compound_reference_uuid=None, + ).one() + uuids["reference.unknown"] = unknown_ref.uuid + + for row in session.query(DBDefaults).all(): + uuids[f"default.{row.table_name}"] = row.uuid + + return uuids + + finally: + session.close() + + +# --------------------------------------------------------------------------- +# Routing environment — patch BaseConfig for @route-decorated functions +# --------------------------------------------------------------------------- + +@pytest.fixture +def db_env(db_engine, monkeypatch) -> None: + """ + Set env vars so that BaseConfig routes directly to the test DB. + + Any test that calls a @route-decorated function (write_to_table, + load_time_data, load_probe_metadata, create_new_tables) must include + this fixture. Pass session=db_session explicitly so the route wrapper + uses your test session rather than opening a new one. + + Usage:: + + def test_write_to_table(db_env, db_session): + write_to_table( + "locations", + {"name": "test-loc", "lat": 35.9, "lon": -84.3}, + session=db_session, + ) + """ + monkeypatch.setenv("ROUTE_TO_BACKEND", "false") + monkeypatch.setenv("DATABASE_URL", str(db_engine.url)) + monkeypatch.delenv("BACKEND_URL", raising=False) \ No newline at end of file diff --git a/tests/integration/test_db_setup.py b/tests/integration/test_db_setup.py new file mode 100644 index 0000000..6f1f542 --- /dev/null +++ b/tests/integration/test_db_setup.py @@ -0,0 +1,106 @@ +""" +tests/integration/test_db_setup.py + +Smoke tests to verify the test database spun up and seeded correctly. +""" + +import pytest +from sqlalchemy import text + +from opensampl.db.orm import Defaults as DBDefaults +from opensampl.db.orm import MetricType as DBMetricType +from opensampl.db.orm import Reference as DBReference +from opensampl.db.orm import ReferenceType as DBReferenceType +from opensampl.metrics import METRICS, MetricType +from opensampl.references import REF_TYPES, ReferenceType + + +def test_schema_exists(db_session): + """castdb schema was created.""" + result = db_session.execute( + text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'castdb'") + ).scalar() + assert result == "castdb" + + +def test_postgis_installed(db_session): + """PostGIS extension is available (required for Locations.geom).""" + result = db_session.execute(text("SELECT extname FROM pg_extension WHERE extname = 'postgis'")).scalar() + assert result == "postgis" + + +def test_all_metrics_seeded(db_session): + """Every MetricType defined on METRICS is present in the metric_type table.""" + expected = {v.name for v in METRICS.__dict__.values() if isinstance(v, MetricType)} + seeded = {row.name for row in db_session.query(DBMetricType).all()} + assert expected == seeded + + +def test_all_reference_types_seeded(db_session): + """Every ReferenceType defined on REF_TYPES is present in the reference_type table.""" + expected = {v.name for v in REF_TYPES.__dict__.values() if isinstance(v, ReferenceType)} + seeded = {row.name for row in db_session.query(DBReferenceType).all()} + assert expected == seeded + + +def test_default_reference_row_exists(db_session): + """A default UNKNOWN reference row exists for get_default_uuid_for('reference').""" + unknown_ref_type = db_session.query(DBReferenceType).filter_by(name=REF_TYPES.UNKNOWN.name).one() + ref = db_session.query(DBReference).filter_by( + reference_type_uuid=unknown_ref_type.uuid, + compound_reference_uuid=None, + ).first() + assert ref is not None + + +def test_defaults_table_seeded(db_session): + """defaults table has entries for both metric_type and reference.""" + rows = {row.table_name for row in db_session.query(DBDefaults).all()} + assert "metric_type" in rows + assert "reference" in rows + + +def test_get_default_uuid_for_metric_type(db_session): + """Stub function returns the UUID of the UNKNOWN metric type.""" + result = db_session.execute(text("SELECT get_default_uuid_for('metric_type')")).scalar() + expected = db_session.query(DBMetricType.uuid).filter_by(name=METRICS.UNKNOWN.name).scalar() + assert result == expected + + +def test_get_default_uuid_for_reference(db_session): + """Stub function returns the UUID of the default UNKNOWN reference row.""" + result = db_session.execute(text("SELECT get_default_uuid_for('reference')")).scalar() + unknown_ref_type = db_session.query(DBReferenceType).filter_by(name=REF_TYPES.UNKNOWN.name).one() + expected = db_session.query(DBReference.uuid).filter_by( + reference_type_uuid=unknown_ref_type.uuid, + compound_reference_uuid=None, + ).scalar() + assert result == expected + + +def test_seeded_uuids_fixture(seeded_uuids): + """seeded_uuids convenience fixture has the expected keys.""" + assert f"metric_type.{METRICS.UNKNOWN.name}" in seeded_uuids + assert f"metric_type.{METRICS.PHASE_OFFSET.name}" in seeded_uuids + assert f"reference_type.{REF_TYPES.UNKNOWN.name}" in seeded_uuids + assert "reference.unknown" in seeded_uuids + assert "default.metric_type" in seeded_uuids + assert "default.reference" in seeded_uuids + + +def test_session_rollback_isolation(db_session, db_engine): + """Writes in one session do not leak — the savepoint rolls back cleanly.""" + from opensampl.db.orm import TestMetadata + + db_session.add(TestMetadata(name="rollback-canary")) + db_session.flush() + + # Row is visible within this session + assert db_session.query(TestMetadata).filter_by(name="rollback-canary").one() + + # After the test ends the fixture rolls back, but we can verify the + # mechanism works by checking a fresh session sees nothing yet + from sqlalchemy.orm import Session + with Session(bind=db_engine) as fresh: + result = fresh.query(TestMetadata).filter_by(name="rollback-canary").first() + assert result is None, "Savepoint did not isolate the write from other sessions" \ No newline at end of file diff --git a/tests/test_geolocator.py b/tests/test_geolocator.py new file mode 100644 index 0000000..a112503 --- /dev/null +++ b/tests/test_geolocator.py @@ -0,0 +1,127 @@ +"""Tests for geolocation helper behavior.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from opensampl.helpers import geolocator + + +class TestLookupGeoIpApi: + """Tests for the ip-api lookup helper.""" + + def test_lookup_geo_ipapi_success_is_cached(self): + """Successful lookups should be cached and return a stable tuple.""" + body = {"status": "success", "lat": 35.9, "lon": -84.3, "city": "Oak Ridge", "country": "United States"} + + class Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): # noqa: ANN001,ARG002 + return False + + def read(self): + return json.dumps(body).encode("utf-8") + + geolocator._GEO_CACHE.clear() + with patch("opensampl.helpers.geolocator.urllib.request.urlopen", return_value=Response()) as mock_urlopen: + first = geolocator._lookup_geo_ipapi("8.8.8.8") + second = geolocator._lookup_geo_ipapi("8.8.8.8") + + assert first == (35.9, -84.3, "Oak Ridge, United States") + assert second == first + mock_urlopen.assert_called_once() + + def test_lookup_geo_ipapi_failure_returns_none(self): + """Lookup failures should degrade to None instead of raising.""" + geolocator._GEO_CACHE.clear() + with patch("opensampl.helpers.geolocator.urllib.request.urlopen", side_effect=OSError("blocked")): + assert geolocator._lookup_geo_ipapi("8.8.8.8") is None + + +class TestCreateLocation: + """Tests for location creation decisions.""" + + def test_create_location_uses_override_without_network_lookup(self): + """Explicit overrides should be written directly.""" + fake_loc = SimpleNamespace(uuid="loc-123") + fake_factory = Mock() + fake_factory.find_existing.return_value = None + fake_factory.write.return_value = fake_loc + + with patch("opensampl.helpers.geolocator.TableFactory", return_value=fake_factory): + result = geolocator.create_location( + session=Mock(), + geolocate_enabled=False, + ip_address="mock-ntp-a", + geo_override={"name": "Docker lab", "lat": 37.37, "lon": -122.05}, + ) + + assert result == "loc-123" + fake_factory.write.assert_called_once_with( + {"name": "Docker lab", "lat": 37.37, "lon": -122.05, "public": True}, + if_exists="ignore", + ) + + def test_create_location_uses_existing_named_location(self): + """Named overrides should reuse existing locations when present.""" + fake_factory = Mock() + fake_factory.find_existing.return_value = SimpleNamespace(uuid="loc-existing") + + with patch("opensampl.helpers.geolocator.TableFactory", return_value=fake_factory): + result = geolocator.create_location( + session=Mock(), + geolocate_enabled=False, + ip_address="mock-ntp-b", + geo_override={"name": "Docker lab", "lat": 37.38, "lon": -122.06}, + ) + + assert result == "loc-existing" + fake_factory.write.assert_not_called() + + def test_create_location_uses_public_lookup_when_enabled(self): + """Public hosts should use lookup-derived coordinates and label when enabled.""" + fake_loc = SimpleNamespace(uuid="loc-public") + fake_factory = Mock() + fake_factory.find_existing.return_value = None + fake_factory.write.return_value = fake_loc + + with ( + patch("opensampl.helpers.geolocator.TableFactory", return_value=fake_factory), + patch("opensampl.helpers.geolocator.socket.gethostbyname", return_value="8.8.8.8"), + patch("opensampl.helpers.geolocator._lookup_geo_ipapi", return_value=(40.71, -74.0, "New York, United States")), + ): + result = geolocator.create_location( + session=Mock(), + geolocate_enabled=True, + ip_address="time.example.com", + geo_override={}, + ) + + assert result == "loc-public" + fake_factory.write.assert_called_once_with( + {"name": "New York, United States", "lat": 40.71, "lon": -74.0, "public": True}, + if_exists="ignore", + ) + + def test_create_location_returns_none_when_name_is_unavailable(self): + """Private/loopback lookups without a name should skip location creation.""" + fake_factory = Mock() + fake_factory.find_existing.return_value = None + + with ( + patch("opensampl.helpers.geolocator.TableFactory", return_value=fake_factory), + patch("opensampl.helpers.geolocator.socket.gethostbyname", return_value="127.0.0.1"), + ): + result = geolocator.create_location( + session=Mock(), + geolocate_enabled=True, + ip_address="localhost", + geo_override={}, + ) + + assert result is None + fake_factory.write.assert_not_called() diff --git a/tests/test_load_data.py b/tests/test_load_data.py index 2e6dfaf..bd6c9fb 100644 --- a/tests/test_load_data.py +++ b/tests/test_load_data.py @@ -299,6 +299,59 @@ def test_real_session_flow( assert entry.probe_id == sample_probe_key.probe_id assert entry.ip_address == sample_probe_key.ip_address + def test_ntp_metadata_creates_probe_and_vendor_rows( + self, + mock_config: Mock, + mock_session: Session, + test_db: MockDB, + mock_table_factory_with_mockdb: Any, + ): # noqa: ARG002 + """NTP metadata loading should create both probe_metadata and ntp_metadata records.""" + from opensampl.vendors.constants import ProbeKey, VENDORS + + mock_config.return_value.ENABLE_GEOLOCATE = False + probe_key = ProbeKey(probe_id="public-time", ip_address="time.cloudflare.com") + payload = { + "mode": "remote", + "reference": False, + "target_host": "time.cloudflare.com", + "target_port": 123, + "sync_status": "synchronized", + "leap_status": "no_warning", + "reference_id": "GPS", + "observation_sources": ["ntplib"], + "collection_id": "collector-host", + "collection_ip": "10.0.0.5", + "timeout": 1.5, + "additional_metadata": {"version": 3}, + } + + result = load_probe_metadata( + vendor=VENDORS.NTP, + probe_key=probe_key, + data=payload.copy(), + session=mock_session, + ) + + ProbeMetadata = test_db.table_mappings["ProbeMetadata"] # noqa: N806 + NtpMetadata = test_db.table_mappings["NtpMetadata"] # noqa: N806 + + assert result is None + + probe_row = mock_session.query(ProbeMetadata).filter_by(ip_address="time.cloudflare.com").one() + ntp_row = mock_session.query(NtpMetadata).filter_by(probe_uuid=probe_row.uuid).one() + + assert probe_row.vendor == "NTP" + assert probe_row.probe_id == "public-time" + assert probe_row.ip_address == "time.cloudflare.com" + assert ntp_row.probe_uuid == probe_row.uuid + assert ntp_row.mode == "remote" + assert ntp_row.target_host == "time.cloudflare.com" + assert ntp_row.target_port == 123 + assert ntp_row.sync_status == "synchronized" + assert ntp_row.collection_id == "collector-host" + assert ntp_row.collection_ip == "10.0.0.5" + class TestMockDb: """Tests for the mock database itself""" diff --git a/tests/test_ntp.py b/tests/test_ntp.py new file mode 100644 index 0000000..00a2eff --- /dev/null +++ b/tests/test_ntp.py @@ -0,0 +1,193 @@ +"""Tests for the NTP probe and collectors.""" + +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import call, patch + +import pytest + +from opensampl.vendors.constants import ProbeKey +from opensampl.vendors.ntp import NTPLocalCollector, NTPRemoteCollector, NtpProbe + + +class TestNTPLocalCollector: + """Tests for local NTP collection helpers.""" + + def test_collect_prefers_chrony_outputs(self): + """Local collection should parse chrony output into probe metrics.""" + collector = NTPLocalCollector( + target_host="127.0.0.1", + collection_id="collector-host", + collection_ip="10.0.0.5", + ) + + outputs = { + ("chronyc", "tracking"): """ +Reference ID : GPS (time.cloudflare.com) +Stratum : 2 +System time : 0.000000100 seconds slow of NTP time +Last offset : +0.000000123 seconds +RMS offset : 0.000001234 seconds +Leap status : Normal +""", + ("chronyc", "sources", "-v"): """ +MS Name/IP address Stratum Poll Reach LastRx Last sample +=============================================================================== +* time.cloudflare.com 2 6 34 377 0.001 0.002 0.008 +""", + ("timedatectl", "show-timesync", "--all"): "System clock synchronized=yes\n", + ("systemctl", "show", "systemd-timesyncd", "--property=ActiveState"): "ActiveState=active\n", + } + + with patch.object( + collector, + "_run", + side_effect=lambda cmd, timeout=8.0: outputs.get(tuple(cmd)), # noqa: ARG005 + ): + collector.collect() + + assert collector.offset_s == pytest.approx(1.23e-7) + assert collector.jitter_s == pytest.approx(1.234e-6) + assert collector.stratum == 2 + assert collector.reachability == 377 + assert collector.reference_id == "time.cloudflare.com" + assert collector.sync_status == "tracking" + assert collector.sync_health == 1.0 + assert collector.probe_id == "ntp-local" + assert collector.observation_sources == [ + "chronyc_tracking", + "chronyc_sources", + "timedatectl", + "systemctl_timesyncd", + ] + + +class TestNTPRemoteCollector: + """Tests for remote NTP collection helpers.""" + + def test_collect_success_sets_metrics(self, monkeypatch: pytest.MonkeyPatch): + """Remote collection should map ntplib responses onto collector fields.""" + response = SimpleNamespace( + leap=0, + stratum=3, + poll=4, + root_delay=0.125, + root_dispersion=0.2, + delay=0.05, + offset=-0.002, + ref_id=b"GPS", + version=3, + ) + + client = SimpleNamespace(request=lambda *args, **kwargs: response) + ntplib_mod = ModuleType("ntplib") + ntplib_mod.NTPClient = lambda: client + monkeypatch.setitem(__import__("sys").modules, "ntplib", ntplib_mod) + + collector = NTPRemoteCollector( + target_host="time.cloudflare.com", + target_port=123, + timeout=1.5, + collection_id="collector-host", + collection_ip="10.0.0.5", + ) + + collector.collect() + + assert collector.sync_status == "synchronized" + assert collector.sync_health == 1.0 + assert collector.stratum == 3 + assert collector.poll_interval_s == 16.0 + assert collector.root_delay_s == pytest.approx(0.125) + assert collector.root_dispersion_s == pytest.approx(0.2) + assert collector.delay_s == pytest.approx(0.05) + assert collector.offset_s == pytest.approx(-0.002) + assert collector.jitter_s == pytest.approx((0.05 * 0.05) + (0.25 * 0.2)) + assert collector.reference_id == "GPS" + assert collector.leap_status == "no_warning" + assert collector.probe_id == "remote:123" + assert collector.observation_sources == ["ntplib"] + assert collector.extras["version"] == 3 + + def test_collect_failure_marks_probe_unreachable(self, monkeypatch: pytest.MonkeyPatch): + """Remote collection should downgrade status cleanly on request errors.""" + + class FailingClient: + def request(self, *args, **kwargs): # noqa: ARG002 + raise TimeoutError("timed out") + + ntplib_mod = ModuleType("ntplib") + ntplib_mod.NTPClient = FailingClient + monkeypatch.setitem(__import__("sys").modules, "ntplib", ntplib_mod) + + collector = NTPRemoteCollector( + target_host="time.cloudflare.com", + target_port=123, + collection_id="collector-host", + collection_ip="10.0.0.5", + ) + + collector.collect() + + assert collector.sync_status == "unreachable" + assert collector.sync_health == 0 + assert collector.observation_sources == ["ntplib", "error"] + assert collector.extras["error"] == "timed out" + + +class TestNtpProbe: + """Tests for the NTP probe parser.""" + + def test_process_metadata_sets_collection_and_target_probes(self, tmp_path: Path): + """Processing file headers should register the collection probe and target probe.""" + ntp_file = tmp_path / "sample-ntp.csv" + ntp_file.write_text( + "\n".join( + [ + "# collection_ip: 10.0.0.5", + "# collection_id: collector-host", + "# target_host: time.cloudflare.com", + "# probe_id: public-time", + "# mode: remote", + "time,value,metric", + "2026-04-24T00:00:00Z,-0.001,phase_offset_s", + ] + ) + + "\n" + ) + + with patch("opensampl.vendors.ntp.load_probe_metadata") as mock_load_probe_metadata: + probe = NtpProbe(ntp_file) + metadata = probe.process_metadata() + + assert metadata["target_host"] == "time.cloudflare.com" + assert probe.collection_probe == ProbeKey(ip_address="10.0.0.5", probe_id="collector-host") + assert probe.probe_key == ProbeKey(ip_address="time.cloudflare.com", probe_id="public-time") + mock_load_probe_metadata.assert_called_once_with( + vendor=probe.vendor, + probe_key=ProbeKey(ip_address="10.0.0.5", probe_id="collector-host"), + data={"reference": True}, + ) + + def test_load_metadata_registers_collection_probe_then_target_probe(self): + """Metadata loading should create the collection reference before the target probe.""" + metadata = { + "collection_ip": "10.0.0.5", + "collection_id": "collector-host", + "target_host": "time.cloudflare.com", + "probe_id": "public-time", + "mode": "remote", + } + probe_key = ProbeKey(ip_address="time.cloudflare.com", probe_id="public-time") + + with patch("opensampl.vendors.ntp.load_probe_metadata") as mock_load_probe_metadata: + NtpProbe.load_metadata(probe_key=probe_key, metadata=metadata) + + assert mock_load_probe_metadata.call_args_list == [ + call( + vendor=NtpProbe.vendor, + probe_key=ProbeKey(ip_address="10.0.0.5", probe_id="collector-host"), + data={"reference": True}, + ), + call(vendor=NtpProbe.vendor, probe_key=probe_key, data=metadata), + ] diff --git a/tests/test_vendors.py b/tests/test_vendors.py index bb17e89..40d8e9c 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -141,9 +141,6 @@ def process_time_data(self): import pandas as pd return pd.DataFrame() - def generate_random_data(self, config: BaseProbe.RandomDataConfig, probe_key: ProbeKey): - return probe_key - probe = TestProbe("test_file.txt") assert probe.input_file == Path("test_file.txt") @@ -164,9 +161,6 @@ def process_time_data(self): import pandas as pd return pd.DataFrame() - def generate_random_data(self, config: BaseProbe.RandomDataConfig, probe_key: ProbeKey): - return probe_key - # Mock the click context mock_ctx = Mock() mock_ctx.obj = {"conf": Mock()} @@ -203,9 +197,6 @@ def process_time_data(self): import pandas as pd return pd.DataFrame() - def generate_random_data(self, config: BaseProbe.RandomDataConfig, probe_key: ProbeKey): - return probe_key - mock_ctx = Mock() mock_ctx.obj = {"conf": Mock()} mock_ctx.obj["conf"].ARCHIVE_PATH = Path("/default/archive") @@ -247,9 +238,6 @@ def process_time_data(self): import pandas as pd return pd.DataFrame() - def generate_random_data(self, config: BaseProbe.RandomDataConfig, probe_key: ProbeKey): - return probe_key - mock_ctx = Mock() mock_ctx.obj = {"conf": Mock()} mock_ctx.obj["conf"].ARCHIVE_PATH = Path("/default/archive") @@ -346,4 +334,4 @@ def test_vendors_get_by_name_case_insensitive(self): def test_vendors_get_by_name_not_found(self): """Test getting vendor by name that doesn't exist.""" with pytest.raises(ValueError): - VENDORS.get_by_name("NONEXISTENT") \ No newline at end of file + VENDORS.get_by_name("NONEXISTENT") diff --git a/tests/utils/mockdb.py b/tests/utils/mockdb.py index 44e27f1..58b5e1c 100644 --- a/tests/utils/mockdb.py +++ b/tests/utils/mockdb.py @@ -499,14 +499,14 @@ def _load_metric_types(self, session: Session) -> str: # Get all metric types from the METRICS class metrics = [attr for attr in METRICS.__dict__.values() if isinstance(attr, MetricType)] - phaseerr_uuid = None + unknown_uuid = None for m in metrics: metric = MetricTypeTable(**m.model_dump()) session.add(metric) session.flush() - if metric.name == "PHASE": - phaseerr_uuid = metric.uuid - return phaseerr_uuid + if metric.name == "UNKNOWN": + unknown_uuid = metric.uuid + return unknown_uuid def _load_reference_types(self, session: Session) -> str: """Load reference types from opensampl.references.REF_TYPES and return the UNKNOWN one's UUID.""" diff --git a/tox.ini b/tox.ini index 838288c..ebb6afa 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39, py310, py311, py312, lint +envlist = py310, py311, py312, py313, py314, lint skipsdist = True isolated_build = True @@ -83,4 +83,4 @@ disable = unused-argument, missing-class-docstring, too few public methods, - missing function or method docstring \ No newline at end of file + missing function or method docstring diff --git a/uv.lock b/uv.lock index 0ba575b..48439e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,37 +1,35 @@ version = 1 -revision = 2 -requires-python = ">=3.9, <3.13" +revision = 3 +requires-python = ">=3.10, <3.15" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version < '3.11'", ] [[package]] name = "alabaster" -version = "0.7.16" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] -name = "alabaster" -version = "1.0.0" +name = "alembic" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] [[package]] @@ -39,19 +37,26 @@ name = "allantools" version = "2024.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "matplotlib", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "matplotlib" }, { name = "numpy" }, { name = "numpydoc" }, { name = "pytest" }, - { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "scipy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/81/1adc1ffe918959f3df124aef8e1dde380a6d2ce24528c5da8107596b3e02/allantools-2024.6.tar.gz", hash = "sha256:c4380c74de834ac869aefc899038e784ef1dd396370be89d6836abffbe484289", size = 4093089, upload-time = "2024-07-04T06:45:16.489Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/a1/d32722ff0475739230c28d3f1194cf16d360d4b328ff590f8ea4a00e6fca/allantools-2024.6-py3-none-any.whl", hash = "sha256:0d0d20e3c45245c4aff5346c59ae0f6e56ed61e691e481a06ffbab94875df2c9", size = 47275, upload-time = "2024-07-04T06:45:10Z" }, ] +[[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" @@ -61,6 +66,20 @@ 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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "astor" version = "0.8.1" @@ -88,6 +107,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/cb/d019ab87fe70e0fe3946196d50d6a4428623dc0c38a6669c8cae0320fbf3/backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d", size = 380337, upload-time = "2025-02-25T16:53:14.607Z" }, { url = "https://files.pythonhosted.org/packages/a9/86/abd17f50ee21b2248075cb6924c6e7f9d23b4925ca64ec660e869c2633f1/backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b", size = 392142, upload-time = "2025-02-25T16:53:17.266Z" }, { url = "https://files.pythonhosted.org/packages/b3/04/7b415bd75c8ab3268cc138c76fa648c19495fcc7d155508a0e62f3f82308/backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486", size = 398021, upload-time = "2025-02-25T16:53:26.378Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/60dcfb90eb03a06e883a92abbc2ab95c71f0d8c9dd0af76ab1d5ce0b1402/backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585", size = 399915, upload-time = "2025-02-25T16:53:28.167Z" }, { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336, upload-time = "2025-02-25T16:53:29.858Z" }, ] @@ -145,48 +165,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, - { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, - { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, - { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, - { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, - { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - [[package]] name = "click" version = "8.2.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ @@ -202,77 +202,12 @@ 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 = "contourpy" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/f6/31a8f28b4a2a4fa0e01085e542f3081ab0588eff8e589d39d775172c9792/contourpy-1.3.0.tar.gz", hash = "sha256:7ffa0db17717a8ffb127efd0c95a4362d996b892c2904db72428d5b52e1938a4", size = 13464370, upload-time = "2024-08-27T21:00:03.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/e0/be8dcc796cfdd96708933e0e2da99ba4bb8f9b2caa9d560a50f3f09a65f3/contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7", size = 265366, upload-time = "2024-08-27T20:50:09.947Z" }, - { url = "https://files.pythonhosted.org/packages/50/d6/c953b400219443535d412fcbbc42e7a5e823291236bc0bb88936e3cc9317/contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42", size = 249226, upload-time = "2024-08-27T20:50:16.1Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b4/6fffdf213ffccc28483c524b9dad46bb78332851133b36ad354b856ddc7c/contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f8557cbb07415a4d6fa191f20fd9d2d9eb9c0b61d1b2f52a8926e43c6e9af7", size = 308460, upload-time = "2024-08-27T20:50:22.536Z" }, - { url = "https://files.pythonhosted.org/packages/cf/6c/118fc917b4050f0afe07179a6dcbe4f3f4ec69b94f36c9e128c4af480fb8/contourpy-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f965570cff02b874773c49bfe85562b47030805d7d8360748f3eca570f4cab", size = 347623, upload-time = "2024-08-27T20:50:28.806Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a4/30ff110a81bfe3abf7b9673284d21ddce8cc1278f6f77393c91199da4c90/contourpy-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cacd81e2d4b6f89c9f8a5b69b86490152ff39afc58a95af002a398273e5ce589", size = 317761, upload-time = "2024-08-27T20:50:35.126Z" }, - { url = "https://files.pythonhosted.org/packages/99/e6/d11966962b1aa515f5586d3907ad019f4b812c04e4546cc19ebf62b5178e/contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69375194457ad0fad3a839b9e29aa0b0ed53bb54db1bfb6c3ae43d111c31ce41", size = 322015, upload-time = "2024-08-27T20:50:40.318Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e3/182383743751d22b7b59c3c753277b6aee3637049197624f333dac5b4c80/contourpy-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a52040312b1a858b5e31ef28c2e865376a386c60c0e248370bbea2d3f3b760d", size = 1262672, upload-time = "2024-08-27T20:50:55.643Z" }, - { url = "https://files.pythonhosted.org/packages/78/53/974400c815b2e605f252c8fb9297e2204347d1755a5374354ee77b1ea259/contourpy-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3faeb2998e4fcb256542e8a926d08da08977f7f5e62cf733f3c211c2a5586223", size = 1321688, upload-time = "2024-08-27T20:51:11.293Z" }, - { url = "https://files.pythonhosted.org/packages/52/29/99f849faed5593b2926a68a31882af98afbeac39c7fdf7de491d9c85ec6a/contourpy-1.3.0-cp310-cp310-win32.whl", hash = "sha256:36e0cff201bcb17a0a8ecc7f454fe078437fa6bda730e695a92f2d9932bd507f", size = 171145, upload-time = "2024-08-27T20:51:15.2Z" }, - { url = "https://files.pythonhosted.org/packages/a9/97/3f89bba79ff6ff2b07a3cbc40aa693c360d5efa90d66e914f0ff03b95ec7/contourpy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:87ddffef1dbe5e669b5c2440b643d3fdd8622a348fe1983fad7a0f0ccb1cd67b", size = 216019, upload-time = "2024-08-27T20:51:19.365Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1f/9375917786cb39270b0ee6634536c0e22abf225825602688990d8f5c6c19/contourpy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fa4c02abe6c446ba70d96ece336e621efa4aecae43eaa9b030ae5fb92b309ad", size = 266356, upload-time = "2024-08-27T20:51:24.146Z" }, - { url = "https://files.pythonhosted.org/packages/05/46/9256dd162ea52790c127cb58cfc3b9e3413a6e3478917d1f811d420772ec/contourpy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:834e0cfe17ba12f79963861e0f908556b2cedd52e1f75e6578801febcc6a9f49", size = 250915, upload-time = "2024-08-27T20:51:28.683Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5d/3056c167fa4486900dfbd7e26a2fdc2338dc58eee36d490a0ed3ddda5ded/contourpy-1.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbc4c3217eee163fa3984fd1567632b48d6dfd29216da3ded3d7b844a8014a66", size = 310443, upload-time = "2024-08-27T20:51:33.675Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c2/1a612e475492e07f11c8e267ea5ec1ce0d89971be496c195e27afa97e14a/contourpy-1.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865cd1d419e0c7a7bf6de1777b185eebdc51470800a9f42b9e9decf17762081", size = 348548, upload-time = "2024-08-27T20:51:39.322Z" }, - { url = "https://files.pythonhosted.org/packages/45/cf/2c2fc6bb5874158277b4faf136847f0689e1b1a1f640a36d76d52e78907c/contourpy-1.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:303c252947ab4b14c08afeb52375b26781ccd6a5ccd81abcdfc1fafd14cf93c1", size = 319118, upload-time = "2024-08-27T20:51:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/03/33/003065374f38894cdf1040cef474ad0546368eea7e3a51d48b8a423961f8/contourpy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637f674226be46f6ba372fd29d9523dd977a291f66ab2a74fbeb5530bb3f445d", size = 323162, upload-time = "2024-08-27T20:51:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/42/80/e637326e85e4105a802e42959f56cff2cd39a6b5ef68d5d9aee3ea5f0e4c/contourpy-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76a896b2f195b57db25d6b44e7e03f221d32fe318d03ede41f8b4d9ba1bff53c", size = 1265396, upload-time = "2024-08-27T20:52:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/8cbd6416ca1bbc0202b50f9c13b2e0b922b64be888f9d9ee88e6cfabfb51/contourpy-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e1fd23e9d01591bab45546c089ae89d926917a66dceb3abcf01f6105d927e2cb", size = 1324297, upload-time = "2024-08-27T20:52:21.843Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2c/021a7afaa52fe891f25535506cc861c30c3c4e5a1c1ce94215e04b293e72/contourpy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:d402880b84df3bec6eab53cd0cf802cae6a2ef9537e70cf75e91618a3801c20c", size = 171808, upload-time = "2024-08-27T20:52:25.163Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/804f02ff30a7fae21f98198828d0857439ec4c91a96e20cf2d6c49372966/contourpy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:6cb6cc968059db9c62cb35fbf70248f40994dfcd7aa10444bbf8b3faeb7c2d67", size = 217181, upload-time = "2024-08-27T20:52:29.13Z" }, - { url = "https://files.pythonhosted.org/packages/c9/92/8e0bbfe6b70c0e2d3d81272b58c98ac69ff1a4329f18c73bd64824d8b12e/contourpy-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:570ef7cf892f0afbe5b2ee410c507ce12e15a5fa91017a0009f79f7d93a1268f", size = 267838, upload-time = "2024-08-27T20:52:33.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/04/33351c5d5108460a8ce6d512307690b023f0cfcad5899499f5c83b9d63b1/contourpy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da84c537cb8b97d153e9fb208c221c45605f73147bd4cadd23bdae915042aad6", size = 251549, upload-time = "2024-08-27T20:52:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/51/3d/aa0fe6ae67e3ef9f178389e4caaaa68daf2f9024092aa3c6032e3d174670/contourpy-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be4d8425bfa755e0fd76ee1e019636ccc7c29f77a7c86b4328a9eb6a26d0639", size = 303177, upload-time = "2024-08-27T20:52:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/56/c3/c85a7e3e0cab635575d3b657f9535443a6f5d20fac1a1911eaa4bbe1aceb/contourpy-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c0da700bf58f6e0b65312d0a5e695179a71d0163957fa381bb3c1f72972537c", size = 341735, upload-time = "2024-08-27T20:52:51.05Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/20f7a211a7be966a53f474bc90b1a8202e9844b3f1ef85f3ae45a77151ee/contourpy-1.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb8b141bb00fa977d9122636b16aa67d37fd40a3d8b52dd837e536d64b9a4d06", size = 314679, upload-time = "2024-08-27T20:52:58.473Z" }, - { url = "https://files.pythonhosted.org/packages/6e/be/524e377567defac0e21a46e2a529652d165fed130a0d8a863219303cee18/contourpy-1.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3634b5385c6716c258d0419c46d05c8aa7dc8cb70326c9a4fb66b69ad2b52e09", size = 320549, upload-time = "2024-08-27T20:53:06.593Z" }, - { url = "https://files.pythonhosted.org/packages/0f/96/fdb2552a172942d888915f3a6663812e9bc3d359d53dafd4289a0fb462f0/contourpy-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0dce35502151b6bd35027ac39ba6e5a44be13a68f55735c3612c568cac3805fd", size = 1263068, upload-time = "2024-08-27T20:53:23.442Z" }, - { url = "https://files.pythonhosted.org/packages/2a/25/632eab595e3140adfa92f1322bf8915f68c932bac468e89eae9974cf1c00/contourpy-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea348f053c645100612b333adc5983d87be69acdc6d77d3169c090d3b01dc35", size = 1322833, upload-time = "2024-08-27T20:53:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/69738782e315a1d26d29d71a550dbbe3eb6c653b028b150f70c1a5f4f229/contourpy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:90f73a5116ad1ba7174341ef3ea5c3150ddf20b024b98fb0c3b29034752c8aeb", size = 172681, upload-time = "2024-08-27T20:53:43.05Z" }, - { url = "https://files.pythonhosted.org/packages/0c/89/9830ba00d88e43d15e53d64931e66b8792b46eb25e2050a88fec4a0df3d5/contourpy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b11b39aea6be6764f84360fce6c82211a9db32a7c7de8fa6dd5397cf1d079c3b", size = 218283, upload-time = "2024-08-27T20:53:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/b9f72758adb6ef7397327ceb8b9c39c75711affb220e4f53c745ea1d5a9a/contourpy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a11077e395f67ffc2c44ec2418cfebed032cd6da3022a94fc227b6faf8e2acb8", size = 265518, upload-time = "2024-08-27T20:56:01.333Z" }, - { url = "https://files.pythonhosted.org/packages/ec/22/19f5b948367ab5260fb41d842c7a78dae645603881ea6bc39738bcfcabf6/contourpy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e8134301d7e204c88ed7ab50028ba06c683000040ede1d617298611f9dc6240c", size = 249350, upload-time = "2024-08-27T20:56:05.432Z" }, - { url = "https://files.pythonhosted.org/packages/26/76/0c7d43263dd00ae21a91a24381b7e813d286a3294d95d179ef3a7b9fb1d7/contourpy-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e12968fdfd5bb45ffdf6192a590bd8ddd3ba9e58360b29683c6bb71a7b41edca", size = 309167, upload-time = "2024-08-27T20:56:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/96/3b/cadff6773e89f2a5a492c1a8068e21d3fccaf1a1c1df7d65e7c8e3ef60ba/contourpy-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2a0fc506eccaaa7595b7e1418951f213cf8255be2600f1ea1b61e46a60c55f", size = 348279, upload-time = "2024-08-27T20:56:15.41Z" }, - { url = "https://files.pythonhosted.org/packages/e1/86/158cc43aa549d2081a955ab11c6bdccc7a22caacc2af93186d26f5f48746/contourpy-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfb5c62ce023dfc410d6059c936dcf96442ba40814aefbfa575425a3a7f19dc", size = 318519, upload-time = "2024-08-27T20:56:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/05/11/57335544a3027e9b96a05948c32e566328e3a2f84b7b99a325b7a06d2b06/contourpy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68a32389b06b82c2fdd68276148d7b9275b5f5cf13e5417e4252f6d1a34f72a2", size = 321922, upload-time = "2024-08-27T20:56:26.983Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e3/02114f96543f4a1b694333b92a6dcd4f8eebbefcc3a5f3bbb1316634178f/contourpy-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:94e848a6b83da10898cbf1311a815f770acc9b6a3f2d646f330d57eb4e87592e", size = 1258017, upload-time = "2024-08-27T20:56:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3b/bfe4c81c6d5881c1c643dde6620be0b42bf8aab155976dd644595cfab95c/contourpy-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d78ab28a03c854a873787a0a42254a0ccb3cb133c672f645c9f9c8f3ae9d0800", size = 1316773, upload-time = "2024-08-27T20:56:58.58Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/c52d2970784383cafb0bd918b6fb036d98d96bbf0bc1befb5d1e31a07a70/contourpy-1.3.0-cp39-cp39-win32.whl", hash = "sha256:81cb5ed4952aae6014bc9d0421dec7c5835c9c8c31cdf51910b708f548cf58e5", size = 171353, upload-time = "2024-08-27T20:57:02.718Z" }, - { url = "https://files.pythonhosted.org/packages/53/23/db9f69676308e094d3c45f20cc52e12d10d64f027541c995d89c11ad5c75/contourpy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:14e262f67bd7e6eb6880bc564dcda30b15e351a594657e55b7eec94b6ef72843", size = 211817, upload-time = "2024-08-27T20:57:06.328Z" }, - { url = "https://files.pythonhosted.org/packages/d1/09/60e486dc2b64c94ed33e58dcfb6f808192c03dfc5574c016218b9b7680dc/contourpy-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe41b41505a5a33aeaed2a613dccaeaa74e0e3ead6dd6fd3a118fb471644fd6c", size = 261886, upload-time = "2024-08-27T20:57:10.863Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/b57f9f7174fcd439a7789fb47d764974ab646fa34d1790551de386457a8e/contourpy-1.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca7e17a65f72a5133bdbec9ecf22401c62bcf4821361ef7811faee695799779", size = 311008, upload-time = "2024-08-27T20:57:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/74/fc/5040d42623a1845d4f17a418e590fd7a79ae8cb2bad2b2f83de63c3bdca4/contourpy-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1ec4dc6bf570f5b22ed0d7efba0dfa9c5b9e0431aeea7581aa217542d9e809a4", size = 215690, upload-time = "2024-08-27T20:57:19.321Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/dc3dcd77ac7460ab7e9d2b01a618cb31406902e50e605a8d6091f0a8f7cc/contourpy-1.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:00ccd0dbaad6d804ab259820fa7cb0b8036bda0686ef844d24125d8287178ce0", size = 261894, upload-time = "2024-08-27T20:57:23.873Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/531642a01cfec39d1682e46b5457b07cf805e3c3c584ec27e2a6223f8f6c/contourpy-1.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca947601224119117f7c19c9cdf6b3ab54c5726ef1d906aa4a69dfb6dd58102", size = 311099, upload-time = "2024-08-27T20:57:28.58Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/94bda024d629f254143a134eead69e21c836429a2a6ce82209a00ddcb79a/contourpy-1.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6ec93afeb848a0845a18989da3beca3eec2c0f852322efe21af1931147d12cb", size = 215838, upload-time = "2024-08-27T20:57:32.913Z" }, -] - [[package]] name = "contourpy" version = "1.3.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.10'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -306,6 +241,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, @@ -352,16 +307,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, - { url = "https://files.pythonhosted.org/packages/71/1e/388267ad9c6aa126438acc1ceafede3bb746afa9872e3ec5f0691b7d5efa/coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a", size = 211566, upload-time = "2025-05-23T11:39:32.333Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a5/acc03e5cf0bba6357f5e7c676343de40fbf431bb1e115fbebf24b2f7f65e/coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d", size = 211996, upload-time = "2025-05-23T11:39:34.512Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a2/0fc0a9f6b7c24fa4f1d7210d782c38cb0d5e692666c36eaeae9a441b6755/coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca", size = 240741, upload-time = "2025-05-23T11:39:36.252Z" }, - { url = "https://files.pythonhosted.org/packages/e6/da/1c6ba2cf259710eed8916d4fd201dccc6be7380ad2b3b9f63ece3285d809/coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d", size = 238672, upload-time = "2025-05-23T11:39:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/ac/51/c8fae0dc3ca421e6e2509503696f910ff333258db672800c3bdef256265a/coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787", size = 239769, upload-time = "2025-05-23T11:39:40.24Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/b97042ae92c59f40be0c989df090027377ba53f2d6cef73c9ca7685c26a6/coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7", size = 239555, upload-time = "2025-05-23T11:39:42.3Z" }, - { url = "https://files.pythonhosted.org/packages/47/35/b8893e682d6e96b1db2af5997fc13ef62219426fb17259d6844c693c5e00/coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3", size = 237768, upload-time = "2025-05-23T11:39:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/03/6c/023b0b9a764cb52d6243a4591dcb53c4caf4d7340445113a1f452bb80591/coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7", size = 238757, upload-time = "2025-05-23T11:39:46.195Z" }, - { url = "https://files.pythonhosted.org/packages/03/ed/3af7e4d721bd61a8df7de6de9e8a4271e67f3d9e086454558fd9f48eb4f6/coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a", size = 214166, upload-time = "2025-05-23T11:39:47.934Z" }, - { url = "https://files.pythonhosted.org/packages/9d/30/ee774b626773750dc6128354884652507df3c59d6aa8431526107e595227/coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e", size = 215050, upload-time = "2025-05-23T11:39:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, + { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, + { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, + { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, + { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, ] @@ -401,6 +368,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.0" +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/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, +] + [[package]] name = "fonttools" version = "4.58.1" @@ -431,14 +414,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/7e/83b409659eb4818f1283a8319f3570497718d6d3b70f4fca2ddf962e948e/fonttools-4.58.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4db9399ee633855c718fe8bea5eecbdc5bf3fdbed2648e50f67f8946b943ed1c", size = 5026677, upload-time = "2025-05-28T15:28:45.354Z" }, { url = "https://files.pythonhosted.org/packages/34/52/1eb69802d3b54e569158c97810195f317d350f56390b83c43e1c999551d8/fonttools-4.58.1-cp312-cp312-win32.whl", hash = "sha256:5cf04c4f73d36b30ea1cff091a7a9e65f8d5b08345b950f82679034e9f7573f4", size = 2176201, upload-time = "2025-05-28T15:28:47.417Z" }, { url = "https://files.pythonhosted.org/packages/6f/25/8dcfeb771de8d9cdffab2b957a05af4395d41ec9a198ec139d2326366a07/fonttools-4.58.1-cp312-cp312-win_amd64.whl", hash = "sha256:4a3841b59c67fa1f739542b05211609c453cec5d11d21f863dd2652d5a81ec9b", size = 2225519, upload-time = "2025-05-28T15:28:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/eaa8b2f38ad5339bc51ff75bf6a9c29e4b619453d8378ae9a374535e954d/fonttools-4.58.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:927762f9fe39ea0a4d9116353251f409389a6b58fab58717d3c3377acfc23452", size = 2740399, upload-time = "2025-05-28T15:29:07.124Z" }, - { url = "https://files.pythonhosted.org/packages/94/a1/6b56d0a5e20be9586c7669189cdcfcabced90bf676030f46397162d56926/fonttools-4.58.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:761ac80efcb7333c71760458c23f728d6fe2dff253b649faf52471fd7aebe584", size = 2309460, upload-time = "2025-05-28T15:29:08.928Z" }, - { url = "https://files.pythonhosted.org/packages/23/3c/bebd50b085d78d64ee518fb9c95fd08b90f9b715ca08c0b43fd53a963560/fonttools-4.58.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef910226f788a4e72aa0fc1c1657fb43fa62a4200b883edffdb1392b03fe86", size = 4701742, upload-time = "2025-05-28T15:29:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/89/4a/dbc6f9efac98718feba2735ceb72237e8965a4878529c0af6d33f32e7403/fonttools-4.58.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff2859ca2319454df8c26af6693269b21f2e9c0e46df126be916a4f6d85fc75", size = 4730821, upload-time = "2025-05-28T15:29:13.405Z" }, - { url = "https://files.pythonhosted.org/packages/63/ed/1a64f06747d05a8bb4d6b2bf7de59e960533d5303f254cf366cc4d827e7d/fonttools-4.58.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:418927e888e1bcc976b4e190a562f110dc27b0b5cac18033286f805dc137fc66", size = 4787238, upload-time = "2025-05-28T15:29:15.593Z" }, - { url = "https://files.pythonhosted.org/packages/86/82/ecb3e23507cca2548902cb1f1c09c7d919b9ad1bf83e464fd2c7c924adf6/fonttools-4.58.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a907007a8b341e8e129d3994d34d1cc85bc8bf38b3a0be65eb14e4668f634a21", size = 4895738, upload-time = "2025-05-28T15:29:18.022Z" }, - { url = "https://files.pythonhosted.org/packages/dc/44/73c560fbcdee65ffcf2dc9069afc21d5afab1cbdf318284d56649e937b30/fonttools-4.58.1-cp39-cp39-win32.whl", hash = "sha256:455cb6adc9f3419273925fadc51a6207046e147ce503797b29895ba6bdf85762", size = 1468967, upload-time = "2025-05-28T15:29:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/70/fe/df31c80575567b7239d225760a820b3abfe307e2830a9119bd4a6eb6bb8f/fonttools-4.58.1-cp39-cp39-win_amd64.whl", hash = "sha256:2e64931258866df187bd597b4e9fff488f059a0bc230fbae434f0f112de3ce46", size = 1513516, upload-time = "2025-05-28T15:29:22.294Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/7ed2e4e381f9b1f5122d33b7e626a40f646cacc1ef72d8806aacece9e580/fonttools-4.58.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68379d1599fc59569956a97eb7b07e0413f76142ac8513fa24c9f2c03970543a", size = 2731231, upload-time = "2025-05-28T15:28:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/e7/28/74864dc9248e917cbe07c903e0ce1517c89d42e2fab6b0ce218387ef0e24/fonttools-4.58.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8631905657de4f9a7ae1e12186c1ed20ba4d6168c2d593b9e0bd2908061d341b", size = 2305224, upload-time = "2025-05-28T15:28:53.114Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/ced758896188c1632c5b034a0741457f305e087eb4fa762d86aa3c1ae422/fonttools-4.58.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2ecea7289061c2c71468723409a8dd6e70d1ecfce6bc7686e5a74b9ce9154fe", size = 4793934, upload-time = "2025-05-28T15:28:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/c1/46/8b46469c6edac393de1c380c7ec61922d5440f25605dfca7849e5ffff295/fonttools-4.58.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b8860f8cd48b345bd1df1d7be650f600f69ee971ffe338c5bd5bcb6bdb3b92c", size = 4863415, upload-time = "2025-05-28T15:28:56.917Z" }, + { url = "https://files.pythonhosted.org/packages/12/1b/82aa678bb96af6663fe163d51493ffb8622948f4908c886cba6b67fbf6c5/fonttools-4.58.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7c9a0acdefcb8d7ccd7c59202056166c400e797047009ecb299b75ab950c2a9c", size = 4865025, upload-time = "2025-05-28T15:28:58.926Z" }, + { url = "https://files.pythonhosted.org/packages/7d/26/b66ab2f2dc34b962caecd6fa72a036395b1bc9fb849f52856b1e1144cd63/fonttools-4.58.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1fac0be6be3e4309058e156948cb73196e5fd994268b89b5e3f5a26ee2b582", size = 5002698, upload-time = "2025-05-28T15:29:01.118Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/cdddc63333ed77e810df56e5e7fb93659022d535a670335d8792be6d59fd/fonttools-4.58.1-cp313-cp313-win32.whl", hash = "sha256:aed7f93a9a072f0ce6fb46aad9474824ac6dd9c7c38a72f8295dd14f2215950f", size = 2174515, upload-time = "2025-05-28T15:29:03.424Z" }, + { url = "https://files.pythonhosted.org/packages/ba/81/c7f395718e44cebe1010fcd7f1b91957d65d512d5f03114d2d6d00cae1c4/fonttools-4.58.1-cp313-cp313-win_amd64.whl", hash = "sha256:b27d69c97c20c9bca807f7ae7fc7df459eb62994859ff6a2a489e420634deac3", size = 2225290, upload-time = "2025-05-28T15:29:05.099Z" }, { url = "https://files.pythonhosted.org/packages/21/ff/995277586691c0cc314c28b24b4ec30610440fd7bf580072aed1409f95b0/fonttools-4.58.1-py3-none-any.whl", hash = "sha256:db88365d0962cd6f5bce54b190a4669aeed9c9941aa7bd60a5af084d8d9173d6", size = 1113429, upload-time = "2025-05-28T15:29:24.185Z" }, ] @@ -476,7 +459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/66/910217271189cc3f32f670040235f4bf026ded8ca07270667d69c06e7324/greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6", size = 267395, upload-time = "2025-05-09T14:50:45.357Z" }, { url = "https://files.pythonhosted.org/packages/a8/36/8d812402ca21017c82880f399309afadb78a0aa300a9b45d741e4df5d954/greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7", size = 625742, upload-time = "2025-05-09T15:23:58.293Z" }, { url = "https://files.pythonhosted.org/packages/7b/77/66d7b59dfb7cc1102b2f880bc61cb165ee8998c9ec13c96606ba37e54c77/greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c", size = 637014, upload-time = "2025-05-09T15:24:47.025Z" }, - { url = "https://files.pythonhosted.org/packages/36/a7/ff0d408f8086a0d9a5aac47fa1b33a040a9fca89bd5a3f7b54d1cd6e2793/greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907", size = 632874, upload-time = "2025-05-09T15:29:20.014Z" }, { url = "https://files.pythonhosted.org/packages/a1/75/1dc2603bf8184da9ebe69200849c53c3c1dca5b3a3d44d9f5ca06a930550/greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f", size = 631652, upload-time = "2025-05-09T14:53:30.961Z" }, { url = "https://files.pythonhosted.org/packages/7b/74/ddc8c3bd4c2c20548e5bf2b1d2e312a717d44e2eca3eadcfc207b5f5ad80/greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13", size = 580619, upload-time = "2025-05-09T14:53:42.049Z" }, { url = "https://files.pythonhosted.org/packages/7e/f2/40f26d7b3077b1c7ae7318a4de1f8ffc1d8ccbad8f1d8979bf5080250fd6/greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5", size = 1109809, upload-time = "2025-05-09T15:26:59.063Z" }, @@ -485,7 +467,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/9f/a47e19261747b562ce88219e5ed8c859d42c6e01e73da6fbfa3f08a7be13/greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068", size = 268635, upload-time = "2025-05-09T14:50:39.007Z" }, { url = "https://files.pythonhosted.org/packages/11/80/a0042b91b66975f82a914d515e81c1944a3023f2ce1ed7a9b22e10b46919/greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce", size = 628786, upload-time = "2025-05-09T15:24:00.692Z" }, { url = "https://files.pythonhosted.org/packages/38/a2/8336bf1e691013f72a6ebab55da04db81a11f68e82bb691f434909fa1327/greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b", size = 640866, upload-time = "2025-05-09T15:24:48.153Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/f2a3a13e424670a5d08826dab7468fa5e403e0fbe0b5f951ff1bc4425b45/greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3", size = 636752, upload-time = "2025-05-09T15:29:23.182Z" }, { url = "https://files.pythonhosted.org/packages/fd/5d/ce4a03a36d956dcc29b761283f084eb4a3863401c7cb505f113f73af8774/greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74", size = 636028, upload-time = "2025-05-09T14:53:32.854Z" }, { url = "https://files.pythonhosted.org/packages/4b/29/b130946b57e3ceb039238413790dd3793c5e7b8e14a54968de1fe449a7cf/greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe", size = 583869, upload-time = "2025-05-09T14:53:43.614Z" }, { url = "https://files.pythonhosted.org/packages/ac/30/9f538dfe7f87b90ecc75e589d20cbd71635531a617a336c386d775725a8b/greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e", size = 1112886, upload-time = "2025-05-09T15:27:01.304Z" }, @@ -494,22 +475,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413, upload-time = "2025-05-09T14:51:32.455Z" }, { url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242, upload-time = "2025-05-09T15:24:02.63Z" }, { url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444, upload-time = "2025-05-09T15:24:49.856Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067, upload-time = "2025-05-09T15:29:24.989Z" }, { url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153, upload-time = "2025-05-09T14:53:34.716Z" }, { url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865, upload-time = "2025-05-09T14:53:45.738Z" }, { url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575, upload-time = "2025-05-09T15:27:04.248Z" }, { url = "https://files.pythonhosted.org/packages/30/05/356813470060bce0e81c3df63ab8cd1967c1ff6f5189760c1a4734d405ba/greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5", size = 1147460, upload-time = "2025-05-09T14:54:00.315Z" }, { url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239, upload-time = "2025-05-09T14:57:17.633Z" }, - { url = "https://files.pythonhosted.org/packages/37/3a/dbf22e1c7c1affc68ad4bc8f06619945c74a92b112ae6a401bed1f1ed63b/greenlet-3.2.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370", size = 266190, upload-time = "2025-05-09T14:50:53.356Z" }, - { url = "https://files.pythonhosted.org/packages/33/b1/21fabb65b13f504e8428595c54be73b78e7a542a2bd08ed9e1c56c8fcee2/greenlet-3.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59", size = 623904, upload-time = "2025-05-09T15:24:24.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/3346e463f13b593aafc683df6a85e9495a9b0c16c54c41f7e34353adea40/greenlet-3.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e", size = 635672, upload-time = "2025-05-09T15:24:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/8e/88/6e8459e4789a276d1a18d656fd95334d21fe0609c6d6f446f88dbfd9483d/greenlet-3.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa", size = 630975, upload-time = "2025-05-09T15:29:29.393Z" }, - { url = "https://files.pythonhosted.org/packages/ab/80/81ccf96daf166e8334c37663498dad742d61114cdf801f4872a38e8e31d5/greenlet-3.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819", size = 630252, upload-time = "2025-05-09T14:53:42.765Z" }, - { url = "https://files.pythonhosted.org/packages/c1/61/3489e3fd3b7dc81c73368177313231a1a1b30df660a0c117830aa18e0f29/greenlet-3.2.2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc", size = 579122, upload-time = "2025-05-09T14:53:49.702Z" }, - { url = "https://files.pythonhosted.org/packages/be/55/57685fe335e88f8c75d204f9967e46e5fba601f861fb80821e5fb7ab959d/greenlet-3.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457", size = 1108299, upload-time = "2025-05-09T15:27:10.193Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e2/3f27dd194989e8481ccac3b36932836b596d58f908106b8608f98587d9f7/greenlet-3.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a8fa80665b1a29faf76800173ff5325095f3e66a78e62999929809907aca5659", size = 1132431, upload-time = "2025-05-09T14:54:05.517Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7b/803075f7b1df9165032af07d81d783b04c59e64fb28b09fd7a0e5a249adc/greenlet-3.2.2-cp39-cp39-win32.whl", hash = "sha256:6629311595e3fe7304039c67f00d145cd1d38cf723bb5b99cc987b23c1433d61", size = 277740, upload-time = "2025-05-09T15:13:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a3/eb7713abfd0a079d24b775d01c6578afbcc6676d89508ab3cbebd5c836ea/greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834", size = 294863, upload-time = "2025-05-09T15:09:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150, upload-time = "2025-05-09T14:50:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381, upload-time = "2025-05-09T15:24:12.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427, upload-time = "2025-05-09T15:24:51.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398, upload-time = "2025-05-09T14:53:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795, upload-time = "2025-05-09T14:53:47.039Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976, upload-time = "2025-05-09T15:27:06.542Z" }, + { url = "https://files.pythonhosted.org/packages/c3/30/d0e88c1cfcc1b3331d63c2b54a0a3a4a950ef202fb8b92e772ca714a9221/greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708", size = 1145509, upload-time = "2025-05-09T14:54:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023, upload-time = "2025-05-09T14:53:24.157Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911, upload-time = "2025-05-09T15:24:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251, upload-time = "2025-05-09T15:24:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851, upload-time = "2025-05-09T14:53:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718, upload-time = "2025-05-09T14:53:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752, upload-time = "2025-05-09T15:27:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/68/3b/3b97f9d33c1f2eb081759da62bd6162159db260f602f048bc2f36b4c453e/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51", size = 1125170, upload-time = "2025-05-09T14:54:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899, upload-time = "2025-05-09T14:54:01.581Z" }, ] [[package]] @@ -524,6 +509,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" }, ] +[[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.10" @@ -542,30 +536,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - -[[package]] -name = "importlib-resources" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, -] - [[package]] name = "iniconfig" version = "2.1.0" @@ -587,102 +557,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "kiwisolver" -version = "1.4.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/85/4d/2255e1c76304cbd60b48cee302b66d1dde4468dc5b1160e4b7cb43778f2a/kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60", size = 97286, upload-time = "2024-09-04T09:39:44.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/14/fc943dd65268a96347472b4fbe5dcc2f6f55034516f80576cd0dd3a8930f/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6", size = 122440, upload-time = "2024-09-04T09:03:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/1e/46/e68fed66236b69dd02fcdb506218c05ac0e39745d696d22709498896875d/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17", size = 65758, upload-time = "2024-09-04T09:03:46.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fa/65de49c85838681fc9cb05de2a68067a683717321e01ddafb5b8024286f0/kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9", size = 64311, upload-time = "2024-09-04T09:03:47.973Z" }, - { url = "https://files.pythonhosted.org/packages/42/9c/cc8d90f6ef550f65443bad5872ffa68f3dee36de4974768628bea7c14979/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9", size = 1637109, upload-time = "2024-09-04T09:03:49.281Z" }, - { url = "https://files.pythonhosted.org/packages/55/91/0a57ce324caf2ff5403edab71c508dd8f648094b18cfbb4c8cc0fde4a6ac/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c", size = 1617814, upload-time = "2024-09-04T09:03:51.444Z" }, - { url = "https://files.pythonhosted.org/packages/12/5d/c36140313f2510e20207708adf36ae4919416d697ee0236b0ddfb6fd1050/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599", size = 1400881, upload-time = "2024-09-04T09:03:53.357Z" }, - { url = "https://files.pythonhosted.org/packages/56/d0/786e524f9ed648324a466ca8df86298780ef2b29c25313d9a4f16992d3cf/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05", size = 1512972, upload-time = "2024-09-04T09:03:55.082Z" }, - { url = "https://files.pythonhosted.org/packages/67/5a/77851f2f201e6141d63c10a0708e996a1363efaf9e1609ad0441b343763b/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407", size = 1444787, upload-time = "2024-09-04T09:03:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/06/5f/1f5eaab84355885e224a6fc8d73089e8713dc7e91c121f00b9a1c58a2195/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278", size = 2199212, upload-time = "2024-09-04T09:03:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/b5/28/9152a3bfe976a0ae21d445415defc9d1cd8614b2910b7614b30b27a47270/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5", size = 2346399, upload-time = "2024-09-04T09:04:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/f6/453d1904c52ac3b400f4d5e240ac5fec25263716723e44be65f4d7149d13/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad", size = 2308688, upload-time = "2024-09-04T09:04:02.216Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/d4968499441b9ae187e81745e3277a8b4d7c60840a52dc9d535a7909fac3/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895", size = 2445493, upload-time = "2024-09-04T09:04:04.571Z" }, - { url = "https://files.pythonhosted.org/packages/07/c9/032267192e7828520dacb64dfdb1d74f292765f179e467c1cba97687f17d/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3", size = 2262191, upload-time = "2024-09-04T09:04:05.969Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ad/db0aedb638a58b2951da46ddaeecf204be8b4f5454df020d850c7fa8dca8/kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc", size = 46644, upload-time = "2024-09-04T09:04:07.408Z" }, - { url = "https://files.pythonhosted.org/packages/12/ca/d0f7b7ffbb0be1e7c2258b53554efec1fd652921f10d7d85045aff93ab61/kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c", size = 55877, upload-time = "2024-09-04T09:04:08.869Z" }, - { url = "https://files.pythonhosted.org/packages/97/6c/cfcc128672f47a3e3c0d918ecb67830600078b025bfc32d858f2e2d5c6a4/kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a", size = 48347, upload-time = "2024-09-04T09:04:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77429fa0a58f941d6e1c58da9efe08597d2e86bf2b2cce6626834f49d07b/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54", size = 122442, upload-time = "2024-09-04T09:04:11.432Z" }, - { url = "https://files.pythonhosted.org/packages/e5/20/8c75caed8f2462d63c7fd65e16c832b8f76cda331ac9e615e914ee80bac9/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95", size = 65762, upload-time = "2024-09-04T09:04:12.468Z" }, - { url = "https://files.pythonhosted.org/packages/f4/98/fe010f15dc7230f45bc4cf367b012d651367fd203caaa992fd1f5963560e/kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935", size = 64319, upload-time = "2024-09-04T09:04:13.635Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1b/b5d618f4e58c0675654c1e5051bcf42c776703edb21c02b8c74135541f60/kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb", size = 1334260, upload-time = "2024-09-04T09:04:14.878Z" }, - { url = "https://files.pythonhosted.org/packages/b8/01/946852b13057a162a8c32c4c8d2e9ed79f0bb5d86569a40c0b5fb103e373/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02", size = 1426589, upload-time = "2024-09-04T09:04:16.514Z" }, - { url = "https://files.pythonhosted.org/packages/70/d1/c9f96df26b459e15cf8a965304e6e6f4eb291e0f7a9460b4ad97b047561e/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51", size = 1541080, upload-time = "2024-09-04T09:04:18.322Z" }, - { url = "https://files.pythonhosted.org/packages/d3/73/2686990eb8b02d05f3de759d6a23a4ee7d491e659007dd4c075fede4b5d0/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052", size = 1470049, upload-time = "2024-09-04T09:04:20.266Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/2db7af3ed3af7c35f388d5f53c28e155cd402a55432d800c543dc6deb731/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18", size = 1426376, upload-time = "2024-09-04T09:04:22.419Z" }, - { url = "https://files.pythonhosted.org/packages/05/83/2857317d04ea46dc5d115f0df7e676997bbd968ced8e2bd6f7f19cfc8d7f/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545", size = 2222231, upload-time = "2024-09-04T09:04:24.526Z" }, - { url = "https://files.pythonhosted.org/packages/0d/b5/866f86f5897cd4ab6d25d22e403404766a123f138bd6a02ecb2cdde52c18/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b", size = 2368634, upload-time = "2024-09-04T09:04:25.899Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ee/73de8385403faba55f782a41260210528fe3273d0cddcf6d51648202d6d0/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36", size = 2329024, upload-time = "2024-09-04T09:04:28.523Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/cd101d8cd2cdfaa42dc06c433df17c8303d31129c9fdd16c0ea37672af91/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3", size = 2468484, upload-time = "2024-09-04T09:04:30.547Z" }, - { url = "https://files.pythonhosted.org/packages/e1/72/84f09d45a10bc57a40bb58b81b99d8f22b58b2040c912b7eb97ebf625bf2/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523", size = 2284078, upload-time = "2024-09-04T09:04:33.218Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d4/71828f32b956612dc36efd7be1788980cb1e66bfb3706e6dec9acad9b4f9/kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d", size = 46645, upload-time = "2024-09-04T09:04:34.371Z" }, - { url = "https://files.pythonhosted.org/packages/a1/65/d43e9a20aabcf2e798ad1aff6c143ae3a42cf506754bcb6a7ed8259c8425/kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b", size = 56022, upload-time = "2024-09-04T09:04:35.786Z" }, - { url = "https://files.pythonhosted.org/packages/35/b3/9f75a2e06f1b4ca00b2b192bc2b739334127d27f1d0625627ff8479302ba/kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376", size = 48536, upload-time = "2024-09-04T09:04:37.525Z" }, - { url = "https://files.pythonhosted.org/packages/97/9c/0a11c714cf8b6ef91001c8212c4ef207f772dd84540104952c45c1f0a249/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2", size = 121808, upload-time = "2024-09-04T09:04:38.637Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d8/0fe8c5f5d35878ddd135f44f2af0e4e1d379e1c7b0716f97cdcb88d4fd27/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a", size = 65531, upload-time = "2024-09-04T09:04:39.694Z" }, - { url = "https://files.pythonhosted.org/packages/80/c5/57fa58276dfdfa612241d640a64ca2f76adc6ffcebdbd135b4ef60095098/kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee", size = 63894, upload-time = "2024-09-04T09:04:41.6Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e9/26d3edd4c4ad1c5b891d8747a4f81b1b0aba9fb9721de6600a4adc09773b/kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640", size = 1369296, upload-time = "2024-09-04T09:04:42.886Z" }, - { url = "https://files.pythonhosted.org/packages/b6/67/3f4850b5e6cffb75ec40577ddf54f7b82b15269cc5097ff2e968ee32ea7d/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f", size = 1461450, upload-time = "2024-09-04T09:04:46.284Z" }, - { url = "https://files.pythonhosted.org/packages/52/be/86cbb9c9a315e98a8dc6b1d23c43cffd91d97d49318854f9c37b0e41cd68/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483", size = 1579168, upload-time = "2024-09-04T09:04:47.91Z" }, - { url = "https://files.pythonhosted.org/packages/0f/00/65061acf64bd5fd34c1f4ae53f20b43b0a017a541f242a60b135b9d1e301/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258", size = 1507308, upload-time = "2024-09-04T09:04:49.465Z" }, - { url = "https://files.pythonhosted.org/packages/21/e4/c0b6746fd2eb62fe702118b3ca0cb384ce95e1261cfada58ff693aeec08a/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e", size = 1464186, upload-time = "2024-09-04T09:04:50.949Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0f/529d0a9fffb4d514f2782c829b0b4b371f7f441d61aa55f1de1c614c4ef3/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107", size = 2247877, upload-time = "2024-09-04T09:04:52.388Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e1/66603ad779258843036d45adcbe1af0d1a889a07af4635f8b4ec7dccda35/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948", size = 2404204, upload-time = "2024-09-04T09:04:54.385Z" }, - { url = "https://files.pythonhosted.org/packages/8d/61/de5fb1ca7ad1f9ab7970e340a5b833d735df24689047de6ae71ab9d8d0e7/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038", size = 2352461, upload-time = "2024-09-04T09:04:56.307Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d2/0edc00a852e369827f7e05fd008275f550353f1f9bcd55db9363d779fc63/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383", size = 2501358, upload-time = "2024-09-04T09:04:57.922Z" }, - { url = "https://files.pythonhosted.org/packages/84/15/adc15a483506aec6986c01fb7f237c3aec4d9ed4ac10b756e98a76835933/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520", size = 2314119, upload-time = "2024-09-04T09:04:59.332Z" }, - { url = "https://files.pythonhosted.org/packages/36/08/3a5bb2c53c89660863a5aa1ee236912269f2af8762af04a2e11df851d7b2/kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b", size = 46367, upload-time = "2024-09-04T09:05:00.804Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/c05f0a6d825c643779fc3c70876bff1ac221f0e31e6f701f0e9578690d70/kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb", size = 55884, upload-time = "2024-09-04T09:05:01.924Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f9/3828d8f21b6de4279f0667fb50a9f5215e6fe57d5ec0d61905914f5b6099/kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a", size = 48528, upload-time = "2024-09-04T09:05:02.983Z" }, - { url = "https://files.pythonhosted.org/packages/11/88/37ea0ea64512997b13d69772db8dcdc3bfca5442cda3a5e4bb943652ee3e/kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd", size = 122449, upload-time = "2024-09-04T09:05:55.311Z" }, - { url = "https://files.pythonhosted.org/packages/4e/45/5a5c46078362cb3882dcacad687c503089263c017ca1241e0483857791eb/kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583", size = 65757, upload-time = "2024-09-04T09:05:56.906Z" }, - { url = "https://files.pythonhosted.org/packages/8a/be/a6ae58978772f685d48dd2e84460937761c53c4bbd84e42b0336473d9775/kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417", size = 64312, upload-time = "2024-09-04T09:05:58.384Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/18ef6f452d311e1e1eb180c9bf5589187fa1f042db877e6fe443ef10099c/kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904", size = 1626966, upload-time = "2024-09-04T09:05:59.855Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/40655f6c3fa11ce740e8a964fa8e4c0479c87d6a7944b95af799c7a55dfe/kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a", size = 1607044, upload-time = "2024-09-04T09:06:02.16Z" }, - { url = "https://files.pythonhosted.org/packages/fd/93/af67dbcfb9b3323bbd2c2db1385a7139d8f77630e4a37bb945b57188eb2d/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8", size = 1391879, upload-time = "2024-09-04T09:06:03.908Z" }, - { url = "https://files.pythonhosted.org/packages/40/6f/d60770ef98e77b365d96061d090c0cd9e23418121c55fff188fa4bdf0b54/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2", size = 1504751, upload-time = "2024-09-04T09:06:05.58Z" }, - { url = "https://files.pythonhosted.org/packages/fa/3a/5f38667d313e983c432f3fcd86932177519ed8790c724e07d77d1de0188a/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88", size = 1436990, upload-time = "2024-09-04T09:06:08.126Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/1520301a47326e6a6043b502647e42892be33b3f051e9791cc8bb43f1a32/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde", size = 2191122, upload-time = "2024-09-04T09:06:10.345Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c4/eb52da300c166239a2233f1f9c4a1b767dfab98fae27681bfb7ea4873cb6/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c", size = 2338126, upload-time = "2024-09-04T09:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/1a/cb/42b92fd5eadd708dd9107c089e817945500685f3437ce1fd387efebc6d6e/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2", size = 2298313, upload-time = "2024-09-04T09:06:14.562Z" }, - { url = "https://files.pythonhosted.org/packages/4f/eb/be25aa791fe5fc75a8b1e0c965e00f942496bc04635c9aae8035f6b76dcd/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb", size = 2437784, upload-time = "2024-09-04T09:06:16.767Z" }, - { url = "https://files.pythonhosted.org/packages/c5/22/30a66be7f3368d76ff95689e1c2e28d382383952964ab15330a15d8bfd03/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327", size = 2253988, upload-time = "2024-09-04T09:06:18.705Z" }, - { url = "https://files.pythonhosted.org/packages/35/d3/5f2ecb94b5211c8a04f218a76133cc8d6d153b0f9cd0b45fad79907f0689/kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644", size = 46980, upload-time = "2024-09-04T09:06:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/ef/17/cd10d020578764ea91740204edc6b3236ed8106228a46f568d716b11feb2/kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4", size = 55847, upload-time = "2024-09-04T09:06:21.407Z" }, - { url = "https://files.pythonhosted.org/packages/91/84/32232502020bd78d1d12be7afde15811c64a95ed1f606c10456db4e4c3ac/kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f", size = 48494, upload-time = "2024-09-04T09:06:22.648Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/741b79775d67ab67ced9bb38552da688c0305c16e7ee24bba7a2be253fb7/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643", size = 59491, upload-time = "2024-09-04T09:06:24.188Z" }, - { url = "https://files.pythonhosted.org/packages/58/cc/fb239294c29a5656e99e3527f7369b174dd9cc7c3ef2dea7cb3c54a8737b/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706", size = 57648, upload-time = "2024-09-04T09:06:25.559Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2f009ac1f7aab9f81efb2d837301d255279d618d27b6015780115ac64bdd/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6", size = 84257, upload-time = "2024-09-04T09:06:27.038Z" }, - { url = "https://files.pythonhosted.org/packages/81/e1/c64f50987f85b68b1c52b464bb5bf73e71570c0f7782d626d1eb283ad620/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2", size = 80906, upload-time = "2024-09-04T09:06:28.48Z" }, - { url = "https://files.pythonhosted.org/packages/fd/71/1687c5c0a0be2cee39a5c9c389e546f9c6e215e46b691d00d9f646892083/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4", size = 79951, upload-time = "2024-09-04T09:06:29.966Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8b/d7497df4a1cae9367adf21665dd1f896c2a7aeb8769ad77b662c5e2bcce7/kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a", size = 55715, upload-time = "2024-09-04T09:06:31.489Z" }, - { url = "https://files.pythonhosted.org/packages/d5/df/ce37d9b26f07ab90880923c94d12a6ff4d27447096b4c849bfc4339ccfdf/kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39", size = 58666, upload-time = "2024-09-04T09:06:43.756Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d3/e4b04f43bc629ac8e186b77b2b1a251cdfa5b7610fa189dc0db622672ce6/kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e", size = 57088, upload-time = "2024-09-04T09:06:45.406Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/752df58e2d339e670a535514d2db4fe8c842ce459776b8080fbe08ebb98e/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608", size = 84321, upload-time = "2024-09-04T09:06:47.557Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f8/fe6484e847bc6e238ec9f9828089fb2c0bb53f2f5f3a79351fde5b565e4f/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674", size = 80776, upload-time = "2024-09-04T09:06:49.235Z" }, - { url = "https://files.pythonhosted.org/packages/9b/57/d7163c0379f250ef763aba85330a19feefb5ce6cb541ade853aaba881524/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225", size = 79984, upload-time = "2024-09-04T09:06:51.336Z" }, - { url = "https://files.pythonhosted.org/packages/8c/95/4a103776c265d13b3d2cd24fb0494d4e04ea435a8ef97e1b2c026d43250b/kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0", size = 55811, upload-time = "2024-09-04T09:06:53.078Z" }, -] - [[package]] name = "kiwisolver" version = "1.4.8" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538, upload-time = "2024-12-24T18:30:51.519Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/47/5f/4d8e9e852d98ecd26cdf8eaf7ed8bc33174033bba5e07001b289f07308fd/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db", size = 124623, upload-time = "2024-12-24T18:28:17.687Z" }, @@ -730,6 +608,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410, upload-time = "2024-12-24T18:29:39.991Z" }, { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853, upload-time = "2024-12-24T18:29:42.006Z" }, { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424, upload-time = "2024-12-24T18:29:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156, upload-time = "2024-12-24T18:29:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555, upload-time = "2024-12-24T18:29:46.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071, upload-time = "2024-12-24T18:29:47.333Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053, upload-time = "2024-12-24T18:29:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278, upload-time = "2024-12-24T18:29:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139, upload-time = "2024-12-24T18:29:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517, upload-time = "2024-12-24T18:29:53.941Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952, upload-time = "2024-12-24T18:29:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132, upload-time = "2024-12-24T18:29:57.989Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997, upload-time = "2024-12-24T18:29:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060, upload-time = "2024-12-24T18:30:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471, upload-time = "2024-12-24T18:30:04.574Z" }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793, upload-time = "2024-12-24T18:30:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855, upload-time = "2024-12-24T18:30:07.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430, upload-time = "2024-12-24T18:30:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294, upload-time = "2024-12-24T18:30:09.508Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736, upload-time = "2024-12-24T18:30:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194, upload-time = "2024-12-24T18:30:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942, upload-time = "2024-12-24T18:30:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341, upload-time = "2024-12-24T18:30:22.102Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455, upload-time = "2024-12-24T18:30:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138, upload-time = "2024-12-24T18:30:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857, upload-time = "2024-12-24T18:30:28.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129, upload-time = "2024-12-24T18:30:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538, upload-time = "2024-12-24T18:30:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661, upload-time = "2024-12-24T18:30:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710, upload-time = "2024-12-24T18:30:37.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, { url = "https://files.pythonhosted.org/packages/1f/f9/ae81c47a43e33b93b0a9819cac6723257f5da2a5a60daf46aa5c7226ea85/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a", size = 60403, upload-time = "2024-12-24T18:30:41.372Z" }, { url = "https://files.pythonhosted.org/packages/58/ca/f92b5cb6f4ce0c1ebfcfe3e2e42b96917e16f7090e45b21102941924f18f/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8", size = 58657, upload-time = "2024-12-24T18:30:42.392Z" }, { url = "https://files.pythonhosted.org/packages/80/28/ae0240f732f0484d3a4dc885d055653c47144bdf59b670aae0ec3c65a7c8/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0", size = 84948, upload-time = "2024-12-24T18:30:44.703Z" }, @@ -743,7 +649,8 @@ name = "libcst" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "pyyaml-ft", marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/7f/33559a16f5e98e8643a463ed5b4d09ecb0589da8ac61b1fcb761809ab037/libcst-1.8.0.tar.gz", hash = "sha256:21cd41dd9bc7ee16f81a6ecf9dc6c044cdaf6af670b85b4754204a5a0c9890d8", size = 778687, upload-time = "2025-05-27T14:23:52.354Z" } wheels = [ @@ -777,16 +684,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/71/da2a1a42b1412231e0925e0aa9be6194399748303108d191c74b86247329/libcst-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6462ec79e043ced913c8ce2329d57b110de9f283c7e11387c112f0614e4e6c1f", size = 2386491, upload-time = "2025-05-27T14:22:41.503Z" }, { url = "https://files.pythonhosted.org/packages/0c/ea/53b89d124b43b768f4cd618dd00c19047b6e322e1bb9cc435beeeb20f4d1/libcst-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:58452ff8a2c448b230154e3b96d1e23497960a42861b4f406383ce6a98ca671e", size = 2095575, upload-time = "2025-05-27T14:22:43.084Z" }, { url = "https://files.pythonhosted.org/packages/13/d8/cfeea7d17cb5c32fcf70dc98be47df587956a5f971a83924e340438bfd64/libcst-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e7a88d23b4f35dcfe3564f03df512a50b50f8f0805cc020a975a6b5b00c9683a", size = 1982560, upload-time = "2025-05-27T14:22:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/a4/96/2f5a3498386f079764a4ee37787b129464ebf2b77ae83a0363a7f3c14e7f/libcst-1.8.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ba1c01ae2439c3577c01beafeed76a431e13aeb6d920b505100f551e32410313", size = 2192882, upload-time = "2025-05-27T14:23:33.804Z" }, - { url = "https://files.pythonhosted.org/packages/d0/eb/932e1477b4563a7f46d6030b976461a56c5d7e36ffadd60b2afae6a37a0b/libcst-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69b09f93e8632d3584b6702a0d9f1d20315f24db2cc798a3d894c037768e52c3", size = 2077631, upload-time = "2025-05-27T14:23:35.898Z" }, - { url = "https://files.pythonhosted.org/packages/60/4a/a2f6f6d62efbd344e43847d79919b18c3e8f2db311b29a0b4374445995d2/libcst-1.8.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ce84140e3c728c0c6f74fa402eba1fd90fb1dc1918d71feab43ad251c72fee83", size = 2217475, upload-time = "2025-05-27T14:23:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cc/d38b7b3baa461fb2473c194294e13b0f618c3513a1df8fcf8163667e565d/libcst-1.8.0-cp39-cp39-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:046acd4f5c2ee8aa14bfb72f76585cfb36ac3e54512ee59c43cd5c704c5c61bf", size = 2188830, upload-time = "2025-05-27T14:23:39.817Z" }, - { url = "https://files.pythonhosted.org/packages/24/49/a58e887f51764114d2202b89ec7dcbc00f3aa787315d949dd695443e16ea/libcst-1.8.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c603bacb69df93dc7435f2dba9efdadc7e53a87b15104af8b63dc61a8e08e612", size = 2310225, upload-time = "2025-05-27T14:23:41.436Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b1/13cb381b11c91a7a9ba0d78781eb6148144c7fbe51061b61ad65b7b93595/libcst-1.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18ce5f20f24b3aadefdd9e59035f962726247501aa17bc6db6b38741dd3a23db", size = 2400292, upload-time = "2025-05-27T14:23:43.856Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fa/687f2b9c0ab53eea0131f37e73a392680f7ec32aa49d5f3fcd50ee442936/libcst-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55e0c390bc2a87de27d997ce102fa44303b86eff4ca1789a1a46d8a014cc901a", size = 2278163, upload-time = "2025-05-27T14:23:45.44Z" }, - { url = "https://files.pythonhosted.org/packages/ae/be/cf00ae8ad0bf27b86a65f15438e00cec4c4fc81077fe841ccfe11a5336f9/libcst-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2001d9996851873da21da3fa4d961247db43da3c02bae533d3689437364cd5c2", size = 2386812, upload-time = "2025-05-27T14:23:47.504Z" }, - { url = "https://files.pythonhosted.org/packages/14/92/0f82fe79f698569fcc2e72f0bb6a6669e79802423a7ff71aac47f7ef3a51/libcst-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:264db7653515b7bed35be5ec6bfed3993abfdf96d3db635abbe14d1085ada504", size = 2093586, upload-time = "2025-05-27T14:23:49.158Z" }, - { url = "https://files.pythonhosted.org/packages/98/b4/991cda8ca2331a4972a25f7d5dffe0eae22a594da98f6b141c201351c48e/libcst-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:3dede96093e5e6c999bb767d0211caedb7e2f485fa4226f9d980c0dae07ee02e", size = 1983679, upload-time = "2025-05-27T14:23:50.741Z" }, + { url = "https://files.pythonhosted.org/packages/e1/28/8a488241fa40306b081b20ca4783000dea3f91a1c4e2b4e4d707ab9839e1/libcst-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4e01ed9d0fcc20093fb39ee4252e70d6a993d26871319a8edad4183c8feb55eb", size = 2183878, upload-time = "2025-05-27T14:22:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ef/4c1033df67546f7e6a380fb91d6ab8dfa93fa33ff1ce5c09dd587bbea461/libcst-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f227625f7be41bd08063d758da33dde338e1460bc790e26b0f2d1475b5f78908", size = 2068087, upload-time = "2025-05-27T14:22:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/9e3992956a1bc7ba42a5b8a6b3588f1ae72b9bade74d2ea232644646e77e/libcst-1.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d855ecbea9ae3efbf7e9a851918120196c7195471d06150773b9b789d95e8aa6", size = 2218294, upload-time = "2025-05-27T14:22:49.59Z" }, + { url = "https://files.pythonhosted.org/packages/56/b7/ab142dbb5de5d6023527e11997f9c1583351230b96928b3b5cbf7c912655/libcst-1.8.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4555f3a357d6f1f99fa09328eed74c0bd4e9fc3a77456acc8a19f1ed087dd69c", size = 2190312, upload-time = "2025-05-27T14:22:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/efbe2312619a3c240d2515c740aad05a08f8e13f386107d664808e9c0a17/libcst-1.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ddd4477c13b0014793cdb0d0c686c33ffe1d2c704923c65f4d9f575ce4262a4c", size = 2309025, upload-time = "2025-05-27T14:22:52.929Z" }, + { url = "https://files.pythonhosted.org/packages/9a/97/25391720d0e4f38637cd98b1722c0f673f795680d4975a84bf0154fe4b1a/libcst-1.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e512c00274110df3ce724a637e5f41e5fe90341db8cc9ea03cce35df7933010", size = 2400661, upload-time = "2025-05-27T14:22:54.545Z" }, + { url = "https://files.pythonhosted.org/packages/64/a5/efd688fe117b9c997338c542ed6c1ffab433351eb42c0179e43c1f60956c/libcst-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05d05612b95d064a08b3ecb8e67c4f8d289f1c12a7e3beb8d7b18f007a7c76eb", size = 2279371, upload-time = "2025-05-27T14:22:56.367Z" }, + { url = "https://files.pythonhosted.org/packages/08/13/0b2572183d5488fe7f892a2db60e9504ebc80af39e22a2ca58f830cc93c0/libcst-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd8328ae3a86fc7001019815dcf84425565e21dd09ecc70bd94f10d287d17034", size = 2386388, upload-time = "2025-05-27T14:22:58.534Z" }, + { url = "https://files.pythonhosted.org/packages/44/ec/8dabe56809cbf60afc3ee832391388c9baf1fcf14f4e4094d4337d12e196/libcst-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7f9b545d607b581f2b3d307f8d5e9524c9e6364535ff4460faefd56395484420", size = 2095604, upload-time = "2025-05-27T14:23:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/11/b5/d5fb9ddf46ceafe6decffb29c9d1bd8f6b8bd4f9b09e263db7e8b113d76a/libcst-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0654aab4eb61ee04d72cff9da68e5e7f2fa8c870efbc0d48754970572c0facf", size = 1982489, upload-time = "2025-05-27T14:23:02.77Z" }, + { url = "https://files.pythonhosted.org/packages/30/1e/27420a92a564ea198eb16a0fa7130ee688b456f8fed3a2240eac304537c7/libcst-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e2fa6fbd7d755e59c58745d997f9805bc11c18e0d6042f6f35498fd5ba90a038", size = 2173926, upload-time = "2025-05-27T14:23:04.88Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/792b80b1a85b97976af1863e4e252624af493acaf8da95fe492d6c5e1d6f/libcst-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ecd71d54648dee629171272cb264c42eaf5321566747bc264984208abc528a6", size = 2059803, upload-time = "2025-05-27T14:23:07.645Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b6/9c82b49916fa816bdcbf5b3e63f9f65ed318e2ea2cf76f9f05bf563b0017/libcst-1.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f0cbde11baa9fb91799432066d0444a90439479e960da3078f841e1ac0c51dfe", size = 2206555, upload-time = "2025-05-27T14:23:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/b7/31/39c110eb66d5fd7cc4891cf55192a358a6be8b8f6ac0e2eb709850104456/libcst-1.8.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:edfc5636e015b5ef8f8ed8b9628d15eaaf415d906cd4cc6a5fa63cbfdd38a23c", size = 2177856, upload-time = "2025-05-27T14:23:20.91Z" }, + { url = "https://files.pythonhosted.org/packages/7e/66/560cf088ae5b93aea3e35aa3fb3fb2fbc2d5bf4ef0097220027f31488f95/libcst-1.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:10e494659e510b5428d2102151149636ad7a6b691bbb57ec7cd7e256938746a9", size = 2299368, upload-time = "2025-05-27T14:23:22.44Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4d/7af5aac7ba3ec04faa40c4fcb2eba16f6259123376fac4eb131ab1af880f/libcst-1.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ae851e0987cc355b8f1dcc9915b4cf8e408dcb6fbb589d47d3db098e67497cd", size = 2376624, upload-time = "2025-05-27T14:23:24.501Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0e/cac4685b0802c2dbd7f88bf100a4b3db92fbdf5bd3f22bc7a7b58139369a/libcst-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:459d8c9ceb2b97058f0ec0775050ec2024ee1a178b421510e7fc12e628b5d2d3", size = 2268894, upload-time = "2025-05-27T14:23:26.577Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b2/6efd6b0d9e88768c7c29ac08d91091a36801d29bbd9deecb16f6e6be7971/libcst-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6913649f1129a7d9a53d22ca602bf1f3c3f4d7cb7d99441dafd0f1bc98601b97", size = 2378827, upload-time = "2025-05-27T14:23:28.767Z" }, + { url = "https://files.pythonhosted.org/packages/99/f8/7d61985de5cb8e65a80c740ee2fa30cd582a91fc2bb860a732e5dccc1276/libcst-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c36850b3df46eedbd9593db28074ae443888d4f22cb71224276b405a7c99d3a", size = 2084719, upload-time = "2025-05-27T14:23:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/d8/fb61859af9a838ffa611ea34a855c7133a3018faf877f4d4555019302d0c/libcst-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3741c5d07b3438b6db94b395e1855c4c1d6a3ecd7ef35bfe599aadfa098578a3", size = 1971986, upload-time = "2025-05-27T14:23:32.225Z" }, ] [[package]] @@ -803,12 +720,21 @@ wheels = [ ] [[package]] -name = "markdown" -version = "3.8" +name = "mako" +version = "1.3.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "markupsafe" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, +] + +[[package]] +name = "markdown" +version = "3.8" +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906, upload-time = "2025-04-11T14:42:50.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210, upload-time = "2025-04-11T14:42:49.178Z" }, @@ -850,88 +776,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.9.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "cycler", marker = "python_full_version < '3.10'" }, - { name = "fonttools", marker = "python_full_version < '3.10'" }, - { name = "importlib-resources", marker = "python_full_version < '3.10'" }, - { name = "kiwisolver", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "numpy", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pillow", marker = "python_full_version < '3.10'" }, - { name = "pyparsing", marker = "python_full_version < '3.10'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/17/1747b4154034befd0ed33b52538f5eb7752d05bb51c5e2a31470c3bc7d52/matplotlib-3.9.4.tar.gz", hash = "sha256:1e00e8be7393cbdc6fedfa8a6fba02cf3e83814b285db1c60b906a023ba41bc3", size = 36106529, upload-time = "2024-12-13T05:56:34.184Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/94/27d2e2c30d54b56c7b764acc1874a909e34d1965a427fc7092bb6a588b63/matplotlib-3.9.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fdd7abfb706dfa8d307af64a87f1a862879ec3cd8d0ec8637458f0885b9c50", size = 7885089, upload-time = "2024-12-13T05:54:24.224Z" }, - { url = "https://files.pythonhosted.org/packages/c6/25/828273307e40a68eb8e9df832b6b2aaad075864fdc1de4b1b81e40b09e48/matplotlib-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d89bc4e85e40a71d1477780366c27fb7c6494d293e1617788986f74e2a03d7ff", size = 7770600, upload-time = "2024-12-13T05:54:27.214Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/f841a422ec994da5123368d76b126acf4fc02ea7459b6e37c4891b555b83/matplotlib-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddf9f3c26aae695c5daafbf6b94e4c1a30d6cd617ba594bbbded3b33a1fcfa26", size = 8200138, upload-time = "2024-12-13T05:54:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/272aca07a38804d93b6050813de41ca7ab0e29ba7a9dd098e12037c919a9/matplotlib-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18ebcf248030173b59a868fda1fe42397253f6698995b55e81e1f57431d85e50", size = 8312711, upload-time = "2024-12-13T05:54:34.396Z" }, - { url = "https://files.pythonhosted.org/packages/98/37/f13e23b233c526b7e27ad61be0a771894a079e0f7494a10d8d81557e0e9a/matplotlib-3.9.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:974896ec43c672ec23f3f8c648981e8bc880ee163146e0312a9b8def2fac66f5", size = 9090622, upload-time = "2024-12-13T05:54:36.808Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8c/b1f5bd2bd70e60f93b1b54c4d5ba7a992312021d0ddddf572f9a1a6d9348/matplotlib-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:4598c394ae9711cec135639374e70871fa36b56afae17bdf032a345be552a88d", size = 7828211, upload-time = "2024-12-13T05:54:40.596Z" }, - { url = "https://files.pythonhosted.org/packages/74/4b/65be7959a8fa118a3929b49a842de5b78bb55475236fcf64f3e308ff74a0/matplotlib-3.9.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d4dd29641d9fb8bc4492420c5480398dd40a09afd73aebe4eb9d0071a05fbe0c", size = 7894430, upload-time = "2024-12-13T05:54:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e9/18/80f70d91896e0a517b4a051c3fd540daa131630fd75e02e250365353b253/matplotlib-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30e5b22e8bcfb95442bf7d48b0d7f3bdf4a450cbf68986ea45fca3d11ae9d099", size = 7780045, upload-time = "2024-12-13T05:54:46.414Z" }, - { url = "https://files.pythonhosted.org/packages/a2/73/ccb381026e3238c5c25c3609ba4157b2d1a617ec98d65a8b4ee4e1e74d02/matplotlib-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bb0030d1d447fd56dcc23b4c64a26e44e898f0416276cac1ebc25522e0ac249", size = 8209906, upload-time = "2024-12-13T05:54:49.459Z" }, - { url = "https://files.pythonhosted.org/packages/ab/33/1648da77b74741c89f5ea95cbf42a291b4b364f2660b316318811404ed97/matplotlib-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca90ed222ac3565d2752b83dbb27627480d27662671e4d39da72e97f657a423", size = 8322873, upload-time = "2024-12-13T05:54:53.066Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/8447ba78bc6593c9044c372d1609f8ea10fb1e071e7a9e0747bea74fc16c/matplotlib-3.9.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a181b2aa2906c608fcae72f977a4a2d76e385578939891b91c2550c39ecf361e", size = 9099566, upload-time = "2024-12-13T05:54:55.522Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/4f0e237bf349c02ff9d1b6e7109f1a17f745263809b9714a8576dc17752b/matplotlib-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:1f6882828231eca17f501c4dcd98a05abb3f03d157fbc0769c6911fe08b6cfd3", size = 7838065, upload-time = "2024-12-13T05:54:58.337Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2b/c918bf6c19d6445d1cefe3d2e42cb740fb997e14ab19d4daeb6a7ab8a157/matplotlib-3.9.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dfc48d67e6661378a21c2983200a654b72b5c5cdbd5d2cf6e5e1ece860f0cc70", size = 7891131, upload-time = "2024-12-13T05:55:02.837Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e5/b4e8fc601ca302afeeabf45f30e706a445c7979a180e3a978b78b2b681a4/matplotlib-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47aef0fab8332d02d68e786eba8113ffd6f862182ea2999379dec9e237b7e483", size = 7776365, upload-time = "2024-12-13T05:55:05.158Z" }, - { url = "https://files.pythonhosted.org/packages/99/06/b991886c506506476e5d83625c5970c656a491b9f80161458fed94597808/matplotlib-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fba1f52c6b7dc764097f52fd9ab627b90db452c9feb653a59945de16752e965f", size = 8200707, upload-time = "2024-12-13T05:55:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e2/556b627498cb27e61026f2d1ba86a78ad1b836fef0996bef5440e8bc9559/matplotlib-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:173ac3748acaac21afcc3fa1633924609ba1b87749006bc25051c52c422a5d00", size = 8313761, upload-time = "2024-12-13T05:55:12.95Z" }, - { url = "https://files.pythonhosted.org/packages/58/ff/165af33ec766ff818306ea88e91f9f60d2a6ed543be1eb122a98acbf3b0d/matplotlib-3.9.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320edea0cadc07007765e33f878b13b3738ffa9745c5f707705692df70ffe0e0", size = 9095284, upload-time = "2024-12-13T05:55:16.199Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8b/3d0c7a002db3b1ed702731c2a9a06d78d035f1f2fb0fb936a8e43cc1e9f4/matplotlib-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a4a4cfc82330b27042a7169533da7991e8789d180dd5b3daeaee57d75cd5a03b", size = 7841160, upload-time = "2024-12-13T05:55:19.991Z" }, - { url = "https://files.pythonhosted.org/packages/56/eb/501b465c9fef28f158e414ea3a417913dc2ac748564c7ed41535f23445b4/matplotlib-3.9.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3c3724d89a387ddf78ff88d2a30ca78ac2b4c89cf37f2db4bd453c34799e933c", size = 7885919, upload-time = "2024-12-13T05:55:59.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/36/236fbd868b6c91309a5206bd90c3f881f4f44b2d997cd1d6239ef652f878/matplotlib-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d5f0a8430ffe23d7e32cfd86445864ccad141797f7d25b7c41759a5b5d17cfd7", size = 7771486, upload-time = "2024-12-13T05:56:04.264Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/105caf2d54d5ed11d9f4335398f5103001a03515f2126c936a752ccf1461/matplotlib-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bb0141a21aef3b64b633dc4d16cbd5fc538b727e4958be82a0e1c92a234160e", size = 8201838, upload-time = "2024-12-13T05:56:06.792Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a7/bb01188fb4013d34d274caf44a2f8091255b0497438e8b6c0a7c1710c692/matplotlib-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57aa235109e9eed52e2c2949db17da185383fa71083c00c6c143a60e07e0888c", size = 8314492, upload-time = "2024-12-13T05:56:09.964Z" }, - { url = "https://files.pythonhosted.org/packages/33/19/02e1a37f7141fc605b193e927d0a9cdf9dc124a20b9e68793f4ffea19695/matplotlib-3.9.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b18c600061477ccfdd1e6fd050c33d8be82431700f3452b297a56d9ed7037abb", size = 9092500, upload-time = "2024-12-13T05:56:13.55Z" }, - { url = "https://files.pythonhosted.org/packages/57/68/c2feb4667adbf882ffa4b3e0ac9967f848980d9f8b5bebd86644aa67ce6a/matplotlib-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:ef5f2d1b67d2d2145ff75e10f8c008bfbf71d45137c4b648c87193e7dd053eac", size = 7822962, upload-time = "2024-12-13T05:56:16.358Z" }, - { url = "https://files.pythonhosted.org/packages/0c/22/2ef6a364cd3f565442b0b055e0599744f1e4314ec7326cdaaa48a4d864d7/matplotlib-3.9.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:44e0ed786d769d85bc787b0606a53f2d8d2d1d3c8a2608237365e9121c1a338c", size = 7877995, upload-time = "2024-12-13T05:56:18.805Z" }, - { url = "https://files.pythonhosted.org/packages/87/b8/2737456e566e9f4d94ae76b8aa0d953d9acb847714f9a7ad80184474f5be/matplotlib-3.9.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:09debb9ce941eb23ecdbe7eab972b1c3e0276dcf01688073faff7b0f61d6c6ca", size = 7769300, upload-time = "2024-12-13T05:56:21.315Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1f/e709c6ec7b5321e6568769baa288c7178e60a93a9da9e682b39450da0e29/matplotlib-3.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc53cf157a657bfd03afab14774d54ba73aa84d42cfe2480c91bd94873952db", size = 8313423, upload-time = "2024-12-13T05:56:26.719Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/5a1f868782cd13f053a679984e222007ecff654a9bfbac6b27a65f4eeb05/matplotlib-3.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ad45da51be7ad02387801fd154ef74d942f49fe3fcd26a64c94842ba7ec0d865", size = 7854624, upload-time = "2024-12-13T05:56:29.359Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] [[package]] name = "matplotlib" version = "3.10.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "cycler", marker = "python_full_version >= '3.10'" }, - { name = "fonttools", marker = "python_full_version >= '3.10'" }, - { name = "kiwisolver", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "numpy", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pillow", marker = "python_full_version >= '3.10'" }, - { name = "pyparsing", marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811, upload-time = "2025-05-08T19:10:54.39Z" } wheels = [ @@ -953,6 +833,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/91/ba0ae1ff4b3f30972ad01cd4a8029e70a0ec3b8ea5be04764b128b66f763/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70453fd99733293ace1aec568255bc51c6361cb0da94fa5ebf0649fdb2150a", size = 8601321, upload-time = "2025-05-08T19:10:14.47Z" }, { url = "https://files.pythonhosted.org/packages/d2/88/d636041eb54a84b889e11872d91f7cbf036b3b0e194a70fa064eb8b04f7a/matplotlib-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbed9917b44070e55640bd13419de83b4c918e52d97561544814ba463811cbc7", size = 9406972, upload-time = "2025-05-08T19:10:16.569Z" }, { url = "https://files.pythonhosted.org/packages/b1/79/0d1c165eac44405a86478082e225fce87874f7198300bbebc55faaf6d28d/matplotlib-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf37d8c6ef1a48829443e8ba5227b44236d7fcaf7647caa3178a4ff9f7a5be05", size = 8067954, upload-time = "2025-05-08T19:10:18.663Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/23cfb566a74c696a3b338d8955c549900d18fe2b898b6e94d682ca21e7c2/matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84", size = 8180318, upload-time = "2025-05-08T19:10:20.426Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/02f1c3b66b30da9ee343c343acbb6251bef5b01d34fad732446eaadcd108/matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e", size = 8051132, upload-time = "2025-05-08T19:10:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ab/8db1a5ac9b3a7352fb914133001dae889f9fcecb3146541be46bed41339c/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15", size = 8457633, upload-time = "2025-05-08T19:10:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/f5/64/41c4367bcaecbc03ef0d2a3ecee58a7065d0a36ae1aa817fe573a2da66d4/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7", size = 8601031, upload-time = "2025-05-08T19:10:27.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/6cc79e9e5ab89d13ed64da28898e40fe5b105a9ab9c98f83abd24e46d7d7/matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d", size = 9406988, upload-time = "2025-05-08T19:10:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/eed564407bd4d935ffabf561ed31099ed609e19287409a27b6d336848653/matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93", size = 8068034, upload-time = "2025-05-08T19:10:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/2f14791ff69b12b09e9975e1d116d9578ac684460860ce542c2588cb7a1c/matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2", size = 8218223, upload-time = "2025-05-08T19:10:33.114Z" }, + { url = "https://files.pythonhosted.org/packages/5c/08/30a94afd828b6e02d0a52cae4a29d6e9ccfcf4c8b56cc28b021d3588873e/matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d", size = 8094985, upload-time = "2025-05-08T19:10:35.337Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/f3bc6b53066c889d7a1a3ea8094c13af6a667c5ca6220ec60ecceec2dabe/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566", size = 8483109, upload-time = "2025-05-08T19:10:37.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/473bc559beec08ebee9f86ca77a844b65747e1a6c2691e8c92e40b9f42a8/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158", size = 8618082, upload-time = "2025-05-08T19:10:39.892Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e9/6ce8edd264c8819e37bbed8172e0ccdc7107fe86999b76ab5752276357a4/matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d", size = 9413699, upload-time = "2025-05-08T19:10:42.376Z" }, + { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload-time = "2025-05-08T19:10:44.551Z" }, { url = "https://files.pythonhosted.org/packages/3d/d1/f54d43e95384b312ffa4a74a4326c722f3b8187aaaa12e9a84cdf3037131/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:86ab63d66bbc83fdb6733471d3bff40897c1e9921cba112accd748eee4bce5e4", size = 8162896, upload-time = "2025-05-08T19:10:46.432Z" }, { url = "https://files.pythonhosted.org/packages/24/a4/fbfc00c2346177c95b353dcf9b5a004106abe8730a62cb6f27e79df0a698/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a48f9c08bf7444b5d2391a83e75edb464ccda3c380384b36532a0962593a1751", size = 8039702, upload-time = "2025-05-08T19:10:49.634Z" }, { url = "https://files.pythonhosted.org/packages/6a/b9/59e120d24a2ec5fc2d30646adb2efb4621aab3c6d83d66fb2a7a182db032/matplotlib-3.10.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb73d8aa75a237457988f9765e4dfe1c0d2453c5ca4eabc897d4309672c8e014", size = 8594298, upload-time = "2025-05-08T19:10:51.738Z" }, @@ -967,16 +859,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mirakuru" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil", marker = "sys_platform != 'cygwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/23/db9034ba28c7d89a540ffb8ca789f70dc12079108ece1cd1762295d5c807/mirakuru-3.0.2.tar.gz", hash = "sha256:21192186a8680ea7567ca68170261df3785768b12962dd19fe8cccab15ad3441", size = 29338, upload-time = "2026-02-11T19:41:15.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/5f/a3f1a7f1f6e55de9285b03ae7e0d3c2a15e044b6e3f9b53bef5609ca05f2/mirakuru-3.0.2-py3-none-any.whl", hash = "sha256:10e5dac4a8f26872c63e9cdfdc01b775aaa2beb3ced98abc497279d2dc525b8f", size = 27583, upload-time = "2026-02-11T19:41:13.578Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, @@ -1012,8 +914,7 @@ name = "mkdocs-click" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, { name = "markdown" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/c7/8c25f3a3b379def41e6d0bb5c4beeab7aa8a394b17e749f498504102cfa5/mkdocs_click-0.9.0.tar.gz", hash = "sha256:6050917628d4740517541422b607404d044117bc31b770c4f9e9e1939a50c908", size = 18720, upload-time = "2025-04-07T16:59:36.387Z" } @@ -1038,7 +939,6 @@ name = "mkdocs-get-deps" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }, @@ -1084,7 +984,6 @@ name = "mkdocstrings" version = "0.29.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, @@ -1117,6 +1016,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/c4/ffa32f2c7cdb1728026c7a34aab87796b895767893aaa54611a79b4eef45/mkdocstrings_python-1.16.11-py3-none-any.whl", hash = "sha256:25d96cc9c1f9c272ea1bd8222c900b5f852bf46c984003e9c7c56eaa4696190f", size = 124282, upload-time = "2025-05-24T10:41:30.008Z" }, ] +[[package]] +name = "ntplib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/14/6b018fb602602d9f6cc7485cbad7c1be3a85d25cea18c233854f05284aed/ntplib-0.4.0.tar.gz", hash = "sha256:899d8fb5f8c2555213aea95efca02934c7343df6ace9d7628a5176b176906267", size = 7135, upload-time = "2021-05-28T19:08:54.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/8c/41da70f6feaca807357206a376b6de2001b439c7f78f53473a914a6dbd1e/ntplib-0.4.0-py2.py3-none-any.whl", hash = "sha256:8d27375329ed7ff38755f7b6d4658b28edc147cadf40338a63a0da8133469d60", size = 6849, upload-time = "2021-05-28T19:08:53.323Z" }, +] + [[package]] name = "numpy" version = "1.26.4" @@ -1147,17 +1055,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/ce71dc08f06534269f66e73c04f5709ee024a1afe92a7b6e1d73f158e1f8/numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c", size = 20636301, upload-time = "2024-02-05T23:59:10.976Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/ab03a7c25741f9ebc92684a20125fbc9fc1b8e1e700beb9197d750fdff88/numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be", size = 13971216, upload-time = "2024-02-05T23:59:35.472Z" }, - { url = "https://files.pythonhosted.org/packages/6d/64/c3bcdf822269421d85fe0d64ba972003f9bb4aa9a419da64b86856c9961f/numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764", size = 14226281, upload-time = "2024-02-05T23:59:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/54/30/c2a907b9443cf42b90c17ad10c1e8fa801975f01cb9764f3f8eb8aea638b/numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", size = 18249516, upload-time = "2024-02-06T00:00:32.79Z" }, - { url = "https://files.pythonhosted.org/packages/43/12/01a563fc44c07095996d0129b8899daf89e4742146f7044cdbdb3101c57f/numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd", size = 13882132, upload-time = "2024-02-06T00:00:58.197Z" }, - { url = "https://files.pythonhosted.org/packages/16/ee/9df80b06680aaa23fc6c31211387e0db349e0e36d6a63ba3bd78c5acdf11/numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c", size = 18084181, upload-time = "2024-02-06T00:01:31.21Z" }, - { url = "https://files.pythonhosted.org/packages/28/7d/4b92e2fe20b214ffca36107f1a3e75ef4c488430e64de2d9af5db3a4637d/numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6", size = 5976360, upload-time = "2024-02-06T00:01:43.013Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/054082bd8220bbf6f297f982f0a8f5479fcbc55c8b511d928df07b965869/numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea", size = 15814633, upload-time = "2024-02-06T00:02:16.694Z" }, - { url = "https://files.pythonhosted.org/packages/3f/72/3df6c1c06fc83d9cfe381cccb4be2532bbd38bf93fbc9fad087b6687f1c0/numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30", size = 20455961, upload-time = "2024-02-06T00:03:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/570545bac308b58ffb21adda0f4e220ba716fb658a63c151daecc3293350/numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c", size = 18061071, upload-time = "2024-02-06T00:03:41.5Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/fafd8c51235f60d49f7a88e2275e13971e90555b67da52dd6416caec32fe/numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0", size = 15709730, upload-time = "2024-02-06T00:04:11.719Z" }, ] [[package]] @@ -1165,8 +1062,7 @@ name = "numpydoc" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "tabulate" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -1178,13 +1074,12 @@ wheels = [ [[package]] name = "opensampl" -version = "1.1.0" +version = "1.1.5" source = { editable = "." } dependencies = [ { name = "allantools" }, { name = "astor" }, - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click" }, { name = "geoalchemy2" }, { name = "jinja2" }, { name = "libcst" }, @@ -1192,6 +1087,7 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "psycopg2-binary" }, + { name = "pydanclick" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, @@ -1200,13 +1096,23 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "sqlalchemy" }, + { name = "tabulate" }, { name = "tqdm" }, ] [package.optional-dependencies] +backend = [ + { name = "fastapi" }, + { name = "prometheus-client" }, + { name = "uvicorn" }, +] collect = [ + { name = "ntplib" }, { name = "telnetlib3" }, ] +migrations = [ + { name = "alembic" }, +] [package.dev-dependencies] dev = [ @@ -1215,37 +1121,46 @@ dev = [ { name = "mkdocs-gen-files" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, + { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-postgresql" }, { name = "ruff" }, { name = "ty" }, ] [package.metadata] requires-dist = [ + { name = "alembic", marker = "extra == 'migrations'" }, { name = "allantools" }, { name = "astor" }, { name = "click", specifier = ">=8.0.0,<9" }, + { name = "fastapi", marker = "extra == 'backend'" }, { name = "geoalchemy2", specifier = "==0.16.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "libcst" }, { name = "loguru", specifier = ">=0.7.0,<0.8" }, + { name = "ntplib", marker = "extra == 'collect'", specifier = ">=0.4.0,<0.5" }, { name = "numpy", specifier = ">=1.26.4,<2" }, { name = "pandas", specifier = ">=2.2.1,<3" }, + { name = "prometheus-client", marker = "extra == 'backend'" }, { name = "psycopg2-binary", specifier = ">=2.9.0,<3" }, + { name = "pydanclick" }, { name = "pydantic", specifier = ">=2.10.3,<3" }, { name = "pydantic-settings", specifier = ">=2.9.0" }, { name = "python-dotenv" }, - { name = "python-multipart", specifier = ">=0.0.20,<0.0.21" }, + { name = "python-multipart", specifier = ">=0.0.26,<0.0.27" }, { name = "pytz", specifier = "~=2024.1" }, { name = "pyyaml", specifier = ">=6.0.0,<7" }, { name = "requests", specifier = ">=2.31.0,<3" }, { name = "sqlalchemy", specifier = ">=2.0.39,<3" }, + { name = "tabulate" }, { name = "telnetlib3", marker = "extra == 'collect'", specifier = "==2.0.4" }, { name = "tqdm", specifier = ">=4.66.2,<5" }, + { name = "uvicorn", marker = "extra == 'backend'" }, ] -provides-extras = ["server", "collect"] +provides-extras = ["migrations", "backend", "collect"] [package.metadata.requires-dev] dev = [ @@ -1254,9 +1169,11 @@ dev = [ { name = "mkdocs-gen-files" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extras = ["python"] }, + { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-postgresql" }, { name = "ruff" }, { name = "ty" }, ] @@ -1312,13 +1229,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235, upload-time = "2024-09-20T19:02:07.094Z" }, { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756, upload-time = "2024-09-20T13:09:20.474Z" }, { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248, upload-time = "2024-09-20T13:09:23.137Z" }, - { url = "https://files.pythonhosted.org/packages/ca/8c/8848a4c9b8fdf5a534fe2077af948bf53cd713d77ffbcd7bd15710348fd7/pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39", size = 12595535, upload-time = "2024-09-20T13:09:51.339Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b9/5cead4f63b6d31bdefeb21a679bc5a7f4aaf262ca7e07e2bc1c341b68470/pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30", size = 11319822, upload-time = "2024-09-20T13:09:54.31Z" }, - { url = "https://files.pythonhosted.org/packages/31/af/89e35619fb573366fa68dc26dad6ad2c08c17b8004aad6d98f1a31ce4bb3/pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c", size = 15625439, upload-time = "2024-09-20T19:02:23.689Z" }, - { url = "https://files.pythonhosted.org/packages/3d/dd/bed19c2974296661493d7acc4407b1d2db4e2a482197df100f8f965b6225/pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c", size = 13068928, upload-time = "2024-09-20T13:09:56.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/a3/18508e10a31ea108d746c848b5a05c0711e0278fa0d6f1c52a8ec52b80a5/pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea", size = 16783266, upload-time = "2024-09-20T19:02:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a5/3429bd13d82bebc78f4d78c3945efedef63a7cd0c15c17b2eeb838d1121f/pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761", size = 14450871, upload-time = "2024-09-20T13:09:59.779Z" }, - { url = "https://files.pythonhosted.org/packages/2f/49/5c30646e96c684570925b772eac4eb0a8cb0ca590fa978f56c5d3ae73ea1/pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e", size = 11618011, upload-time = "2024-09-20T13:10:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643, upload-time = "2024-09-20T13:09:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573, upload-time = "2024-09-20T13:09:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085, upload-time = "2024-09-20T19:02:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809, upload-time = "2024-09-20T13:09:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316, upload-time = "2024-09-20T19:02:13.825Z" }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055, upload-time = "2024-09-20T13:09:33.462Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175, upload-time = "2024-09-20T13:09:35.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650, upload-time = "2024-09-20T13:09:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177, upload-time = "2024-09-20T13:09:41.141Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526, upload-time = "2024-09-20T19:02:16.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013, upload-time = "2024-09-20T13:09:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620, upload-time = "2024-09-20T19:02:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, ] [[package]] @@ -1369,17 +1292,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" }, { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" }, { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/21/3a/c1835d1c7cf83559e95b4f4ed07ab0bb7acc689712adfce406b3f456e9fd/pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8", size = 3198391, upload-time = "2025-04-12T17:49:10.122Z" }, - { url = "https://files.pythonhosted.org/packages/b6/4d/dcb7a9af3fc1e8653267c38ed622605d9d1793349274b3ef7af06457e257/pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909", size = 3030573, upload-time = "2025-04-12T17:49:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/530ca098c1a1eb31d4e163d317d0e24e6d2ead907991c69ca5b663de1bc5/pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928", size = 4398677, upload-time = "2025-04-12T17:49:13.861Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ee/0e5e51db34de1690264e5f30dcd25328c540aa11d50a3bc0b540e2a445b6/pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79", size = 4484986, upload-time = "2025-04-12T17:49:15.948Z" }, - { url = "https://files.pythonhosted.org/packages/93/7d/bc723b41ce3d2c28532c47678ec988974f731b5c6fadd5b3a4fba9015e4f/pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35", size = 4501897, upload-time = "2025-04-12T17:49:17.839Z" }, - { url = "https://files.pythonhosted.org/packages/be/0b/532e31abc7389617ddff12551af625a9b03cd61d2989fa595e43c470ec67/pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb", size = 4592618, upload-time = "2025-04-12T17:49:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f0/21ed6499a6216fef753e2e2254a19d08bff3747108ba042422383f3e9faa/pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a", size = 4570493, upload-time = "2025-04-12T17:49:21.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/de/17004ddb8ab855573fe1127ab0168d11378cdfe4a7ee2a792a70ff2e9ba7/pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36", size = 4647748, upload-time = "2025-04-12T17:49:23.579Z" }, - { url = "https://files.pythonhosted.org/packages/c7/23/82ecb486384bb3578115c509d4a00bb52f463ee700a5ca1be53da3c88c19/pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67", size = 2331731, upload-time = "2025-04-12T17:49:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/58/bb/87efd58b3689537a623d44dbb2550ef0bb5ff6a62769707a0fe8b1a7bdeb/pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1", size = 2676346, upload-time = "2025-04-12T17:49:27.342Z" }, - { url = "https://files.pythonhosted.org/packages/80/08/dc268475b22887b816e5dcfae31bce897f524b4646bab130c2142c9b2400/pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e", size = 2414623, upload-time = "2025-04-12T17:49:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload-time = "2025-04-12T17:48:25.738Z" }, + { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload-time = "2025-04-12T17:48:27.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload-time = "2025-04-12T17:48:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload-time = "2025-04-12T17:48:31.874Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload-time = "2025-04-12T17:48:34.422Z" }, + { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload-time = "2025-04-12T17:48:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload-time = "2025-04-12T17:48:39.652Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload-time = "2025-04-12T17:48:41.765Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload-time = "2025-04-12T17:48:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload-time = "2025-04-12T17:48:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload-time = "2025-04-12T17:48:47.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload-time = "2025-04-12T17:48:50.189Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload-time = "2025-04-12T17:48:52.346Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload-time = "2025-04-12T17:48:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload-time = "2025-04-12T17:48:56.383Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload-time = "2025-04-12T17:48:58.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload-time = "2025-04-12T17:49:00.709Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload-time = "2025-04-12T17:49:02.946Z" }, + { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload-time = "2025-04-12T17:49:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload-time = "2025-04-12T17:49:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" }, { url = "https://files.pythonhosted.org/packages/33/49/c8c21e4255b4f4a2c0c68ac18125d7f5460b109acc6dfdef1a24f9b960ef/pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156", size = 3181727, upload-time = "2025-04-12T17:49:31.898Z" }, { url = "https://files.pythonhosted.org/packages/6d/f1/f7255c0838f8c1ef6d55b625cfb286835c17e8136ce4351c5577d02c443b/pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772", size = 2999833, upload-time = "2025-04-12T17:49:34.2Z" }, { url = "https://files.pythonhosted.org/packages/e2/57/9968114457bd131063da98d87790d080366218f64fa2943b65ac6739abb3/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363", size = 3437472, upload-time = "2025-04-12T17:49:36.294Z" }, @@ -1414,6 +1348,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "port-for" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/a0/80a64e8cc096c7a9d0f546a28994af849b4775afc5e4ee44bf2739a55115/port_for-1.0.0.tar.gz", hash = "sha256:404d161b1b2c82e2f6b31d8646396b4847d02bf5ee10068c92b7263657a14582", size = 21681, upload-time = "2025-09-30T10:22:51.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/2c/b1faca65b9728b4ac43f0bee4bb9e7294bd0a62cc2ee59fd59403bf575f6/port_for-1.0.0-py3-none-any.whl", hash = "sha256:35a848b98cf4cc075fe80dc49ae5c3a78e3ca345a23bd39bf5252277b4eef5c2", size = 17544, upload-time = "2025-09-30T10:22:49.878Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.10" @@ -1456,17 +1516,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bc/e77648009b6e61af327c607543f65fdf25bcfb4100f5a6f3bdb62ddac03c/psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", size = 3043437, upload-time = "2024-10-16T11:23:42.946Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e8/5a12211a1f5b959f3e3ccd342eace60c1f26422f53e06d687821dc268780/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", size = 2851340, upload-time = "2024-10-16T11:23:50.038Z" }, - { url = "https://files.pythonhosted.org/packages/47/ed/5932b0458a7fc61237b653df050513c8d18a6f4083cc7f90dcef967f7bce/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", size = 3080905, upload-time = "2024-10-16T11:23:57.932Z" }, - { url = "https://files.pythonhosted.org/packages/71/df/8047d85c3d23864aca4613c3be1ea0fe61dbe4e050a89ac189f9dce4403e/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", size = 3264640, upload-time = "2024-10-16T11:24:06.122Z" }, - { url = "https://files.pythonhosted.org/packages/f3/de/6157e4ef242920e8f2749f7708d5cc8815414bdd4a27a91996e7cd5c80df/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", size = 3019812, upload-time = "2024-10-16T11:24:17.025Z" }, - { url = "https://files.pythonhosted.org/packages/25/f9/0fc49efd2d4d6db3a8d0a3f5749b33a0d3fdd872cad49fbf5bfce1c50027/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", size = 2871933, upload-time = "2024-10-16T11:24:24.858Z" }, - { url = "https://files.pythonhosted.org/packages/57/bc/2ed1bd182219065692ed458d218d311b0b220b20662d25d913bc4e8d3549/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", size = 2820990, upload-time = "2024-10-16T11:24:29.571Z" }, - { url = "https://files.pythonhosted.org/packages/71/2a/43f77a9b8ee0b10e2de784d97ddc099d9fe0d9eec462a006e4d2cc74756d/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", size = 2919352, upload-time = "2024-10-16T11:24:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/57/86/d2943df70469e6afab3b5b8e1367fccc61891f46de436b24ddee6f2c8404/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", size = 2957614, upload-time = "2024-10-16T11:24:44.423Z" }, - { url = "https://files.pythonhosted.org/packages/85/21/195d69371330983aa16139e60ba855d0a18164c9295f3a3696be41bbcd54/psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", size = 1025341, upload-time = "2024-10-16T11:24:48.056Z" }, - { url = "https://files.pythonhosted.org/packages/ad/53/73196ebc19d6fbfc22427b982fbc98698b7b9c361e5e7707e3a3247cf06d/psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", size = 1163958, upload-time = "2024-10-16T11:24:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "pydanclick" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/b5/4de7da87d181c8809e3be95a61256a9efa557030b097f50bb6ddeefcd126/pydanclick-0.5.1.tar.gz", hash = "sha256:ddbf2ec8ce3086b0e011bd1b7cd13474de7210d27e031a37c9cec4a76971d518", size = 20911, upload-time = "2025-02-26T07:39:21.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/45/a7e33af2da5d4396e974f33ed7ed406e2cc218427cf8fdbe01c9491e51f8/pydanclick-0.5.1-py3-none-any.whl", hash = "sha256:9ef7dd384f0e04c9db3908251db8f5fe7c05d52a8824c659778036cc671f416a", size = 22163, upload-time = "2025-02-26T07:39:19.926Z" }, ] [[package]] @@ -1534,19 +1603,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, - { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, - { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, - { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, - { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, @@ -1565,15 +1638,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, - { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, - { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, - { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, ] [[package]] @@ -1663,6 +1727,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, ] +[[package]] +name = "pytest-postgresql" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mirakuru" }, + { name = "packaging" }, + { name = "port-for" }, + { name = "psycopg" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/c8/b9607675904b3e4004c76109741aac6479daecfe6fb38ad89f330c6c5adc/pytest_postgresql-8.0.0.tar.gz", hash = "sha256:26cbd44a0adef76cf4a82a3a2263f0e029bc54b5863556a9bd86ca3edfa91cce", size = 49737, upload-time = "2026-01-23T21:14:34.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/cf/2c962ce63904e2b051b644a0036bdeeae8ae05d91e11e829b220c3db9935/pytest_postgresql-8.0.0-py3-none-any.whl", hash = "sha256:125b63b16d630c2dea19807062ed4c96e6123f06058ce65b82b4b14174d6c4b8", size = 40228, upload-time = "2026-01-23T21:14:33.08Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1686,11 +1766,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] @@ -1735,15 +1815,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] [[package]] @@ -1758,6 +1838,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -1807,55 +1911,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" }, ] -[[package]] -name = "scipy" -version = "1.13.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/00/48c2f661e2816ccf2ecd77982f6605b2950afe60f60a52b4cbbc2504aa8f/scipy-1.13.1.tar.gz", hash = "sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c", size = 57210720, upload-time = "2024-05-23T03:29:26.079Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/59/41b2529908c002ade869623b87eecff3e11e3ce62e996d0bdcb536984187/scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca", size = 39328076, upload-time = "2024-05-23T03:19:01.687Z" }, - { url = "https://files.pythonhosted.org/packages/d5/33/f1307601f492f764062ce7dd471a14750f3360e33cd0f8c614dae208492c/scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f", size = 30306232, upload-time = "2024-05-23T03:19:09.089Z" }, - { url = "https://files.pythonhosted.org/packages/c0/66/9cd4f501dd5ea03e4a4572ecd874936d0da296bd04d1c45ae1a4a75d9c3a/scipy-1.13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfa31f1def5c819b19ecc3a8b52d28ffdcc7ed52bb20c9a7589669dd3c250989", size = 33743202, upload-time = "2024-05-23T03:19:15.138Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ba/7255e5dc82a65adbe83771c72f384d99c43063648456796436c9a5585ec3/scipy-1.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26264b282b9da0952a024ae34710c2aff7d27480ee91a2e82b7b7073c24722f", size = 38577335, upload-time = "2024-05-23T03:19:21.984Z" }, - { url = "https://files.pythonhosted.org/packages/49/a5/bb9ded8326e9f0cdfdc412eeda1054b914dfea952bda2097d174f8832cc0/scipy-1.13.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eccfa1906eacc02de42d70ef4aecea45415f5be17e72b61bafcfd329bdc52e94", size = 38820728, upload-time = "2024-05-23T03:19:28.225Z" }, - { url = "https://files.pythonhosted.org/packages/12/30/df7a8fcc08f9b4a83f5f27cfaaa7d43f9a2d2ad0b6562cced433e5b04e31/scipy-1.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:2831f0dc9c5ea9edd6e51e6e769b655f08ec6db6e2e10f86ef39bd32eb11da54", size = 46210588, upload-time = "2024-05-23T03:19:35.661Z" }, - { url = "https://files.pythonhosted.org/packages/b4/15/4a4bb1b15bbd2cd2786c4f46e76b871b28799b67891f23f455323a0cdcfb/scipy-1.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:27e52b09c0d3a1d5b63e1105f24177e544a222b43611aaf5bc44d4a0979e32f9", size = 39333805, upload-time = "2024-05-23T03:19:43.081Z" }, - { url = "https://files.pythonhosted.org/packages/ba/92/42476de1af309c27710004f5cdebc27bec62c204db42e05b23a302cb0c9a/scipy-1.13.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:54f430b00f0133e2224c3ba42b805bfd0086fe488835effa33fa291561932326", size = 30317687, upload-time = "2024-05-23T03:19:48.799Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/8be64fe225360a4beb6840f3cbee494c107c0887f33350d0a47d55400b01/scipy-1.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89369d27f9e7b0884ae559a3a956e77c02114cc60a6058b4e5011572eea9299", size = 33694638, upload-time = "2024-05-23T03:19:55.104Z" }, - { url = "https://files.pythonhosted.org/packages/36/07/035d22ff9795129c5a847c64cb43c1fa9188826b59344fee28a3ab02e283/scipy-1.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a78b4b3345f1b6f68a763c6e25c0c9a23a9fd0f39f5f3d200efe8feda560a5fa", size = 38569931, upload-time = "2024-05-23T03:20:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/d9/10/f9b43de37e5ed91facc0cfff31d45ed0104f359e4f9a68416cbf4e790241/scipy-1.13.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45484bee6d65633752c490404513b9ef02475b4284c4cfab0ef946def50b3f59", size = 38838145, upload-time = "2024-05-23T03:20:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/4a/48/4513a1a5623a23e95f94abd675ed91cfb19989c58e9f6f7d03990f6caf3d/scipy-1.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:5713f62f781eebd8d597eb3f88b8bf9274e79eeabf63afb4a737abc6c84ad37b", size = 46196227, upload-time = "2024-05-23T03:20:16.433Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7b/fb6b46fbee30fc7051913068758414f2721003a89dd9a707ad49174e3843/scipy-1.13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5d72782f39716b2b3509cd7c33cdc08c96f2f4d2b06d51e52fb45a19ca0c86a1", size = 39357301, upload-time = "2024-05-23T03:20:23.538Z" }, - { url = "https://files.pythonhosted.org/packages/dc/5a/2043a3bde1443d94014aaa41e0b50c39d046dda8360abd3b2a1d3f79907d/scipy-1.13.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:017367484ce5498445aade74b1d5ab377acdc65e27095155e448c88497755a5d", size = 30363348, upload-time = "2024-05-23T03:20:29.885Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cb/26e4a47364bbfdb3b7fb3363be6d8a1c543bcd70a7753ab397350f5f189a/scipy-1.13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:949ae67db5fa78a86e8fa644b9a6b07252f449dcf74247108c50e1d20d2b4627", size = 33406062, upload-time = "2024-05-23T03:20:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/88/ab/6ecdc526d509d33814835447bbbeedbebdec7cca46ef495a61b00a35b4bf/scipy-1.13.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3ade0e53bc1f21358aa74ff4830235d716211d7d077e340c7349bc3542e884", size = 38218311, upload-time = "2024-05-23T03:20:42.086Z" }, - { url = "https://files.pythonhosted.org/packages/0b/00/9f54554f0f8318100a71515122d8f4f503b1a2c4b4cfab3b4b68c0eb08fa/scipy-1.13.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2ac65fb503dad64218c228e2dc2d0a0193f7904747db43014645ae139c8fad16", size = 38442493, upload-time = "2024-05-23T03:20:48.292Z" }, - { url = "https://files.pythonhosted.org/packages/3e/df/963384e90733e08eac978cd103c34df181d1fec424de383cdc443f418dd4/scipy-1.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:cdd7dacfb95fea358916410ec61bbc20440f7860333aee6d882bb8046264e949", size = 45910955, upload-time = "2024-05-23T03:20:55.091Z" }, - { url = "https://files.pythonhosted.org/packages/7f/29/c2ea58c9731b9ecb30b6738113a95d147e83922986b34c685b8f6eefde21/scipy-1.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:436bbb42a94a8aeef855d755ce5a465479c721e9d684de76bf61a62e7c2b81d5", size = 39352927, upload-time = "2024-05-23T03:21:01.95Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c0/e71b94b20ccf9effb38d7147c0064c08c622309fd487b1b677771a97d18c/scipy-1.13.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8335549ebbca860c52bf3d02f80784e91a004b71b059e3eea9678ba994796a24", size = 30324538, upload-time = "2024-05-23T03:21:07.634Z" }, - { url = "https://files.pythonhosted.org/packages/6d/0f/aaa55b06d474817cea311e7b10aab2ea1fd5d43bc6a2861ccc9caec9f418/scipy-1.13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d533654b7d221a6a97304ab63c41c96473ff04459e404b83275b60aa8f4b7004", size = 33732190, upload-time = "2024-05-23T03:21:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/d0ad1a96f80962ba65e2ce1de6a1e59edecd1f0a7b55990ed208848012e0/scipy-1.13.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637e98dcf185ba7f8e663e122ebf908c4702420477ae52a04f9908707456ba4d", size = 38612244, upload-time = "2024-05-23T03:21:21.827Z" }, - { url = "https://files.pythonhosted.org/packages/8d/02/1165905f14962174e6569076bcc3315809ae1291ed14de6448cc151eedfd/scipy-1.13.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a014c2b3697bde71724244f63de2476925596c24285c7a637364761f8710891c", size = 38845637, upload-time = "2024-05-23T03:21:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/3e/77/dab54fe647a08ee4253963bcd8f9cf17509c8ca64d6335141422fe2e2114/scipy-1.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:392e4ec766654852c25ebad4f64e4e584cf19820b980bc04960bca0b0cd6eaa2", size = 46227440, upload-time = "2024-05-23T03:21:35.888Z" }, -] - [[package]] name = "scipy" version = "1.15.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.10'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -1886,6 +1947,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, ] [[package]] @@ -1906,63 +1985,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] -[[package]] -name = "sphinx" -version = "7.4.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "imagesize", marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, -] - [[package]] name = "sphinx" version = "8.1.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -1974,11 +2021,12 @@ name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "alabaster", marker = "python_full_version >= '3.11'" }, { name = "babel", marker = "python_full_version >= '3.11'" }, { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, { name = "docutils", marker = "python_full_version >= '3.11'" }, @@ -2060,7 +2108,7 @@ name = "sqlalchemy" version = "2.0.41" 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 = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } @@ -2089,17 +2137,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1c/3d2a893c020fcc18463794e0a687de58044d1c8a9892d23548ca7e71274a/sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2", size = 2121327, upload-time = "2025-05-14T18:01:30.842Z" }, - { url = "https://files.pythonhosted.org/packages/3e/84/389c8f7c7b465682c4e5ba97f6e7825149a6625c629e09b5e872ec3b378f/sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f", size = 2110739, upload-time = "2025-05-14T18:01:32.881Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3d/036e84ecb46d6687fa57dc25ab366dff50773a19364def210b8770fd1516/sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769", size = 3198018, upload-time = "2025-05-14T17:57:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/8d/de/112e2142bf730a16a6cb43efc87e36dd62426e155727490c041130c6e852/sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b", size = 3197074, upload-time = "2025-05-14T17:36:18.732Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/a766c78ec3050cb5b734c3087cd20bafd7370b0ab0c8636a87652631af1f/sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826", size = 3138698, upload-time = "2025-05-14T17:57:55.395Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/245e39ec45e1a8c86ff1ac3a88b13d0457307ac728eaeb217834a3ac6813/sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923", size = 3160877, upload-time = "2025-05-14T17:36:20.178Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0c/cda8631405f6417208e160070b513bb752da0885e462fce42ac200c8262f/sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440", size = 2089270, upload-time = "2025-05-14T18:01:41.315Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1f/f68c58970d80ea5a1868ca5dc965d154a3b711f9ab06376ad9840d1475b8/sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71", size = 2113134, upload-time = "2025-05-14T18:01:42.801Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "tabulate" version = "0.9.0" @@ -2144,6 +2205,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] @@ -2195,14 +2266,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +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/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { 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]] @@ -2223,6 +2294,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, ] +[[package]] +name = "uvicorn" +version = "0.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -2238,13 +2323,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" }, - { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, - { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -2265,12 +2348,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] - -[[package]] -name = "zipp" -version = "3.22.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/b6/7b3d16792fdf94f146bed92be90b4eb4563569eca91513c8609aebf0c167/zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5", size = 25257, upload-time = "2025-05-26T14:46:32.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/da/f64669af4cae46f17b90798a827519ce3737d31dbafad65d391e49643dc4/zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343", size = 9796, upload-time = "2025-05-26T14:46:30.775Z" }, -]