diff --git a/diagrams/erdiagram_targetdb_latest.pdf b/diagrams/erdiagram_targetdb_latest.pdf index b3a2300..0091ba3 100644 Binary files a/diagrams/erdiagram_targetdb_latest.pdf and b/diagrams/erdiagram_targetdb_latest.pdf differ diff --git a/docs/getting_started.md b/docs/getting_started.md index fff26cf..6e408c8 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -145,6 +145,46 @@ print(df.head()) db.close() ``` +## Running Tests Locally + +The unit tests (`tests/test_*.py`, excluding `tests/integration/`) do not require a database +and can be run at any time: + +```bash +uv run pytest tests -m "not integration" +``` + +The integration tests (`tests/integration/`) exercise the same sequence of `pfs-targetdb-cli` +commands as the `Test Database` GitHub Actions workflow +(`.github/workflows/test_database.yml`): creating the database, installing the Q3C extension, +creating the schema, and inserting the example data in `examples/data/`, this time also +verifying the inserted rows with SQL queries. They require Docker (for a disposable +PostgreSQL + Q3C container) and are skipped automatically, with a reason, if Docker is not +available. + +```bash +# Docker must be running (e.g. Docker Desktop or OrbStack on macOS) +uv run pytest tests/integration -v +``` + +A dedicated PostgreSQL container is built from `tests/docker/Dockerfile` and started/stopped +automatically for the test session by `docker compose` (`tests/docker/docker-compose.test.yml`). +It listens on `localhost:15433` by default; set `TARGETDB_TEST_PG_PORT` to use a different port. +Its data directory is a tmpfs mount, so nothing is left on disk once the tests finish, and all +generated files (converted flux standard catalogs, transferred target lists) are written under a +pytest-managed temporary directory rather than into `examples/data/`. + +If a test fails and you want to inspect the database afterwards, pass `--keep-db` to leave the +container running: + +```bash +uv run pytest tests/integration -v --keep-db +psql -h localhost -p 15433 -U postgres -d test_targetdb + +# once done, stop the container manually +docker compose -f tests/docker/docker-compose.test.yml down -v +``` + ## Build the Documentation The documentation can be built by the following command: diff --git a/docs/tbls/public.fluxstd.md b/docs/tbls/public.fluxstd.md index 7e61766..3ca7a85 100644 --- a/docs/tbls/public.fluxstd.md +++ b/docs/tbls/public.fluxstd.md @@ -102,9 +102,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) | | 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) | -| ix_fluxstd_version | CREATE INDEX ix_fluxstd_version ON public.fluxstd USING btree (version) | ## Relations diff --git a/docs/tbls/public.sky.md b/docs/tbls/public.sky.md index 6b4f98d..f4d93bf 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) | +| ix_sky_input_catalog_id | CREATE INDEX ix_sky_input_catalog_id ON public.sky USING btree (input_catalog_id) | ## Relations diff --git a/docs/tbls/public.target.md b/docs/tbls/public.target.md index 12ceb5a..1728dae 100644 --- a/docs/tbls/public.target.md +++ b/docs/tbls/public.target.md @@ -121,11 +121,11 @@ | target_pkey | CREATE UNIQUE INDEX target_pkey ON public.target USING btree (target_id) | | target_propid_obcode_catid_objid_resolution_key | CREATE UNIQUE INDEX target_propid_obcode_catid_objid_resolution_key ON public.target USING btree (proposal_id, ob_code, input_catalog_id, obj_id, is_medium_resolution) | | target_propid_obcode_key | CREATE UNIQUE INDEX target_propid_obcode_key ON public.target USING btree (proposal_id, ob_code) | -| target_proposal_id_idx | CREATE INDEX target_proposal_id_idx ON public.target USING btree (proposal_id) | -| target_obj_id_input_catalog_id_idx | CREATE INDEX target_obj_id_input_catalog_id_idx ON public.target USING btree (obj_id, input_catalog_id) | | target_input_catalog_id_idx | CREATE INDEX target_input_catalog_id_idx ON public.target USING btree (input_catalog_id) | | target_proposal_id_obj_id_idx | CREATE INDEX target_proposal_id_obj_id_idx ON public.target USING btree (proposal_id, obj_id) | | target_q3c_ang2ipix_idx | CREATE INDEX target_q3c_ang2ipix_idx ON public.target USING btree (q3c_ang2ipix(ra, "dec")) | +| target_proposal_id_idx | CREATE INDEX target_proposal_id_idx ON public.target USING btree (proposal_id) | +| target_obj_id_input_catalog_id_idx | CREATE INDEX target_obj_id_input_catalog_id_idx ON public.target USING btree (obj_id, input_catalog_id) | ## Relations diff --git a/pyproject.toml b/pyproject.toml index 5b0c4d8..e436c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,13 +45,19 @@ source = "https://github.com/Subaru-PFS/ets_target_database" pfs-targetdb-cli = "targetdb.cli.cli_main:app" [project.optional-dependencies] -dev = ["ipython", "pytest", "black", "ruff", "setuptools-scm>=8.0"] +dev = ["ipython", "pytest", "pytest-cov", "black", "ruff", "setuptools-scm>=8.0"] doc = ["mkdocs<2", "mkdocs-material[imaging]"] [build-system] requires = ["setuptools>=64", "setuptools-scm>=8.0", "wheel"] build-backend = "setuptools.build_meta" +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: tests requiring a PostgreSQL container via Docker", +] + [tool.black] line-length = 88 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2ec181a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import shutil +import subprocess +from functools import lru_cache +from pathlib import Path + +import pytest + +INTEGRATION_TESTS_DIR = Path(__file__).parent / "integration" + + +def pytest_addoption(parser): + parser.addoption( + "--keep-db", + action="store_true", + default=False, + help=( + "Do not tear down the PostgreSQL test container after the " + "integration test session. Useful for inspecting the database " + "after a failure, e.g. with " + "`psql -h localhost -p 15433 -U postgres -d test_targetdb`." + ), + ) + + +@lru_cache(maxsize=1) +def docker_available_reason(): + """ + Check whether a usable Docker daemon is available. + + Returns + ------- + reason : str or None + None if Docker is available and responsive, otherwise a short + string explaining why it is not, suitable for a pytest skip reason. + """ + if shutil.which("docker") is None: + return "docker executable not found on PATH" + + try: + result = subprocess.run( + ["docker", "info"], + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as e: + return f"docker info failed to run: {e}" + + if result.returncode != 0: + return "docker daemon is not reachable (`docker info` failed)" + + return None + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "integration: tests requiring a PostgreSQL container via Docker", + ) + + +def pytest_collection_modifyitems(config, items): + reason = docker_available_reason() + + for item in items: + try: + item.path.relative_to(INTEGRATION_TESTS_DIR) + except ValueError: + continue + + item.add_marker(pytest.mark.integration) + + if reason is not None: + item.add_marker(pytest.mark.skip(reason=f"Docker unavailable: {reason}")) diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 0000000..d549c60 --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,9 @@ +# PostgreSQL image with the Q3C extension pre-installed, mirroring the +# packages installed on-the-fly in .github/workflows/test_database.yml. +# Building this once and reusing the image is much faster than the CI +# approach of installing q3c into a freshly-started container every run. +FROM postgres:16 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-16-q3c \ + && rm -rf /var/lib/apt/lists/* diff --git a/tests/docker/docker-compose.test.yml b/tests/docker/docker-compose.test.yml new file mode 100644 index 0000000..be67075 --- /dev/null +++ b/tests/docker/docker-compose.test.yml @@ -0,0 +1,26 @@ +# Standalone PostgreSQL + Q3C stack used by the pytest integration tests +# (tests/integration/). Kept separate from examples/docker/, which is a +# developer-facing, persistent local database and not meant to be +# started/stopped/discarded by the test suite. +name: targetdb-test + +services: + postgres: + build: + context: . + dockerfile: Dockerfile + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "${TARGETDB_TEST_PG_PORT:-15433}:5432" + # Data lives in tmpfs: each test session starts from a clean database + # and nothing is left on disk after teardown. + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + interval: 2s + timeout: 5s + retries: 30 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..0aaad31 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Session-scoped fixture pipeline that reproduces, locally, the sequence of +`pfs-targetdb-cli` commands run by .github/workflows/test_database.yml: + + postgres_service -> db_config -> database -> q3c_installed -> schema + -> master_data -> fluxstd_data + -> master_data -> target_data -> pointing_data + +Each fixture requests the one(s) it depends on, so a test only needs to +request the fixture for the stage it cares about (and pytest builds +everything upstream automatically). All of it is torn down once per test +session (or left running with `--keep-db`). + +Unlike the CI workflow, output files (prep-fluxstd output, transferred +target lists) are written under a pytest-managed temporary directory +instead of into the repository, so running the suite never leaves the +working tree dirty. +""" + +import subprocess +from pathlib import Path + +import pandas as pd +import pytest +from astropy.table import Table +from sqlalchemy import create_engine, text +from typer.testing import CliRunner + +from targetdb.cli.cli_main import app +from targetdb.utils import get_url_object, load_config + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCKER_DIR = REPO_ROOT / "tests" / "docker" +COMPOSE_FILE = DOCKER_DIR / "docker-compose.test.yml" + +EXAMPLES_DATA = REPO_ROOT / "examples" / "data" +TARGETS_DATA_DIR = EXAMPLES_DATA / "targets" +FLUXSTD_INPUT_DIR = EXAMPLES_DATA / "fluxstd" / "Fstar_v3.3" / "feather-original" + +# Master tables inserted in the same order as the "Insert example data" CI step. +MASTER_TABLE_FILES = [ + ("target_type", "target_types.csv"), + ("filter_name", "filter_names.csv"), + ("partner", "partner.csv"), + ("pfs_arm", "pfs_arm.csv"), + ("proposal_category", "proposal_category.csv"), + ("proposal", "proposals.csv"), + ("input_catalog", "input_catalogs.csv"), +] + +PG_PORT = 15433 + +_cli_runner = CliRunner() + + +def run_cli(*args): + """ + Invoke the ``pfs-targetdb-cli`` Typer app in-process and assert success. + + Running in-process (rather than via subprocess) means failures surface + as normal Python tracebacks/output in the pytest report instead of an + opaque non-zero exit code. + """ + result = _cli_runner.invoke(app, [str(a) for a in args], catch_exceptions=False) + assert result.exit_code == 0, ( + f"CLI command {args!r} failed with exit code {result.exit_code}\n" + f"Output:\n{result.output}" + ) + return result + + +def count_rows(engine, table): + with engine.connect() as conn: + return conn.execute(text(f"SELECT count(*) FROM {table}")).scalar_one() + + +def csv_row_count(path): + return len(pd.read_csv(path)) + + +def ecsv_row_count(path): + return len(Table.read(path, format="ascii.ecsv")) + + +def _run_compose(*args): + cmd = ["docker", "compose", "-f", str(COMPOSE_FILE), *args] + result = subprocess.run(cmd, cwd=DOCKER_DIR, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"Command failed: {' '.join(cmd)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +@pytest.fixture(scope="session") +def postgres_service(request): + """Bring up the PostgreSQL+Q3C container for the whole test session.""" + _run_compose("up", "-d", "--build", "--wait", "--wait-timeout", "120") + try: + yield {"host": "localhost", "port": PG_PORT} + finally: + if request.config.getoption("--keep-db"): + print( + f"\n--keep-db: leaving the test PostgreSQL container running " + f"on port {PG_PORT}. Stop it manually with:\n" + f" docker compose -f {COMPOSE_FILE} down -v" + ) + else: + _run_compose("down", "-v", "--remove-orphans") + + +@pytest.fixture(scope="session") +def work_dir(tmp_path_factory): + """Scratch directory for generated config/data files, unique per session.""" + return tmp_path_factory.mktemp("targetdb-integration") + + +@pytest.fixture(scope="session") +def db_config(postgres_service, work_dir): + """Write a test dbconf.toml pointing at the container and return its path.""" + config_path = work_dir / "test_db_config.toml" + config_path.write_text( + f"""\ +[targetdb.db] +dialect = "postgresql" +user = "postgres" +password = "postgres" +host = "{postgres_service["host"]}" +port = {postgres_service["port"]} +dbname = "test_targetdb" + +[uploader] +host = "localhost" +data_dir = "{EXAMPLES_DATA / "uploader"}" +""" + ) + return config_path + + +@pytest.fixture(scope="session") +def engine(database, db_config): + config = load_config(str(db_config)) + eng = create_engine(get_url_object(config)) + yield eng + eng.dispose() + + +@pytest.fixture(scope="session") +def database(db_config): + run_cli("create-db", "-c", db_config) + + +@pytest.fixture(scope="session") +def q3c_installed(database, db_config): + run_cli("install-q3c", "-c", db_config) + + +@pytest.fixture(scope="session") +def schema(q3c_installed, db_config): + run_cli("create-schema", "-c", db_config) + + +@pytest.fixture(scope="session") +def master_data(schema, db_config): + """Insert the master/reference tables shared by every downstream stage.""" + for table, filename in MASTER_TABLE_FILES: + run_cli( + "insert", EXAMPLES_DATA / filename, "-c", db_config, "-t", table, "--commit" + ) + # input_catalog entry required by the flux standard star catalog. + run_cli( + "insert", + EXAMPLES_DATA / "input_catalog_fluxstd.csv", + "-c", + db_config, + "-t", + "input_catalog", + "--commit", + ) + + +@pytest.fixture(scope="session") +def fluxstd_data(master_data, db_config, work_dir): + """Run prep-fluxstd and insert the resulting rows into the fluxstd table.""" + output_dir = work_dir / "fluxstd_feather" + run_cli( + "prep-fluxstd", + FLUXSTD_INPUT_DIR, + output_dir, + "--version", + "3.3", + "--input_catalog_id", + "3006", + "--rename-cols", + '{"fstar_gaia": "is_fstar_gaia"}', + "--format", + "feather", + ) + generated_files = sorted(output_dir.glob("*.feather")) + assert generated_files, f"prep-fluxstd produced no feather files in {output_dir}" + + for f in generated_files: + run_cli("insert", f, "-c", db_config, "-t", "fluxstd", "--commit") + + return {"output_dir": output_dir, "files": generated_files} + + +@pytest.fixture(scope="session") +def target_data(master_data, db_config, work_dir): + """Insert proposals/catalogs for the target examples, then transfer and insert targets.""" + run_cli( + "insert", + TARGETS_DATA_DIR / "example_proposals.csv", + "-c", + db_config, + "-t", + "proposal", + "--commit", + ) + run_cli( + "insert", + TARGETS_DATA_DIR / "example_input_catalogs.csv", + "-c", + db_config, + "-t", + "input_catalog", + "--commit", + "--fetch", + ) + + local_dir = work_dir / "targets" + run_cli( + "transfer-targets", + TARGETS_DATA_DIR / "example_input_catalogs.csv", + "-c", + db_config, + "--local-dir", + local_dir, + "--force", + ) + run_cli( + "insert-targets", + TARGETS_DATA_DIR / "example_input_catalogs.csv", + "-c", + db_config, + "--data-dir", + local_dir, + "--commit", + ) + + return {"local_dir": local_dir} + + +@pytest.fixture(scope="session") +def pointing_data(target_data, db_config): + run_cli( + "insert-pointings", + TARGETS_DATA_DIR / "example_input_catalogs.csv", + "-c", + db_config, + "--data-dir", + target_data["local_dir"], + "--commit", + ) diff --git a/tests/integration/test_catalog_active.py b/tests/integration/test_catalog_active.py new file mode 100644 index 0000000..c669f42 --- /dev/null +++ b/tests/integration/test_catalog_active.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verify update-catalog-active only changes the targeted input_catalog row.""" + +from sqlalchemy import text + +from .conftest import run_cli + +TARGET_CATALOG_ID = 1004 + + +def _read_active_flags(engine): + with engine.connect() as conn: + rows = conn.execute(text("SELECT input_catalog_id, active FROM input_catalog")) + return dict(rows.all()) + + +def test_update_catalog_active_only_affects_target_row(engine, master_data, db_config): + before = _read_active_flags(engine) + assert TARGET_CATALOG_ID in before, ( + f"expected input_catalog_id={TARGET_CATALOG_ID} from examples/data/input_catalogs.csv" + ) + + run_cli( + "update-catalog-active", + TARGET_CATALOG_ID, + "true", + "-c", + db_config, + "--verbose", + "--commit", + ) + + after = _read_active_flags(engine) + + assert after[TARGET_CATALOG_ID] is True + + unaffected_before = {k: v for k, v in before.items() if k != TARGET_CATALOG_ID} + unaffected_after = {k: v for k, v in after.items() if k != TARGET_CATALOG_ID} + assert unaffected_after == unaffected_before diff --git a/tests/integration/test_fluxstd.py b/tests/integration/test_fluxstd.py new file mode 100644 index 0000000..8b2619b --- /dev/null +++ b/tests/integration/test_fluxstd.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Verify prep-fluxstd output and the resulting fluxstd table contents.""" + +import pandas as pd +from sqlalchemy import text + +from .conftest import FLUXSTD_INPUT_DIR, count_rows + +INPUT_FEATHER = FLUXSTD_INPUT_DIR / "ra354.8_354.9_dec-40.0_90.0.feather" + + +def test_prep_fluxstd_generates_output_files(fluxstd_data): + assert fluxstd_data["files"], "expected at least one generated feather file" + for f in fluxstd_data["files"]: + assert f.exists() + + +def test_fluxstd_row_count_matches_input(engine, fluxstd_data): + expected = len(pd.read_feather(INPUT_FEATHER)) + assert count_rows(engine, "fluxstd") == expected + + +def test_fluxstd_rows_tagged_with_catalog_and_version(engine, fluxstd_data): + with engine.connect() as conn: + rows = conn.execute( + text("SELECT DISTINCT input_catalog_id, version FROM fluxstd") + ).fetchall() + assert rows == [(3006, "3.3")] + + +def test_fluxstd_rename_cols_applied(fluxstd_data): + df = pd.read_feather(fluxstd_data["files"][0]) + assert "is_fstar_gaia" in df.columns + assert "fstar_gaia" not in df.columns diff --git a/tests/integration/test_master_tables.py b/tests/integration/test_master_tables.py new file mode 100644 index 0000000..130d22e --- /dev/null +++ b/tests/integration/test_master_tables.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verify the master/reference tables inserted from examples/data/*.csv.""" + +import pytest +from sqlalchemy import text + +from .conftest import EXAMPLES_DATA, MASTER_TABLE_FILES, count_rows, csv_row_count + +# input_catalog receives an extra insert (input_catalog_fluxstd.csv) on top of +# input_catalogs.csv, so it is checked separately below rather than via the +# generic 1:1 CSV-to-table comparison. +SIMPLE_MASTER_TABLES = [ + (table, filename) for table, filename in MASTER_TABLE_FILES if table != "input_catalog" +] + + +@pytest.mark.parametrize("table, filename", SIMPLE_MASTER_TABLES) +def test_master_table_row_count(engine, master_data, table, filename): + expected = csv_row_count(EXAMPLES_DATA / filename) + assert count_rows(engine, table) == expected + + +def test_input_catalog_row_count(engine, master_data): + expected = csv_row_count(EXAMPLES_DATA / "input_catalogs.csv") + csv_row_count( + EXAMPLES_DATA / "input_catalog_fluxstd.csv" + ) + assert count_rows(engine, "input_catalog") == expected + + +def test_fluxstd_input_catalog_registered(engine, master_data): + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT input_catalog_name FROM input_catalog " + "WHERE input_catalog_id = 3006" + ) + ).fetchone() + assert row is not None + assert row[0] == "Fstar_v3.3" diff --git a/tests/integration/test_pointings.py b/tests/integration/test_pointings.py new file mode 100644 index 0000000..84b4e1d --- /dev/null +++ b/tests/integration/test_pointings.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Verify insert-pointings only creates user_pointing rows for catalogs +flagged with is_user_pointing=True, and that the row count and catalog +linkage are correct.""" + +import pandas as pd +from sqlalchemy import text + +from .conftest import TARGETS_DATA_DIR, count_rows, ecsv_row_count + +INPUT_CATALOGS_CSV = TARGETS_DATA_DIR / "example_input_catalogs.csv" + + +def test_user_pointing_only_for_flagged_catalogs(engine, pointing_data, target_data): + catalogs_df = pd.read_csv(INPUT_CATALOGS_CSV) + flagged = catalogs_df[catalogs_df["is_user_pointing"]] + not_flagged = catalogs_df[~catalogs_df["is_user_pointing"]] + assert len(flagged) > 0, "test fixture data must include a user-pointing catalog" + + local_dir = target_data["local_dir"] + + with engine.connect() as conn: + for _, row in flagged.iterrows(): + upload_dir = next(local_dir.glob(f"????????-??????-{row['upload_id']}")) + expected = ecsv_row_count(upload_dir / f"ppc_{row['upload_id']}.ecsv") + + actual = conn.execute( + text( + "SELECT count(*) FROM user_pointing up " + "JOIN input_catalog c ON up.input_catalog_id = c.input_catalog_id " + "WHERE c.upload_id = :upload_id" + ), + {"upload_id": row["upload_id"]}, + ).scalar_one() + assert actual == expected + + for _, row in not_flagged.iterrows(): + actual = conn.execute( + text( + "SELECT count(*) FROM user_pointing up " + "JOIN input_catalog c ON up.input_catalog_id = c.input_catalog_id " + "WHERE c.upload_id = :upload_id" + ), + {"upload_id": row["upload_id"]}, + ).scalar_one() + assert actual == 0 + + +def test_user_pointing_total_matches_flagged_catalogs(engine, pointing_data, target_data): + catalogs_df = pd.read_csv(INPUT_CATALOGS_CSV) + flagged = catalogs_df[catalogs_df["is_user_pointing"]] + + local_dir = target_data["local_dir"] + total_expected = 0 + for _, row in flagged.iterrows(): + upload_dir = next(local_dir.glob(f"????????-??????-{row['upload_id']}")) + total_expected += ecsv_row_count(upload_dir / f"ppc_{row['upload_id']}.ecsv") + + assert count_rows(engine, "user_pointing") == total_expected diff --git a/tests/integration/test_setup.py b/tests/integration/test_setup.py new file mode 100644 index 0000000..a38041c --- /dev/null +++ b/tests/integration/test_setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Verify the initial database setup: q3c extension and table creation.""" + +from sqlalchemy import text + +from targetdb import models + + +def test_q3c_extension_installed(engine, q3c_installed): + with engine.connect() as conn: + extnames = {row[0] for row in conn.execute(text("SELECT extname FROM pg_extension"))} + assert "q3c" in extnames + + +def test_all_model_tables_created(engine, schema): + with engine.connect() as conn: + existing_tables = { + row[0] + for row in conn.execute( + text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public'" + ) + ) + } + + expected_tables = set(models.Base.metadata.tables.keys()) + missing = expected_tables - existing_tables + assert not missing, f"Tables missing after create-schema: {sorted(missing)}" diff --git a/tests/integration/test_targets.py b/tests/integration/test_targets.py new file mode 100644 index 0000000..bdbbddd --- /dev/null +++ b/tests/integration/test_targets.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Verify transfer-targets + insert-targets: local data is fetched for every +catalog, and the resulting `target` rows match the transferred ECSV files +and reference valid input_catalog/proposal/target_type rows. +""" + +import pandas as pd +import pytest +from sqlalchemy import text + +from .conftest import TARGETS_DATA_DIR, count_rows, ecsv_row_count + +INPUT_CATALOGS_CSV = TARGETS_DATA_DIR / "example_input_catalogs.csv" + + +@pytest.fixture(scope="module") +def catalogs_df(): + return pd.read_csv(INPUT_CATALOGS_CSV) + + +def test_transfer_targets_downloads_all_catalogs(target_data, catalogs_df): + local_dir = target_data["local_dir"] + for upload_id in catalogs_df["upload_id"]: + matches = list(local_dir.glob(f"????????-??????-{upload_id}")) + assert len(matches) == 1, ( + f"expected exactly one transferred data dir for upload_id={upload_id}, " + f"got {matches}" + ) + + +def test_input_catalogs_registered_with_upload_id(engine, target_data, catalogs_df): + with engine.connect() as conn: + rows = conn.execute( + text( + "SELECT input_catalog_name, upload_id FROM input_catalog " + "WHERE upload_id IS NOT NULL" + ) + ).fetchall() + registered = {name: upload_id for name, upload_id in rows} + + for _, row in catalogs_df.iterrows(): + assert registered.get(row["input_catalog_name"]) == row["upload_id"] + + +def test_target_row_count_matches_ecsv_inputs(engine, target_data): + local_dir = target_data["local_dir"] + total_expected = 0 + for upload_dir in sorted(local_dir.glob("????????-??????-*")): + upload_id = upload_dir.name.split("-", 2)[2] + total_expected += ecsv_row_count(upload_dir / f"target_{upload_id}.ecsv") + + assert count_rows(engine, "target") == total_expected + + +def test_targets_reference_valid_foreign_keys(engine, target_data): + with engine.connect() as conn: + orphan_catalog = conn.execute( + text( + "SELECT count(*) FROM target t " + "LEFT JOIN input_catalog c ON t.input_catalog_id = c.input_catalog_id " + "WHERE c.input_catalog_id IS NULL" + ) + ).scalar_one() + orphan_proposal = conn.execute( + text( + "SELECT count(*) FROM target t " + "LEFT JOIN proposal p ON t.proposal_id = p.proposal_id " + "WHERE t.proposal_id IS NOT NULL AND p.proposal_id IS NULL" + ) + ).scalar_one() + orphan_target_type = conn.execute( + text( + "SELECT count(*) FROM target t " + "LEFT JOIN target_type tt ON t.target_type_id = tt.target_type_id " + "WHERE t.target_type_id IS NOT NULL AND tt.target_type_id IS NULL" + ) + ).scalar_one() + + assert orphan_catalog == 0 + assert orphan_proposal == 0 + assert orphan_target_type == 0 diff --git a/uv.lock b/uv.lock index afcfb7d..b09118d 100644 --- a/uv.lock +++ b/uv.lock @@ -244,6 +244,50 @@ 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 = "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" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { 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" @@ -308,13 +352,13 @@ dev = [ { name = "black" }, { name = "ipython" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "setuptools-scm" }, ] doc = [ { name = "mkdocs" }, { name = "mkdocs-material", extra = ["imaging"] }, - { name = "mkdocstrings-python" }, ] [package.metadata] @@ -324,15 +368,15 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'" }, { name = "ipython", marker = "extra == 'dev'" }, { name = "loguru" }, - { name = "mkdocs", marker = "extra == 'doc'" }, + { name = "mkdocs", marker = "extra == 'doc'", specifier = "<2" }, { name = "mkdocs-material", extras = ["imaging"], marker = "extra == 'doc'" }, - { name = "mkdocstrings-python", marker = "extra == 'doc'" }, { name = "numpy", specifier = ">=2.0" }, { name = "openpyxl" }, { name = "pandas" }, { name = "psycopg2-binary" }, { name = "pyarrow" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "requests" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "setuptools-scm", marker = "extra == 'dev'", specifier = ">=8.0" }, @@ -374,7 +418,6 @@ 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/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, { 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" }, @@ -382,25 +425,12 @@ wheels = [ { 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" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, ] -[[package]] -name = "griffe" -version = "1.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -607,20 +637,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] -[[package]] -name = "mkdocs-autorefs" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, -] - [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -672,37 +688,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] -[[package]] -name = "mkdocstrings" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/13/10bbf9d56565fd91b91e6f5a8cd9b9d8a2b101c4e8ad6eeafa35a706301d/mkdocstrings-1.0.0.tar.gz", hash = "sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a", size = 101086, upload-time = "2025-11-27T15:39:40.534Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/fc/80aa31b79133634721cf7855d37b76ea49773599214896f2ff10be03de2a/mkdocstrings-1.0.0-py3-none-any.whl", hash = "sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa", size = 35135, upload-time = "2025-11-27T15:39:39.301Z" }, -] - -[[package]] -name = "mkdocstrings-python" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffe" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1039,6 +1024,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1275,6 +1274,33 @@ 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"