From 2c57d5a1e9e446b161ba3f6729ff492e19b16658 Mon Sep 17 00:00:00 2001 From: Masato Onodera Date: Wed, 5 Aug 2026 17:30:58 +0900 Subject: [PATCH 1/7] refactor!: build TargetDB on pfs.utils.database.db.DB TargetDB was a hand-rolled SQLAlchemy wrapper carrying real debt: reset_* passed raw strings to session.execute() and had been broken since SQLAlchemy 2.x, insert_by_copy/fetch_by_copy used psycopg2-only cursor APIs, and bulk inserts went through the ORM despite fluxstd/sky reaching millions of rows. Subclass pfs.utils.database.db.DB instead, which brings a process-wide engine cache, pool_pre_ping and COPY-based bulk insert. Only what DB lacks is implemented locally: a password and explicit dialect in the URL, update(), close(), and the dry_run= keyword backing the CLI's --commit default. Two overrides are load-bearing: * connect() returns None. DB.connect() hands back a pooled Connection and every caller here and downstream discards it, which would leak one checked-out connection per call. * connection() begins its transaction eagerly and rolls back under dry-run. The eager begin matters because pandas.to_sql starts *and commits* a transaction of its own when given a connection not already in one, which would otherwise defeat the rollback and silently break --commit. Standardise on psycopg3. normalize_drivername() rewrites the bare "postgresql" dialect to postgresql+psycopg at the single point every path passes through, so no deployed config file needs editing. Drop psycopg2-binary and raise requires-python to >=3.12 (pfs-utils needs it); CI already installs 3.12. Make the password optional: when absent it is omitted from the URL and libpq resolves it via PGPASSWORD or ~/.pgpass, so a config file with no secrets can be version controlled. Also in this change: * inserts drop DataFrame columns the table lacks. add_backref_values() merges whole reference tables in to resolve foreign keys, leaving columns like partner_name behind; bulk_insert_mappings() ignored unmapped keys and COPY does not. * fill in group_id for 8 rows of examples/data/proposals.csv. The column is NOT NULL and the values were blank; psycopg2 stored the literal string "NaN" there, which COPY correctly rejects as a null. * fix the tbls DSN. Replacing the scheme on the rendered string produced "postgres+psycopg://", which tbls cannot parse; swap the drivername on the URL object instead. * close three connection leaks where db.close() sat outside try/finally. * alembic env.py now builds its URL from the TOML file named by TARGETDB_CONF, falling back to alembic.ini. Credentials live in one place and deployments pick up psycopg3 without hand-edited ini files. config.set_main_option() is avoided because ConfigParser reads "%" as an interpolation marker and would mangle passwords containing it. * track alembic/pfsa-db01-gb-dev/ and add credential-free alembic.ini files, so every deployment directory is runnable from a fresh checkout. * lint and format the alembic env.py scripts. The previous blanket exclusion of alembic/ was broader than its own rationale, which only covers the historical revision scripts under versions/. Removed: reset_all, reset_target, reset_fluxstd, reset_sky, insert_by_copy, fetch_by_copy, insert_mappings, rollback. None are called from ets_target_database, ets_pointing or pfs_obsproc_planning_tools. insert() no longer takes return_defaults. connect, close, fetch_query, fetch_all and fetch_by_id are kept unchanged for downstream callers. Tests: new unit coverage for URL construction and the API surface, plus integration tests proving the COPY dry-run rollback against a real server, the absence of connection leaks, and TARGETDB_CONF-driven alembic. Co-Authored-By: Claude Opus 5 --- .github/workflows/lint.yml | 6 +- .pre-commit-config.yaml | 9 +- CLAUDE.md | 55 ++ README.md | 41 +- alembic/local_test/alembic.ini | 77 +++ alembic/local_test/alembic/env.py | 38 +- alembic/pfsa-db01-gb-dev/alembic.ini | 77 +++ alembic/pfsa-db01-gb-dev/alembic/README.md | 80 +++ alembic/pfsa-db01-gb-dev/alembic/env.py | 95 ++++ alembic/pfsa-db01-gb-dev/alembic/memo.md | 22 + .../pfsa-db01-gb-dev/alembic/script.py.mako | 24 + ...12-103708_33e97fab1034_initial_revision.py | 24 + ...-103828_9c71ca3a4254_add_filter_columns.py | 66 +++ ..._add_columns_for_photometric_errors_in_.py | 24 + ..._add_columns_for_photometric_errors_in_.py | 24 + ..._add_columns_for_photometric_errors_in_.py | 50 ++ ...add_error_columns_for_psf__mag_flux_in_.py | 50 ++ ...5c7fd1caf42_add_ob_code_in_target_table.py | 28 + ..._make_nullable_false_for_ob_code_column.py | 34 ++ ...098339_new_table_filter_name_is_created.py | 35 ++ ...4a_foreign_key_relationships_to_filter_.py | 62 +++ ...b9b_add_nullable_false_for_some_columns.py | 155 ++++++ ...add_a_unique_constraint_proposal_id_ob_.py | 34 ++ ...dd_a_unique_constraint_by_obj_id_input_.py | 34 ++ ...add_columns_of_stellar_parameters_teff_.py | 44 ++ ...f339_add_an_index_to_version_in_fluxstd.py | 34 ++ ...94_add_indexes_to_input_catalog_id_and_.py | 36 ++ ...05-181644_099e9069f2b7_test_q3c_indexes.py | 28 + ...9_add_allocated_time_in_total_lr_and_mr.py | 53 ++ ...3b_add_upload_id_to_input_catalog_table.py | 28 + ...dd_gaia_parameters_in_the_fluxstd_table.py | 67 +++ ...46e_fix_typo_in_filter_relationship_in_.py | 28 + ...bd9b8d91_autoincrement_input_catalog_id.py | 157 ++++++ ...d8_remove_unique_constraint_from_input_.py | 66 +++ ...pdate_unique_constraint_for_the_target_.py | 39 ++ ..._add_unique_constraint_on_the_sky_table.py | 43 ++ ...get_created_at_updated_at_automatically.py | 272 ++++++++++ ...071_refine_default_values_and_nullable_.py | 84 +++ ...f_add_active_flag_to_the_input_catalog_.py | 43 ++ ..._8f188c3cb652_set_default_to_created_at.py | 172 ++++++ ...8dce443754_unique_constraints_on_target.py | 53 ++ ..._add_single_exptime_to_the_target_table.py | 28 + ...c2966c63_set_single_exptime_un_nullable.py | 34 ++ ...240606-145135_63584f7b182a_add_arm_info.py | 39 ++ ..._bc79254cc64b_pfs_arm_name_to_be_unique.py | 28 + ...c300ae87f_add_queue_classical_too_flags.py | 40 ++ ...38_b68482e38606_add_user_pointing_table.py | 109 ++++ ...d44_add_partner_column_in_the_proposal_.py | 41 ++ ...ed8dcd48_create_index_on_proposal_grade.py | 83 +++ ...05-162126_fc8ea63b8c9d_add_total_fluxes.py | 50 ++ ...e4392_add_index_target_input_catalog_id.py | 28 + ...1_add_index_in_obj_id_input_catalog_id_.py | 32 ++ ...emove_redundant_unique_constraint_from_.py | 142 +++++ ...7ee4ad568_generate_missing_foreign_keys.py | 130 +++++ ...855_385b6cdf44f7_add_indexes_to_fluxstd.py | 30 ++ ...1f_add_gc_neighbor_and_is_dense_region_.py | 40 ++ alembic/pfsa-db01-gb/alembic.ini | 77 +++ alembic/pfsa-db01-gb/alembic/env.py | 30 +- docs/getting_started.md | 99 +++- examples/data/proposals.csv | 16 +- pyproject.toml | 32 +- src/targetdb/targetdb.py | 437 ++++++++------- src/targetdb/utils.py | 288 ++++++---- tests/__init__.py | 0 tests/integration/test_alembic.py | 99 ++++ tests/integration/test_dry_run.py | 147 +++++ tests/integration/test_targetdb_api.py | 134 +++++ tests/test_targetdb.py | 172 ++++++ uv.lock | 506 +++++++++++------- 69 files changed, 4656 insertions(+), 626 deletions(-) create mode 100644 CLAUDE.md create mode 100644 alembic/local_test/alembic.ini create mode 100644 alembic/pfsa-db01-gb-dev/alembic.ini create mode 100644 alembic/pfsa-db01-gb-dev/alembic/README.md create mode 100644 alembic/pfsa-db01-gb-dev/alembic/env.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/memo.md create mode 100644 alembic/pfsa-db01-gb-dev/alembic/script.py.mako create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103708_33e97fab1034_initial_revision.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103828_9c71ca3a4254_add_filter_columns.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221020-172724_99f7f6b4c0d1_add_columns_for_photometric_errors_in_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221020-173436_a63ff2b68f44_add_columns_for_photometric_errors_in_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221111-135546_92d3534d8bf8_add_error_columns_for_psf__mag_flux_in_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221206-142611_85c7fd1caf42_add_ob_code_in_target_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221206-190448_9ef97473b712_make_nullable_false_for_ob_code_column.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221206-193037_db972b098339_new_table_filter_name_is_created.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221206-221328_d34d4aa2914a_foreign_key_relationships_to_filter_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20221207-141054_c5f125bd0b9b_add_nullable_false_for_some_columns.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230419-181440_790fae2257ac_add_a_unique_constraint_proposal_id_ob_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230420-150337_10e6d0a4610a_add_a_unique_constraint_by_obj_id_input_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230420-163804_8ac6065d2225_add_columns_of_stellar_parameters_teff_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230421-091340_6aec47e0f339_add_an_index_to_version_in_fluxstd.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230504-175323_c987ab91b094_add_indexes_to_input_catalog_id_and_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20230505-181644_099e9069f2b7_test_q3c_indexes.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240202-151144_71eba81781d9_add_allocated_time_in_total_lr_and_mr.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240202-153755_320fc1f7de3b_add_upload_id_to_input_catalog_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240404-173437_d024af8c52f2_add_gaia_parameters_in_the_fluxstd_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240408-092814_b010233c946e_fix_typo_in_filter_relationship_in_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240415-102112_eeb9bd9b8d91_autoincrement_input_catalog_id.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240416-104950_1f393a55b6d8_remove_unique_constraint_from_input_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240416-105545_065b74905fb6_update_unique_constraint_for_the_target_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240417-100959_c636e8e2ecc0_add_unique_constraint_on_the_sky_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240422-150510_0049efa2957b_get_created_at_updated_at_automatically.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240422-161159_1d0bd9825071_refine_default_values_and_nullable_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240501-170926_f8bacfde2cef_add_active_flag_to_the_input_catalog_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240502-112946_8f188c3cb652_set_default_to_created_at.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240502-155205_368dce443754_unique_constraints_on_target.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145050_f630d28235eb_add_single_exptime_to_the_target_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145817_1027c2966c63_set_single_exptime_un_nullable.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240606-145135_63584f7b182a_add_arm_info.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240606-172208_bc79254cc64b_pfs_arm_name_to_be_unique.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20240624-165136_f07c300ae87f_add_queue_classical_too_flags.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20241024-120338_b68482e38606_add_user_pointing_table.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20241220-145409_0b72e62efd44_add_partner_column_in_the_proposal_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20250331-121154_5b40ed8dcd48_create_index_on_proposal_grade.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20250505-162126_fc8ea63b8c9d_add_total_fluxes.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20250731-212720_3923581e4392_add_index_target_input_catalog_id.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20250801-202638_89865530fdf1_add_index_in_obj_id_input_catalog_id_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20251107-150645_ce8ac566901b_remove_redundant_unique_constraint_from_.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20251107-191607_ae27ee4ad568_generate_missing_foreign_keys.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20251216-182855_385b6cdf44f7_add_indexes_to_fluxstd.py create mode 100644 alembic/pfsa-db01-gb-dev/alembic/versions/20260129-172838_a251bdccb11f_add_gc_neighbor_and_is_dense_region_.py create mode 100644 alembic/pfsa-db01-gb/alembic.ini create mode 100644 tests/__init__.py create mode 100644 tests/integration/test_alembic.py create mode 100644 tests/integration/test_dry_run.py create mode 100644 tests/integration/test_targetdb_api.py create mode 100644 tests/test_targetdb.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 58efb6c..f3a4933 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,8 +30,10 @@ jobs: # --output-format=github renders findings as inline PR annotations, # which is the part of the Codacy integration worth keeping. + # alembic is passed as a directory so that the versions/ exclusion in + # pyproject.toml applies; only the env.py scripts are actually checked. - name: Run ruff - run: uv run ruff check --output-format=github src tests + run: uv run ruff check --output-format=github src tests alembic - name: Run black - run: uv run black --check --diff src tests + run: uv run black --check --diff src tests alembic diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d81b501..6fa7035 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,15 +10,16 @@ repos: # (e.g. models/proposal.py's `partner # noqa: F401`) that F401's # autofix would silently delete. Report-only also matches the # check-only Lint workflow. - # Match the Lint workflow, which checks src and tests only. - # alembic/ migrations are historical and deliberately unlinted. - files: ^(src|tests)/ + # Match the Lint workflow, which checks src, tests and the alembic + # env.py scripts. The revision scripts under alembic/*/versions/ are + # historical and deliberately unlinted (see pyproject.toml). + files: ^(src|tests)/|^alembic/[^/]+/alembic/env\.py$ - repo: https://github.com/psf/black rev: 25.12.0 hooks: - id: black - files: ^(src|tests)/ + files: ^(src|tests)/|^alembic/[^/]+/alembic/env\.py$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b2e376 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +See @README.md for project overview and @docs/getting_started.md for setup and usage details. + +## Commands + +```bash +# Install (development) +pip install -e . # or: uv sync + +# Run tests +pytest tests/ +pytest tests/test_models.py::test_relations_consistency # single test + +# Lint / format +ruff check src/ +black src/ + +# Docs (requires pip install -e ".[doc]" or uv sync --extra doc) +mkdocs serve +mkdocs build +``` + +## CLI + +All CLI commands require a TOML config file (`-c dbconf.toml`). The config shape is: + +```toml +[targetdb.db] +host = "localhost" +port = 5432 +dbname = "targetdb" +user = "admin" +password = "admin" # optional; omitted from the URL when absent, so libpq uses ~/.pgpass +dialect = "postgresql" +``` + +CLI flags default to `--commit False` (dry-run). Pass `--commit` explicitly to write to the database. + +## Non-obvious architecture + +- **PostgreSQL + Q3C extension required.** The Q3C extension must be installed before creating the schema (`pfs-targetdb-cli install-q3c`). It provides spatial indexing via `q3c_ang2ipix(ra, dec)` — every positional table (`target`, `fluxstd`, `sky`) has a `*_q3c_ang2ipix_idx` index. +- **Model import order in `src/targetdb/models/__init__.py` is load-order-sensitive** due to SQLAlchemy FK relationships. New models must be imported after their FK dependencies and added to `__all__`. +- **`input_catalog_id` is range-constrained.** The PostgreSQL `Identity` sequence starts at `10000`, max `89999`. Values up to `99999` are reserved for special use. +- **`version` column in `fluxstd` and `sky` is a string**, not a number (e.g., `"3.3"`). +- **All timestamps use UTC** via a custom `utcnow()` SQLAlchemy `FunctionElement` defined in `models/__init__.py`. +- **CLI delegates entirely to `utils.py`.** `cli/cli_main.py` is only argument parsing; all business logic lives in `utils.py`. +- **`TargetDB` is a subclass of `pfs.utils.database.db.DB`** (from the `pfs-utils` package, installed from GitHub). `DB` supplies the engine cache, `pool_pre_ping`, `COPY`-based bulk inserts and the `query_*` API. `targetdb.py` only overrides what `DB` lacks: a password/dialect in the URL, `update()`, `close()`, and the `dry_run=` keyword. Two overrides are load-bearing and easy to break: + - `connect()` returns `None` on purpose. `DB.connect()` hands back a pooled `Connection`, and every caller here and downstream discards it, which would leak one checked-out connection per call. + - `connection()` opens its transaction eagerly (`conn.begin()`) and rolls back when `_dry_run` is set. The eager begin matters because `pandas.to_sql` starts *and commits* a transaction of its own when handed a connection that is not already in one, which would defeat the rollback. Every dry-run path funnels through here. `tests/integration/test_dry_run.py` guards this. +- **psycopg3 only.** `dialect = "postgresql"` in a config file is rewritten to `postgresql+psycopg` by `normalize_drivername()` in `utils.py`, the single choke point that every code path (`get_url_object`, `TargetDB.__init__`) passes through. psycopg2 is not a dependency. +- **Inserts drop DataFrame columns the target table does not have** (`TargetDB._drop_unknown_columns`). `add_backref_values()` resolves foreign keys by merging whole reference tables in, leaving columns like `partner_name` behind; the old ORM path ignored unmapped keys and `COPY` does not. +- **Alembic has separate directories per deployment target** under `alembic/` (`local_test/`, `pfsa-db01-gb/`, `pfsa-db01-gb-dev/`). Each has its own `alembic.ini`. Run migrations from within the appropriate subdirectory. `env.py` builds the URL from the TOML file named by the `TARGETDB_CONF` environment variable, falling back to `sqlalchemy.url` in `alembic.ini` when it is unset — so credentials live in one place: `TARGETDB_CONF=dbconf.toml alembic upgrade head`. Do not use `config.set_main_option()` for the URL: ConfigParser reads `%` as an interpolation marker and would mangle passwords containing it. diff --git a/README.md b/README.md index 9ac634e..84e429c 100644 --- a/README.md +++ b/README.md @@ -19,29 +19,28 @@ The Q3C extension is required for the database. You can install it by the follow ### Python environment Python and the following packages as well as their dependencies will be used by installing `targetdb`. -Package versions shown here are those used for the development (as of April 2025). +Package versions shown here are those used for the development (as of May 2026). Newer (and somewhat older) versions should also work. -| Package | Version | -|------------------------------------------------------------------------|--------:| -| [Python](https://www.python.org/) | 3.11.x | -| [SQLAlchemy](https://www.sqlalchemy.org/) | 2.0.x | -| [pandas](https://pandas.pydata.org/) | 2.2.3 | -| [NumPy](https://numpy.org) | 1.26.4 | -| [Astropy](https://www.astropy.org/) | 7.0.1 | -| [loguru](https://loguru.readthedocs.io/) | 0.7.3 | -| [SQLAlchemy-Utils](https://sqlalchemy-utils.readthedocs.io/en/latest/) | 0.41.2 | -| [tabulate](https://pypi.org/project/tabulate/) | 0.9.0 | -| [alembic](https://alembic.sqlalchemy.org/en/latest/) | 1.13.1 | -| [pyarrow](https://arrow.apache.org/docs/python/) | 15.0.2 | -| [Typer](https://typer.tiangolo.com/) | 0.15.2 | -| [openpyxl](https://openpyxl.readthedocs.io/en/stable/) | 3.1.2 | - -If you are using Python 3.10 or earlier, you may need to install [tomli](https://github.com/hukkin/tomli) package. - -| Package | Version | -|------------------------------------------|--------:| -| [tomli](https://github.com/hukkin/tomli) | 2.0.1 | +| Package | Version | +|------------------------------------------------------------------------|------------:| +| [Python](https://www.python.org/) | 3.12.x | +| [SQLAlchemy](https://www.sqlalchemy.org/) | 2.0.x | +| [pandas](https://pandas.pydata.org/) | 2.3.3 | +| [NumPy](https://numpy.org) | 2.4.0 | +| [Astropy](https://www.astropy.org/) | 7.2.0 | +| [loguru](https://loguru.readthedocs.io/) | 0.7.3 | +| [pfs-utils](https://github.com/Subaru-PFS/pfs_utils) | 7.2026.3100 | +| [psycopg](https://www.psycopg.org/) | 3.3.4 | +| [SQLAlchemy-Utils](https://sqlalchemy-utils.readthedocs.io/en/latest/) | 0.42.1 | +| [tabulate](https://pypi.org/project/tabulate/) | 0.9.0 | +| [alembic](https://alembic.sqlalchemy.org/en/latest/) | 1.18.0 | +| [pyarrow](https://arrow.apache.org/docs/python/) | 22.0.0 | +| [Typer](https://typer.tiangolo.com/) | 0.21.1 | +| [openpyxl](https://openpyxl.readthedocs.io/en/stable/) | 3.1.5 | + +`targetdb.TargetDB` subclasses `pfs.utils.database.db.DB`, so `pfs-utils` is installed +straight from GitHub and a `.git` directory plus network access are needed at install time. For building the documentation, the following packages are required. diff --git a/alembic/local_test/alembic.ini b/alembic/local_test/alembic.ini new file mode 100644 index 0000000..d0c7363 --- /dev/null +++ b/alembic/local_test/alembic.ini @@ -0,0 +1,77 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +prepend_sys_path = . + +# timezone used for the date in the migration file and its filename. +# UTC matches the rest of targetdb and keeps filenames identical whichever +# machine generates them. Resolved via dateutil, which pandas already pulls in. +timezone = UTC + +# max length of characters to apply to the "slug" field +truncate_slug_length = 40 + +# run env.py during the 'revision' command even without --autogenerate +revision_environment = false + +# allow .pyc/.pyo files without a source .py to be detected as revisions +sourceless = false + +# version path separator; "os" uses os.pathsep. +version_path_separator = os + +# the output encoding used when revision files are written from script.py.mako +output_encoding = utf-8 + +# Fallback only: env.py builds the URL from the TOML file named by the +# TARGETDB_CONF environment variable when it is set. Placeholder -- do not +# commit real credentials. +sqlalchemy.url = postgresql://username:password@localhost:5432/targetdb + + +[post_write_hooks] +# Deliberately empty: alembic/ is excluded from black and ruff in +# pyproject.toml, so generated revisions are left as alembic writes them. + + +# 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/alembic/local_test/alembic/env.py b/alembic/local_test/alembic/env.py index bc75cf3..433e1b7 100644 --- a/alembic/local_test/alembic/env.py +++ b/alembic/local_test/alembic/env.py @@ -1,10 +1,10 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from targetdb import models +from sqlalchemy import create_engine, pool from alembic import context +from targetdb import models +from targetdb.utils import get_alembic_url # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -27,6 +27,23 @@ # ... etc. +def get_url(): + """Resolve the database URL, preferring the TARGETDB_CONF TOML file. + + Point TARGETDB_CONF at a targetdb config file to keep the credentials in + one place (and out of alembic.ini): + + TARGETDB_CONF=~/database_configs/config_targetdb.toml alembic upgrade head + + Falling back to alembic.ini's sqlalchemy.url when it is unset. + + Note that config.set_main_option() is deliberately not used: ConfigParser + treats "%" as an interpolation marker, so a password containing "%" would + be mangled. Building the engine directly avoids the problem entirely. + """ + return get_alembic_url() or config.get_main_option("sqlalchemy.url") + + def run_migrations_offline(): """Run migrations in 'offline' mode. @@ -39,12 +56,13 @@ def run_migrations_offline(): script output. """ - url = config.get_main_option("sqlalchemy.url") + url = get_url() context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + # compare_server_default=True, ) with context.begin_transaction(): @@ -58,14 +76,14 @@ def run_migrations_online(): and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + connectable = create_engine(get_url(), poolclass=pool.NullPool) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + # compare_server_default=True, + ) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/pfsa-db01-gb-dev/alembic.ini b/alembic/pfsa-db01-gb-dev/alembic.ini new file mode 100644 index 0000000..ce325d7 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic.ini @@ -0,0 +1,77 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +prepend_sys_path = . + +# timezone used for the date in the migration file and its filename. +# UTC matches the rest of targetdb and keeps filenames identical whichever +# machine generates them. Resolved via dateutil, which pandas already pulls in. +timezone = UTC + +# max length of characters to apply to the "slug" field +truncate_slug_length = 40 + +# run env.py during the 'revision' command even without --autogenerate +revision_environment = false + +# allow .pyc/.pyo files without a source .py to be detected as revisions +sourceless = false + +# version path separator; "os" uses os.pathsep. +version_path_separator = os + +# the output encoding used when revision files are written from script.py.mako +output_encoding = utf-8 + +# Fallback only: env.py builds the URL from the TOML file named by the +# TARGETDB_CONF environment variable when it is set. Placeholder -- do not +# commit real credentials. +sqlalchemy.url = postgresql://username:password@pfsa-db:5437/targetdb_dev + + +[post_write_hooks] +# Deliberately empty: alembic/ is excluded from black and ruff in +# pyproject.toml, so generated revisions are left as alembic writes them. + + +# 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/alembic/pfsa-db01-gb-dev/alembic/README.md b/alembic/pfsa-db01-gb-dev/alembic/README.md new file mode 100644 index 0000000..3fcbccd --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/README.md @@ -0,0 +1,80 @@ +# Generic single-database configuration. + +## Basic workflow + +Reference: https://alembic.sqlalchemy.org/en/latest/tutorial.html + +### Modify code of targetdb + + +### Run alembic + +Create a new revision file. + +```bash +alembic -c revision --autogenerate -m "Add columns" +``` + +Check the output file under the `versions` directory. + +Upgrade to the new revision. + +```bash +alembic -c upgrade head +``` + +### count duplicated rows + +```sql +SELECT COUNT(*) +FROM sky a +JOIN sky b ON a.obj_id = b.obj_id + AND a.input_catalog_id = b.input_catalog_id + AND a.version = b.version + AND a.sky_id < b.sky_id +; +``` + +```sql +SELECT ct, count(*) AS ct_ct FROM (SELECT sky_id, input_catalog_id, version, count(*) AS ct FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; + +SELECT ct, count(*) AS ct_ct FROM (SELECT obj_id, input_catalog_id, version, count(*) AS ct FROM sky WHERE version='20221031' GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; + +SELECT sky_id, input_catalog_id, version, count(*) FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1; +SELECT DISTINCT version FROM sky; + +SELECT obj_id, ra, dec, input_catalog_id, version, count(*) FROM sky GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1; + +SELECT version, count(*) AS ct FROM sky GROUP BY 1; + version | ct +----------+------------ + 20220427 | 2747 + 20220915 | 2004836366 + 20221031 | 897209700 +(3 rows) +``` + +### delete duplicated rows + +```sql + +``` + + +### error + +``` +> time alembic -c /work/monodera/Subaru-PFS/alembic_configs/alembic_pfsa-db01-gb.ini upgrade head +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.runtime.migration] Running upgrade 6aec47e0f339 -> eeca77238d00, Add an index and uniqueconstraint on sky + by obj_id, input_catalog_id, and version +Traceback (most recent call last): + File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1964, in _exec_single +_context + self.dialect.do_execute( + File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/default.py", line 748, in do_execute + cursor.execute(statement, parameters) +psycopg2.errors.UniqueViolation: could not create unique index "uq_sky_obj_id_input_catalog_id_version" +DETAIL: Key (obj_id, input_catalog_id, version)=(44332, 1002, 20220915) is duplicated. +``` diff --git a/alembic/pfsa-db01-gb-dev/alembic/env.py b/alembic/pfsa-db01-gb-dev/alembic/env.py new file mode 100644 index 0000000..433e1b7 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/env.py @@ -0,0 +1,95 @@ +from logging.config import fileConfig + +from sqlalchemy import create_engine, pool + +from alembic import context +from targetdb import models +from targetdb.utils import get_alembic_url + +# 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 = models.Base.metadata + +# 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. + + +def get_url(): + """Resolve the database URL, preferring the TARGETDB_CONF TOML file. + + Point TARGETDB_CONF at a targetdb config file to keep the credentials in + one place (and out of alembic.ini): + + TARGETDB_CONF=~/database_configs/config_targetdb.toml alembic upgrade head + + Falling back to alembic.ini's sqlalchemy.url when it is unset. + + Note that config.set_main_option() is deliberately not used: ConfigParser + treats "%" as an interpolation marker, so a password containing "%" would + be mangled. Building the engine directly avoids the problem entirely. + """ + return get_alembic_url() or config.get_main_option("sqlalchemy.url") + + +def run_migrations_offline(): + """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 = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + # compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = create_engine(get_url(), poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + # compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/pfsa-db01-gb-dev/alembic/memo.md b/alembic/pfsa-db01-gb-dev/alembic/memo.md new file mode 100644 index 0000000..1e76497 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/memo.md @@ -0,0 +1,22 @@ + +monodera on pfsa-usr01-gb: .../ets_target_database/alembic/pfsa-db01-gb [ tickets/OBSPROC-31/monodera][][ v3.9.13(venv39)] + alembic -c /work/monodera/Subaru-PFS/alembic_configs/alembic_pfsa-db01-gb.ini revision --autogenerate -m "Add columns for photometric errors in the fluxstd table" +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.ddl.postgresql] Detected sequence named 'fluxstd_fluxstd_id_seq' as owned by integer column 'fluxstd(fluxstd_id)', assuming SERIAL and omitting +INFO [alembic.ddl.postgresql] Detected sequence named 'sky_sky_id_seq' as owned by integer column 'sky(sky_id)', assuming SERIAL and omitting +alembic/env.py:71: SAWarning: Skipped unsupported reflection of expression-based index sky_q3c_ang2ipix_idx + context.run_migrations() +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_g' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_r' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_i' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_z' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_y' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_j' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_g' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_r' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_i' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_z' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_y' +INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_j' + Generating /work/monodera/Subaru-PFS/ets_target_database/alembic/pfsa-db01-gb/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py ... done diff --git a/alembic/pfsa-db01-gb-dev/alembic/script.py.mako b/alembic/pfsa-db01-gb-dev/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103708_33e97fab1034_initial_revision.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103708_33e97fab1034_initial_revision.py new file mode 100644 index 0000000..69b8fd3 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103708_33e97fab1034_initial_revision.py @@ -0,0 +1,24 @@ +"""initial revision + +Revision ID: 33e97fab1034 +Revises: +Create Date: 2022-05-12 10:37:08.039899 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '33e97fab1034' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103828_9c71ca3a4254_add_filter_columns.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103828_9c71ca3a4254_add_filter_columns.py new file mode 100644 index 0000000..1912a7b --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20220512-103828_9c71ca3a4254_add_filter_columns.py @@ -0,0 +1,66 @@ +"""add filter columns + +Revision ID: 9c71ca3a4254 +Revises: 33e97fab1034 +Create Date: 2022-05-12 10:38:28.402465 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9c71ca3a4254' +down_revision = '33e97fab1034' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'cluster', ['cluster_id', 'target_id']) + op.add_column('fluxstd', sa.Column('filter_g', sa.String(), nullable=True, comment='g-band filter (g_hsc, g_ps1, g_sdss, etc.)')) + op.add_column('fluxstd', sa.Column('filter_r', sa.String(), nullable=True, comment='r-band filter (r_hsc, r_ps1, r_sdss, etc.)')) + op.add_column('fluxstd', sa.Column('filter_i', sa.String(), nullable=True, comment='i-band filter (i_hsc, i_ps1, i_sdss, etc.)')) + op.add_column('fluxstd', sa.Column('filter_z', sa.String(), nullable=True, comment='z-band filter (z_hsc, z_ps1, z_sdss, etc.)')) + op.add_column('fluxstd', sa.Column('filter_y', sa.String(), nullable=True, comment='y-band filter (y_hsc, y_ps1, y_sdss, etc.)')) + op.add_column('fluxstd', sa.Column('filter_j', sa.String(), nullable=True, comment='j-band filter (j_mko, etc.)')) + op.create_unique_constraint(None, 'fluxstd', ['fluxstd_id']) + op.create_unique_constraint(None, 'input_catalog', ['input_catalog_id']) + op.create_unique_constraint(None, 'proposal', ['proposal_id']) + op.create_unique_constraint(None, 'proposal_category', ['proposal_category_id']) + op.create_unique_constraint(None, 'sky', ['sky_id']) + op.add_column('target', sa.Column('filter_g', sa.String(), nullable=True, comment='g-band filter (g_hsc, g_ps1, g_sdss, etc.)')) + op.add_column('target', sa.Column('filter_r', sa.String(), nullable=True, comment='r-band filter (r_hsc, r_ps1, r_sdss, etc.)')) + op.add_column('target', sa.Column('filter_i', sa.String(), nullable=True, comment='i-band filter (i_hsc, i_ps1, i_sdss, etc.)')) + op.add_column('target', sa.Column('filter_z', sa.String(), nullable=True, comment='z-band filter (z_hsc, z_ps1, z_sdss, etc.)')) + op.add_column('target', sa.Column('filter_y', sa.String(), nullable=True, comment='y-band filter (y_hsc, y_ps1, y_sdss, etc.)')) + op.add_column('target', sa.Column('filter_j', sa.String(), nullable=True, comment='j-band filter (j_mko, etc.)')) + op.create_unique_constraint(None, 'target', ['target_id']) + op.create_unique_constraint(None, 'target_type', ['target_type_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'target_type', type_='unique') + op.drop_constraint(None, 'target', type_='unique') + op.drop_column('target', 'filter_j') + op.drop_column('target', 'filter_y') + op.drop_column('target', 'filter_z') + op.drop_column('target', 'filter_i') + op.drop_column('target', 'filter_r') + op.drop_column('target', 'filter_g') + op.drop_constraint(None, 'sky', type_='unique') + op.drop_constraint(None, 'proposal_category', type_='unique') + op.drop_constraint(None, 'proposal', type_='unique') + op.drop_constraint(None, 'input_catalog', type_='unique') + op.drop_constraint(None, 'fluxstd', type_='unique') + op.drop_column('fluxstd', 'filter_j') + op.drop_column('fluxstd', 'filter_y') + op.drop_column('fluxstd', 'filter_z') + op.drop_column('fluxstd', 'filter_i') + op.drop_column('fluxstd', 'filter_r') + op.drop_column('fluxstd', 'filter_g') + op.drop_constraint(None, 'cluster', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-172724_99f7f6b4c0d1_add_columns_for_photometric_errors_in_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-172724_99f7f6b4c0d1_add_columns_for_photometric_errors_in_.py new file mode 100644 index 0000000..5422aca --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-172724_99f7f6b4c0d1_add_columns_for_photometric_errors_in_.py @@ -0,0 +1,24 @@ +"""Add columns for photometric errors in the fluxstd table + +Revision ID: 99f7f6b4c0d1 +Revises: 9c71ca3a4254 +Create Date: 2022-10-20 17:27:24.541747 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '99f7f6b4c0d1' +down_revision = '9c71ca3a4254' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-173436_a63ff2b68f44_add_columns_for_photometric_errors_in_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-173436_a63ff2b68f44_add_columns_for_photometric_errors_in_.py new file mode 100644 index 0000000..5f136ba --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-173436_a63ff2b68f44_add_columns_for_photometric_errors_in_.py @@ -0,0 +1,24 @@ +"""Add columns for photometric errors in the fluxstd table + +Revision ID: a63ff2b68f44 +Revises: 99f7f6b4c0d1 +Create Date: 2022-10-20 17:34:36.681006 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a63ff2b68f44' +down_revision = '99f7f6b4c0d1' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py new file mode 100644 index 0000000..4464e97 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py @@ -0,0 +1,50 @@ +"""Add columns for photometric errors in the fluxstd table + +Revision ID: c4e16db3e7e1 +Revises: a63ff2b68f44 +Create Date: 2022-10-20 17:45:27.991776 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c4e16db3e7e1' +down_revision = 'a63ff2b68f44' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('fluxstd', sa.Column('psf_mag_error_g', sa.Float(), nullable=True, comment='Error in g-band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_mag_error_r', sa.Float(), nullable=True, comment='Error in r-band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_mag_error_i', sa.Float(), nullable=True, comment='Error in i-band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_mag_error_z', sa.Float(), nullable=True, comment='Error in z-band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_mag_error_y', sa.Float(), nullable=True, comment='Error in y-band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_mag_error_j', sa.Float(), nullable=True, comment='Error in J band PSF magnitude (AB mag)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_g', sa.Float(), nullable=True, comment='Error in g-band PSF flux (nJy)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_r', sa.Float(), nullable=True, comment='Error in r-band PSF flux (nJy)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_i', sa.Float(), nullable=True, comment='Error in i-band PSF flux (nJy)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_z', sa.Float(), nullable=True, comment='Error in z-band PSF flux (nJy)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_y', sa.Float(), nullable=True, comment='Error in y-band PSF flux (nJy)')) + op.add_column('fluxstd', sa.Column('psf_flux_error_j', sa.Float(), nullable=True, comment='Error in J band PSF flux (nJy)')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('fluxstd', 'psf_flux_error_j') + op.drop_column('fluxstd', 'psf_flux_error_y') + op.drop_column('fluxstd', 'psf_flux_error_z') + op.drop_column('fluxstd', 'psf_flux_error_i') + op.drop_column('fluxstd', 'psf_flux_error_r') + op.drop_column('fluxstd', 'psf_flux_error_g') + op.drop_column('fluxstd', 'psf_mag_error_j') + op.drop_column('fluxstd', 'psf_mag_error_y') + op.drop_column('fluxstd', 'psf_mag_error_z') + op.drop_column('fluxstd', 'psf_mag_error_i') + op.drop_column('fluxstd', 'psf_mag_error_r') + op.drop_column('fluxstd', 'psf_mag_error_g') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221111-135546_92d3534d8bf8_add_error_columns_for_psf__mag_flux_in_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221111-135546_92d3534d8bf8_add_error_columns_for_psf__mag_flux_in_.py new file mode 100644 index 0000000..abd6b83 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221111-135546_92d3534d8bf8_add_error_columns_for_psf__mag_flux_in_.py @@ -0,0 +1,50 @@ +"""Add error columns for psf_{mag,flux} in the target table + +Revision ID: 92d3534d8bf8 +Revises: c4e16db3e7e1 +Create Date: 2022-11-11 13:55:46.817819 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '92d3534d8bf8' +down_revision = 'c4e16db3e7e1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('target', sa.Column('psf_mag_error_g', sa.Float(), nullable=True, comment='Error in g-band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_mag_error_r', sa.Float(), nullable=True, comment='Error in r-band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_mag_error_i', sa.Float(), nullable=True, comment='Error in i-band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_mag_error_z', sa.Float(), nullable=True, comment='Error in z-band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_mag_error_y', sa.Float(), nullable=True, comment='Error in y-band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_mag_error_j', sa.Float(), nullable=True, comment='Error in J band PSF magnitude (AB mag)')) + op.add_column('target', sa.Column('psf_flux_error_g', sa.Float(), nullable=True, comment='Error in g-band PSF flux (nJy)')) + op.add_column('target', sa.Column('psf_flux_error_r', sa.Float(), nullable=True, comment='Error in r-band PSF flux (nJy)')) + op.add_column('target', sa.Column('psf_flux_error_i', sa.Float(), nullable=True, comment='Error in i-band PSF flux (nJy)')) + op.add_column('target', sa.Column('psf_flux_error_z', sa.Float(), nullable=True, comment='Error in z-band PSF flux (nJy)')) + op.add_column('target', sa.Column('psf_flux_error_y', sa.Float(), nullable=True, comment='Error in y-band PSF flux (nJy)')) + op.add_column('target', sa.Column('psf_flux_error_j', sa.Float(), nullable=True, comment='Error in J band PSF flux (nJy)')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('target', 'psf_flux_error_j') + op.drop_column('target', 'psf_flux_error_y') + op.drop_column('target', 'psf_flux_error_z') + op.drop_column('target', 'psf_flux_error_i') + op.drop_column('target', 'psf_flux_error_r') + op.drop_column('target', 'psf_flux_error_g') + op.drop_column('target', 'psf_mag_error_j') + op.drop_column('target', 'psf_mag_error_y') + op.drop_column('target', 'psf_mag_error_z') + op.drop_column('target', 'psf_mag_error_i') + op.drop_column('target', 'psf_mag_error_r') + op.drop_column('target', 'psf_mag_error_g') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-142611_85c7fd1caf42_add_ob_code_in_target_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-142611_85c7fd1caf42_add_ob_code_in_target_table.py new file mode 100644 index 0000000..a5b5bf0 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-142611_85c7fd1caf42_add_ob_code_in_target_table.py @@ -0,0 +1,28 @@ +"""Add ob_code in target table. + +Revision ID: 85c7fd1caf42 +Revises: 92d3534d8bf8 +Create Date: 2022-12-06 14:26:11.366386 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '85c7fd1caf42' +down_revision = '92d3534d8bf8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('target', sa.Column('ob_code', sa.String(), nullable=True, comment='Identifer for a combination of a target, observing mode, and exposure time in a program.')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('target', 'ob_code') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-190448_9ef97473b712_make_nullable_false_for_ob_code_column.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-190448_9ef97473b712_make_nullable_false_for_ob_code_column.py new file mode 100644 index 0000000..0ef02da --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-190448_9ef97473b712_make_nullable_false_for_ob_code_column.py @@ -0,0 +1,34 @@ +"""Make nullable=False for ob_code column. + +Revision ID: 9ef97473b712 +Revises: 85c7fd1caf42 +Create Date: 2022-12-06 19:04:48.390219 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9ef97473b712' +down_revision = '85c7fd1caf42' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'ob_code', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Identifer for a combination of a target, observing mode, and exposure time in a program.') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'ob_code', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Identifer for a combination of a target, observing mode, and exposure time in a program.') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-193037_db972b098339_new_table_filter_name_is_created.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-193037_db972b098339_new_table_filter_name_is_created.py new file mode 100644 index 0000000..b538713 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-193037_db972b098339_new_table_filter_name_is_created.py @@ -0,0 +1,35 @@ +"""New table, filter_name, is created. + +Revision ID: db972b098339 +Revises: 9ef97473b712 +Create Date: 2022-12-06 19:30:37.646535 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'db972b098339' +down_revision = '9ef97473b712' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('filter_name', + sa.Column('filter_name', sa.String(), autoincrement=False, nullable=False, comment='Filter name (e.g., g_ps1)'), + sa.Column('filter_name_description', sa.String(), nullable=True, comment='Descriptino of the filter'), + sa.Column('created_at', sa.DateTime(), nullable=True, comment='Creation time [YYYY-MM-DDThh:mm:ss] (UTC)'), + sa.Column('updated_at', sa.DateTime(), nullable=True, comment='Update time [YYYY-MM-DDThh:mm:ss] (UTC)'), + sa.PrimaryKeyConstraint('filter_name'), + sa.UniqueConstraint('filter_name') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('filter_name') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-221328_d34d4aa2914a_foreign_key_relationships_to_filter_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-221328_d34d4aa2914a_foreign_key_relationships_to_filter_.py new file mode 100644 index 0000000..0f85988 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221206-221328_d34d4aa2914a_foreign_key_relationships_to_filter_.py @@ -0,0 +1,62 @@ +"""Foreign key relationships to filter_name created + +Revision ID: d34d4aa2914a +Revises: db972b098339 +Create Date: 2022-12-06 22:13:28.300403 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd34d4aa2914a' +down_revision = 'db972b098339' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('filter_name', 'filter_name_description', + existing_type=sa.VARCHAR(), + comment='Description of the filter', + existing_comment='Descriptino of the filter', + existing_nullable=True) + op.create_unique_constraint(None, 'filter_name', ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_j'], ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_z'], ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_g'], ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_r'], ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_y'], ['filter_name']) + op.create_foreign_key(None, 'fluxstd', 'filter_name', ['filter_i'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_j'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_z'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_y'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_i'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_g'], ['filter_name']) + op.create_foreign_key(None, 'target', 'filter_name', ['filter_r'], ['filter_name']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'fluxstd', type_='foreignkey') + op.drop_constraint(None, 'filter_name', type_='unique') + op.alter_column('filter_name', 'filter_name_description', + existing_type=sa.VARCHAR(), + comment='Descriptino of the filter', + existing_comment='Description of the filter', + existing_nullable=True) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20221207-141054_c5f125bd0b9b_add_nullable_false_for_some_columns.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20221207-141054_c5f125bd0b9b_add_nullable_false_for_some_columns.py new file mode 100644 index 0000000..2ea0dc5 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20221207-141054_c5f125bd0b9b_add_nullable_false_for_some_columns.py @@ -0,0 +1,155 @@ +"""Add nullable=False for some columns + +Revision ID: c5f125bd0b9b +Revises: d34d4aa2914a +Create Date: 2022-12-07 14:10:54.033977 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c5f125bd0b9b' +down_revision = 'd34d4aa2914a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('fluxstd', 'obj_id', + existing_type=sa.BIGINT(), + nullable=False, + existing_comment='Gaia EDR3 sourceid') + op.alter_column('fluxstd', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='RA (ICRS, degree)') + op.alter_column('fluxstd', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='Dec (ICRS, degree)') + op.alter_column('fluxstd', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=False, + existing_comment='input_catalog_id from the input_catalog table') + op.alter_column('fluxstd', 'version', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Version string of the F-star selection') + op.alter_column('sky', 'obj_id', + existing_type=sa.BIGINT(), + nullable=False, + existing_comment='Object ID in the sky catalog') + op.alter_column('sky', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='RA (ICRS, degree)') + op.alter_column('sky', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='Dec (ICRS, degree)') + op.alter_column('sky', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=False, + existing_comment='input_catalog_id from the input_catalog table') + op.alter_column('sky', 'version', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Version string of the sky position') + op.alter_column('target', 'obj_id', + existing_type=sa.BIGINT(), + nullable=False, + existing_comment='Object ID as specified by the observer at Phase 2 (can be same as the input_catalog_object_id)') + op.alter_column('target', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='RA (ICRS, degree)') + op.alter_column('target', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='Dec (ICRS, degree)') + op.alter_column('target', 'target_type_id', + existing_type=sa.INTEGER(), + comment='target type ID (default: 1 = SCIENCE)', + existing_nullable=True) + op.alter_column('target', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=False, + existing_comment='Input catalog ID from the input_catalog table') + op.alter_column('target_type', 'target_type_name', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Name for the target type.') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target_type', 'target_type_name', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Name for the target type.') + op.alter_column('target', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=True, + existing_comment='Input catalog ID from the input_catalog table') + op.alter_column('target', 'target_type_id', + existing_type=sa.INTEGER(), + comment=None, + existing_comment='target type ID (default: 1 = SCIENCE)', + existing_nullable=True) + op.alter_column('target', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='Dec (ICRS, degree)') + op.alter_column('target', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='RA (ICRS, degree)') + op.alter_column('target', 'obj_id', + existing_type=sa.BIGINT(), + nullable=True, + existing_comment='Object ID as specified by the observer at Phase 2 (can be same as the input_catalog_object_id)') + op.alter_column('sky', 'version', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Version string of the sky position') + op.alter_column('sky', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=True, + existing_comment='input_catalog_id from the input_catalog table') + op.alter_column('sky', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='Dec (ICRS, degree)') + op.alter_column('sky', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='RA (ICRS, degree)') + op.alter_column('sky', 'obj_id', + existing_type=sa.BIGINT(), + nullable=True, + existing_comment='Object ID in the sky catalog') + op.alter_column('fluxstd', 'version', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Version string of the F-star selection') + op.alter_column('fluxstd', 'input_catalog_id', + existing_type=sa.INTEGER(), + nullable=True, + existing_comment='input_catalog_id from the input_catalog table') + op.alter_column('fluxstd', 'dec', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='Dec (ICRS, degree)') + op.alter_column('fluxstd', 'ra', + existing_type=postgresql.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='RA (ICRS, degree)') + op.alter_column('fluxstd', 'obj_id', + existing_type=sa.BIGINT(), + nullable=True, + existing_comment='Gaia EDR3 sourceid') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230419-181440_790fae2257ac_add_a_unique_constraint_proposal_id_ob_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230419-181440_790fae2257ac_add_a_unique_constraint_proposal_id_ob_.py new file mode 100644 index 0000000..6aeaeb0 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230419-181440_790fae2257ac_add_a_unique_constraint_proposal_id_ob_.py @@ -0,0 +1,34 @@ +"""Add a unique constraint (proposal_id, ob_code) in the target table + +Revision ID: 790fae2257ac +Revises: c5f125bd0b9b +Create Date: 2023-04-19 18:14:40.791644 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '790fae2257ac' +down_revision = 'c5f125bd0b9b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_index('fluxstd_q3c_ang2ipix_idx', table_name='fluxstd') + # op.drop_index('sky_q3c_ang2ipix_idx', table_name='sky') + # op.drop_index('target_q3c_ang2ipix_idx', table_name='target') + op.create_unique_constraint('uq_proposal_id_ob_code', 'target', ['proposal_id', 'ob_code']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_proposal_id_ob_code', 'target', type_='unique') + # op.create_index('target_q3c_ang2ipix_idx', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('sky_q3c_ang2ipix_idx', 'sky', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('fluxstd_q3c_ang2ipix_idx', 'fluxstd', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-150337_10e6d0a4610a_add_a_unique_constraint_by_obj_id_input_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-150337_10e6d0a4610a_add_a_unique_constraint_by_obj_id_input_.py new file mode 100644 index 0000000..0636a05 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-150337_10e6d0a4610a_add_a_unique_constraint_by_obj_id_input_.py @@ -0,0 +1,34 @@ +"""Add a unique constraint by (obj_id, input_catalog_id, version) for fluxstd + +Revision ID: 10e6d0a4610a +Revises: 790fae2257ac +Create Date: 2023-04-20 15:03:37.686829 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '10e6d0a4610a' +down_revision = '790fae2257ac' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_index('fluxstd_q3c_ang2ipix_idx', table_name='fluxstd') + op.create_unique_constraint('uq_obj_id_input_catalog_id_version', 'fluxstd', ['obj_id', 'input_catalog_id', 'version']) + # op.drop_index('sky_q3c_ang2ipix_idx', table_name='sky') + # op.drop_index('target_q3c_ang2ipix_idx', table_name='target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.create_index('target_q3c_ang2ipix_idx', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('sky_q3c_ang2ipix_idx', 'sky', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + op.drop_constraint('uq_obj_id_input_catalog_id_version', 'fluxstd', type_='unique') + # op.create_index('fluxstd_q3c_ang2ipix_idx', 'fluxstd', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-163804_8ac6065d2225_add_columns_of_stellar_parameters_teff_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-163804_8ac6065d2225_add_columns_of_stellar_parameters_teff_.py new file mode 100644 index 0000000..76cb688 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230420-163804_8ac6065d2225_add_columns_of_stellar_parameters_teff_.py @@ -0,0 +1,44 @@ +"""Add columns of stellar parameters (teff and logg + confidence intervals) in the fluxstd table + +Revision ID: 8ac6065d2225 +Revises: 10e6d0a4610a +Create Date: 2023-04-20 16:38:04.417493 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ac6065d2225' +down_revision = '10e6d0a4610a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('fluxstd', sa.Column('teff_brutus', sa.Float(), nullable=True, comment='Effective temperature from Brutus code [K]')) + op.add_column('fluxstd', sa.Column('teff_brutus_low', sa.Float(), nullable=True, comment='Lower confidence level (16%) of effective temperature from Brutus code [K]')) + op.add_column('fluxstd', sa.Column('teff_brutus_high', sa.Float(), nullable=True, comment='Upper confidence level (84%) of effective temperature from Brutus code [K]')) + op.add_column('fluxstd', sa.Column('logg_brutus', sa.Float(), nullable=True, comment='Surface gravity from Brutus code [log cgs]')) + op.add_column('fluxstd', sa.Column('logg_brutus_low', sa.Float(), nullable=True, comment='Lower confidence level (16%) of surface gravity from Brutus code [log cgs]')) + op.add_column('fluxstd', sa.Column('logg_brutus_high', sa.Float(), nullable=True, comment='Upper confidence level (84%) of surface gravity from Brutus code [log cgs]')) + # op.drop_index('fluxstd_q3c_ang2ipix_idx', table_name='fluxstd') + # op.drop_index('sky_q3c_ang2ipix_idx', table_name='sky') + # op.drop_index('target_q3c_ang2ipix_idx', table_name='target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.create_index('target_q3c_ang2ipix_idx', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('sky_q3c_ang2ipix_idx', 'sky', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('fluxstd_q3c_ang2ipix_idx', 'fluxstd', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + op.drop_column('fluxstd', 'logg_brutus_high') + op.drop_column('fluxstd', 'logg_brutus_low') + op.drop_column('fluxstd', 'logg_brutus') + op.drop_column('fluxstd', 'teff_brutus_high') + op.drop_column('fluxstd', 'teff_brutus_low') + op.drop_column('fluxstd', 'teff_brutus') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230421-091340_6aec47e0f339_add_an_index_to_version_in_fluxstd.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230421-091340_6aec47e0f339_add_an_index_to_version_in_fluxstd.py new file mode 100644 index 0000000..123a63d --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230421-091340_6aec47e0f339_add_an_index_to_version_in_fluxstd.py @@ -0,0 +1,34 @@ +"""Add an index to version in fluxstd + +Revision ID: 6aec47e0f339 +Revises: 8ac6065d2225 +Create Date: 2023-04-21 09:13:40.822777 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6aec47e0f339' +down_revision = '8ac6065d2225' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_index('fluxstd_q3c_ang2ipix_idx', table_name='fluxstd') + op.create_index(op.f('ix_fluxstd_version'), 'fluxstd', ['version'], unique=False) + # op.drop_index('sky_q3c_ang2ipix_idx', table_name='sky') + # op.drop_index('target_q3c_ang2ipix_idx', table_name='target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.create_index('target_q3c_ang2ipix_idx', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('sky_q3c_ang2ipix_idx', 'sky', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + op.drop_index(op.f('ix_fluxstd_version'), table_name='fluxstd') + # op.create_index('fluxstd_q3c_ang2ipix_idx', 'fluxstd', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230504-175323_c987ab91b094_add_indexes_to_input_catalog_id_and_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230504-175323_c987ab91b094_add_indexes_to_input_catalog_id_and_.py new file mode 100644 index 0000000..ec15b91 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230504-175323_c987ab91b094_add_indexes_to_input_catalog_id_and_.py @@ -0,0 +1,36 @@ +"""Add indexes to input_catalog_id and version + +Revision ID: c987ab91b094 +Revises: 6aec47e0f339 +Create Date: 2023-05-04 17:53:23.315181 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c987ab91b094' +down_revision = '6aec47e0f339' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_index('fluxstd_q3c_ang2ipix_idx', table_name='fluxstd') + # op.drop_index('sky_q3c_ang2ipix_idx', table_name='sky') + op.create_index(op.f('ix_sky_input_catalog_id'), 'sky', ['input_catalog_id'], unique=False) + op.create_index(op.f('ix_sky_version'), 'sky', ['version'], unique=False) + # op.drop_index('target_q3c_ang2ipix_idx', table_name='target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.create_index('target_q3c_ang2ipix_idx', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + op.drop_index(op.f('ix_sky_version'), table_name='sky') + op.drop_index(op.f('ix_sky_input_catalog_id'), table_name='sky') + # op.create_index('sky_q3c_ang2ipix_idx', 'sky', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # op.create_index('fluxstd_q3c_ang2ipix_idx', 'fluxstd', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20230505-181644_099e9069f2b7_test_q3c_indexes.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20230505-181644_099e9069f2b7_test_q3c_indexes.py new file mode 100644 index 0000000..e34e4bd --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20230505-181644_099e9069f2b7_test_q3c_indexes.py @@ -0,0 +1,28 @@ +"""Test q3c indexes + +Revision ID: 099e9069f2b7 +Revises: c987ab91b094 +Create Date: 2023-05-05 18:16:44.225953 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '099e9069f2b7' +down_revision = 'c987ab91b094' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-151144_71eba81781d9_add_allocated_time_in_total_lr_and_mr.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-151144_71eba81781d9_add_allocated_time_in_total_lr_and_mr.py new file mode 100644 index 0000000..10ecedf --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-151144_71eba81781d9_add_allocated_time_in_total_lr_and_mr.py @@ -0,0 +1,53 @@ +"""Add allocated_time in total, lr, and mr + +Revision ID: 71eba81781d9 +Revises: 099e9069f2b7 +Create Date: 2024-02-02 15:11:44.124658 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "71eba81781d9" +down_revision = "099e9069f2b7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "proposal", "allocated_time", new_column_name="allocated_time_total" + ) + op.add_column( + "proposal", + sa.Column( + "allocated_time_lr", + sa.Float(), + nullable=True, + comment="Total fiberhours for the low-resolution mode allocated by TAC (hour)", + ), + ) + op.add_column( + "proposal", + sa.Column( + "allocated_time_mr", + sa.Float(), + nullable=True, + comment="Total fiberhours for the medium-resolution mode allocated by TAC (hour)", + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "proposal", "allocated_time_total", new_column_name="allocated_time" + ) + op.drop_column("proposal", "allocated_time_mr") + op.drop_column("proposal", "allocated_time_lr") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-153755_320fc1f7de3b_add_upload_id_to_input_catalog_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-153755_320fc1f7de3b_add_upload_id_to_input_catalog_table.py new file mode 100644 index 0000000..d9e4f33 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240202-153755_320fc1f7de3b_add_upload_id_to_input_catalog_table.py @@ -0,0 +1,28 @@ +"""Add upload_id to input_catalog table + +Revision ID: 320fc1f7de3b +Revises: 71eba81781d9 +Create Date: 2024-02-02 15:37:55.782110 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '320fc1f7de3b' +down_revision = '71eba81781d9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('input_catalog', sa.Column('upload_id', sa.String(length=16), nullable=True, comment='A 8-bit hex string (16 characters) assigned at the submission of the target list (default: empty string)')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('input_catalog', 'upload_id') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240404-173437_d024af8c52f2_add_gaia_parameters_in_the_fluxstd_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240404-173437_d024af8c52f2_add_gaia_parameters_in_the_fluxstd_table.py new file mode 100644 index 0000000..cd3e0ec --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240404-173437_d024af8c52f2_add_gaia_parameters_in_the_fluxstd_table.py @@ -0,0 +1,67 @@ +"""Add gaia parameters in the fluxstd table + +Revision ID: d024af8c52f2 +Revises: 320fc1f7de3b +Create Date: 2024-04-04 17:34:37.233549 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d024af8c52f2" +down_revision = "320fc1f7de3b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "fluxstd", + sa.Column( + "teff_gspphot", + sa.Float(), + nullable=True, + comment="Effective temperature inferred by GSP-phot Aeneas [K]", + ), + ) + op.add_column( + "fluxstd", + sa.Column( + "teff_gspphot_lower", + sa.Float(), + nullable=True, + comment="Lower confidence level (16%) of effective temperature inferred by GSP-phot Aeneas [K]", + ), + ) + op.add_column( + "fluxstd", + sa.Column( + "teff_gspphot_upper", + sa.Float(), + nullable=True, + comment="Upper confidence level (84%) of effective temperature inferred by GSP-phot Aeneas [K]", + ), + ) + op.add_column( + "fluxstd", + sa.Column( + "is_fstar_gaia", + sa.Boolean(), + default=False, + comment="Flag for F-star from Gaia (Teff=6000-7500K if True)", + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("fluxstd", "is_fstar_gaia") + op.drop_column("fluxstd", "teff_gspphot_upper") + op.drop_column("fluxstd", "teff_gspphot_lower") + op.drop_column("fluxstd", "teff_gspphot") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240408-092814_b010233c946e_fix_typo_in_filter_relationship_in_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240408-092814_b010233c946e_fix_typo_in_filter_relationship_in_.py new file mode 100644 index 0000000..e225bf3 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240408-092814_b010233c946e_fix_typo_in_filter_relationship_in_.py @@ -0,0 +1,28 @@ +"""Fix typo in filter relationship in fluxstd + +Revision ID: b010233c946e +Revises: d024af8c52f2 +Create Date: 2024-04-08 09:28:14.313541 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b010233c946e' +down_revision = 'd024af8c52f2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240415-102112_eeb9bd9b8d91_autoincrement_input_catalog_id.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240415-102112_eeb9bd9b8d91_autoincrement_input_catalog_id.py new file mode 100644 index 0000000..a6b102c --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240415-102112_eeb9bd9b8d91_autoincrement_input_catalog_id.py @@ -0,0 +1,157 @@ +"""autoincrement input_catalog_id + +Revision ID: eeb9bd9b8d91 +Revises: b010233c946e +Create Date: 2024-04-15 10:21:12.653189 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "eeb9bd9b8d91" +down_revision: Union[str, None] = "b010233c946e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# generated by the help of Copilot. +def upgrade() -> None: + # Start a transaction block + op.execute("BEGIN;") + + # Drop the foreign key constraints in the other tables + op.execute( + """ + ALTER TABLE sky DROP CONSTRAINT sky_input_catalog_id_fkey; + ALTER TABLE fluxstd DROP CONSTRAINT fluxstd_input_catalog_id_fkey; + ALTER TABLE target DROP CONSTRAINT target_input_catalog_id_fkey; + ALTER TABLE cluster DROP CONSTRAINT cluster_input_catalog_id_fkey; + """ + ) + + # Rename the existing input_catalog_id column to tmp_input_catalog_id + op.execute( + """ + ALTER TABLE input_catalog RENAME COLUMN input_catalog_id TO tmp_input_catalog_id; + """ + ) + + # Create a new input_catalog_id column with the Identity option + op.execute( + """ + ALTER TABLE input_catalog ADD COLUMN input_catalog_id INT GENERATED BY DEFAULT AS IDENTITY; + """ + ) + + # Set the starting value of the sequence associated with the input_catalog_id column to 10000 + op.execute( + """ + ALTER SEQUENCE input_catalog_input_catalog_id_seq RESTART WITH 10000 MAXVALUE 89999; + """ + ) + + # Copy the values from tmp_input_catalog_id to input_catalog_id + op.execute( + """ + UPDATE input_catalog SET input_catalog_id = tmp_input_catalog_id; + """ + ) + + # Drop the primary key constraint on tmp_input_catalog_id and add a primary key constraint on input_catalog_id in a single step + op.execute( + """ + ALTER TABLE input_catalog + DROP CONSTRAINT input_catalog_pkey, + ADD PRIMARY KEY (input_catalog_id); + """ + ) + + # Delete the tmp_input_catalog_id column + op.execute( + """ + ALTER TABLE input_catalog DROP COLUMN tmp_input_catalog_id; + """ + ) + + # Add the foreign key constraints back to the other tables + op.execute( + """ + ALTER TABLE sky ADD CONSTRAINT sky_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE fluxstd ADD CONSTRAINT fluxstd_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE target ADD CONSTRAINT target_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE cluster ADD CONSTRAINT cluster_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + """ + ) + + # Commit the transaction block + op.execute("COMMIT;") + + +def downgrade() -> None: + # Start a transaction block + op.execute("BEGIN;") + + # Drop the foreign key constraints in the other tables + op.execute( + """ + ALTER TABLE sky DROP CONSTRAINT sky_input_catalog_id_fkey; + ALTER TABLE fluxstd DROP CONSTRAINT fluxstd_input_catalog_id_fkey; + ALTER TABLE target DROP CONSTRAINT target_input_catalog_id_fkey; + ALTER TABLE cluster DROP CONSTRAINT cluster_input_catalog_id_fkey; + """ + ) + + # Rename the existing input_catalog_id column to tmp_input_catalog_id + op.execute( + """ + ALTER TABLE input_catalog RENAME COLUMN input_catalog_id TO tmp_input_catalog_id; + """ + ) + + # Create a new input_catalog_id column without the Identity option + op.execute( + """ + ALTER TABLE input_catalog ADD COLUMN input_catalog_id INT; + """ + ) + + # Copy the values from tmp_input_catalog_id to input_catalog_id + op.execute( + """ + UPDATE input_catalog SET input_catalog_id = tmp_input_catalog_id; + """ + ) + + # Drop the primary key constraint on tmp_input_catalog_id and add a primary key constraint on input_catalog_id in a single step + op.execute( + """ + ALTER TABLE input_catalog + DROP CONSTRAINT input_catalog_pkey, + ADD PRIMARY KEY (input_catalog_id); + """ + ) + + # Delete the tmp_input_catalog_id column + op.execute( + """ + ALTER TABLE input_catalog DROP COLUMN tmp_input_catalog_id; + """ + ) + + # Add the foreign key constraints back to the other tables + op.execute( + """ + ALTER TABLE sky ADD CONSTRAINT sky_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE fluxstd ADD CONSTRAINT fluxstd_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE target ADD CONSTRAINT target_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + ALTER TABLE cluster ADD CONSTRAINT cluster_input_catalog_id_fkey FOREIGN KEY (input_catalog_id) REFERENCES input_catalog (input_catalog_id); + """ + ) + + # Commit the transaction block + op.execute("COMMIT;") diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-104950_1f393a55b6d8_remove_unique_constraint_from_input_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-104950_1f393a55b6d8_remove_unique_constraint_from_input_.py new file mode 100644 index 0000000..a003384 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-104950_1f393a55b6d8_remove_unique_constraint_from_input_.py @@ -0,0 +1,66 @@ +"""remove unique constraint from input_catalog_name + +Revision ID: 1f393a55b6d8 +Revises: eeb9bd9b8d91 +Create Date: 2024-04-16 10:49:50.036660 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1f393a55b6d8" +down_revision: Union[str, None] = "eeb9bd9b8d91" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "input_catalog", + "input_catalog_id", + existing_type=sa.INTEGER(), + server_default=sa.Identity( + always=False, start=10000, maxvalue=89999, cycle=False + ), + comment="Unique identifier for input catalogs", + existing_nullable=False, + autoincrement=True, + ) + op.drop_constraint( + "input_catalog_input_catalog_name_key", "input_catalog", type_="unique" + ) + op.create_unique_constraint(None, "input_catalog", ["input_catalog_id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "input_catalog", type_="unique") + op.create_unique_constraint( + "input_catalog_input_catalog_name_key", "input_catalog", ["input_catalog_name"] + ) + op.alter_column( + "input_catalog", + "input_catalog_id", + existing_type=sa.INTEGER(), + server_default=sa.Identity( + always=False, + start=1, + increment=1, + minvalue=1, + maxvalue=89999, + cycle=False, + cache=1, + ), + comment=None, + existing_comment="Unique identifier for input catalogs", + existing_nullable=False, + autoincrement=True, + ) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-105545_065b74905fb6_update_unique_constraint_for_the_target_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-105545_065b74905fb6_update_unique_constraint_for_the_target_.py new file mode 100644 index 0000000..6ab6412 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240416-105545_065b74905fb6_update_unique_constraint_for_the_target_.py @@ -0,0 +1,39 @@ +"""Update unique constraint for the target table + +Revision ID: 065b74905fb6 +Revises: 1f393a55b6d8 +Create Date: 2024-04-16 10:55:45.817899 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "065b74905fb6" +down_revision = "1f393a55b6d8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_proposal_id_ob_code", "target", type_="unique") + op.create_unique_constraint( + "uq_proposal_id_ob_code_input_catalog_id_obj_id", + "target", + ["proposal_id", "ob_code", "input_catalog_id", "obj_id"], + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "uq_proposal_id_ob_code_input_catalog_id_obj_id", "target", type_="unique" + ) + op.create_unique_constraint( + "uq_proposal_id_ob_code", "target", ["proposal_id", "ob_code"] + ) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240417-100959_c636e8e2ecc0_add_unique_constraint_on_the_sky_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240417-100959_c636e8e2ecc0_add_unique_constraint_on_the_sky_table.py new file mode 100644 index 0000000..7cfe4bf --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240417-100959_c636e8e2ecc0_add_unique_constraint_on_the_sky_table.py @@ -0,0 +1,43 @@ +"""add unique constraint on the sky table + +Revision ID: c636e8e2ecc0 +Revises: 065b74905fb6 +Create Date: 2024-04-17 10:09:59.291914 + +Note: +Before upgrading the database, I run the following query to avoid the violation of the unique constaint: +``` +UPDATE sky SET obj_id = sky_id WHERE version = '20220915'; +``` +This is already implemented in the integrated code when processing sky objects. + +- [ ] production db +- [x] e2e db + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c636e8e2ecc0" +down_revision = "065b74905fb6" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + "sky_obj_id_input_catalog_id_version_key", + "sky", + ["obj_id", "input_catalog_id", "version"], + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("sky_obj_id_input_catalog_id_version_key", "sky", type_="unique") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-150510_0049efa2957b_get_created_at_updated_at_automatically.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-150510_0049efa2957b_get_created_at_updated_at_automatically.py new file mode 100644 index 0000000..92e8401 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-150510_0049efa2957b_get_created_at_updated_at_automatically.py @@ -0,0 +1,272 @@ +"""get created_at/updated_at automatically + +Revision ID: 0049efa2957b +Revises: c636e8e2ecc0 +Create Date: 2024-04-22 15:05:10.412259 + +""" + +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "0049efa2957b" +down_revision = "c636e8e2ecc0" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "filter_name", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_comment="Creation time [YYYY-MM-DDThh:mm:ss] (UTC)", + existing_nullable=True, + ) + op.alter_column( + "filter_name", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_comment="Update time [YYYY-MM-DDThh:mm:ss] (UTC)", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_comment="Creation time [YYYY-MM-DDThh:mm:ss] (UTC or HST?)", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_comment="Update time [YYYY-MM-DDThh:mm:ss] (UTC or HST?)", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_comment="Creation time", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_comment="Update time", + existing_nullable=True, + ) + op.alter_column( + "sky", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "sky", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "target", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "target_type", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target_type", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "target_type", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "target_type", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "target", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "sky", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "sky", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="Update time", + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="Creation time", + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="Update time [YYYY-MM-DDThh:mm:ss] (UTC or HST?)", + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="Creation time [YYYY-MM-DDThh:mm:ss] (UTC or HST?)", + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "filter_name", + "updated_at", + existing_type=postgresql.TIMESTAMP(), + comment="Update time [YYYY-MM-DDThh:mm:ss] (UTC)", + existing_comment="The date and time in UTC when the record was last updated", + existing_nullable=True, + ) + op.alter_column( + "filter_name", + "created_at", + existing_type=postgresql.TIMESTAMP(), + comment="Creation time [YYYY-MM-DDThh:mm:ss] (UTC)", + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-161159_1d0bd9825071_refine_default_values_and_nullable_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-161159_1d0bd9825071_refine_default_values_and_nullable_.py new file mode 100644 index 0000000..0874dc8 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240422-161159_1d0bd9825071_refine_default_values_and_nullable_.py @@ -0,0 +1,84 @@ +"""refine default values and nullable conditions + +Revision ID: 1d0bd9825071 +Revises: 0049efa2957b +Create Date: 2024-04-22 16:11:59.655450 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1d0bd9825071' +down_revision = '0049efa2957b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('input_catalog', 'input_catalog_name', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Name of the input catalog (e.g., Gaia DR2, HSC-SSP PDR3, etc.)') + op.alter_column('proposal', 'group_id', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Group ID in STARS (e.g., o21195?)', + autoincrement=False) + op.alter_column('proposal', 'pi_last_name', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment="PI's last name") + op.alter_column('proposal', 'rank', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='TAC score') + op.alter_column('proposal', 'grade', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='TAC grade (A/B/C/F in the case of HSC queue)') + op.alter_column('proposal_category', 'proposal_category_name', + existing_type=sa.VARCHAR(), + nullable=False, + existing_comment='Proposal category name (e.g., Openuse, Keck, Gemini, and UH)') + op.alter_column('target', 'effective_exptime', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='Requested effective exposure time (s)') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'effective_exptime', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='Requested effective exposure time (s)') + op.alter_column('proposal_category', 'proposal_category_name', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Proposal category name (e.g., Openuse, Keck, Gemini, and UH)') + op.alter_column('proposal', 'grade', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='TAC grade (A/B/C/F in the case of HSC queue)') + op.alter_column('proposal', 'rank', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='TAC score') + op.alter_column('proposal', 'pi_last_name', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment="PI's last name") + op.alter_column('proposal', 'group_id', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Group ID in STARS (e.g., o21195?)', + autoincrement=False) + op.alter_column('input_catalog', 'input_catalog_name', + existing_type=sa.VARCHAR(), + nullable=True, + existing_comment='Name of the input catalog (e.g., Gaia DR2, HSC-SSP PDR3, etc.)') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240501-170926_f8bacfde2cef_add_active_flag_to_the_input_catalog_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240501-170926_f8bacfde2cef_add_active_flag_to_the_input_catalog_.py new file mode 100644 index 0000000..3da17a7 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240501-170926_f8bacfde2cef_add_active_flag_to_the_input_catalog_.py @@ -0,0 +1,43 @@ +"""add active flag to the input_catalog table + +Revision ID: f8bacfde2cef +Revises: 1d0bd9825071 +Create Date: 2024-05-01 17:09:26.387829 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f8bacfde2cef" +down_revision = "1d0bd9825071" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "input_catalog", + sa.Column( + "active", + sa.Boolean(), + nullable=True, + comment="Flag to indicate if the input catalog is active (default: True)", + ), + ) + op.execute( + """ + UPDATE input_catalog + SET active = TRUE + """ + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("input_catalog", "active") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-112946_8f188c3cb652_set_default_to_created_at.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-112946_8f188c3cb652_set_default_to_created_at.py new file mode 100644 index 0000000..9006952 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-112946_8f188c3cb652_set_default_to_created_at.py @@ -0,0 +1,172 @@ +"""set default to created_at + +Revision ID: 8f188c3cb652 +Revises: f8bacfde2cef +Create Date: 2024-05-02 11:29:46.318970 + +""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8f188c3cb652" +down_revision = "f8bacfde2cef" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "cluster", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="UTC", + existing_nullable=True, + ) + op.alter_column( + "filter_name", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "sky", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target_type", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "target_type", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "target", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "sky", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal_category", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "proposal", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "input_catalog", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "fluxstd", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "filter_name", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="The date and time in UTC when the record was created", + existing_nullable=True, + ) + op.alter_column( + "cluster", + "created_at", + existing_type=postgresql.TIMESTAMP(), + server_default=None, + existing_comment="UTC", + existing_nullable=True, + ) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-155205_368dce443754_unique_constraints_on_target.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-155205_368dce443754_unique_constraints_on_target.py new file mode 100644 index 0000000..751dde0 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240502-155205_368dce443754_unique_constraints_on_target.py @@ -0,0 +1,53 @@ +"""unique constraints on target + +Revision ID: 368dce443754 +Revises: 8f188c3cb652 +Create Date: 2024-05-02 15:52:05.728826 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "368dce443754" +down_revision = "8f188c3cb652" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "uq_proposal_id_ob_code_input_catalog_id_obj_id", "target", type_="unique" + ) + op.create_unique_constraint( + "target_propid_obcode_catid_objid_resolution_key", + "target", + [ + "proposal_id", + "ob_code", + "input_catalog_id", + "obj_id", + "is_medium_resolution", + ], + ) + op.create_unique_constraint( + "target_propid_obcode_key", "target", ["proposal_id", "ob_code"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("target_propid_obcode_key", "target", type_="unique") + op.drop_constraint( + "target_propid_obcode_catid_objid_resolution_key", "target", type_="unique" + ) + op.create_unique_constraint( + "uq_proposal_id_ob_code_input_catalog_id_obj_id", + "target", + ["proposal_id", "ob_code", "input_catalog_id", "obj_id"], + ) + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145050_f630d28235eb_add_single_exptime_to_the_target_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145050_f630d28235eb_add_single_exptime_to_the_target_table.py new file mode 100644 index 0000000..1d268f2 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145050_f630d28235eb_add_single_exptime_to_the_target_table.py @@ -0,0 +1,28 @@ +"""Add single_exptime to the target table + +Revision ID: f630d28235eb +Revises: 368dce443754 +Create Date: 2024-05-24 14:50:50.589144 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f630d28235eb' +down_revision = '368dce443754' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('target', sa.Column('single_exptime', sa.Float(), nullable=True, comment='Individual exposure time (s)')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('target', 'single_exptime') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145817_1027c2966c63_set_single_exptime_un_nullable.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145817_1027c2966c63_set_single_exptime_un_nullable.py new file mode 100644 index 0000000..4f75e40 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240524-145817_1027c2966c63_set_single_exptime_un_nullable.py @@ -0,0 +1,34 @@ +"""set single_exptime un-nullable + +Revision ID: 1027c2966c63 +Revises: f630d28235eb +Create Date: 2024-05-24 14:58:17.757023 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1027c2966c63' +down_revision = 'f630d28235eb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'single_exptime', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False, + existing_comment='Individual exposure time (s)') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'single_exptime', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True, + existing_comment='Individual exposure time (s)') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-145135_63584f7b182a_add_arm_info.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-145135_63584f7b182a_add_arm_info.py new file mode 100644 index 0000000..86f1b8c --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-145135_63584f7b182a_add_arm_info.py @@ -0,0 +1,39 @@ +"""add arm info + +Revision ID: 63584f7b182a +Revises: 1027c2966c63 +Create Date: 2024-06-06 14:51:35.709957 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '63584f7b182a' +down_revision = '1027c2966c63' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pfs_arm', + sa.Column('name', sa.String(), nullable=False, comment="Arm name (e.g., 'b', 'r', 'n', and 'm')"), + sa.Column('description', sa.String(), nullable=True, comment='Arm description'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), nullable=True, comment='The date and time in UTC when the record was created'), + sa.Column('updated_at', sa.DateTime(), nullable=True, comment='The date and time in UTC when the record was last updated'), + sa.PrimaryKeyConstraint('name'), + sa.UniqueConstraint('name') + ) + op.add_column('target', sa.Column('qa_reference_arm', sa.String(), nullable=True, comment="Reference arm to evaluate effective exposure time ['b'|'r'|'n'|'m']")) + op.create_foreign_key(None, 'target', 'pfs_arm', ['qa_reference_arm'], ['name']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'target', type_='foreignkey') + op.drop_column('target', 'qa_reference_arm') + op.drop_table('pfs_arm') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-172208_bc79254cc64b_pfs_arm_name_to_be_unique.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-172208_bc79254cc64b_pfs_arm_name_to_be_unique.py new file mode 100644 index 0000000..e314fcc --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240606-172208_bc79254cc64b_pfs_arm_name_to_be_unique.py @@ -0,0 +1,28 @@ +"""pfs_arm.name to be unique + +Revision ID: bc79254cc64b +Revises: 63584f7b182a +Create Date: 2024-06-06 17:22:08.628641 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bc79254cc64b' +down_revision = '63584f7b182a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'pfs_arm', ['name']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'pfs_arm', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20240624-165136_f07c300ae87f_add_queue_classical_too_flags.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20240624-165136_f07c300ae87f_add_queue_classical_too_flags.py new file mode 100644 index 0000000..dac3378 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20240624-165136_f07c300ae87f_add_queue_classical_too_flags.py @@ -0,0 +1,40 @@ +"""add queue-classical-too flags + +Revision ID: f07c300ae87f +Revises: bc79254cc64b +Create Date: 2024-06-24 16:51:36.392229 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f07c300ae87f' +down_revision = 'bc79254cc64b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('input_catalog', sa.Column('is_classical', sa.Boolean(), nullable=True, comment='True if the classical mode is requested')) + op.add_column('proposal', sa.Column('is_too', sa.Boolean(), nullable=True, comment='True when the proposal is ToO')) + op.alter_column('target', 'qa_reference_arm', + existing_type=sa.VARCHAR(), + comment="Reference arm to evaluate effective exposure time ('b', 'r', 'n', 'm')", + existing_comment="Reference arm to evaluate effective exposure time ['b'|'r'|'n'|'m']", + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('target', 'qa_reference_arm', + existing_type=sa.VARCHAR(), + comment="Reference arm to evaluate effective exposure time ['b'|'r'|'n'|'m']", + existing_comment="Reference arm to evaluate effective exposure time ('b', 'r', 'n', 'm')", + existing_nullable=True) + op.drop_column('proposal', 'is_too') + op.drop_column('input_catalog', 'is_classical') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20241024-120338_b68482e38606_add_user_pointing_table.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20241024-120338_b68482e38606_add_user_pointing_table.py new file mode 100644 index 0000000..8fab0cb --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20241024-120338_b68482e38606_add_user_pointing_table.py @@ -0,0 +1,109 @@ +"""add user_pointing table + +Revision ID: b68482e38606 +Revises: f07c300ae87f +Create Date: 2024-10-24 12:03:38.150098 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b68482e38606" +down_revision = "f07c300ae87f" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_pointing", + sa.Column( + "user_pointing_id", + sa.BigInteger(), + nullable=False, + comment="Unique identifier for each user-defined pointing (autoincremented primary key)", + ), + sa.Column( + "ppc_code", + sa.String(), + nullable=False, + comment="String identifier of the pointing set either by the uploader or user", + ), + sa.Column( + "ppc_ra", + sa.Float(), + nullable=False, + comment="RA of the pointing center (ICRS, degree)", + ), + sa.Column( + "ppc_dec", + sa.Float(), + nullable=False, + comment="Dec of the pointing center (ICRS, degree)", + ), + sa.Column( + "ppc_pa", + sa.Float(), + nullable=False, + comment="Position angle of the pointing center (degree)", + ), + sa.Column( + "ppc_resolution", + sa.Enum("L", "M", name="resolutionmode"), + nullable=False, + comment="Resolution mode of the pointing ('L' or 'M')", + ), + sa.Column( + "ppc_priority", + sa.Float(), + nullable=False, + comment="Priority of the pointing calculated by the uploader", + ), + sa.Column( + "input_catalog_id", + sa.Integer(), + nullable=False, + comment="Input catalog ID from the input_catalog table", + ), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), + nullable=True, + comment="The date and time in UTC when the record was created", + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + comment="The date and time in UTC when the record was last updated", + ), + sa.ForeignKeyConstraint( + ["input_catalog_id"], + ["input_catalog.input_catalog_id"], + ), + sa.PrimaryKeyConstraint("user_pointing_id"), + ) + op.add_column( + "input_catalog", + sa.Column( + "is_user_pointing", + sa.Boolean(), + nullable=True, + comment="True if user-defined pointings are provided", + ), + ) + # op.drop_index('target_q3c_ang2ipix_idx1', table_name='target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # op.create_index('target_q3c_ang2ipix_idx1', 'target', [sa.text('q3c_ang2ipix(ra, "dec")')], unique=False) + op.drop_column("input_catalog", "is_user_pointing") + op.drop_table("user_pointing") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20241220-145409_0b72e62efd44_add_partner_column_in_the_proposal_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20241220-145409_0b72e62efd44_add_partner_column_in_the_proposal_.py new file mode 100644 index 0000000..862fb74 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20241220-145409_0b72e62efd44_add_partner_column_in_the_proposal_.py @@ -0,0 +1,41 @@ +"""add partner column in the proposal table and partner table + +Revision ID: 0b72e62efd44 +Revises: b68482e38606 +Create Date: 2024-12-20 14:54:09.851011 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0b72e62efd44' +down_revision = 'b68482e38606' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('partner', + sa.Column('partner_id', sa.Integer(), autoincrement=False, nullable=False, comment='Unique identifier of the partner'), + sa.Column('partner_name', sa.String(), nullable=False, comment='Name of the partner (e.g., subaru, gemini, keck, and uh)'), + sa.Column('partner_description', sa.String(), nullable=True, comment='Description of the partner'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), nullable=True, comment='The date and time in UTC when the record was created'), + sa.Column('updated_at', sa.DateTime(), nullable=True, comment='The date and time in UTC when the record was last updated'), + sa.PrimaryKeyConstraint('partner_id'), + sa.UniqueConstraint('partner_id'), + sa.UniqueConstraint('partner_name') + ) + op.add_column('proposal', sa.Column('partner_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'proposal', 'partner', ['partner_id'], ['partner_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'proposal', type_='foreignkey') + op.drop_column('proposal', 'partner_id') + op.drop_table('partner') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20250331-121154_5b40ed8dcd48_create_index_on_proposal_grade.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20250331-121154_5b40ed8dcd48_create_index_on_proposal_grade.py new file mode 100644 index 0000000..c4e175b --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20250331-121154_5b40ed8dcd48_create_index_on_proposal_grade.py @@ -0,0 +1,83 @@ +"""create index on proposal.grade + +Revision ID: 5b40ed8dcd48 +Revises: 0b72e62efd44 +Create Date: 2025-03-31 12:11:54.201748 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5b40ed8dcd48" +down_revision = "0b72e62efd44" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, "partner", ["partner_id"]) + op.alter_column( + "proposal", + "proposal_id", + existing_type=sa.VARCHAR(), + comment="Unique identifier for proposal (e.g, S21B-OT06)", + existing_comment="Unique identifier for proposal (e.g, S21B-OT06?)", + existing_nullable=False, + autoincrement=False, + ) + op.alter_column( + "proposal", + "group_id", + existing_type=sa.VARCHAR(), + comment="Group ID in STARS (e.g., o21195)", + existing_comment="Group ID in STARS (e.g., o21195?)", + existing_nullable=False, + autoincrement=False, + ) + op.alter_column( + "proposal", + "grade", + existing_type=sa.VARCHAR(), + comment="TAC grade (A/B/C/F and N/A)", + existing_comment="TAC grade (A/B/C/F in the case of HSC queue)", + existing_nullable=False, + ) + op.create_index("idx_proposal_grade", "proposal", ["grade"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("idx_proposal_grade", table_name="proposal") + op.alter_column( + "proposal", + "grade", + existing_type=sa.VARCHAR(), + comment="TAC grade (A/B/C/F in the case of HSC queue)", + existing_comment="TAC grade (A/B/C/F and N/A)", + existing_nullable=False, + ) + op.alter_column( + "proposal", + "group_id", + existing_type=sa.VARCHAR(), + comment="Group ID in STARS (e.g., o21195?)", + existing_comment="Group ID in STARS (e.g., o21195)", + existing_nullable=False, + autoincrement=False, + ) + op.alter_column( + "proposal", + "proposal_id", + existing_type=sa.VARCHAR(), + comment="Unique identifier for proposal (e.g, S21B-OT06?)", + existing_comment="Unique identifier for proposal (e.g, S21B-OT06)", + existing_nullable=False, + autoincrement=False, + ) + op.drop_constraint(None, "partner", type_="unique") + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20250505-162126_fc8ea63b8c9d_add_total_fluxes.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20250505-162126_fc8ea63b8c9d_add_total_fluxes.py new file mode 100644 index 0000000..5b35449 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20250505-162126_fc8ea63b8c9d_add_total_fluxes.py @@ -0,0 +1,50 @@ +"""add total fluxes + +Revision ID: fc8ea63b8c9d +Revises: 5b40ed8dcd48 +Create Date: 2025-05-05 16:21:26.652825 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fc8ea63b8c9d' +down_revision = '5b40ed8dcd48' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('target', sa.Column('total_flux_g', sa.Float(), nullable=True, comment='g-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_r', sa.Float(), nullable=True, comment='r-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_i', sa.Float(), nullable=True, comment='i-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_z', sa.Float(), nullable=True, comment='z-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_y', sa.Float(), nullable=True, comment='y-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_j', sa.Float(), nullable=True, comment='J band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_g', sa.Float(), nullable=True, comment='Error in g-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_r', sa.Float(), nullable=True, comment='Error in r-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_i', sa.Float(), nullable=True, comment='Error in i-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_z', sa.Float(), nullable=True, comment='Error in z-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_y', sa.Float(), nullable=True, comment='Error in y-band total flux (nJy)')) + op.add_column('target', sa.Column('total_flux_error_j', sa.Float(), nullable=True, comment='Error in J band total flux (nJy)')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('target', 'total_flux_error_j') + op.drop_column('target', 'total_flux_error_y') + op.drop_column('target', 'total_flux_error_z') + op.drop_column('target', 'total_flux_error_i') + op.drop_column('target', 'total_flux_error_r') + op.drop_column('target', 'total_flux_error_g') + op.drop_column('target', 'total_flux_j') + op.drop_column('target', 'total_flux_y') + op.drop_column('target', 'total_flux_z') + op.drop_column('target', 'total_flux_i') + op.drop_column('target', 'total_flux_r') + op.drop_column('target', 'total_flux_g') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20250731-212720_3923581e4392_add_index_target_input_catalog_id.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20250731-212720_3923581e4392_add_index_target_input_catalog_id.py new file mode 100644 index 0000000..2648f90 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20250731-212720_3923581e4392_add_index_target_input_catalog_id.py @@ -0,0 +1,28 @@ +"""add index target.input_catalog_id + +Revision ID: 3923581e4392 +Revises: fc8ea63b8c9d +Create Date: 2025-07-31 21:27:20.574882 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3923581e4392' +down_revision = 'fc8ea63b8c9d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('target_input_catalog_id_idx', 'target', ['input_catalog_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('target_input_catalog_id_idx', table_name='target') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20250801-202638_89865530fdf1_add_index_in_obj_id_input_catalog_id_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20250801-202638_89865530fdf1_add_index_in_obj_id_input_catalog_id_.py new file mode 100644 index 0000000..271a2d4 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20250801-202638_89865530fdf1_add_index_in_obj_id_input_catalog_id_.py @@ -0,0 +1,32 @@ +"""add index in obj_id, input_catalog_id, and proposal_id for target table + +Revision ID: 89865530fdf1 +Revises: 3923581e4392 +Create Date: 2025-08-01 20:26:38.686170 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '89865530fdf1' +down_revision = '3923581e4392' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('target_obj_id_input_catalog_id_idx', 'target', ['obj_id', 'input_catalog_id'], unique=False) + op.create_index('target_proposal_id_idx', 'target', ['proposal_id'], unique=False) + op.create_index('target_proposal_id_obj_id_idx', 'target', ['proposal_id', 'obj_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('target_proposal_id_obj_id_idx', table_name='target') + op.drop_index('target_proposal_id_idx', table_name='target') + op.drop_index('target_obj_id_input_catalog_id_idx', table_name='target') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-150645_ce8ac566901b_remove_redundant_unique_constraint_from_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-150645_ce8ac566901b_remove_redundant_unique_constraint_from_.py new file mode 100644 index 0000000..9635958 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-150645_ce8ac566901b_remove_redundant_unique_constraint_from_.py @@ -0,0 +1,142 @@ +"""remove redundant unique constraint from primary keys + +Revision ID: ce8ac566901b +Revises: 89865530fdf1 +Create Date: 2025-11-07 15:06:45.280873 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ce8ac566901b" +down_revision = "89865530fdf1" +branch_labels = None +depends_on = None + + +def upgrade(): + # Remove redundant unique constraints on primary key columns + # These constraints are unnecessary because primary keys already guarantee uniqueness + # and have their own indexes. + # + # Note: Some of these unique constraints may be referenced by foreign keys. + # PostgreSQL foreign keys can reference either unique constraints or primary keys. + # When we drop a unique constraint that's referenced by a foreign key with CASCADE, + # the dependent foreign keys are automatically dropped. + # IMPORTANT: CASCADE does NOT automatically recreate these foreign keys. + # They must be manually recreated (see migration ae27ee4ad568). + + # Drop constraints that are NOT referenced by foreign keys + op.drop_constraint(op.f("sky_sky_id_key"), "sky", type_="unique") + op.drop_constraint(op.f("fluxstd_fluxstd_id_key"), "fluxstd", type_="unique") + op.drop_constraint(op.f("target_target_id_key"), "target", type_="unique") + + # These constraints ARE referenced by foreign keys, so we use raw SQL with CASCADE + # CASCADE will drop the dependent foreign keys (they are recreated in migration ae27ee4ad568) + op.execute("ALTER TABLE pfs_arm DROP CONSTRAINT IF EXISTS pfs_arm_name_key CASCADE") + op.execute( + "ALTER TABLE proposal DROP CONSTRAINT IF EXISTS proposal_proposal_id_key CASCADE" + ) + op.execute( + "ALTER TABLE proposal_category DROP CONSTRAINT IF EXISTS proposal_category_proposal_category_id_key CASCADE" + ) + op.execute( + "ALTER TABLE input_catalog DROP CONSTRAINT IF EXISTS input_catalog_input_catalog_id_key CASCADE" + ) + op.execute( + "ALTER TABLE partner DROP CONSTRAINT IF EXISTS partner_partner_id_key CASCADE" + ) + op.execute( + "ALTER TABLE target_type DROP CONSTRAINT IF EXISTS target_type_target_type_id_key CASCADE" + ) + op.execute( + "ALTER TABLE filter_name DROP CONSTRAINT IF EXISTS filter_name_filter_name_key CASCADE" + ) + + # Recreate one of the foreign keys that was dropped by CASCADE + # Note: This is incomplete - 17 foreign keys were dropped but only 1 is recreated here. + # The remaining 16 foreign keys are recreated in migration ae27ee4ad568. + op.create_foreign_key( + "target_qa_reference_arm_fkey", + "target", + "pfs_arm", + ["qa_reference_arm"], + ["name"], + ) + + +def downgrade(): + # Recreate the redundant unique constraints + # First drop the foreign key that we recreated in upgrade() + op.drop_constraint("target_qa_reference_arm_fkey", "target", type_="foreignkey") + + # Recreate unique constraints + op.create_unique_constraint( + op.f("filter_name_filter_name_key"), + "filter_name", + ["filter_name"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("target_type_target_type_id_key"), + "target_type", + ["target_type_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("partner_partner_id_key"), + "partner", + ["partner_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("input_catalog_input_catalog_id_key"), + "input_catalog", + ["input_catalog_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("proposal_category_proposal_category_id_key"), + "proposal_category", + ["proposal_category_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("pfs_arm_name_key"), + "pfs_arm", + ["name"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("proposal_proposal_id_key"), + "proposal", + ["proposal_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("target_target_id_key"), + "target", + ["target_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("fluxstd_fluxstd_id_key"), + "fluxstd", + ["fluxstd_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_unique_constraint( + op.f("sky_sky_id_key"), "sky", ["sky_id"], postgresql_nulls_not_distinct=False + ) + + # Recreate the foreign key that will now reference the unique constraint + op.create_foreign_key( + "target_qa_reference_arm_fkey", + "target", + "pfs_arm", + ["qa_reference_arm"], + ["name"], + ) diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-191607_ae27ee4ad568_generate_missing_foreign_keys.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-191607_ae27ee4ad568_generate_missing_foreign_keys.py new file mode 100644 index 0000000..4af6ce8 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20251107-191607_ae27ee4ad568_generate_missing_foreign_keys.py @@ -0,0 +1,130 @@ +"""generate missing foreign keys + +Revision ID: ae27ee4ad568 +Revises: ce8ac566901b +Create Date: 2025-11-07 19:16:07.094831 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ae27ee4ad568" +down_revision = "ce8ac566901b" +branch_labels = None +depends_on = None + + +def upgrade(): + # Recreate foreign keys that were dropped by CASCADE in migration ce8ac566901b + # These foreign keys reference primary keys instead of the redundant unique constraints + + # Foreign keys to input_catalog.input_catalog_id + op.create_foreign_key( + "cluster_input_catalog_id_fkey", + "cluster", + "input_catalog", + ["input_catalog_id"], + ["input_catalog_id"], + ) + op.create_foreign_key( + "fluxstd_input_catalog_id_fkey", + "fluxstd", + "input_catalog", + ["input_catalog_id"], + ["input_catalog_id"], + ) + op.create_foreign_key( + "sky_input_catalog_id_fkey", + "sky", + "input_catalog", + ["input_catalog_id"], + ["input_catalog_id"], + ) + op.create_foreign_key( + "target_input_catalog_id_fkey", + "target", + "input_catalog", + ["input_catalog_id"], + ["input_catalog_id"], + ) + op.create_foreign_key( + "user_pointing_input_catalog_id_fkey", + "user_pointing", + "input_catalog", + ["input_catalog_id"], + ["input_catalog_id"], + ) + + # Foreign keys from fluxstd to filter_name.filter_name + op.create_foreign_key( + "fluxstd_filter_g_fkey", "fluxstd", "filter_name", ["filter_g"], ["filter_name"] + ) + op.create_foreign_key( + "fluxstd_filter_r_fkey", "fluxstd", "filter_name", ["filter_r"], ["filter_name"] + ) + op.create_foreign_key( + "fluxstd_filter_i_fkey", "fluxstd", "filter_name", ["filter_i"], ["filter_name"] + ) + op.create_foreign_key( + "fluxstd_filter_z_fkey", "fluxstd", "filter_name", ["filter_z"], ["filter_name"] + ) + op.create_foreign_key( + "fluxstd_filter_y_fkey", "fluxstd", "filter_name", ["filter_y"], ["filter_name"] + ) + op.create_foreign_key( + "fluxstd_filter_j_fkey", "fluxstd", "filter_name", ["filter_j"], ["filter_name"] + ) + + # Foreign keys from target to filter_name.filter_name + op.create_foreign_key( + "target_filter_g_fkey", "target", "filter_name", ["filter_g"], ["filter_name"] + ) + op.create_foreign_key( + "target_filter_r_fkey", "target", "filter_name", ["filter_r"], ["filter_name"] + ) + op.create_foreign_key( + "target_filter_i_fkey", "target", "filter_name", ["filter_i"], ["filter_name"] + ) + op.create_foreign_key( + "target_filter_z_fkey", "target", "filter_name", ["filter_z"], ["filter_name"] + ) + op.create_foreign_key( + "target_filter_y_fkey", "target", "filter_name", ["filter_y"], ["filter_name"] + ) + op.create_foreign_key( + "target_filter_j_fkey", "target", "filter_name", ["filter_j"], ["filter_name"] + ) + + +def downgrade(): + # Drop the foreign keys created in upgrade() + # Note: This downgrade would recreate the situation where foreign keys are missing + # This should only be used if you need to rollback to the state after ce8ac566901b + + # Drop foreign keys from target to filter_name + op.drop_constraint("target_filter_j_fkey", "target", type_="foreignkey") + op.drop_constraint("target_filter_y_fkey", "target", type_="foreignkey") + op.drop_constraint("target_filter_z_fkey", "target", type_="foreignkey") + op.drop_constraint("target_filter_i_fkey", "target", type_="foreignkey") + op.drop_constraint("target_filter_r_fkey", "target", type_="foreignkey") + op.drop_constraint("target_filter_g_fkey", "target", type_="foreignkey") + + # Drop foreign keys from fluxstd to filter_name + op.drop_constraint("fluxstd_filter_j_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("fluxstd_filter_y_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("fluxstd_filter_z_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("fluxstd_filter_i_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("fluxstd_filter_r_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("fluxstd_filter_g_fkey", "fluxstd", type_="foreignkey") + + # Drop foreign keys to input_catalog + op.drop_constraint( + "user_pointing_input_catalog_id_fkey", "user_pointing", type_="foreignkey" + ) + op.drop_constraint("target_input_catalog_id_fkey", "target", type_="foreignkey") + op.drop_constraint("sky_input_catalog_id_fkey", "sky", type_="foreignkey") + op.drop_constraint("fluxstd_input_catalog_id_fkey", "fluxstd", type_="foreignkey") + op.drop_constraint("cluster_input_catalog_id_fkey", "cluster", type_="foreignkey") diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20251216-182855_385b6cdf44f7_add_indexes_to_fluxstd.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20251216-182855_385b6cdf44f7_add_indexes_to_fluxstd.py new file mode 100644 index 0000000..5e490f7 --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20251216-182855_385b6cdf44f7_add_indexes_to_fluxstd.py @@ -0,0 +1,30 @@ +"""add indexes to fluxstd + +Revision ID: 385b6cdf44f7 +Revises: ae27ee4ad568 +Create Date: 2025-12-16 18:28:55.673441 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '385b6cdf44f7' +down_revision = 'ae27ee4ad568' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('ix_fluxstd_input_catalog_fluxstdid', 'fluxstd', ['input_catalog_id', 'fluxstd_id'], unique=False) + op.create_index('ix_fluxstd_version_fluxstdid', 'fluxstd', ['version', 'fluxstd_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_fluxstd_version_fluxstdid', table_name='fluxstd') + op.drop_index('ix_fluxstd_input_catalog_fluxstdid', table_name='fluxstd') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb-dev/alembic/versions/20260129-172838_a251bdccb11f_add_gc_neighbor_and_is_dense_region_.py b/alembic/pfsa-db01-gb-dev/alembic/versions/20260129-172838_a251bdccb11f_add_gc_neighbor_and_is_dense_region_.py new file mode 100644 index 0000000..3b97b8f --- /dev/null +++ b/alembic/pfsa-db01-gb-dev/alembic/versions/20260129-172838_a251bdccb11f_add_gc_neighbor_and_is_dense_region_.py @@ -0,0 +1,40 @@ +"""add gc_neighbor and is_dense_region columns + +Revision ID: a251bdccb11f +Revises: 385b6cdf44f7 +Create Date: 2026-01-29 17:28:38.712851 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a251bdccb11f' +down_revision = '385b6cdf44f7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('fluxstd', sa.Column('is_gc_neighbor', sa.Boolean(), nullable=True, comment='Flag for globular cluster neighbor')) + op.add_column('fluxstd', sa.Column('is_dense_region', sa.Boolean(), nullable=True, comment='Flag for dense stellar region')) + op.alter_column('fluxstd', 'obj_id', + existing_type=sa.BIGINT(), + comment='source_id (e.g., Gaia EDR3, DR3, etc.)', + existing_comment='Gaia EDR3 sourceid', + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('fluxstd', 'obj_id', + existing_type=sa.BIGINT(), + comment='Gaia EDR3 sourceid', + existing_comment='source_id (e.g., Gaia EDR3, DR3, etc.)', + existing_nullable=False) + op.drop_column('fluxstd', 'is_dense_region') + op.drop_column('fluxstd', 'is_gc_neighbor') + # ### end Alembic commands ### diff --git a/alembic/pfsa-db01-gb/alembic.ini b/alembic/pfsa-db01-gb/alembic.ini new file mode 100644 index 0000000..0dd9c2e --- /dev/null +++ b/alembic/pfsa-db01-gb/alembic.ini @@ -0,0 +1,77 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +prepend_sys_path = . + +# timezone used for the date in the migration file and its filename. +# UTC matches the rest of targetdb and keeps filenames identical whichever +# machine generates them. Resolved via dateutil, which pandas already pulls in. +timezone = UTC + +# max length of characters to apply to the "slug" field +truncate_slug_length = 40 + +# run env.py during the 'revision' command even without --autogenerate +revision_environment = false + +# allow .pyc/.pyo files without a source .py to be detected as revisions +sourceless = false + +# version path separator; "os" uses os.pathsep. +version_path_separator = os + +# the output encoding used when revision files are written from script.py.mako +output_encoding = utf-8 + +# Fallback only: env.py builds the URL from the TOML file named by the +# TARGETDB_CONF environment variable when it is set. Placeholder -- do not +# commit real credentials. +sqlalchemy.url = postgresql://username:password@pfsa-db01-gb:5433/targetdb + + +[post_write_hooks] +# Deliberately empty: alembic/ is excluded from black and ruff in +# pyproject.toml, so generated revisions are left as alembic writes them. + + +# 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/alembic/pfsa-db01-gb/alembic/env.py b/alembic/pfsa-db01-gb/alembic/env.py index fa0e27c..433e1b7 100644 --- a/alembic/pfsa-db01-gb/alembic/env.py +++ b/alembic/pfsa-db01-gb/alembic/env.py @@ -1,9 +1,10 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config, pool -from targetdb import models +from sqlalchemy import create_engine, pool from alembic import context +from targetdb import models +from targetdb.utils import get_alembic_url # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -26,6 +27,23 @@ # ... etc. +def get_url(): + """Resolve the database URL, preferring the TARGETDB_CONF TOML file. + + Point TARGETDB_CONF at a targetdb config file to keep the credentials in + one place (and out of alembic.ini): + + TARGETDB_CONF=~/database_configs/config_targetdb.toml alembic upgrade head + + Falling back to alembic.ini's sqlalchemy.url when it is unset. + + Note that config.set_main_option() is deliberately not used: ConfigParser + treats "%" as an interpolation marker, so a password containing "%" would + be mangled. Building the engine directly avoids the problem entirely. + """ + return get_alembic_url() or config.get_main_option("sqlalchemy.url") + + def run_migrations_offline(): """Run migrations in 'offline' mode. @@ -38,7 +56,7 @@ def run_migrations_offline(): script output. """ - url = config.get_main_option("sqlalchemy.url") + url = get_url() context.configure( url=url, target_metadata=target_metadata, @@ -58,11 +76,7 @@ def run_migrations_online(): and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + connectable = create_engine(get_url(), poolclass=pool.NullPool) with connectable.connect() as connection: context.configure( diff --git a/docs/getting_started.md b/docs/getting_started.md index 6e408c8..a391cb8 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -21,22 +21,27 @@ The dependencies are automatically installed when you install the `targetdb` pac Package versions shown here are those used for the development (as of May 2026). Newer (and somewhat older) versions should also work. -| Package | Version | -| ---------------------------------------------------------------------- | ------: | -| [Python](https://www.python.org/) | 3.12.12 | -| [alembic](https://alembic.sqlalchemy.org/en/latest/) | 1.18.0 | -| [Astropy](https://www.astropy.org/) | 7.20 | -| [loguru](https://loguru.readthedocs.io/) | 0.7.3 | -| [NumPy](https://numpy.org) | 2.4.0 | -| [openpyxl](https://openpyxl.readthedocs.io/en/stable/) | 3.1.5 | -| [pandas](https://pandas.pydata.org/) | 2.3.3 | -| [psycopg2-binary](https://www.psycopg.org/) | 2.9.11 | -| [pyarrow](https://arrow.apache.org/docs/python/) | 22.0.0 | -| [requests](https://requests.readthedocs.io/en/latest/) | 2.32.5 | -| [SQLAlchemy](https://www.sqlalchemy.org/) | 2.0.45 | -| [SQLAlchemy-Utils](https://sqlalchemy-utils.readthedocs.io/en/latest/) | 0.42.1 | -| [tabulate](https://pypi.org/project/tabulate/) | 0.9.0 | -| [Typer](https://typer.tiangolo.com/) | 0.21.1 | +| Package | Version | +| ---------------------------------------------------------------------- | ----------: | +| [Python](https://www.python.org/) | 3.12.12 | +| [alembic](https://alembic.sqlalchemy.org/en/latest/) | 1.18.0 | +| [Astropy](https://www.astropy.org/) | 7.20 | +| [loguru](https://loguru.readthedocs.io/) | 0.7.3 | +| [NumPy](https://numpy.org) | 2.4.0 | +| [openpyxl](https://openpyxl.readthedocs.io/en/stable/) | 3.1.5 | +| [pandas](https://pandas.pydata.org/) | 2.3.3 | +| [pfs-utils](https://github.com/Subaru-PFS/pfs_utils) | 7.2026.3100 | +| [psycopg](https://www.psycopg.org/) | 3.3.4 | +| [pyarrow](https://arrow.apache.org/docs/python/) | 22.0.0 | +| [requests](https://requests.readthedocs.io/en/latest/) | 2.32.5 | +| [SQLAlchemy](https://www.sqlalchemy.org/) | 2.0.45 | +| [SQLAlchemy-Utils](https://sqlalchemy-utils.readthedocs.io/en/latest/) | 0.42.1 | +| [tabulate](https://pypi.org/project/tabulate/) | 0.9.0 | +| [Typer](https://typer.tiangolo.com/) | 0.21.1 | + +`pfs-utils` is installed straight from GitHub (`targetdb.TargetDB` subclasses +`pfs.utils.database.db.DB`), so a `.git` directory and network access are needed +at install time. For building the documentation, the following packages are required. @@ -85,7 +90,7 @@ host = "localhost" # database host port = 5432 # database port dbname = "targetdb" # database name user = "admin" # database user -password = "admin" # database password +password = "admin" # database password (optional, see below) dialect = "postgresql" # database dialect @@ -93,6 +98,42 @@ dialect = "postgresql" # database dialect SCHEMACRAWLERDIR = "" # "_schemacrawler/bin/schemacrawler.sh" under the path will be used ``` +`dialect = "postgresql"` is resolved to the psycopg3 driver +(`postgresql+psycopg`); psycopg2 is no longer used. Existing configuration +files need no change. + +### Keeping the password out of `dbconf.toml` + +`password` is optional. Leave it out and it is omitted from the connection URL +entirely, so libpq resolves the credential itself -- first `PGPASSWORD`, then +`PGPASSFILE` or `~/.pgpass`. This lets a configuration file with no secrets in +it be kept under version control. + +```toml title="dbconf.toml (no password)" +[targetdb.db] +host = "pfsa-db.example.org" +port = 5433 +dbname = "targetdb" +user = "admin" +dialect = "postgresql" +``` + +```text title="~/.pgpass" +pfsa-db.example.org:5433:targetdb:admin:the-actual-password +``` + +Two things to watch out for: + +- Each `~/.pgpass` field is matched against the corresponding TOML value **as a + literal string**. `localhost` and a fully qualified domain name are different + hosts as far as libpq is concerned, even when they resolve to the same server. + `*` may be used as a wildcard for any of the first four fields. +- The file must be mode `0600`. libpq ignores it silently otherwise -- there is + no warning, only an authentication failure. + +When `password` *is* present in the TOML it is used as before and takes +precedence, which is the same ordering libpq itself applies. + The following commands are to create the `targetdb` database, install the Q3C extension, create tables, and generate an entity-relationship diagram of the database. @@ -145,6 +186,30 @@ print(df.head()) db.close() ``` +## Database Migrations with Alembic + +Each deployment target has its own directory under `alembic/` +(`local_test/`, `pfsa-db01-gb/`, `pfsa-db01-gb-dev/`), each with its own +`alembic.ini` and revision history. Run alembic from within the relevant +directory. + +The `env.py` scripts build the connection URL from the same TOML configuration +file the CLI uses. Point `TARGETDB_CONF` at it: + +```bash +cd alembic/local_test +TARGETDB_CONF=~/database_configs/config_targetdb.toml alembic upgrade head +``` + +This keeps the credentials in one place instead of duplicating them between +`dbconf.toml` and `alembic.ini`, and means the switch to psycopg3 is picked up +automatically without hand-editing the `sqlalchemy.url` of each deployment. +Combined with the `~/.pgpass` arrangement above, an `alembic.ini` with no +secrets in it can be kept in the repository. + +When `TARGETDB_CONF` is not set, `env.py` falls back to the `sqlalchemy.url` +entry in `alembic.ini` as before. + ## Running Tests Locally The unit tests (`tests/test_*.py`, excluding `tests/integration/`) do not require a database diff --git a/examples/data/proposals.csv b/examples/data/proposals.csv index 3ecdab6..655ca93 100644 --- a/examples/data/proposals.csv +++ b/examples/data/proposals.csv @@ -14,14 +14,14 @@ S23A-QN900,o22016,Kiyoto,Yabe0,None,6.0,c,36000.0,0.0,0.0,1,subaru,False S23A-QN901,o22016,Kiyoto,Yabe1,None,7.0,b,12000.0,0.0,0.0,1,subaru,False S23A-QN902,o22016,Kiyoto,Yabe2,None,8.0,b,7200.0,0.0,0.0,1,subaru,False S23A-QN903,o22016,Kiyoto,Yabe3,None,9.0,a,3600.0,0.0,0.0,1,subaru,False -S23B-QT901,,M.,Ishigaki,,10.0,A,595.5,319.5,276.0,1,subaru,False -S23B-QT902,,K.,Yabe,,8.8,A,1730.0,1730.0,0.0,1,subaru,False -S23B-QT903,,W.,He,,9.1,B,234.5,0.0,234.5,1,subaru,False -S23B-QT904,,M.,Tanaka,,8.2,B,62.25,0.0,62.25,1,subaru,False -S23B-QT905,,M.,Onodera,,7.5,B,5166.0,0.0,5166.0,1,subaru,False -S23B-QT906,,Y.,Moritani,,7.0,A,2542.25,2542.25,0.0,1,subaru,False -S23B-QT907,,J.,Eric,,6.5,C,2594.5,2594.5,0.0,1,subaru,False -S23B-QT908,,Y.,Takagi,,5.6,C,320.0,320.0,0.0,1,subaru,False +S23B-QT901,o23901,M.,Ishigaki,,10.0,A,595.5,319.5,276.0,1,subaru,False +S23B-QT902,o23902,K.,Yabe,,8.8,A,1730.0,1730.0,0.0,1,subaru,False +S23B-QT903,o23903,W.,He,,9.1,B,234.5,0.0,234.5,1,subaru,False +S23B-QT904,o23904,M.,Tanaka,,8.2,B,62.25,0.0,62.25,1,subaru,False +S23B-QT905,o23905,M.,Onodera,,7.5,B,5166.0,0.0,5166.0,1,subaru,False +S23B-QT906,o23906,Y.,Moritani,,7.0,A,2542.25,2542.25,0.0,1,subaru,False +S23B-QT907,o23907,J.,Eric,,6.5,C,2594.5,2594.5,0.0,1,subaru,False +S23B-QT908,o23908,Y.,Takagi,,5.6,C,320.0,320.0,0.0,1,subaru,False S24B-QT901,o00001,Sakurako,Okamoto,NaN,9.0,A,284.25,0.0,284.25,1,subaru,False S24B-QT902,o00002,Masayuki,Tanaka,NaN,8.5,B,951.0,0.0,951.0,1,subaru,False S24B-QT903,o00003,Tae-Soo,Pyo,NaN,8.5,B,3190.75,0.0,3190.75,1,subaru,False diff --git a/pyproject.toml b/pyproject.toml index 96d24ea..238acee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "ets_target_database" description = "PFS target database (targetDB) tools" readme = "README.md" -requires-python = ">=3.11, <3.13" +requires-python = ">=3.12, <3.13" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Masato Onodera", email = "monodera@naoj.org" }] @@ -11,7 +11,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Database", "Topic :: Scientific/Engineering :: Astronomy", @@ -23,7 +22,12 @@ dependencies = [ "numpy>=2.0", "openpyxl", "pandas", - "psycopg2-binary", + # TargetDB subclasses pfs.utils.database.db.DB; tracking master because its + # database/ module is still actively developed. `uv.lock` pins the resolved + # commit, so picking up upstream changes needs an explicit + # `uv lock --upgrade-package pfs-utils` followed by the integration tests. + "pfs-utils @ git+https://github.com/Subaru-PFS/pfs_utils.git@master", + "psycopg[binary]", "pyarrow", "requests", "sqlalchemy", @@ -31,7 +35,6 @@ dependencies = [ "sqlalchemy-utils", "tabulate", # "wheel", - 'tomli >= 1.1.0 ; python_version < "3.11"', "typer", ] # version = "0.1.0" @@ -70,10 +73,11 @@ markers = [ line-length = 88 # Generated by setuptools-scm and gitignored; see the matching ruff # extend-exclude below for why it's kept out of linting/formatting. -# alembic/ migrations are historical records and are deliberately left +# alembic revision scripts are historical records and are deliberately left # unformatted -- excluded here too so an editor's black extension with -# format-on-save doesn't rewrite them. -extend-exclude = "(src/targetdb/_version\\.py|alembic/)" +# format-on-save doesn't rewrite them. Only versions/ is excluded: env.py is +# live code and is formatted like the rest of the package. +extend-exclude = "(src/targetdb/_version\\.py|alembic/.*/versions/)" [tool.pdm.scripts] serve-doc = { shell = "mkdocs serve", help = "Start the dev server for doc preview" } @@ -92,18 +96,20 @@ gen-requirements = { cmd = [ ], help = "Generate requirements.txt" } [tool.ruff] -# Matches requires-python (">=3.11"). Do not raise this without raising +# Matches requires-python (">=3.12"). Do not raise this without raising # requires-python: with UP rules enabled, ruff rewrites code to the target # version's syntax. -target-version = "py311" +target-version = "py312" line-length = 88 # Generated by setuptools-scm and gitignored. It exists locally but not in a # fresh CI checkout, so linting it makes the checked file set environment- # dependent. -# alembic/ migrations are historical records and are deliberately not -# linted -- excluding it here (not just in the runners' arguments) keeps -# editors and a bare `ruff check .` in agreement with CI. -extend-exclude = ["src/targetdb/_version.py", "alembic"] +# alembic revision scripts are historical records and are deliberately not +# linted -- excluding them here (not just in the runners' arguments) keeps +# editors and a bare `ruff check .` in agreement with CI. Only versions/ is +# excluded; the env.py scripts are live code and are linted like the rest of +# the package. +extend-exclude = ["src/targetdb/_version.py", "alembic/*/alembic/versions"] [tool.ruff.lint] # C4 is flake8-comprehensions, unrelated to C901 (mccabe), which stays off. diff --git a/src/targetdb/targetdb.py b/src/targetdb/targetdb.py index 7661431..fa89079 100644 --- a/src/targetdb/targetdb.py +++ b/src/targetdb/targetdb.py @@ -1,18 +1,32 @@ #!/usr/bin/env python -import io +from contextlib import contextmanager import pandas as pd from loguru import logger -from sqlalchemy import create_engine, select -from sqlalchemy.orm import sessionmaker -from sqlalchemy.sql import text +from pfs.utils.database.db import DB +from sqlalchemy import URL, and_, bindparam, select from . import models -class TargetDB: - # url = "postgresql://pfs@db-ics:5432/opdb" +class TargetDB(DB): + """PFS targetDB accessor built on top of ``pfs.utils.database.db.DB``. + + ``DB`` supplies the engine cache, ``pool_pre_ping``, ``COPY``-based bulk + inserts and the ``query_*`` family. Only what ``DB`` does not provide is + implemented here: + + * a password and an explicit dialect in the URL (``DB`` always omits the + password and hardcodes ``postgresql+psycopg``), + * ``update()``, + * the ``dry_run=`` keyword that backs the CLI's ``--commit`` default, + * ``close()``. + + The public methods kept for backwards compatibility (``connect``, + ``close``, ``fetch_query``, ``fetch_all``, ``fetch_by_id``) are all used by + downstream packages (``ets_pointing``, ``pfs_obsproc_planning_tools``). + """ def __init__( self, @@ -23,34 +37,72 @@ def __init__( password=None, dialect="postgresql", ): - for param in [dbname, user, password]: - if param is None: - logger.error(f"{param} is not provided") - raise ValueError(f"{param} is not provided") + for name, value in [("dbname", dbname), ("user", user)]: + if value is None: + logger.error(f"{name} is not provided") + raise ValueError(f"{name} is not provided") - self.dbinfo = f"{dialect}://{user}:{password}@{host}:{port}/{dbname}" + # Set before super().__init__() because DB.__init__ logs self.url. + # `password` is deliberately optional: when it is None it is left out + # of the URL and libpq resolves it (PGPASSWORD, then ~/.pgpass), which + # is exactly the arrangement DB itself documents. + self._password = password + # Imported lazily to avoid a circular import (utils imports TargetDB). + from .utils import normalize_drivername - def connect(self): - self.engine = create_engine(self.dbinfo) - SessionClass = sessionmaker(self.engine) - self.session = SessionClass() - # print('connection to {0} started'.format(self.dbinfo)) + self._drivername = normalize_drivername(dialect) + self._dry_run = False - def close(self): - try: - self.session.close() - finally: - # Dispose the engine to release all pooled connections back to the database - # server. Without this, SQLAlchemy's connection pool keeps the underlying - # TCP sockets open until the engine is garbage-collected, which may never - # happen on abnormal process termination. - self.engine.dispose() - # print('connection to {0} closed'.format(self.dbinfo)) + super().__init__(host=host, user=user, dbname=dbname, port=port) + + @property + def url(self) -> str: + """Connection URL, including the password when one was configured. + + Overrides ``DB.url``, which always omits the password and hardcodes the + ``postgresql+psycopg`` driver. + + Note + ---- + ``DB.engine`` caches engines in a process-global dict keyed by this + string, so a configured password is held in that key for the lifetime + of the process. Never log this value directly -- use + ``URL.create(...).render_as_string()`` (which masks it) instead. + """ + return URL.create( + drivername=self._drivername, + username=self.user, + password=self._password, + host=self.host, + port=self.port, + database=self.dbname, + ).render_as_string(hide_password=False) + + def connect(self) -> None: + """Materialize the engine. Deliberately returns nothing. + + ``DB.connect()`` returns a pooled ``Connection``; callers here (and in + the downstream packages) discard the return value, which would leak one + checked-out connection per call. ``DB.connection()`` is the only + internal user of ``DB.connect()`` and is overridden below, so narrowing + this to "make sure the engine exists" is safe. + """ + _ = self.engine + + def close(self) -> None: + """Dispose the engine, closing every pooled connection. + + ``DB`` has no ``close()``. Without this, connections linger until the + engine is garbage-collected and ``drop-db`` fails with "database is + being accessed by other users". ``Engine.dispose()`` replaces the pool + rather than invalidating the engine, so the instance cached in + ``pfs.utils.database.db._DB_ENGINES`` stays usable afterwards. + """ + self.engine.dispose() def __enter__(self): """Support usage as a context manager: ``with TargetDB(...) as db:``.""" - if not hasattr(self, "session"): - self.connect() + self.connect() return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -58,79 +110,62 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.close() return False # re-raise any exception - def reset_all(self, full=True): - # - # Order of the resetting tables is important - # - self.session.query(models.cluster).delete() - self.session.query(models.target).delete() - self.session.query(models.fluxstd).delete() - self.session.query(models.sky).delete() - self.session.query(models.proposal).delete() - self.session.query(models.input_catalog).delete() - self.session.query(models.target_type).delete() - self.session.query(models.proposal_category).delete() - self.session.execute("ALTER SEQUENCE target_target_id_seq RESTART WITH 1") - self.session.execute("ALTER SEQUENCE fluxstd_fluxstd_id_seq RESTART WITH 1") - self.session.execute("ALTER SEQUENCE sky_sky_id_seq RESTART WITH 1") - - self.session.commit() - - def reset_target(self): - self.session.query(models.target).delete() - self.session.execute("ALTER SEQUENCE target_target_id_seq RESTART WITH 1") - - def reset_fluxstd(self): - self.session.query(models.fluxstd).delete() - self.session.execute("ALTER SEQUENCE fluxstd_fluxstd_id_seq RESTART WITH 1") - - def reset_sky(self): - self.session.query(models.sky).delete() - self.session.execute("ALTER SEQUENCE sky_sky_id_seq RESTART WITH 1") - - def rollback(self): - self.session.rollback() - - # functionality to insert/update information into the database - - def insert_mappings( - self, tablename, mappings, return_defaults=False, dry_run=False - ): - """ - Description - ----------- - Insert information into a table - Parameters - ---------- - tablename : `string` - mappings : `dictionnary list` - Returns - ------- - None - """ - model = getattr(models, tablename) + @contextmanager + def _dry_run_scope(self, dry_run): + """Temporarily flip the dry-run flag consulted by ``connection()``.""" + previous = self._dry_run + self._dry_run = dry_run try: - # print(mappings) - self.session.bulk_insert_mappings( - model, mappings, return_defaults=return_defaults - ) - if dry_run: - self.session.rollback() - return None - else: - self.session.commit() - - if return_defaults: - df_ret = pd.DataFrame.from_records(mappings) - return df_ret + yield + finally: + self._dry_run = previous + + @contextmanager + def connection(self): + """Pooled connection that commits, or rolls back under dry-run. + + Overrides ``DB.connection()``, which always commits. Every ``DB`` method + that touches the database goes through here, so honouring ``_dry_run`` + at this one point makes ``insert``/``update``/``execute_query`` all obey + the CLI's ``--commit`` contract -- including the ``COPY`` fast path, + whose raw cursor runs inside this same transaction. + + The transaction is opened eagerly rather than left to SQLAlchemy's + implicit begin: ``pandas.DataFrame.to_sql`` starts *and commits* a + transaction of its own when handed a connection that is not already in + one, which would make the rollback below a no-op. + """ + with self.engine.connect() as conn: + conn.begin() + yield conn + if self._dry_run: + conn.rollback() else: - return None - # print(mappings) - except Exception as e: - self.session.rollback() - raise e - - def insert(self, tablename, dataframe, return_defaults=False, dry_run=False): + conn.commit() + + # ################################################## + # functionality to insert/update information + # ################################################## + + @staticmethod + def _drop_unknown_columns(tablename, dataframe): + """Drop DataFrame columns the table does not have. + + `add_backref_values()` resolves foreign keys by merging whole reference + tables into the DataFrame, which leaves descriptive columns behind + (`partner_name` and `proposal_category_name` when inserting proposals, + for instance). The ORM `bulk_insert_mappings()` path this class used to + take ignored keys that were not mapped to a column; `COPY` and Core + `update()` do not, so the same leniency is applied explicitly here. + """ + known = set(getattr(models, tablename).__table__.columns.keys()) + extra = [column for column in dataframe.columns if column not in known] + if not extra: + return dataframe + logger.debug(f"Ignoring columns absent from the {tablename} table: {extra}") + return dataframe.drop(columns=extra) + + def insert(self, tablename, dataframe, dry_run=False): """ Description ----------- @@ -139,47 +174,20 @@ def insert(self, tablename, dataframe, return_defaults=False, dry_run=False): ---------- tablename : `string` dataframe : `pandas.DataFrame` + dry_run : `bool` roll back instead of committing Returns ------- - None + n_inserted : `int` or `None` Note ---- - Column labels of `dataframe` should be exactly the same as those of the table - """ - mappings_dict = dataframe.to_dict(orient="records") - df_ret = self.insert_mappings( - tablename, mappings_dict, return_defaults=return_defaults, dry_run=dry_run - ) - if return_defaults: - return df_ret - else: - return None - - def insert_by_copy(self, tablename, data, colnames, dry_run=False): + Column labels of `dataframe` should be exactly the same as those of + the table. Delegates to `DB.insert_dataframe()`, which uses + PostgreSQL's `COPY`. """ - Description - ----------- - Insert information into a table using COPY FROM method - Parameters - ---------- - tablename : `string` - data : `a text stream` - colnames: `list` of `string` - Returns - ------- - None - Note - ---- - """ - conn = self.engine.raw_connection() - cur = conn.cursor() - cur.copy_from(data, tablename, ",", columns=colnames) - if dry_run: - conn.rollback() - else: - conn.commit() - cur.close() - conn.close() + with self._dry_run_scope(dry_run): + return self.insert_dataframe( + tablename, self._drop_unknown_columns(tablename, dataframe) + ) def update(self, tablename, dataframe, dry_run=False): """ @@ -190,25 +198,60 @@ def update(self, tablename, dataframe, dry_run=False): ---------- tablename : `string` dataframe : `pandas.DataFrame` + dry_run : `bool` roll back instead of committing Returns ------- None Note ---- - Column labels of `dataframe` should be exactly the same as those of the table + Column labels of `dataframe` should be exactly the same as those of + the table, and must include the primary key column(s), which are + matched on rather than written. `DB` has no equivalent, so this is a + single Core UPDATE statement executed once per row by executemany. """ - model = getattr(models, tablename) - try: - self.session.bulk_update_mappings( - model, dataframe.to_dict(orient="records") + table = getattr(models, tablename).__table__ + dataframe = self._drop_unknown_columns(tablename, dataframe) + records = dataframe.to_dict(orient="records") + if not records: + logger.warning(f"No rows to update in the {tablename} table.") + return + + pk_names = [c.name for c in table.primary_key.columns] + missing = [name for name in pk_names if name not in dataframe.columns] + if missing: + raise ValueError( + f"Primary key column(s) {missing} missing from the DataFrame " + f"for the {tablename} table." ) - if dry_run: - self.session.rollback() - else: - self.session.commit() - except: - self.session.rollback() - raise + + value_names = [name for name in dataframe.columns if name not in pk_names] + if not value_names: + raise ValueError( + f"The DataFrame for the {tablename} table contains only primary " + "key columns; there is nothing to update." + ) + + # The primary key values are bound under a "_pk_" prefix so that they do + # not collide with the SET parameters of the same name. + for record in records: + for name in pk_names: + record[f"_pk_{name}"] = record.pop(name) + + stmt = ( + table.update() + .where( + and_( + *[ + c == bindparam(f"_pk_{c.name}") + for c in table.primary_key.columns + ] + ) + ) + .values({name: bindparam(name) for name in value_names}) + ) + + with self._dry_run_scope(dry_run), self.connection() as conn: + conn.execute(stmt, records) """ ################################################## @@ -216,6 +259,11 @@ def update(self, tablename, dataframe, dry_run=False): ################################################## """ + def _fetch(self, stmt): + """Run a Core SELECT and return the result as a DataFrame.""" + with self.connection() as conn: + return pd.read_sql(stmt, con=conn) + def fetch_all(self, tablename): """ Description @@ -229,23 +277,11 @@ def fetch_all(self, tablename): df : `pandas.DataFrame` Note ---- + Uses a Core `select()` on the model's `Table` rather than + `DB.query_dataframe()`, which would require interpolating the table + name into a SQL string. """ - model = getattr(models, tablename) - try: - # Use session.execute() with select() to avoid immutabledict issues with pd.read_sql() - stmt = select(model) - result = self.session.execute(stmt) - # Get column names from the model's mapper - columns = [c.key for c in model.__mapper__.columns] - data = result.fetchall() - # Extract values from Row objects (each row is a tuple with one element - the model instance) - rows = [[getattr(row[0], col) for col in columns] for row in data] - df = pd.DataFrame(rows, columns=columns) - except: - self.session.rollback() - raise - - return df + return self._fetch(select(getattr(models, tablename).__table__)) def fetch_by_id(self, tablename, **kwargs): """ @@ -262,24 +298,11 @@ def fetch_by_id(self, tablename, **kwargs): Note ---- """ - model = getattr(models, tablename) - try: - # Use session.execute() with select() to avoid immutabledict issues with pd.read_sql() - stmt = select(model) - for k, v in kwargs.items(): - stmt = stmt.filter(getattr(model, k) == v) - result = self.session.execute(stmt) - # Get column names from the model's mapper - columns = [c.key for c in model.__mapper__.columns] - data = result.fetchall() - # Extract values from Row objects (each row is a tuple with one element - the model instance) - rows = [[getattr(row[0], col) for col in columns] for row in data] - df = pd.DataFrame(rows, columns=columns) - except: - self.session.rollback() - raise - - return df + table = getattr(models, tablename).__table__ + stmt = select(table) + for k, v in kwargs.items(): + stmt = stmt.where(table.c[k] == v) + return self._fetch(stmt) def fetch_query(self, query): """ @@ -294,42 +317,9 @@ def fetch_query(self, query): df : `pandas.DataFrame` Note ---- + Delegates to `DB.query_dataframe()`. """ - try: - # Use session.execute() with text() to avoid immutabledict issues with pd.read_sql() - result = self.session.execute(text(query)) - columns = result.keys() - data = result.fetchall() - df = pd.DataFrame(data, columns=columns) - except: - self.session.rollback() - raise - - return df - - def fetch_by_copy(self, tablename, colnames): - """ - Description - ----------- - Get selected records from a table by using COPY TO method - Parameters - ---------- - tablename : `string` - colnames : `list` of `string` - Returns - ------- - data : `io.StringIO` (comma-separated) - Note - ---- - """ - data = io.StringIO() - conn = self.engine.raw_connection() - cur = conn.cursor() - cur.copy_to(data, tablename, sep=",", null="\\N", columns=colnames) - cur.close() - conn.close() - data.seek(0) - return data + return self.query_dataframe(query) def execute_query(self, query, dry_run=False): """ @@ -338,19 +328,14 @@ def execute_query(self, query, dry_run=False): Execute a SQL query Parameters ---------- - query : `string` + query : `string` + dry_run : `bool` roll back instead of committing Returns ------- None Note ---- + Delegates to `DB.commit()`. """ - try: - self.session.execute(text(query)) - if dry_run: - self.session.rollback() - else: - self.session.commit() - except Exception as e: - self.session.rollback() - raise e + with self._dry_run_scope(dry_run): + self.commit(query) diff --git a/src/targetdb/utils.py b/src/targetdb/utils.py index e361dd6..49eca04 100644 --- a/src/targetdb/utils.py +++ b/src/targetdb/utils.py @@ -7,6 +7,7 @@ import subprocess import tempfile import time +import tomllib import zipfile from datetime import datetime from pathlib import Path @@ -26,11 +27,6 @@ input_catalog_id_start, ) -try: - import tomllib -except ModuleNotFoundError: - import tomli as tomllib - def load_config(config_file): """ @@ -177,6 +173,43 @@ def read_excel(input_file, sheetnames=None): # return {"proposal": df_proposal, "allocation": df_allocation} +# targetdb speaks to PostgreSQL through psycopg3 only; psycopg2 is no longer a +# dependency. Existing dbconf.toml files say `dialect = "postgresql"`, which +# SQLAlchemy resolves to psycopg2 by default, so the bare dialect names are +# mapped to the psycopg3 driver here. Doing it in one place means no deployed +# configuration file has to be edited. +_DRIVER_ALIASES = { + "postgresql": "postgresql+psycopg", + "postgres": "postgresql+psycopg", +} + + +def normalize_drivername(dialect): + """ + Map a bare PostgreSQL dialect name onto the psycopg3 driver. + + Parameters + ---------- + dialect : str + The dialect string from the configuration file (e.g. ``"postgresql"``). + + Returns + ------- + drivername : str + ``"postgresql+psycopg"`` for the bare PostgreSQL dialect names, and the + input unchanged for anything else (an explicit ``dialect+driver`` value + is always honoured as written). + + Examples + -------- + >>> normalize_drivername("postgresql") + 'postgresql+psycopg' + >>> normalize_drivername("postgresql+psycopg2") + 'postgresql+psycopg2' + """ + return _DRIVER_ALIASES.get(dialect, dialect) + + def get_url_object(config): """ Create a URL object from the given configuration. @@ -185,7 +218,8 @@ def get_url_object(config): ---------- config : dict The configuration dictionary containing the database connection details. - Expected keys are 'targetdb'->'db'->'dialect', 'user', 'password', 'host', 'port', 'dbname'. + Expected keys are 'targetdb'->'db'->'dialect', 'user', 'host', 'port', + and 'dbname'. 'password' is optional. Returns ------- @@ -197,17 +231,23 @@ def get_url_object(config): KeyError If a necessary key is missing from the config dictionary. + Notes + ----- + ``password`` is optional. When it is absent the password is left out of the + URL entirely and libpq resolves it itself (``PGPASSWORD``, then + ``PGPASSFILE`` / ``~/.pgpass``). See ``docs/getting_started.md``. + Examples -------- >>> config = {'targetdb': {'db': {'dialect':'postgresql', 'user':'username', 'password':'password', 'host':'localhost', 'port':5432, 'dbname':'test_db'}}} >>> url_object = get_url_object(config) >>> print(url_object) - postgresql://username:password@localhost:5432/test_db + postgresql+psycopg://username:***@localhost:5432/test_db """ url_object = URL.create( - drivername=config["targetdb"]["db"]["dialect"], + drivername=normalize_drivername(config["targetdb"]["db"]["dialect"]), username=config["targetdb"]["db"]["user"], - password=config["targetdb"]["db"]["password"], + password=config["targetdb"]["db"].get("password"), host=config["targetdb"]["db"]["host"], port=config["targetdb"]["db"]["port"], database=config["targetdb"]["db"]["dbname"], @@ -216,21 +256,47 @@ def get_url_object(config): return url_object -def install_q3c_extension(config, dry_run=False): +def get_alembic_url(conf_file=None): + """ + Resolve the database URL used by the alembic ``env.py`` scripts. - # connect to the targetDB - db = TargetDB(**config["targetdb"]["db"]) - db.connect() + Parameters + ---------- + conf_file : str or pathlib.Path, optional + Path to a targetdb TOML configuration file. When ``None`` the + ``TARGETDB_CONF`` environment variable is consulted instead. + + Returns + ------- + url : str or None + The rendered connection URL, or ``None`` when neither ``conf_file`` nor + ``TARGETDB_CONF`` is set. ``None`` tells the caller to fall back to the + ``sqlalchemy.url`` entry of its own ``alembic.ini``. + + Notes + ----- + This lives in ``targetdb.utils`` rather than in ``env.py`` so that the URL + resolution can be unit tested: ``env.py`` is executed by alembic and is + awkward to import directly. + """ + if conf_file is None: + conf_file = os.environ.get("TARGETDB_CONF") + if not conf_file: + return None + return get_url_object(load_config(conf_file)).render_as_string(hide_password=False) + + +def install_q3c_extension(config, dry_run=False): logger.info("Installing q3c extension.") - try: - db.execute_query("CREATE EXTENSION IF NOT EXISTS q3c", dry_run=dry_run) - except Exception as e: - logger.error(f"Error installing q3c extension: {e}") - raise e - # close the connection - db.close() + # connect to the targetDB + with TargetDB(**config["targetdb"]["db"]) as db: + try: + db.execute_query("CREATE EXTENSION IF NOT EXISTS q3c", dry_run=dry_run) + except Exception as e: + logger.error(f"Error installing q3c extension: {e}") + raise e def generate_schema_markdown(output_file=None): @@ -334,10 +400,12 @@ def draw_diagram( subprocess.run(comm, shell=False) elif generator == "tbls": url_object = get_url_object(config) + # tbls wants a plain `postgres://` DSN with no SQLAlchemy driver + # qualifier. Replacing the scheme on the rendered string would turn + # `postgresql+psycopg://` into `postgres+psycopg://`, which tbls cannot + # parse, so swap the drivername on the URL object instead. url_object_tbls = ( - url_object.render_as_string(hide_password=False).replace( - "postgresql", "postgres", 1 - ) + url_object.set(drivername="postgres").render_as_string(hide_password=False) + "?sslmode=disable" ) @@ -931,76 +999,72 @@ def add_database_rows( raise ValueError(f"flux_type must be 'total' or 'psf'. {flux_type=}") logger.info("Connecting to targetDB") - db = TargetDB(**config["targetdb"]["db"]) - db.connect() - - t_begin = time.time() - if table in ["proposal", "fluxstd", "sky", "user_pointing"]: - df = add_backref_values(df, db=db, table=table, upload_id=upload_id) - elif table in ["target"]: - if from_uploader: - df = make_target_df_from_uploader( + # The context manager guarantees close() runs even if the insert/update + # raises, which otherwise leaves pooled connections open and makes a later + # drop-db fail with "database is being accessed by other users". + with TargetDB(**config["targetdb"]["db"]) as db: + t_begin = time.time() + if table in ["proposal", "fluxstd", "sky", "user_pointing"]: + df = add_backref_values(df, db=db, table=table, upload_id=upload_id) + elif table in ["target"]: + if from_uploader: + df = make_target_df_from_uploader( + df, + db=db, + table=table, + proposal_id=proposal_id, + upload_id=upload_id, + flux_type=flux_type, + insert=insert, + update=update, + ) + else: + df = add_backref_values(df, db=db, table=table) + elif table in ["input_catalog"]: + check_input_catalog( df, db=db, - table=table, - proposal_id=proposal_id, - upload_id=upload_id, - flux_type=flux_type, - insert=insert, - update=update, + input_catalog_id_start=input_catalog_id_start, + input_catalog_id_max=input_catalog_id_max, ) - else: - df = add_backref_values(df, db=db, table=table) - elif table in ["input_catalog"]: - check_input_catalog( - df, - db=db, - input_catalog_id_start=input_catalog_id_start, - input_catalog_id_max=input_catalog_id_max, - ) - t_end = time.time() - logger.info(f"Added back reference values in {t_end - t_begin:.2f} s") - - if verbose: - logger.debug(f"{df.columns=}") - - if verbose: - logger.debug(f"Working on the following DataFrame: \n{df}") + t_end = time.time() + logger.info(f"Added back reference values in {t_end - t_begin:.2f} s") - try: - t_begin = time.time() - if commit: - dry_run = False - logger.info("Committing the changes to targetDB") - else: - dry_run = True - logger.info("No changes will be committed to targetDB (i.e., dry run)") + if verbose: + logger.debug(f"{df.columns=}") - # logger.info(f"{df['total_flux_r'][:100]}") - # db.close() - # exit() + if verbose: + logger.debug(f"Working on the following DataFrame: \n{df}") - if insert: - db.insert(table, df, dry_run=dry_run) - elif update: - db.update(table, df, dry_run=dry_run) - t_end = time.time() - logger.info( - f"Insert data to the {table} table successful for {df.index.size} rows in {input_file} ({t_end - t_begin:.2f} s)" - if insert - else f"Update data in the {table} table successful for {df.index.size} rows in {input_file} ({t_end - t_begin:.2f} s)" - ) - except Exception as e: - logger.error(f"Operation failed: {e}: {input_file}") - raise + try: + t_begin = time.time() + if commit: + dry_run = False + logger.info("Committing the changes to targetDB") + else: + dry_run = True + logger.info("No changes will be committed to targetDB (i.e., dry run)") + + if insert: + db.insert(table, df, dry_run=dry_run) + elif update: + db.update(table, df, dry_run=dry_run) + t_end = time.time() + logger.info( + f"Insert data to the {table} table successful for {df.index.size} rows in {input_file} ({t_end - t_begin:.2f} s)" + if insert + else f"Update data in the {table} table successful for {df.index.size} rows in {input_file} ({t_end - t_begin:.2f} s)" + ) + except Exception as e: + logger.error(f"Operation failed: {e}: {input_file}") + raise - if fetch: - logger.info("Fetching the first 100 table entries") - res = db.fetch_all(table) - logger.info(f"Fetched the first 100 entries in the {table} table: \n{res}") + if fetch: + logger.info("Fetching the first 100 table entries") + res = db.fetch_all(table) + logger.info(f"Fetched the first 100 entries in the {table} table: \n{res}") - logger.info("Closing targetDB") - db.close() + logger.info("Closing targetDB") def check_duplicates( @@ -1950,40 +2014,36 @@ def update_input_catalog_active( >>> update_input_catalog_active(123, True, config, commit=True, verbose=True) """ - db = TargetDB(**config["targetdb"]["db"]) - db.connect() - df = pd.DataFrame( {"input_catalog_id": [input_catalog_id], "active": [active_flag]}, # index="input_catalog_id", ) - if verbose: - logger.info( - f"Updating input_catalog_id {input_catalog_id} to active={active_flag}" - ) - df_res = db.fetch_by_id( - "input_catalog", - input_catalog_id=input_catalog_id, - ) - logger.info(f"Original input_catalog table: \n{df_res}") - - if commit: - logger.info( - f"Updating input_catalog_id {input_catalog_id} to active={active_flag}" - ) - else: - logger.info( - f"Updating input_catalog_id {input_catalog_id} to active={active_flag} (dry run)" - ) + with TargetDB(**config["targetdb"]["db"]) as db: + if verbose: + logger.info( + f"Updating input_catalog_id {input_catalog_id} to active={active_flag}" + ) + df_res = db.fetch_by_id( + "input_catalog", + input_catalog_id=input_catalog_id, + ) + logger.info(f"Original input_catalog table: \n{df_res}") - db.update("input_catalog", df, dry_run=not commit) + if commit: + logger.info( + f"Updating input_catalog_id {input_catalog_id} to active={active_flag}" + ) + else: + logger.info( + f"Updating input_catalog_id {input_catalog_id} to active={active_flag} (dry run)" + ) - if verbose: - df_res = db.fetch_by_id( - "input_catalog", - input_catalog_id=input_catalog_id, - ) - logger.info(f"Updated input_catalog table: \n{df_res}") + db.update("input_catalog", df, dry_run=not commit) - db.close() + if verbose: + df_res = db.fetch_by_id( + "input_catalog", + input_catalog_id=input_catalog_id, + ) + logger.info(f"Updated input_catalog table: \n{df_res}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_alembic.py b/tests/integration/test_alembic.py new file mode 100644 index 0000000..2a0b20b --- /dev/null +++ b/tests/integration/test_alembic.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Check that the alembic `env.py` scripts build their URL from TARGETDB_CONF. + +Only `alembic/local_test/` is exercised, and only against the throwaway +container -- never a deployment target. `alembic current` is enough: it opens a +connection using the URL `env.py` assembled, which is exactly the part that +changed. No upgrade is attempted. + +These tests assert on the connection, not on alembic's exit status. +`local_test`'s revision history is broken independently of any of this: +`80f8276e2ee7` names a `down_revision` (`ecfad41204d1`) that is not in the +repository, so alembic raises `KeyError` once it walks the revision map -- +after the database connection has already been made. + +The no-TARGETDB_CONF fallback to `alembic.ini` is covered by +`tests/test_targetdb.py::TestGetAlembicUrl`, which needs no server. +""" + +import os +import subprocess +import sys + +import pytest + +from targetdb.utils import load_config + +from .conftest import REPO_ROOT + +ALEMBIC_DIR = REPO_ROOT / "alembic" / "local_test" + + +def run_alembic(*args, env=None): + return subprocess.run( + [sys.executable, "-m", "alembic", *args], + cwd=ALEMBIC_DIR, + env={**os.environ, **(env or {})}, + capture_output=True, + text=True, + ) + + +@pytest.fixture +def alembic_ini_exists(): + if not (ALEMBIC_DIR / "alembic.ini").exists(): + pytest.skip(f"no alembic.ini in {ALEMBIC_DIR}") + + +def test_current_connects_via_targetdb_conf(db_config, schema, alembic_ini_exists): + """env.py must reach the container using only the URL built from the TOML. + + "Context impl PostgresqlImpl" is logged from context.configure(), which + run_migrations_online() only reaches once connectable.connect() has + succeeded -- so it is proof the connection was made. + """ + result = run_alembic("current", env={"TARGETDB_CONF": str(db_config)}) + combined = result.stdout + result.stderr + + assert "Context impl PostgresqlImpl" in combined, combined + for failure in [ + "password authentication failed", + "could not connect", + "OperationalError", + ]: + assert failure not in combined, combined + + +def test_percent_in_password_is_not_interpolated( + db_config, schema, work_dir, alembic_ini_exists +): + """A "%" in the password must reach libpq untouched. + + ConfigParser reads "%" as an interpolation marker, which is why env.py + builds the engine directly instead of going through + config.set_main_option(). The wrong password means the connection is + refused -- the point is *how*: an authentication failure proves the URL was + assembled and handed over intact, whereas an interpolation error would mean + it never got that far. + """ + config = load_config(str(db_config))["targetdb"]["db"] + conf_path = work_dir / "percent_password.toml" + conf_path.write_text( + f"""\ +[targetdb.db] +dialect = "{config["dialect"]}" +user = "{config["user"]}" +password = "pa%%ss" +host = "{config["host"]}" +port = {config["port"]} +dbname = "{config["dbname"]}" +""" + ) + + result = run_alembic("current", env={"TARGETDB_CONF": str(conf_path)}) + combined = result.stdout + result.stderr + + assert result.returncode != 0 + assert "password authentication failed" in combined, combined + assert "Interpolation" not in combined, combined diff --git a/tests/integration/test_dry_run.py b/tests/integration/test_dry_run.py new file mode 100644 index 0000000..f7342ee --- /dev/null +++ b/tests/integration/test_dry_run.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Prove the `--commit` contract against a real PostgreSQL server. + +The CLI defaults to `--commit False`, so every write path has to roll back +unless the flag is passed. Since `TargetDB` moved onto +`pfs.utils.database.db.DB` that guarantee rests on two things only a live +server can demonstrate: + +* `TargetDB.connection()` opens its transaction eagerly, because + `pandas.DataFrame.to_sql` starts *and commits* one of its own when handed a + connection that is not already in a transaction, and +* the `COPY` fast path (`_psql_insert_copy`) writes through a raw psycopg + cursor, so it has to be enrolled in that same transaction to be undone. + +Every test here restores the database before it returns: the fixtures in +conftest.py are session-scoped and other modules assert exact row counts on +them. +""" + +import pandas as pd +import pytest +from sqlalchemy import text + +from targetdb import TargetDB +from targetdb.utils import load_config + +from .conftest import EXAMPLES_DATA, count_rows, run_cli + +PROPOSAL_CSV = EXAMPLES_DATA / "proposals.csv" +# Prefix used by the rows these tests create, so cleanup can find them. +PROBE_PREFIX = "ZZZ-DRYRUN" +TARGET_CATALOG_ID = 1004 + + +@pytest.fixture +def probe_proposals(master_data, engine, tmp_path): + """A CSV of proposals that do not exist yet, removed again afterwards. + + Re-inserting the example rows would fail on the primary key inside COPY + itself, which says nothing about whether the transaction was committed. + """ + df = pd.read_csv(PROPOSAL_CSV).head(2).copy() + df["proposal_id"] = [f"{PROBE_PREFIX}-{i}" for i in range(len(df))] + df["group_id"] = [f"o99{i:03d}" for i in range(len(df))] + csv_path = tmp_path / "probe_proposals.csv" + df.to_csv(csv_path, index=False) + + yield csv_path, len(df) + + with engine.connect() as conn: + conn.execute( + text("DELETE FROM proposal WHERE proposal_id LIKE :p"), + {"p": f"{PROBE_PREFIX}%"}, + ) + conn.commit() + + +def read_active_flag(engine, input_catalog_id): + with engine.connect() as conn: + return conn.execute( + text("SELECT active FROM input_catalog WHERE input_catalog_id = :i"), + {"i": input_catalog_id}, + ).scalar_one() + + +def test_insert_without_commit_writes_nothing(engine, db_config, probe_proposals): + """The raw-cursor COPY write must be rolled back with the transaction.""" + csv_path, _ = probe_proposals + before = count_rows(engine, "proposal") + + run_cli("insert", csv_path, "-c", db_config, "-t", "proposal") + + assert count_rows(engine, "proposal") == before + + +def test_insert_with_commit_writes_rows(engine, db_config, probe_proposals): + csv_path, n_rows = probe_proposals + before = count_rows(engine, "proposal") + + run_cli("insert", csv_path, "-c", db_config, "-t", "proposal", "--commit") + + assert count_rows(engine, "proposal") == before + n_rows + + +def test_update_without_commit_changes_nothing(engine, master_data, db_config): + before = read_active_flag(engine, TARGET_CATALOG_ID) + + run_cli( + "update-catalog-active", TARGET_CATALOG_ID, str(not before), "-c", db_config + ) + + assert read_active_flag(engine, TARGET_CATALOG_ID) == before + + +def test_update_with_commit_changes_the_value(engine, master_data, db_config): + before = read_active_flag(engine, TARGET_CATALOG_ID) + flipped = not before + + run_cli( + "update-catalog-active", + TARGET_CATALOG_ID, + str(flipped), + "-c", + db_config, + "--commit", + ) + assert read_active_flag(engine, TARGET_CATALOG_ID) == flipped + + # Restore, so this module leaves the session fixtures as it found them. + run_cli( + "update-catalog-active", + TARGET_CATALOG_ID, + str(before), + "-c", + db_config, + "--commit", + ) + assert read_active_flag(engine, TARGET_CATALOG_ID) == before + + +def test_execute_query_honours_dry_run(engine, master_data, db_config): + """execute_query() delegates to DB.commit(), which goes through + connection() -- the single place the dry-run decision is made.""" + config = load_config(str(db_config)) + + with engine.connect() as conn: + before = conn.execute( + text( + "SELECT count(*) FROM proposal_category WHERE proposal_category_name = 'dry-run probe'" + ) + ).scalar_one() + assert before == 0 + + with TargetDB(**config["targetdb"]["db"]) as db: + db.execute_query( + "UPDATE proposal_category SET proposal_category_name = 'dry-run probe'", + dry_run=True, + ) + + with engine.connect() as conn: + after = conn.execute( + text( + "SELECT count(*) FROM proposal_category WHERE proposal_category_name = 'dry-run probe'" + ) + ).scalar_one() + assert after == 0 diff --git a/tests/integration/test_targetdb_api.py b/tests/integration/test_targetdb_api.py new file mode 100644 index 0000000..c88a7cd --- /dev/null +++ b/tests/integration/test_targetdb_api.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Exercise the `TargetDB` public API the way downstream packages call it. + +`ets_pointing` and `pfs_obsproc_planning_tools` use `connect()` / `close()` / +`fetch_query()` / `fetch_all()` / `fetch_by_id()` and discard what `connect()` +returns. Reproducing that shape here keeps the contract under test inside this +repository, without needing either package installed. +""" + +import pandas as pd +import pytest +from sqlalchemy import create_engine, text + +from targetdb import TargetDB, models +from targetdb.utils import get_url_object, load_config + + +@pytest.fixture +def db(db_config, master_data): + config = load_config(str(db_config)) + database = TargetDB(**config["targetdb"]["db"]) + yield database + database.close() + + +def test_downstream_call_sequence(db): + """The exact shape used in the downstream dbutils.py modules.""" + db.connect() # return value deliberately discarded + df = db.fetch_query("SELECT * FROM proposal") + assert isinstance(df, pd.DataFrame) + assert not df.empty + db.close() + + +def test_connect_does_not_leak_a_connection(db): + """DB.connect() checks a connection out of the pool and hands it over; the + override must not, since every caller throws the return value away.""" + db.connect() + assert db.engine.pool.checkedout() == 0 + + +def test_query_methods_return_connections_to_the_pool(db): + db.connect() + db.fetch_query("SELECT 1") + db.fetch_all("proposal") + db.fetch_by_id("proposal", proposal_id="S21B-EN01") + assert db.engine.pool.checkedout() == 0 + + +def test_close_releases_server_side_connections(db, db_config): + """Without this, `drop-db` fails with "database is being accessed by other + users".""" + db.connect() + db.fetch_query("SELECT 1") + + config = load_config(str(db_config)) + dbname = config["targetdb"]["db"]["dbname"] + # A separate engine, so counting does not observe its own connection as one + # of TargetDB's. + observer = create_engine(get_url_object(config)) + try: + with observer.connect() as conn: + before = conn.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = :d AND pid <> pg_backend_pid()" + ), + {"d": dbname}, + ).scalar_one() + assert before >= 1 + + db.close() + + with observer.connect() as conn: + after = conn.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = :d AND pid <> pg_backend_pid()" + ), + {"d": dbname}, + ).scalar_one() + assert after == before - 1 + finally: + observer.dispose() + + +def test_engine_is_reusable_after_close(db): + """Engine.dispose() replaces the pool rather than invalidating the engine, + so the instance cached in pfs.utils.database.db._DB_ENGINES stays usable.""" + db.connect() + db.close() + assert not db.fetch_all("proposal").empty + + +def test_fetch_all_column_names_and_order_match_the_model(db): + """join_backref_values() merges these results into user DataFrames, so a + renamed or reordered column would break things silently.""" + df = db.fetch_all("proposal") + expected = list(models.proposal.__table__.columns.keys()) + assert list(df.columns) == expected + + +@pytest.mark.parametrize("table", ["proposal", "input_catalog", "target_type"]) +def test_fetch_all_matches_a_direct_count(db, engine, table): + from .conftest import count_rows + + assert len(db.fetch_all(table)) == count_rows(engine, table) + + +def test_fetch_by_id_filters_on_every_keyword(db): + df = db.fetch_by_id("proposal", proposal_id="S21B-EN01") + assert len(df) == 1 + assert df["proposal_id"].iloc[0] == "S21B-EN01" + + assert db.fetch_by_id("proposal", proposal_id="does-not-exist").empty + + # Two keywords must be ANDed, not the last one winning. + assert db.fetch_by_id( + "proposal", proposal_id="S21B-EN01", pi_last_name="Nobody" + ).empty + + +def test_fetch_by_id_columns_match_fetch_all(db): + assert list(db.fetch_by_id("proposal", proposal_id="S21B-EN01").columns) == list( + db.fetch_all("proposal").columns + ) + + +def test_context_manager_closes_the_engine(db_config, master_data): + config = load_config(str(db_config)) + with TargetDB(**config["targetdb"]["db"]) as database: + assert not database.fetch_all("proposal").empty + assert database.engine.pool.checkedout() == 0 diff --git a/tests/test_targetdb.py b/tests/test_targetdb.py new file mode 100644 index 0000000..64e8687 --- /dev/null +++ b/tests/test_targetdb.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Unit tests for `TargetDB` and the URL helpers it shares with `targetdb.utils`. + +None of these need a database: URL construction is a pure function of the +configuration, and `connect()` only materializes a lazily-created engine. +The behaviour that does need a live server is covered by +`tests/integration/test_targetdb_api.py` and `tests/integration/test_dry_run.py`. +""" + +import pytest + +from targetdb import TargetDB +from targetdb.utils import get_alembic_url, get_url_object, normalize_drivername + +# Methods dropped when TargetDB became a subclass of pfs.utils.database.db.DB. +# reset_* stopped working under SQLAlchemy 2.x and *_by_copy used psycopg2-only +# APIs; none of the three PFS repositories called any of them. +REMOVED_METHODS = [ + "reset_all", + "reset_target", + "reset_fluxstd", + "reset_sky", + "insert_by_copy", + "fetch_by_copy", + "insert_mappings", + "rollback", +] + + +def make_config(**overrides): + db = { + "dialect": "postgresql", + "user": "admin", + "password": "secret", + "host": "db.example.org", + "port": 5433, + "dbname": "targetdb", + } + db.update(overrides) + for key in [k for k, v in db.items() if v is None]: + del db[key] + return {"targetdb": {"db": db}} + + +class TestNormalizeDrivername: + @pytest.mark.parametrize("dialect", ["postgresql", "postgres"]) + def test_bare_postgres_names_map_to_psycopg3(self, dialect): + assert normalize_drivername(dialect) == "postgresql+psycopg" + + @pytest.mark.parametrize( + "dialect", ["postgresql+psycopg", "postgresql+psycopg2", "mysql+pymysql"] + ) + def test_explicit_driver_is_passed_through(self, dialect): + assert normalize_drivername(dialect) == dialect + + +class TestUrl: + def test_url_contains_password_and_psycopg3_driver(self): + db = TargetDB(**make_config()["targetdb"]["db"]) + assert ( + db.url == "postgresql+psycopg://admin:secret@db.example.org:5433/targetdb" + ) + + def test_url_omits_password_when_not_configured(self): + """Without a password the URL matches DB.url's native shape, so libpq + resolves the credential itself via PGPASSWORD / ~/.pgpass.""" + db = TargetDB(**make_config(password=None)["targetdb"]["db"]) + assert db.url == "postgresql+psycopg://admin@db.example.org:5433/targetdb" + assert "secret" not in db.url + + def test_password_special_characters_are_escaped(self): + db = TargetDB(**make_config(password="p%w@rd")["targetdb"]["db"]) + assert db.url == ( + "postgresql+psycopg://admin:p%25w%40rd@db.example.org:5433/targetdb" + ) + + def test_explicit_dialect_is_honoured(self): + db = TargetDB(**make_config(dialect="postgresql+psycopg")["targetdb"]["db"]) + assert db.url.startswith("postgresql+psycopg://") + + +class TestGetUrlObject: + def test_applies_the_same_driver_normalization(self): + url = get_url_object(make_config()) + assert url.drivername == "postgresql+psycopg" + + def test_password_is_optional(self): + url = get_url_object(make_config(password=None)) + assert url.password is None + assert url.render_as_string(hide_password=False) == ( + "postgresql+psycopg://admin@db.example.org:5433/targetdb" + ) + + def test_matches_targetdb_url(self): + config = make_config() + url = get_url_object(config).render_as_string(hide_password=False) + assert url == TargetDB(**config["targetdb"]["db"]).url + + +class TestRequiredParameters: + @pytest.mark.parametrize("missing", ["dbname", "user"]) + def test_missing_parameter_raises_with_its_name(self, missing): + with pytest.raises(ValueError, match=f"{missing} is not provided"): + TargetDB(**make_config(**{missing: None})["targetdb"]["db"]) + + def test_missing_password_does_not_raise(self): + TargetDB(**make_config(password=None)["targetdb"]["db"]) + + +class TestApiSurface: + def test_connect_returns_none(self): + """DB.connect() hands back a pooled Connection, but every caller here + and downstream discards the return value, which would leak one + checked-out connection per call. The override must return nothing.""" + db = TargetDB(**make_config()["targetdb"]["db"]) + assert db.connect() is None + + @pytest.mark.parametrize("name", REMOVED_METHODS) + def test_removed_methods_are_gone(self, name): + db = TargetDB(**make_config()["targetdb"]["db"]) + assert not hasattr(db, name) + + @pytest.mark.parametrize( + "name", ["connect", "close", "fetch_query", "fetch_all", "fetch_by_id"] + ) + def test_downstream_methods_are_kept(self, name): + db = TargetDB(**make_config()["targetdb"]["db"]) + assert callable(getattr(db, name)) + + +class TestGetAlembicUrl: + def _write_config(self, tmp_path, password="secret"): + conf = tmp_path / "dbconf.toml" + conf.write_text( + f"""\ +[targetdb.db] +dialect = "postgresql" +user = "admin" +password = "{password}" +host = "db.example.org" +port = 5433 +dbname = "targetdb" +""" + ) + return conf + + def test_reads_the_file_named_by_targetdb_conf(self, tmp_path, monkeypatch): + monkeypatch.setenv("TARGETDB_CONF", str(self._write_config(tmp_path))) + assert get_alembic_url() == ( + "postgresql+psycopg://admin:secret@db.example.org:5433/targetdb" + ) + + def test_returns_none_without_targetdb_conf(self, monkeypatch): + """None is the signal for env.py to fall back to alembic.ini.""" + monkeypatch.delenv("TARGETDB_CONF", raising=False) + assert get_alembic_url() is None + + def test_explicit_argument_wins_over_the_environment(self, tmp_path, monkeypatch): + monkeypatch.setenv("TARGETDB_CONF", "/nonexistent/does-not-exist.toml") + conf = self._write_config(tmp_path) + assert "db.example.org" in get_alembic_url(conf_file=conf) + + def test_percent_in_password_survives(self, tmp_path, monkeypatch): + """ConfigParser would treat "%" as an interpolation marker, which is why + env.py builds the engine directly instead of calling + config.set_main_option().""" + monkeypatch.setenv( + "TARGETDB_CONF", str(self._write_config(tmp_path, password="pa%%ss")) + ) + assert get_alembic_url().endswith("@db.example.org:5433/targetdb") + assert "pa%25%25ss" in get_alembic_url() diff --git a/uv.lock b/uv.lock index 34401b5..6716197 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11, <3.13" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", -] +requires-python = "==3.12.*" [[package]] name = "alembic" @@ -20,6 +16,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/fd/68773667babd452fb48f974c4c1f6e6852c6e41bcf622c745faca1b06605/alembic-1.18.0-py3-none-any.whl", hash = "sha256:3993fcfbc371aa80cdcf13f928b7da21b1c9f783c914f03c3c6375f58efd9250", size = 260967, upload-time = "2026-01-09T21:22:25.333Z" }, ] +[[package]] +name = "astroplan" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "numpy" }, + { name = "pytz" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/80/1be3af06717245a51adaf6994b682b3ee25ace9bfe5d443d440209d627df/astroplan-0.10.1.tar.gz", hash = "sha256:39d97c3377e1630abff3a94d8c956980f77a3e809e27a0376dd7d30abe3b6959", size = 140603, upload-time = "2024-08-13T20:12:04.462Z" } + [[package]] name = "astropy" version = "7.2.0" @@ -96,11 +104,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, - { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, - { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, @@ -155,19 +158,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, @@ -197,22 +187,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, @@ -253,27 +227,34 @@ 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.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + [[package]] name = "coverage" version = "7.15.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, - { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, - { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, - { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, - { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, - { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, @@ -292,11 +273,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cssselect2" version = "0.8.0" @@ -310,6 +286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -328,6 +313,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -356,7 +353,8 @@ dependencies = [ { name = "numpy" }, { name = "openpyxl" }, { name = "pandas" }, - { name = "psycopg2-binary" }, + { name = "pfs-utils" }, + { name = "psycopg", extra = ["binary"] }, { name = "pyarrow" }, { name = "requests" }, { name = "sqlalchemy" }, @@ -392,8 +390,9 @@ requires-dist = [ { name = "numpy", specifier = ">=2.0" }, { name = "openpyxl" }, { name = "pandas" }, + { name = "pfs-utils", git = "https://github.com/Subaru-PFS/pfs_utils.git?rev=master" }, { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "psycopg2-binary" }, + { name = "psycopg", extras = ["binary"] }, { name = "pyarrow" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, @@ -403,7 +402,6 @@ requires-dist = [ { name = "sqlalchemy" }, { name = "sqlalchemy-utils" }, { name = "tabulate" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, { name = "typer" }, ] provides-extras = ["dev", "doc"] @@ -426,6 +424,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, ] +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -444,13 +459,6 @@ version = "3.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, @@ -502,7 +510,6 @@ dependencies = [ { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } wheels = [ @@ -545,6 +552,33 @@ 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.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -558,6 +592,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "lsst-utils" +version = "30.2026.2900" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "deprecated" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "structlog" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/b6/3198405f2110508a76d918b75de8a82a5ffa858ce4813f49d877b6d122a7/lsst_utils-30.2026.2900.tar.gz", hash = "sha256:7ee345e1df58030e061c78a3ae5a0f018b6a5c330a4a3f2bcac5449388c137b8", size = 94768, upload-time = "2026-07-16T08:28:50.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/e1/31ad285a8e92a1d246672e110f79d513114d30aab06a0ef826b09574fa65/lsst_utils-30.2026.2900-py3-none-any.whl", hash = "sha256:df6ebc7c65121547b239ea8734e8585465da21389cbd2fdff87cd65e673f90ff", size = 76024, upload-time = "2026-07-16T08:28:49.375Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -597,17 +649,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, @@ -621,6 +662,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -750,17 +817,6 @@ version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" }, - { url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" }, - { url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" }, - { url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" }, - { url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" }, - { url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" }, { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" }, { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" }, { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" }, @@ -772,13 +828,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" }, { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" }, { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" }, - { url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" }, - { url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" }, - { url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" }, ] [[package]] @@ -823,13 +872,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, @@ -869,23 +911,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pfs-datamodel" +version = "0.0.0" +source = { git = "https://github.com/Subaru-PFS/datamodel.git#24aa6992d5f30599324b3df60e2b3224201021ee" } +dependencies = [ + { name = "astroplan" }, + { name = "astropy" }, + { name = "lsst-utils" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "scipy" }, +] + +[[package]] +name = "pfs-instdata" +version = "1.8.88" +source = { git = "https://github.com/Subaru-PFS/pfs_instdata.git#434af74a3fb5a1a7d96fd24abfc65da442465091" } + +[[package]] +name = "pfs-utils" +version = "7.2026.3100" +source = { git = "https://github.com/Subaru-PFS/pfs_utils.git?rev=master#441699f826ef4edc6d203ee91a4aeb05b9f659e0" } +dependencies = [ + { name = "astroplan" }, + { name = "astropy" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pfs-datamodel" }, + { name = "pfs-instdata" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pytz" }, + { name = "scipy" }, + { name = "sqlalchemy" }, +] + [[package]] name = "pillow" version = "11.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, @@ -897,13 +967,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] @@ -953,33 +1016,55 @@ wheels = [ ] [[package]] -name = "psycopg2-binary" -version = "2.9.11" +name = "psutil" +version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +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/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, - { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, - { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, - { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, - { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, - { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, - { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, - { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, - { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { 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.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, ] [[package]] @@ -1006,13 +1091,6 @@ version = "22.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/30/53/04a7fdc63e6056116c9ddc8b43bc28c12cdd181b85cbeadb79278475f3ae/pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9", size = 1151151, upload-time = "2025-10-24T12:30:00.762Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/b7/18f611a8cdc43417f9394a3ccd3eace2f32183c08b9eddc3d17681819f37/pyarrow-22.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3e294c5eadfb93d78b0763e859a0c16d4051fc1c5231ae8956d61cb0b5666f5a", size = 34272022, upload-time = "2025-10-24T10:04:28.973Z" }, - { url = "https://files.pythonhosted.org/packages/26/5c/f259e2526c67eb4b9e511741b19870a02363a47a35edbebc55c3178db22d/pyarrow-22.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:69763ab2445f632d90b504a815a2a033f74332997052b721002298ed6de40f2e", size = 35995834, upload-time = "2025-10-24T10:04:35.467Z" }, - { url = "https://files.pythonhosted.org/packages/50/8d/281f0f9b9376d4b7f146913b26fac0aa2829cd1ee7e997f53a27411bbb92/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b41f37cabfe2463232684de44bad753d6be08a7a072f6a83447eeaf0e4d2a215", size = 45030348, upload-time = "2025-10-24T10:04:43.366Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e5/53c0a1c428f0976bf22f513d79c73000926cb00b9c138d8e02daf2102e18/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:35ad0f0378c9359b3f297299c3309778bb03b8612f987399a0333a560b43862d", size = 47699480, upload-time = "2025-10-24T10:04:51.486Z" }, - { url = "https://files.pythonhosted.org/packages/95/e1/9dbe4c465c3365959d183e6345d0a8d1dc5b02ca3f8db4760b3bc834cf25/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8382ad21458075c2e66a82a29d650f963ce51c7708c7c0ff313a8c206c4fd5e8", size = 48011148, upload-time = "2025-10-24T10:04:59.585Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/7caf5d21930061444c3cf4fa7535c82faf5263e22ce43af7c2759ceb5b8b/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a812a5b727bc09c3d7ea072c4eebf657c2f7066155506ba31ebf4792f88f016", size = 50276964, upload-time = "2025-10-24T10:05:08.175Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f3/cec89bd99fa3abf826f14d4e53d3d11340ce6f6af4d14bdcd54cd83b6576/pyarrow-22.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ec5d40dd494882704fb876c16fa7261a69791e784ae34e6b5992e977bd2e238c", size = 28106517, upload-time = "2025-10-24T10:05:14.314Z" }, { url = "https://files.pythonhosted.org/packages/af/63/ba23862d69652f85b615ca14ad14f3bcfc5bf1b99ef3f0cd04ff93fdad5a/pyarrow-22.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:bea79263d55c24a32b0d79c00a1c58bb2ee5f0757ed95656b01c0fb310c5af3d", size = 34211578, upload-time = "2025-10-24T10:05:21.583Z" }, { url = "https://files.pythonhosted.org/packages/b1/d0/f9ad86fe809efd2bcc8be32032fa72e8b0d112b01ae56a053006376c5930/pyarrow-22.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:12fe549c9b10ac98c91cf791d2945e878875d95508e1a5d14091a7aaa66d9cf8", size = 35989906, upload-time = "2025-10-24T10:05:29.485Z" }, { url = "https://files.pythonhosted.org/packages/b4/a8/f910afcb14630e64d673f15904ec27dd31f1e009b77033c365c84e8c1e1d/pyarrow-22.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:334f900ff08ce0423407af97e6c26ad5d4e3b0763645559ece6fbf3747d6a8f5", size = 45021677, upload-time = "2025-10-24T10:05:38.274Z" }, @@ -1071,6 +1149,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1092,7 +1179,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1149,15 +1236,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -1236,6 +1314,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, ] +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -1286,13 +1385,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, - { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, @@ -1328,6 +1420,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + [[package]] name = "tabulate" version = "0.9.0" @@ -1337,6 +1438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tinycss2" version = "1.5.1" @@ -1349,33 +1459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "traitlets" version = "5.14.3" @@ -1448,9 +1531,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { 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" }, @@ -1492,3 +1572,23 @@ 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 = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] From daced887dd7472357885f8be8cdc69022452b2ea Mon Sep 17 00:00:00 2001 From: Masato Onodera Date: Wed, 5 Aug 2026 18:23:09 +0900 Subject: [PATCH 2/7] docs: consolidate the alembic notes, and fix fallout from the optional password Follow-ups to the pfs-utils migration, found while auditing the branch for leaked credentials. No real credential was committed: every password-like string in tracked files is a placeholder, and all URL logging goes through render_as_string(), which masks it. The audit did turn up three problems: * draw_diagram() raised KeyError on a config with no password. Making the password optional left the SchemaCrawler path indexing it directly, so the very configs that change enables would crash. Omitting --password is safe: pgjdbc resolves the password from PGPASSFILE, or from ~/.pgpass via the JVM's user.home. Verified against a live server -- a password-less config plus PGPASSFILE produces a diagram, a wrong password in that file fails authentication, and no file at all reports that none was supplied. The password is now appended after the debug log rather than before, so it is no longer written to the log verbatim. * examples/docker/db-data/ is a live 48MB PostgreSQL cluster that was neither tracked nor ignored, so `git add -A` would have committed it. It holds no role secrets today (the example cluster uses trust auth), but global/ would carry a SCRAM verifier the moment one is set. Now ignored, along with the alembic run logs and local tool state. * a comment claimed DB.__init__ logs self.url; it logs only the database name. The four alembic note files are consolidated into alembic/README.md. The two deployment READMEs were byte-identical, and local_test/ held only the stock one-liner. Their 2022 debugging material is kept as lessons rather than a log: the duplicate-row SQL is generalised and paired with the UniqueViolation it explains, and memo.md yields a gotcha worth keeping -- SQLAlchemy cannot reflect expression-based indexes, so autogenerate is blind to every q3c index in this schema. Stale paths, row counts and Python 3.9 tracebacks are dropped; git history still has them. Finally, remove requirements.txt. Nothing referenced it -- not CI, Docker or the docs -- and it had drifted in both directions: it still listed psycopg2-binary and tomli, and was missing pfs-utils, psycopg and requests. Anyone trusting it would have installed the driver this branch removed. The pdm gen-requirements script that regenerates it goes too; the rest of the PDM setup is left for a separate change. Co-Authored-By: Claude Opus 5 --- .gitignore | 14 +++ CLAUDE.md | 2 +- alembic/README.md | 135 +++++++++++++++++++++ alembic/local_test/alembic/README | 1 - alembic/pfsa-db01-gb-dev/alembic/README.md | 80 ------------ alembic/pfsa-db01-gb-dev/alembic/memo.md | 22 ---- alembic/pfsa-db01-gb/alembic/README.md | 80 ------------ docs/getting_started.md | 3 + pyproject.toml | 12 -- requirements.txt | 16 --- src/targetdb/targetdb.py | 3 +- src/targetdb/utils.py | 22 +++- 12 files changed, 176 insertions(+), 214 deletions(-) create mode 100644 alembic/README.md delete mode 100644 alembic/local_test/alembic/README delete mode 100644 alembic/pfsa-db01-gb-dev/alembic/README.md delete mode 100644 alembic/pfsa-db01-gb-dev/alembic/memo.md delete mode 100644 alembic/pfsa-db01-gb/alembic/README.md delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 8e356cb..f277faa 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,17 @@ cython_debug/ # Version file generated by setuptools-scm src/targetdb/_version.py + +# Live PostgreSQL cluster created by examples/docker/docker-compose.yml. +# Never commit this: it is the server's on-disk state, and once a role has a +# password set, global/ holds its SCRAM verifier. +examples/docker/db-data/ + +# Alembic run logs kept alongside the migration directories +alembic/*/log.alembic-* + +# Local scratch and tool state +tmp/ +.pyscn/ +.pdm-python +.python-version diff --git a/CLAUDE.md b/CLAUDE.md index 8b2e376..9e8a130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,4 +52,4 @@ CLI flags default to `--commit False` (dry-run). Pass `--commit` explicitly to w - `connection()` opens its transaction eagerly (`conn.begin()`) and rolls back when `_dry_run` is set. The eager begin matters because `pandas.to_sql` starts *and commits* a transaction of its own when handed a connection that is not already in one, which would defeat the rollback. Every dry-run path funnels through here. `tests/integration/test_dry_run.py` guards this. - **psycopg3 only.** `dialect = "postgresql"` in a config file is rewritten to `postgresql+psycopg` by `normalize_drivername()` in `utils.py`, the single choke point that every code path (`get_url_object`, `TargetDB.__init__`) passes through. psycopg2 is not a dependency. - **Inserts drop DataFrame columns the target table does not have** (`TargetDB._drop_unknown_columns`). `add_backref_values()` resolves foreign keys by merging whole reference tables in, leaving columns like `partner_name` behind; the old ORM path ignored unmapped keys and `COPY` does not. -- **Alembic has separate directories per deployment target** under `alembic/` (`local_test/`, `pfsa-db01-gb/`, `pfsa-db01-gb-dev/`). Each has its own `alembic.ini`. Run migrations from within the appropriate subdirectory. `env.py` builds the URL from the TOML file named by the `TARGETDB_CONF` environment variable, falling back to `sqlalchemy.url` in `alembic.ini` when it is unset — so credentials live in one place: `TARGETDB_CONF=dbconf.toml alembic upgrade head`. Do not use `config.set_main_option()` for the URL: ConfigParser reads `%` as an interpolation marker and would mangle passwords containing it. +- **Alembic has separate directories per deployment target** under `alembic/` (`local_test/`, `pfsa-db01-gb/`, `pfsa-db01-gb-dev/`). Each has its own `alembic.ini`. Run migrations from within the appropriate subdirectory. `env.py` builds the URL from the TOML file named by the `TARGETDB_CONF` environment variable, falling back to `sqlalchemy.url` in `alembic.ini` when it is unset — so credentials live in one place: `TARGETDB_CONF=dbconf.toml alembic upgrade head`. Do not use `config.set_main_option()` for the URL: ConfigParser reads `%` as an interpolation marker and would mangle passwords containing it. See `alembic/README.md` for the workflow and known gotchas (q3c expression indexes are invisible to autogenerate; `local_test/`'s revision chain is broken). diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 0000000..550ff31 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,135 @@ +# Alembic migrations for targetdb + +Schema changes to `targetdb` are applied with +[Alembic](https://alembic.sqlalchemy.org/en/latest/tutorial.html). The models in +`src/targetdb/models/` are the source of truth; the revision scripts here record +how each deployed database was brought to match them. + +## Layout + +Each deployment target has its own directory, with its own `alembic.ini` and its +own independent revision history. **Run alembic from inside the directory for +the database you are migrating** — the histories are not interchangeable. + +| Directory | Database | +| ------------------- | ----------------------------------------------- | +| `local_test/` | A local database used for trying migrations out | +| `pfsa-db01-gb/` | Production | +| `pfsa-db01-gb-dev/` | Development | + +The revision scripts under `*/alembic/versions/` are historical records. They +are deliberately excluded from `ruff` and `black` (see `pyproject.toml`) and +should not be reformatted or "cleaned up" — only the `env.py` scripts are +linted. Once a revision has been applied to a deployed database, edit it only +to fix something that is actually broken. + +## Configuring the connection + +Set `TARGETDB_CONF` to a targetdb TOML configuration file — the same one the +CLI takes with `-c` — and `env.py` builds the connection URL from it: + +```bash +cd alembic/pfsa-db01-gb +TARGETDB_CONF=~/database_configs/config_targetdb.toml alembic upgrade head +``` + +This keeps the credentials in one place rather than duplicating them into every +`alembic.ini`, and the psycopg3 driver is selected automatically. If +`TARGETDB_CONF` is unset, `env.py` falls back to the `sqlalchemy.url` entry in +`alembic.ini`, which is a credentials-free placeholder. + +Passwords may be left out of the TOML entirely, in which case libpq resolves +them from `PGPASSWORD` or `~/.pgpass`. See `docs/getting_started.md`. + +## Workflow + +1. **Change the models** under `src/targetdb/models/`. + +2. **Generate a revision.** Autogenerate compares the models against the live + database, so this needs a database that is currently at `head`: + + ```bash + TARGETDB_CONF= alembic revision --autogenerate -m "Add columns" + ``` + +3. **Read the generated file** in `versions/` before running it. Autogenerate is + a starting point, not an answer — see the gotchas below for what it gets + wrong on this schema. + +4. **Apply it.** + + ```bash + TARGETDB_CONF= alembic upgrade head + ``` + + Check where you are at any time with `alembic current`, and what exists with + `alembic history`. + +Try a migration against `local_test/` (or a throwaway container — see +`tests/docker/`) before running it on a deployed database. + +## Gotchas + +### A unique constraint cannot be added while duplicates exist + +`CREATE UNIQUE INDEX` fails outright if the table already violates the +constraint, and the migration aborts partway: + +```text +UniqueViolation: could not create unique index "uq_sky_obj_id_input_catalog_id_version" +DETAIL: Key (obj_id, input_catalog_id, version)=(44332, 1002, 20220915) is duplicated. +``` + +Count the offending rows first, and decide what to do with them before writing +the migration: + +```sql +-- How many groups are duplicated, and by how much? +SELECT ct, count(*) AS n_groups +FROM ( + SELECT obj_id, input_catalog_id, version, count(*) AS ct + FROM sky + GROUP BY obj_id, input_catalog_id, version + HAVING count(*) > 1 +) sub +GROUP BY 1 ORDER BY 1; + +-- Which rows are they? +SELECT obj_id, ra, dec, input_catalog_id, version, count(*) +FROM sky +GROUP BY obj_id, ra, dec, input_catalog_id, version +HAVING count(*) > 1; +``` + +Substitute the table and the intended constraint columns. `sky` has run to +billions of rows per `version`, so on it and `fluxstd` expect these queries to +take a while, and restrict them by `version` where you can. + +### Autogenerate does not see the q3c indexes + +The positional tables are indexed on the expression `q3c_ang2ipix(ra, dec)`, and +SQLAlchemy cannot reflect expression-based indexes: + +```text +SAWarning: Skipped unsupported reflection of expression-based index sky_q3c_ang2ipix_idx +``` + +The warning is harmless, but it means autogenerate will neither create nor +notice a change to those indexes. Write that DDL by hand. + +Identity/serial sequences are likewise reported as skipped +(`Detected sequence named 'fluxstd_fluxstd_id_seq' ... assuming SERIAL and +omitting`); this is normal and needs nothing. + +### `%` in a password + +`env.py` builds the engine directly rather than going through +`config.set_main_option()`, because ConfigParser reads `%` as an interpolation +marker and would corrupt a password containing one. Keep it that way. + +### `local_test/` has a broken revision chain + +`80f8276e2ee7` names a `down_revision` (`ecfad41204d1`) that is not present in +the repository, so any command that walks the full revision map — `alembic +current` and `alembic history` included — raises `KeyError`. This predates the +move to `TARGETDB_CONF`; connecting to the database itself works. diff --git a/alembic/local_test/alembic/README b/alembic/local_test/alembic/README deleted file mode 100644 index 2500aa1..0000000 --- a/alembic/local_test/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. diff --git a/alembic/pfsa-db01-gb-dev/alembic/README.md b/alembic/pfsa-db01-gb-dev/alembic/README.md deleted file mode 100644 index 3fcbccd..0000000 --- a/alembic/pfsa-db01-gb-dev/alembic/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Generic single-database configuration. - -## Basic workflow - -Reference: https://alembic.sqlalchemy.org/en/latest/tutorial.html - -### Modify code of targetdb - - -### Run alembic - -Create a new revision file. - -```bash -alembic -c revision --autogenerate -m "Add columns" -``` - -Check the output file under the `versions` directory. - -Upgrade to the new revision. - -```bash -alembic -c upgrade head -``` - -### count duplicated rows - -```sql -SELECT COUNT(*) -FROM sky a -JOIN sky b ON a.obj_id = b.obj_id - AND a.input_catalog_id = b.input_catalog_id - AND a.version = b.version - AND a.sky_id < b.sky_id -; -``` - -```sql -SELECT ct, count(*) AS ct_ct FROM (SELECT sky_id, input_catalog_id, version, count(*) AS ct FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; - -SELECT ct, count(*) AS ct_ct FROM (SELECT obj_id, input_catalog_id, version, count(*) AS ct FROM sky WHERE version='20221031' GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; - -SELECT sky_id, input_catalog_id, version, count(*) FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1; -SELECT DISTINCT version FROM sky; - -SELECT obj_id, ra, dec, input_catalog_id, version, count(*) FROM sky GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1; - -SELECT version, count(*) AS ct FROM sky GROUP BY 1; - version | ct -----------+------------ - 20220427 | 2747 - 20220915 | 2004836366 - 20221031 | 897209700 -(3 rows) -``` - -### delete duplicated rows - -```sql - -``` - - -### error - -``` -> time alembic -c /work/monodera/Subaru-PFS/alembic_configs/alembic_pfsa-db01-gb.ini upgrade head -INFO [alembic.runtime.migration] Context impl PostgresqlImpl. -INFO [alembic.runtime.migration] Will assume transactional DDL. -INFO [alembic.runtime.migration] Running upgrade 6aec47e0f339 -> eeca77238d00, Add an index and uniqueconstraint on sky - by obj_id, input_catalog_id, and version -Traceback (most recent call last): - File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1964, in _exec_single -_context - self.dialect.do_execute( - File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/default.py", line 748, in do_execute - cursor.execute(statement, parameters) -psycopg2.errors.UniqueViolation: could not create unique index "uq_sky_obj_id_input_catalog_id_version" -DETAIL: Key (obj_id, input_catalog_id, version)=(44332, 1002, 20220915) is duplicated. -``` diff --git a/alembic/pfsa-db01-gb-dev/alembic/memo.md b/alembic/pfsa-db01-gb-dev/alembic/memo.md deleted file mode 100644 index 1e76497..0000000 --- a/alembic/pfsa-db01-gb-dev/alembic/memo.md +++ /dev/null @@ -1,22 +0,0 @@ - -monodera on pfsa-usr01-gb: .../ets_target_database/alembic/pfsa-db01-gb [ tickets/OBSPROC-31/monodera][][ v3.9.13(venv39)] - alembic -c /work/monodera/Subaru-PFS/alembic_configs/alembic_pfsa-db01-gb.ini revision --autogenerate -m "Add columns for photometric errors in the fluxstd table" -INFO [alembic.runtime.migration] Context impl PostgresqlImpl. -INFO [alembic.runtime.migration] Will assume transactional DDL. -INFO [alembic.ddl.postgresql] Detected sequence named 'fluxstd_fluxstd_id_seq' as owned by integer column 'fluxstd(fluxstd_id)', assuming SERIAL and omitting -INFO [alembic.ddl.postgresql] Detected sequence named 'sky_sky_id_seq' as owned by integer column 'sky(sky_id)', assuming SERIAL and omitting -alembic/env.py:71: SAWarning: Skipped unsupported reflection of expression-based index sky_q3c_ang2ipix_idx - context.run_migrations() -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_g' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_r' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_i' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_z' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_y' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_mag_error_j' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_g' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_r' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_i' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_z' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_y' -INFO [alembic.autogenerate.compare] Detected added column 'fluxstd.psf_flux_error_j' - Generating /work/monodera/Subaru-PFS/ets_target_database/alembic/pfsa-db01-gb/alembic/versions/20221020-174527_c4e16db3e7e1_add_columns_for_photometric_errors_in_.py ... done diff --git a/alembic/pfsa-db01-gb/alembic/README.md b/alembic/pfsa-db01-gb/alembic/README.md deleted file mode 100644 index 3fcbccd..0000000 --- a/alembic/pfsa-db01-gb/alembic/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Generic single-database configuration. - -## Basic workflow - -Reference: https://alembic.sqlalchemy.org/en/latest/tutorial.html - -### Modify code of targetdb - - -### Run alembic - -Create a new revision file. - -```bash -alembic -c revision --autogenerate -m "Add columns" -``` - -Check the output file under the `versions` directory. - -Upgrade to the new revision. - -```bash -alembic -c upgrade head -``` - -### count duplicated rows - -```sql -SELECT COUNT(*) -FROM sky a -JOIN sky b ON a.obj_id = b.obj_id - AND a.input_catalog_id = b.input_catalog_id - AND a.version = b.version - AND a.sky_id < b.sky_id -; -``` - -```sql -SELECT ct, count(*) AS ct_ct FROM (SELECT sky_id, input_catalog_id, version, count(*) AS ct FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; - -SELECT ct, count(*) AS ct_ct FROM (SELECT obj_id, input_catalog_id, version, count(*) AS ct FROM sky WHERE version='20221031' GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1) sub GROUP BY 1 ORDER BY 1; - -SELECT sky_id, input_catalog_id, version, count(*) FROM sky GROUP BY sky_id, input_catalog_id, version HAVING count(*) > 1; -SELECT DISTINCT version FROM sky; - -SELECT obj_id, ra, dec, input_catalog_id, version, count(*) FROM sky GROUP BY obj_id, input_catalog_id, version HAVING count(*) > 1; - -SELECT version, count(*) AS ct FROM sky GROUP BY 1; - version | ct -----------+------------ - 20220427 | 2747 - 20220915 | 2004836366 - 20221031 | 897209700 -(3 rows) -``` - -### delete duplicated rows - -```sql - -``` - - -### error - -``` -> time alembic -c /work/monodera/Subaru-PFS/alembic_configs/alembic_pfsa-db01-gb.ini upgrade head -INFO [alembic.runtime.migration] Context impl PostgresqlImpl. -INFO [alembic.runtime.migration] Will assume transactional DDL. -INFO [alembic.runtime.migration] Running upgrade 6aec47e0f339 -> eeca77238d00, Add an index and uniqueconstraint on sky - by obj_id, input_catalog_id, and version -Traceback (most recent call last): - File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1964, in _exec_single -_context - self.dialect.do_execute( - File "/work/monodera/pyvenvs/venv39/lib/python3.9/site-packages/sqlalchemy/engine/default.py", line 748, in do_execute - cursor.execute(statement, parameters) -psycopg2.errors.UniqueViolation: could not create unique index "uq_sky_obj_id_input_catalog_id_version" -DETAIL: Key (obj_id, input_catalog_id, version)=(44332, 1002, 20220915) is duplicated. -``` diff --git a/docs/getting_started.md b/docs/getting_started.md index a391cb8..9cc9f40 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -210,6 +210,9 @@ secrets in it can be kept in the repository. When `TARGETDB_CONF` is not set, `env.py` falls back to the `sqlalchemy.url` entry in `alembic.ini` as before. +See `alembic/README.md` for the full migration workflow and the gotchas specific +to this schema. + ## Running Tests Locally The unit tests (`tests/test_*.py`, excluding `tests/integration/`) do not require a database diff --git a/pyproject.toml b/pyproject.toml index 238acee..cacb8b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,18 +82,6 @@ extend-exclude = "(src/targetdb/_version\\.py|alembic/.*/versions/)" [tool.pdm.scripts] serve-doc = { shell = "mkdocs serve", help = "Start the dev server for doc preview" } build-doc = { shell = "mkdocs build", help = "Build documentation" } -gen-requirements = { cmd = [ - "pdm", - "export", - "--format", - "requirements", - "--without-hashes", - "--pyproject", - "--dev", - "--output", - "requirements.txt", - "--verbose", -], help = "Generate requirements.txt" } [tool.ruff] # Matches requires-python (">=3.12"). Do not raise this without raising diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d03e856..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -# This file is @generated by PDM. -# Please do not edit it manually. - -alembic -astropy -loguru -numpy>=2.0 -openpyxl -pandas -psycopg2-binary -pyarrow -sqlalchemy -sqlalchemy-utils -tabulate -tomli>=1.1.0; python_version < "3.11" -typer diff --git a/src/targetdb/targetdb.py b/src/targetdb/targetdb.py index fa89079..c354b52 100644 --- a/src/targetdb/targetdb.py +++ b/src/targetdb/targetdb.py @@ -42,7 +42,8 @@ def __init__( logger.error(f"{name} is not provided") raise ValueError(f"{name} is not provided") - # Set before super().__init__() because DB.__init__ logs self.url. + # Set before super().__init__(): the url property below reads both, and + # anything the base class does must already see them. # `password` is deliberately optional: when it is None it is left out # of the URL and libpq resolves it (PGPASSWORD, then ~/.pgpass), which # is exactly the arrangement DB itself documents. diff --git a/src/targetdb/utils.py b/src/targetdb/utils.py index 49eca04..80ecc74 100644 --- a/src/targetdb/utils.py +++ b/src/targetdb/utils.py @@ -387,7 +387,6 @@ def draw_diagram( f"--database={config['targetdb']['db']['dbname']}", "--schemas=public", f"--user={config['targetdb']['db']['user']}", - f"--password={config['targetdb']['db']['password']}", f"--info-level={sc_info_level}", f"--log-level={sc_log_level}", "--portable-names", @@ -396,7 +395,28 @@ def draw_diagram( f"--output-file={outfile}", "--no-remarks", ] + + # Logged before the password is appended below, so it is not written to + # the log verbatim. It remains visible in the process list while + # SchemaCrawler runs. logger.debug(f"{comm}") + + # `password` is optional in the config file, so it cannot be indexed + # directly -- doing so raised KeyError for password-less configs. + # Omitting --password is fine: SchemaCrawler connects over JDBC, and + # pgjdbc resolves the password from PGPASSFILE, or from ~/.pgpass via + # the JVM's user.home. Note that user.home is not taken from $HOME on + # macOS, so overriding HOME does not redirect the lookup -- use + # PGPASSFILE for that. + password = config["targetdb"]["db"].get("password") + if password is None: + logger.debug( + "No password in the config file; letting SchemaCrawler resolve " + "it from PGPASSFILE or ~/.pgpass." + ) + else: + comm.append(f"--password={password}") + subprocess.run(comm, shell=False) elif generator == "tbls": url_object = get_url_object(config) From dbf6f1a6e6ea5747cb8ab1a066a21e0c7ca32f51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Aug 2026 09:26:11 +0000 Subject: [PATCH 3/7] chore: update ER diagrams [skip ci] --- diagrams/erdiagram_targetdb_latest.pdf | Bin 47903 -> 47888 bytes docs/tbls/public.fluxstd.md | 4 ++-- docs/tbls/public.sky.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/diagrams/erdiagram_targetdb_latest.pdf b/diagrams/erdiagram_targetdb_latest.pdf index 271f109ac93be7bd7800dd51b2646fde7cd417ce..27fa4cfa603c385bbf29e2f6f6fe05918e086221 100644 GIT binary patch delta 13351 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 DYgkgp diff --git a/docs/tbls/public.fluxstd.md b/docs/tbls/public.fluxstd.md index 8a0e7d2..3ca7a85 100644 --- a/docs/tbls/public.fluxstd.md +++ b/docs/tbls/public.fluxstd.md @@ -101,10 +101,10 @@ | ---- | ---------- | | fluxstd_pkey | CREATE UNIQUE INDEX fluxstd_pkey ON public.fluxstd USING btree (fluxstd_id) | | uq_obj_id_input_catalog_id_version | CREATE UNIQUE INDEX uq_obj_id_input_catalog_id_version ON public.fluxstd USING btree (obj_id, input_catalog_id, version) | -| ix_fluxstd_version_fluxstdid | CREATE INDEX ix_fluxstd_version_fluxstdid ON public.fluxstd USING btree (version, fluxstd_id) | -| ix_fluxstd_version | CREATE INDEX ix_fluxstd_version ON public.fluxstd USING btree (version) | | fluxstd_q3c_ang2ipix_idx | CREATE INDEX fluxstd_q3c_ang2ipix_idx ON public.fluxstd USING btree (q3c_ang2ipix(ra, "dec")) | +| ix_fluxstd_version | CREATE INDEX ix_fluxstd_version ON public.fluxstd USING btree (version) | | ix_fluxstd_input_catalog_fluxstdid | CREATE INDEX ix_fluxstd_input_catalog_fluxstdid ON public.fluxstd USING btree (input_catalog_id, fluxstd_id) | +| ix_fluxstd_version_fluxstdid | CREATE INDEX ix_fluxstd_version_fluxstdid ON public.fluxstd USING btree (version, fluxstd_id) | ## Relations diff --git a/docs/tbls/public.sky.md b/docs/tbls/public.sky.md index 1acf434..8df7986 100644 --- a/docs/tbls/public.sky.md +++ b/docs/tbls/public.sky.md @@ -34,9 +34,9 @@ | ---- | ---------- | | sky_pkey | CREATE UNIQUE INDEX sky_pkey ON public.sky USING btree (sky_id) | | sky_obj_id_input_catalog_id_version_key | CREATE UNIQUE INDEX sky_obj_id_input_catalog_id_version_key ON public.sky USING btree (obj_id, input_catalog_id, version) | -| ix_sky_input_catalog_id | CREATE INDEX ix_sky_input_catalog_id ON public.sky USING btree (input_catalog_id) | | ix_sky_version | CREATE INDEX ix_sky_version ON public.sky USING btree (version) | | sky_q3c_ang2ipix_idx | CREATE INDEX sky_q3c_ang2ipix_idx ON public.sky USING btree (q3c_ang2ipix(ra, "dec")) | +| ix_sky_input_catalog_id | CREATE INDEX ix_sky_input_catalog_id ON public.sky USING btree (input_catalog_id) | ## Relations From 7b290e8a19b33164ab7586d2f3d99675e153e5f3 Mon Sep 17 00:00:00 2001 From: Masato Onodera Date: Wed, 5 Aug 2026 18:34:36 -1000 Subject: [PATCH 4/7] feat: add sane DEFAULT_* connection parameters to TargetDB TargetDB.__init__ carried non-None defaults (host="localhost", etc.) and raised ValueError when dbname/user were omitted, so the class-level DEFAULT_HOST/DEFAULT_USER/DEFAULT_DBNAME/DEFAULT_PORT that DB.__init__ resolves omitted arguments from were unreachable, and the inherited set_default_connection() classmethod was a silent no-op. Bring TargetDB in line with the OpDB/QaDB convention in pfs.utils.database.db: declare the four DEFAULT_* attributes and default every __init__ parameter to None. DEFAULT_USER is the read-only `obsproc` account rather than the `pfs` that OpDB/QaDB use, since every write path in this package already passes an explicit, privileged user from a config file -- a bare TargetDB() can now read production but never write to it. Confirmed against the live database that obsproc has SELECT-only grants and that INSERT is rejected. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 3 +- alembic/README.md | 8 +++++ docs/getting_started.md | 37 ++++++++++++++++++++-- src/targetdb/targetdb.py | 67 +++++++++++++++++++++++++++++++--------- tests/test_targetdb.py | 49 ++++++++++++++++++++++++++--- 5 files changed, 141 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e8a130..c24a648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ CLI flags default to `--commit False` (dry-run). Pass `--commit` explicitly to w - **CLI delegates entirely to `utils.py`.** `cli/cli_main.py` is only argument parsing; all business logic lives in `utils.py`. - **`TargetDB` is a subclass of `pfs.utils.database.db.DB`** (from the `pfs-utils` package, installed from GitHub). `DB` supplies the engine cache, `pool_pre_ping`, `COPY`-based bulk inserts and the `query_*` API. `targetdb.py` only overrides what `DB` lacks: a password/dialect in the URL, `update()`, `close()`, and the `dry_run=` keyword. Two overrides are load-bearing and easy to break: - `connect()` returns `None` on purpose. `DB.connect()` hands back a pooled `Connection`, and every caller here and downstream discards it, which would leak one checked-out connection per call. - - `connection()` opens its transaction eagerly (`conn.begin()`) and rolls back when `_dry_run` is set. The eager begin matters because `pandas.to_sql` starts *and commits* a transaction of its own when handed a connection that is not already in one, which would defeat the rollback. Every dry-run path funnels through here. `tests/integration/test_dry_run.py` guards this. + - `connection()` opens its transaction eagerly (`conn.begin()`) and rolls back when `_dry_run` is set. The eager begin matters because `pandas.to_sql` starts _and commits_ a transaction of its own when handed a connection that is not already in one, which would defeat the rollback. Every dry-run path funnels through here. `tests/integration/test_dry_run.py` guards this. +- **`TargetDB`'s connection defaults live in `DEFAULT_HOST`/`DEFAULT_USER`/`DEFAULT_DBNAME`/`DEFAULT_PORT` class attributes**, the same convention `OpDB`/`QaDB` use in `pfs.utils.database.db`. `DB.__init__` resolves any argument left `None` from `type(self).DEFAULT_*`, so every parameter in `TargetDB.__init__` must default to `None`, never to a literal — a non-`None` default in the signature shadows the class attribute and silently breaks the inherited `set_default_connection()` classmethod. `DEFAULT_USER` is `obsproc`, a read-only account, on purpose: these defaults only apply to a bare `TargetDB()`, since every write path (the CLI, `utils.add_database_rows`, …) splats a full `[targetdb.db]` table with an explicit `user`, so a least-privilege default costs nothing and makes an accidental write to production through a bare `TargetDB()` impossible. - **psycopg3 only.** `dialect = "postgresql"` in a config file is rewritten to `postgresql+psycopg` by `normalize_drivername()` in `utils.py`, the single choke point that every code path (`get_url_object`, `TargetDB.__init__`) passes through. psycopg2 is not a dependency. - **Inserts drop DataFrame columns the target table does not have** (`TargetDB._drop_unknown_columns`). `add_backref_values()` resolves foreign keys by merging whole reference tables in, leaving columns like `partner_name` behind; the old ORM path ignored unmapped keys and `COPY` does not. - **Alembic has separate directories per deployment target** under `alembic/` (`local_test/`, `pfsa-db01-gb/`, `pfsa-db01-gb-dev/`). Each has its own `alembic.ini`. Run migrations from within the appropriate subdirectory. `env.py` builds the URL from the TOML file named by the `TARGETDB_CONF` environment variable, falling back to `sqlalchemy.url` in `alembic.ini` when it is unset — so credentials live in one place: `TARGETDB_CONF=dbconf.toml alembic upgrade head`. Do not use `config.set_main_option()` for the URL: ConfigParser reads `%` as an interpolation marker and would mangle passwords containing it. See `alembic/README.md` for the workflow and known gotchas (q3c expression indexes are invisible to autogenerate; `local_test/`'s revision chain is broken). diff --git a/alembic/README.md b/alembic/README.md index 550ff31..bdf8537 100644 --- a/alembic/README.md +++ b/alembic/README.md @@ -17,6 +17,14 @@ the database you are migrating** — the histories are not interchangeable. | `pfsa-db01-gb/` | Production | | `pfsa-db01-gb-dev/` | Development | +Directory names are historical and no longer match the current hostnames: +`pfsa-db01-gb` and `pfsa-db` resolve to different machines (`.102` and +`.110` respectively), and as of 2026-08 the production `targetdb` — the one +`TargetDB`'s own `DEFAULT_HOST`/`DEFAULT_PORT` point at — is reachable at +`pfsa-db:5433`, not `pfsa-db01-gb:5433`. Confirm which host actually holds +the database you intend to migrate before running `alembic upgrade` from +either directory. + The revision scripts under `*/alembic/versions/` are historical records. They are deliberately excluded from `ruff` and `black` (see `pyproject.toml`) and should not be reformatted or "cleaned up" — only the `env.py` scripts are diff --git a/docs/getting_started.md b/docs/getting_started.md index 9cc9f40..9d4aed5 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -131,7 +131,7 @@ Two things to watch out for: - The file must be mode `0600`. libpq ignores it silently otherwise -- there is no warning, only an authentication failure. -When `password` *is* present in the TOML it is used as before and takes +When `password` _is_ present in the TOML it is used as before and takes precedence, which is the same ordering libpq itself applies. The following commands are to create the `targetdb` database, install the Q3C extension, @@ -170,7 +170,7 @@ To connect to the database with Python, you can use the following code snippet. from targetdb import TargetDB # create a database connection -db = TargetDB(host="localhost", port= 5432, dbname="testdb", user="admin", password="admin") +db = TargetDB(host="localhost", port=5432, dbname="testdb", user="admin", password="admin") db.connect() # fetch all data from the input_catalog table as pandas.DataFrame @@ -186,6 +186,39 @@ print(df.head()) db.close() ``` +### Connecting without arguments + +`TargetDB()` can be called with no arguments at all. Any parameter left +unset falls back to a class default (`TargetDB.DEFAULT_HOST`, +`DEFAULT_PORT`, `DEFAULT_DBNAME`, `DEFAULT_USER`), which point at the +production database using `obsproc`, a **read-only** account: + +```python +from targetdb import TargetDB + +with TargetDB() as db: + df = db.fetch_all("input_catalog") +``` + +Because `obsproc` has no password default, this only works once +`~/.pgpass` has a matching entry (mode `0600`, as described above): + +```text title="~/.pgpass" +pfsa-db:5433:targetdb:obsproc: +``` + +`obsproc` can only `SELECT`; inserting or updating still requires a config +file with an explicit, privileged `user` (as in the `pfs-targetdb-cli` +examples above). To point the bare `TargetDB()` form at a different +database for a whole process -- a dev instance, say -- call +`TargetDB.set_default_connection(...)` once before constructing any +instances: + +```python +TargetDB.set_default_connection(host="pfsa-db", port=5437, dbname="targetdb_dev") +db = TargetDB() # now defaults to targetdb_dev +``` + ## Database Migrations with Alembic Each deployment target has its own directory under `alembic/` diff --git a/src/targetdb/targetdb.py b/src/targetdb/targetdb.py index c354b52..ef2f43e 100644 --- a/src/targetdb/targetdb.py +++ b/src/targetdb/targetdb.py @@ -26,36 +26,73 @@ class TargetDB(DB): The public methods kept for backwards compatibility (``connect``, ``close``, ``fetch_query``, ``fetch_all``, ``fetch_by_id``) are all used by downstream packages (``ets_pointing``, ``pfs_obsproc_planning_tools``). + + Connection parameters default to ``DEFAULT_HOST``/``DEFAULT_USER``/ + ``DEFAULT_DBNAME``/``DEFAULT_PORT`` below, the same convention + ``pfs.utils.database.db`` uses for ``OpDB`` and ``QaDB``: ``DB.__init__`` + resolves any argument left as ``None`` from ``type(self).DEFAULT_*``, so + declaring these class attributes is also what makes the inherited + ``set_default_connection()`` classmethod usable on ``TargetDB``. The + default user, ``obsproc``, is read-only in production -- every write path + in this package (the CLI, ``utils.add_database_rows``, etc.) passes an + explicit ``user`` from a config file instead of relying on these + defaults, so a bare ``TargetDB()`` can read but never write. """ + DEFAULT_HOST = "pfsa-db" + DEFAULT_USER = "obsproc" + DEFAULT_DBNAME = "targetdb" + DEFAULT_PORT = 5433 + # Not part of DB: DB.url hardcodes postgresql+psycopg, TargetDB.url does + # not, so TargetDB needs its own default for the dialect argument below. + DEFAULT_DIALECT = "postgresql" + def __init__( self, - host="localhost", - port: int = 5432, - dbname=None, - user=None, - password=None, - dialect="postgresql", + host: str | None = None, + port: int | None = None, + dbname: str | None = None, + user: str | None = None, + password: str | None = None, + dialect: str | None = None, ): - for name, value in [("dbname", dbname), ("user", user)]: - if value is None: - logger.error(f"{name} is not provided") - raise ValueError(f"{name} is not provided") - # Set before super().__init__(): the url property below reads both, and # anything the base class does must already see them. - # `password` is deliberately optional: when it is None it is left out - # of the URL and libpq resolves it (PGPASSWORD, then ~/.pgpass), which - # is exactly the arrangement DB itself documents. + # `password` is deliberately optional and has no DEFAULT_PASSWORD: + # when it is None it is left out of the URL and libpq resolves it + # (PGPASSWORD, then ~/.pgpass), which is exactly the arrangement DB + # itself documents. self._password = password # Imported lazily to avoid a circular import (utils imports TargetDB). from .utils import normalize_drivername - self._drivername = normalize_drivername(dialect) + self._drivername = normalize_drivername( + dialect if dialect is not None else type(self).DEFAULT_DIALECT + ) self._dry_run = False super().__init__(host=host, user=user, dbname=dbname, port=port) + # The CLI and every other write path splat a full [targetdb.db] table, + # so this never fires there. It fires exactly when a parameter was + # omitted and a class default therefore decided which database (and, + # for `user`, which privilege level) this instance talks to. + defaulted = [ + name + for name, value in [ + ("host", host), + ("port", port), + ("dbname", dbname), + ("user", user), + ] + if value is None + ] + if defaulted: + logger.info( + f"Using TargetDB class defaults for {', '.join(defaulted)}: " + f"{self.user}@{self.host}:{self.port}/{self.dbname}" + ) + @property def url(self) -> str: """Connection URL, including the password when one was configured. diff --git a/tests/test_targetdb.py b/tests/test_targetdb.py index 64e8687..d450fa5 100644 --- a/tests/test_targetdb.py +++ b/tests/test_targetdb.py @@ -98,11 +98,50 @@ def test_matches_targetdb_url(self): assert url == TargetDB(**config["targetdb"]["db"]).url -class TestRequiredParameters: - @pytest.mark.parametrize("missing", ["dbname", "user"]) - def test_missing_parameter_raises_with_its_name(self, missing): - with pytest.raises(ValueError, match=f"{missing} is not provided"): - TargetDB(**make_config(**{missing: None})["targetdb"]["db"]) +class TestClassDefaults: + """DB.__init__ resolves omitted parameters from type(self).DEFAULT_*. + + These class attributes are the only thing that makes ``TargetDB()`` work + bare, and the only thing that makes the inherited + ``set_default_connection()`` classmethod do anything -- it was a silent + no-op while ``__init__`` carried non-None defaults in its own signature. + """ + + def test_bare_construction_uses_the_class_defaults(self): + db = TargetDB() + assert db.url == "postgresql+psycopg://obsproc@pfsa-db:5433/targetdb" + + def test_the_default_user_is_the_read_only_one(self): + """Bare TargetDB() must not be able to write to production: every + write path passes an explicit user from a config file.""" + assert TargetDB.DEFAULT_USER == "obsproc" + + @pytest.mark.parametrize("missing", ["host", "port", "dbname", "user"]) + def test_omitted_parameter_falls_back_to_its_default(self, missing): + db = TargetDB(**make_config(**{missing: None})["targetdb"]["db"]) + assert getattr(db, missing) == getattr(TargetDB, f"DEFAULT_{missing.upper()}") + + def test_explicit_values_win_over_the_defaults(self): + db = TargetDB(**make_config()["targetdb"]["db"]) + assert (db.host, db.user, db.dbname) == ( + "db.example.org", + "admin", + "targetdb", + ) + + def test_omitted_dialect_uses_the_default(self): + db = TargetDB(**make_config(dialect=None)["targetdb"]["db"]) + assert db.url.startswith("postgresql+psycopg://") + + def test_set_default_connection_is_honoured(self, monkeypatch): + # Setting each attribute to its current value first registers the + # undo, so the class-level mutation below does not leak into other + # tests. + monkeypatch.setattr(TargetDB, "DEFAULT_HOST", TargetDB.DEFAULT_HOST) + monkeypatch.setattr(TargetDB, "DEFAULT_PORT", TargetDB.DEFAULT_PORT) + TargetDB.set_default_connection(host="pfsa-db01-gb", port=5555) + db = TargetDB() + assert (db.host, db.port) == ("pfsa-db01-gb", 5555) def test_missing_password_does_not_raise(self): TargetDB(**make_config(password=None)["targetdb"]["db"]) From 643e18b22c8a7f87831cf8f588a95840221223de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Aug 2026 04:36:24 +0000 Subject: [PATCH 5/7] chore: update ER diagrams [skip ci] --- diagrams/erdiagram_targetdb_latest.pdf | Bin 47888 -> 47901 bytes docs/tbls/public.fluxstd.md | 4 ++-- docs/tbls/public.sky.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/diagrams/erdiagram_targetdb_latest.pdf b/diagrams/erdiagram_targetdb_latest.pdf index 27fa4cfa603c385bbf29e2f6f6fe05918e086221..f8f7ab165f3fdf8650bb6c7ef226c6532d22f294 100644 GIT binary patch delta 13371 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 Date: Wed, 5 Aug 2026 18:56:26 -1000 Subject: [PATCH 6/7] fix: keep normalize_filter_columns() from losing None under pandas' string dtype pandas' own default string dtype (the default from pandas 3.0, and available earlier via future.infer_string) uses its own NA sentinel, so assigning None into a filter_* column with that dtype silently rewrote it back to NaN -- defeating the point of the function, since a NaN reaching the database driver gets coerced to the literal string "NaN" and violates the filter_name foreign key. Casting the column to object dtype before the assignment keeps None as a real None regardless of which string dtype pandas resolved the column to. Found while chasing a test failure that turned out to be caused by running against pandas 3.0.5 in an ad hoc virtualenv instead of the pandas 2.3.3 this project's uv.lock pins -- the project's own pyproject.toml has no upper bound on pandas, so the same failure would have hit real inserts once pandas 3 was eventually resolved. Co-Authored-By: Claude Sonnet 5 --- src/targetdb/utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/targetdb/utils.py b/src/targetdb/utils.py index 80ecc74..9b3b62a 100644 --- a/src/targetdb/utils.py +++ b/src/targetdb/utils.py @@ -490,7 +490,15 @@ def normalize_filter_columns(df): col = f"filter_{band}" if col not in df.columns: continue - df.loc[df[col].isna() | (df[col] == ""), col] = None + mask = df[col].isna() | (df[col] == "") + # Force object dtype before assigning None: pandas' default string + # dtype (used from pandas 3.0 on, and optionally via + # future.infer_string on 2.x) stores its own NA sentinel and silently + # rewrites an assigned None back to NaN, defeating the point of this + # function -- the caller needs a real None, not a NaN that the + # database driver would coerce to the literal string "NaN". + df[col] = df[col].astype(object) + df.loc[mask, col] = None return df From ad1574cd2b1daa2adccda97f3462d5617fc7936e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Aug 2026 04:58:16 +0000 Subject: [PATCH 7/7] chore: update ER diagrams [skip ci] --- diagrams/erdiagram_targetdb_latest.pdf | Bin 47901 -> 47906 bytes docs/tbls/public.fluxstd.md | 2 +- docs/tbls/public.sky.md | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/diagrams/erdiagram_targetdb_latest.pdf b/diagrams/erdiagram_targetdb_latest.pdf index f8f7ab165f3fdf8650bb6c7ef226c6532d22f294..6267b554aaf7fd7c771d8ee84d1155d01b6dac0c 100644 GIT binary patch delta 31596 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�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 delta 31592 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� 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 diff --git a/docs/tbls/public.fluxstd.md b/docs/tbls/public.fluxstd.md index eba888d..e02e860 100644 --- a/docs/tbls/public.fluxstd.md +++ b/docs/tbls/public.fluxstd.md @@ -101,9 +101,9 @@ | ---- | ---------- | | fluxstd_pkey | CREATE UNIQUE INDEX fluxstd_pkey ON public.fluxstd USING btree (fluxstd_id) | | uq_obj_id_input_catalog_id_version | CREATE UNIQUE INDEX uq_obj_id_input_catalog_id_version ON public.fluxstd USING btree (obj_id, input_catalog_id, version) | -| ix_fluxstd_input_catalog_fluxstdid | CREATE INDEX ix_fluxstd_input_catalog_fluxstdid ON public.fluxstd USING btree (input_catalog_id, fluxstd_id) | | ix_fluxstd_version | CREATE INDEX ix_fluxstd_version ON public.fluxstd USING btree (version) | | ix_fluxstd_version_fluxstdid | CREATE INDEX ix_fluxstd_version_fluxstdid ON public.fluxstd USING btree (version, fluxstd_id) | +| ix_fluxstd_input_catalog_fluxstdid | CREATE INDEX ix_fluxstd_input_catalog_fluxstdid ON public.fluxstd USING btree (input_catalog_id, fluxstd_id) | | fluxstd_q3c_ang2ipix_idx | CREATE INDEX fluxstd_q3c_ang2ipix_idx ON public.fluxstd USING btree (q3c_ang2ipix(ra, "dec")) | ## Relations diff --git a/docs/tbls/public.sky.md b/docs/tbls/public.sky.md index 6b4f98d..8df7986 100644 --- a/docs/tbls/public.sky.md +++ b/docs/tbls/public.sky.md @@ -34,9 +34,9 @@ | ---- | ---------- | | sky_pkey | CREATE UNIQUE INDEX sky_pkey ON public.sky USING btree (sky_id) | | sky_obj_id_input_catalog_id_version_key | CREATE UNIQUE INDEX sky_obj_id_input_catalog_id_version_key ON public.sky USING btree (obj_id, input_catalog_id, version) | -| ix_sky_input_catalog_id | CREATE INDEX ix_sky_input_catalog_id ON public.sky USING btree (input_catalog_id) | -| sky_q3c_ang2ipix_idx | CREATE INDEX sky_q3c_ang2ipix_idx ON public.sky USING btree (q3c_ang2ipix(ra, "dec")) | | ix_sky_version | CREATE INDEX ix_sky_version ON public.sky USING btree (version) | +| sky_q3c_ang2ipix_idx | CREATE INDEX sky_q3c_ang2ipix_idx ON public.sky USING btree (q3c_ang2ipix(ra, "dec")) | +| ix_sky_input_catalog_id | CREATE INDEX ix_sky_input_catalog_id ON public.sky USING btree (input_catalog_id) | ## Relations