diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fad01a82..304c2488 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,10 +24,11 @@ jobs: with: python-version-file: ".python-version" - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true - version: 0.11.* + version: 0.11.28 + - run: uv sync --locked - run: | uv run ruff check @@ -42,10 +43,12 @@ jobs: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: ".python-version" - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8 + + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true - version: 0.11.* + version: 0.11.28 + - run: uv sync --locked - run: uv run ty check @@ -58,9 +61,11 @@ jobs: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: ".python-version" - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8 + + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true - version: 0.11.* + version: 0.11.28 + - run: uv sync --locked - run: uv run vulture --min-confidence 100 --exclude ".venv" . diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5aa54dab..d03ab62c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,10 +28,10 @@ jobs: python-version-file: "pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true - version: 0.*.* + version: 0.11.28 - name: Install dependencies run: uv sync --locked diff --git a/.last_release b/.last_release index 26aaba0e..6085e946 100644 --- a/.last_release +++ b/.last_release @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/.pre-commit-config.yml b/.pre-commit-config.yml index 64c79c07..c2694e23 100644 --- a/.pre-commit-config.yml +++ b/.pre-commit-config.yml @@ -1,21 +1,33 @@ +--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace - - id: check-yaml - - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: end-of-file-fixer - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.4 hooks: - - id: ruff-check - args: [--fix] - files: "\\.py$" - - id: ruff-format - files: "\\.py$" + - id: ruff-check + args: [--fix] + files: "\\.py$" + - id: ruff-format + files: "\\.py$" - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: - - id: actionlint + - id: actionlint + + - repo: https://github.com/betterleaks/betterleaks + rev: v1.6.1 + hooks: + - id: betterleaks + + - repo: https://github.com/renovatebot/pre-commit-hooks + rev: 43.264.2 + hooks: + - id: renovate-config-validator + files: renovate.json diff --git a/Dockerfile b/Dockerfile index 1c1578b1..1b1a0f6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:noble AS builder +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS builder ARG DEBIAN_FRONTEND=noninteractive @@ -7,7 +7,7 @@ RUN apt-get update \ python3.12 \ && rm -rf /var/lib/apt/lists/* -COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/ +COPY --from=ghcr.io/astral-sh/uv:0.11.28@sha256:0f36cb9361a3346885ca3677e3767016687b5a170c1a6b88465ec14aefec90aa /uv /usr/local/bin/ WORKDIR /build @@ -21,7 +21,7 @@ ENV UV_PYTHON=/usr/bin/python3.12 \ RUN uv sync --locked --no-dev --no-install-project -FROM eclipse-temurin:21.0.9_10-jre-noble +FROM eclipse-temurin:21.0.9_10-jre-noble@sha256:d3eb69add1874bc785382d6282db53a67841f602a1139dee6c4a1221d8c56568 ARG DEBIAN_FRONTEND=noninteractive ARG PHOTON_VERSION diff --git a/README.md b/README.md index 1588cc8b..f4f3de00 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,12 @@ The container can be configured using the following environment variables: | ---------------------- | -------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `IMPORT_MODE` | `db`, `jsonl` | `db` | Selects how the index is built. `db` downloads a prebuilt index for a single region. `jsonl` (**experimental**) builds the index in-container from OpenStreetMap JSONL dumps and supports combining multiple regions. See [Import Modes](#import-modes). | | `UPDATE_STRATEGY` | `PARALLEL`, `SEQUENTIAL`, `DISABLED` | `SEQUENTIAL` | Controls how index updates are handled. `PARALLEL` downloads the new index in the background then swaps with minimal downtime (requires 2x space). `SEQUENTIAL` stops Photon, deletes the existing index, downloads the new one, then restarts. `DISABLED` prevents automatic updates. Only applies in `db` mode. | -| `UPDATE_INTERVAL` | Time string (e.g., "720h", "30d") | `30d` | How often to check for updates. To reduce server load, it is recommended to set this to a long interval (e.g., `720h` for 30 days) or disable updates altogether if you do not need the latest data. | +| `UPDATE_INTERVAL` | Time string (e.g., "720h", "30d") | `30d` | Maximum age of the local index before a new one is fetched. The age is read from the index itself, so it is not reset by container restarts. Once the index is older than this, the mirror is checked at most once an hour until a newer index is published; nothing is downloaded while the mirror has nothing newer. To reduce server load, set this to a long interval (e.g., `720h` for 30 days) or disable updates altogether if you do not need the latest data. | | `REGION` | Region name, country code, or `planet` | `planet` | Region for a specific dataset. Can be a continent (`europe`, `asia`), individual country/region (`germany`, `usa`, `japan`), country code (`de`, `us`, `jp`), or `planet` for worldwide data. In `db` mode exactly one region may be set, and it must be one with a prebuilt index. In `jsonl` mode you may pass multiple regions as a comma-separated list. See [Available Regions](#available-regions) section for details. | | `LANGUAGES` | Comma-separated language codes | - | Only used in `jsonl` mode. Languages to import, passed to Photon's `-languages` (e.g. `en,de,fr`). | | `EXTRA_TAGS` | Comma-separated OSM tags | - | Only used in `jsonl` mode. Additional OSM tags to import, passed to Photon's `-extra-tags`. | | `IMPORT_GEOMETRIES` | `TRUE`, `FALSE` | `FALSE` | Only used in `jsonl` mode. When `TRUE`, imports full geometries (`-full-geometries`) instead of centroids. | +| `REVERSE_ONLY` | `TRUE`, `FALSE` | `FALSE` | Only used in `jsonl` mode. When `TRUE`, builds a reverse-geocoding-only index (`-reverse-only`), saving roughly 60% of disk space and import time. The forward search endpoints (`/api`, `/structured`) and `/metrics` are disabled on the resulting index; only `/reverse` is served. | | `LOG_LEVEL` | `DEBUG`, `INFO`, `ERROR` | `INFO` | Controls logging verbosity. | | `PHOTON_LISTEN_IP` | IP Address | 0.0.0.0 | Populates `-listen-ip` parameter for photon | | `FORCE_UPDATE` | `TRUE`, `FALSE` | `FALSE` | Forces an index update on container startup, regardless of `UPDATE_STRATEGY`. | @@ -90,7 +91,7 @@ The container can be configured using the following environment variables: The image can build its search index in two ways, selected with `IMPORT_MODE`: - **`db` (default):** Downloads a prebuilt index (`.tar.bz2`) for a single region. This is the original behaviour and supports scheduled updates via `UPDATE_STRATEGY` / `UPDATE_INTERVAL`. -- **`jsonl` (experimental):** Builds the index inside the container from compressed OpenStreetMap JSONL dumps. Supports combining multiple regions and tuning the import with `LANGUAGES`, `EXTRA_TAGS`, and `IMPORT_GEOMETRIES`. +- **`jsonl` (experimental):** Builds the index inside the container from compressed OpenStreetMap JSONL dumps. Supports combining multiple regions and tuning the import with `LANGUAGES`, `EXTRA_TAGS`, `IMPORT_GEOMETRIES`, and `REVERSE_ONLY`. ### JSONL Import Mode (Experimental) @@ -119,6 +120,8 @@ volumes: When multiple regions are requested, the smallest dump that covers all of them (a continent, or `planet`) is downloaded, then filtered down to the requested countries. +To build a reverse-geocoding-only index (useful for address lookups from coordinates, e.g. with [Dawarich](https://github.com/Freika/dawarich)), set `REVERSE_ONLY=TRUE`. This produces a much smaller index and faster import, but only the `/reverse` endpoint is served, the forward search endpoints (`/api`, `/structured`) and `/metrics` are disabled. + > ⚠️ **Note:** `jsonl` mode does not currently support scheduled or automatic updates. `UPDATE_STRATEGY` and `UPDATE_INTERVAL` are ignored, and the index is built once at startup. To rebuild, set `FORCE_UPDATE=TRUE` or recreate the data volume. `FILE_URL` and `MD5_URL` are not supported in this mode. ## Available Regions diff --git a/docker-compose.yml b/docker-compose.yml index 0d62728e..9ddf0509 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: photon: - image: ghcr.io/rtuszik/photon-docker:latest + image: ghcr.io/rtuszik/photon-docker:latest@sha256:729f883612bde3215c156111b421d8e0a27b1eb411b92ef4a24bd587b8259b04 container_name: "photon-docker" environment: - UPDATE_STRATEGY=PARALLEL diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..39bd82ff --- /dev/null +++ b/mise.toml @@ -0,0 +1,9 @@ +[tools] +uv = "0.11.28" +python = "3.13" +betterleaks = "1.6.1" +prek = "0.4.9" +zizmor = "1.27.0" + +[deps] +uv = { auto = true } diff --git a/pyproject.toml b/pyproject.toml index 836fd0fa..ac163988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ +#:schema https://www.schemastore.org/pyproject.json + [project] name = "photon-docker" -version = "2.1.0" +version = "2.3.1" description = "Unofficial docker image for the Photon Geocoder" requires-python = ">=3.12" dependencies = [ @@ -10,12 +12,12 @@ dependencies = [ "requests==2.33.1", "schedule>=1.2.2", "tqdm==4.67.3", - "urllib3==2.7.0", "zstandard>=0.23.0", ] [dependency-groups] dev = [ + "deptry>=0.25.1", "prek>=0.3.4", "pytest>=9.0.2", "pytest-cov>=7.0.0", @@ -37,14 +39,14 @@ line-length = 120 [tool.ruff.lint] ignore = [ - "ANN", # flake8-annotations - "COM", # flake8-commas - "DJ", # django - "EXE", # flake8-executable - "BLE", # blind except - "PTH", # flake8-pathlib - "T10", # debugger - "TID", # flake8-tidy-imports + "ANN", # flake8-annotations + "COM", # flake8-commas + "DJ", # django + "EXE", # flake8-executable + "BLE", # blind except + "PTH", # flake8-pathlib + "T10", # debugger + "TID", # flake8-tidy-imports "D100", "D101", "D102", @@ -59,10 +61,10 @@ ignore = [ "D415", "G004", "PLR2004", - "E501", # line too long + "E501", # line too long "TRY", - "SIM105", # faster without contextlib -] # select = ["ALL"] + "SIM105", # faster without contextlib +] # select = ["ALL"] extend-select = [ "A", "B", diff --git a/renovate.json b/renovate.json index 53e8d4a0..91e7c6ad 100644 --- a/renovate.json +++ b/renovate.json @@ -7,8 +7,6 @@ "labels": ["renovate"], "baseBranchPatterns": ["dev"], "useBaseBranchConfig": "merge", - "internalChecksFilter": "strict", - "minimumReleaseAge": "3 days", "packageRules": [ { "matchUpdateTypes": ["patch"], @@ -39,6 +37,15 @@ "groupName": "Python Version", "matchPackageNames": ["python"], "matchManagers": ["custom.regex", "pyenv", "pep621"] + }, + { + "groupName": "uv", + "matchPackageNames": [ + "uv", + "astral-sh/uv", + "ghcr.io/astral-sh/uv", + "uv_build" + ] } ], diff --git a/src/entrypoint.py b/src/entrypoint.py index c5640f14..5284d1c7 100644 --- a/src/entrypoint.py +++ b/src/entrypoint.py @@ -20,6 +20,7 @@ def log_config() -> None: logger.info(f"LANGUAGES: {config.LANGUAGES}") logger.info(f"EXTRA_TAGS: {config.EXTRA_TAGS}") logger.info(f"IMPORT_GEOMETRIES: {config.IMPORT_GEOMETRIES}") + logger.info(f"REVERSE_ONLY: {config.REVERSE_ONLY}") logger.info(f"FORCE_UPDATE: {config.FORCE_UPDATE}") logger.info(f"DOWNLOAD_MAX_RETRIES: {config.DOWNLOAD_MAX_RETRIES}") logger.info(f"CHECKSUM_MAX_RETRIES: {config.CHECKSUM_MAX_RETRIES}") diff --git a/src/importer.py b/src/importer.py index 2f75150d..f09ef70b 100644 --- a/src/importer.py +++ b/src/importer.py @@ -67,5 +67,9 @@ def _start_photon_import(input_source: str, country_codes: list[str] | None = No if config.IMPORT_GEOMETRIES: cmd.append("-full-geometries") + if config.REVERSE_ONLY: + cmd.append("-reverse-only") + logger.info("Reverse-only import enabled: forward search (/api, /structured) and /metrics will be disabled") + logger.info(f"Starting Photon JSONL import for region(s): {', '.join(config.get_jsonl_regions())}") return subprocess.Popen(cmd, cwd=config.PHOTON_DIR, stdin=subprocess.PIPE) # noqa: S603 diff --git a/src/index.py b/src/index.py index 54e9351a..85742d76 100644 --- a/src/index.py +++ b/src/index.py @@ -1,5 +1,6 @@ import os import shutil +import time from pathlib import Path from src.utils import config @@ -38,6 +39,13 @@ def last_updated() -> float: return os.path.getmtime(config.OS_NODE_DIR) +def age_seconds() -> float: + timestamp = last_updated() + if timestamp == 0.0: + return float("inf") + return max(0.0, time.time() - timestamp) + + def mark_updated(): marker_file = _updated_marker() try: diff --git a/src/process_manager.py b/src/process_manager.py index 4756299b..ba186877 100644 --- a/src/process_manager.py +++ b/src/process_manager.py @@ -22,6 +22,9 @@ logger = get_logger() +POLL_TICK_SECONDS = 60 +POLL_BACKOFF_SECONDS = 3600 + def check_photon_health(timeout=30, max_retries=10) -> bool: url = "http://localhost:2322/status" @@ -70,6 +73,7 @@ def __init__(self): self.state = AppState.INITIALIZING self.photon_process = None self.should_exit = False + self._next_poll_at = 0.0 signal.signal(signal.SIGTERM, self.handle_shutdown) signal.signal(signal.SIGINT, self.handle_shutdown) @@ -246,6 +250,21 @@ def run_update(self): finally: self.state = AppState.RUNNING + def _is_update_due(self) -> bool: + elapsed = index.age_seconds() + interval = config.parse_interval(config.UPDATE_INTERVAL) + + if elapsed == float("inf"): + logger.info("No index timestamp found, update is due") + return True + + if elapsed < interval: + logger.debug(f"Index is {elapsed / 86400:.1f}d old (interval {config.UPDATE_INTERVAL}), not due") + return False + + logger.info(f"Index is {elapsed / 86400:.1f}d old (interval {config.UPDATE_INTERVAL}), update due") + return True + def schedule_updates(self): if config.UPDATE_STRATEGY == "DISABLED": logger.info("Updates disabled, not scheduling") @@ -255,23 +274,11 @@ def schedule_updates(self): logger.info("Skipping scheduled updates in JSONL mode until rebuild support is implemented") return - interval = config.UPDATE_INTERVAL.lower() - - if interval.endswith("d"): - days = int(interval[:-1]) - schedule.every(days).days.do(self.run_update) - logger.info(f"Scheduling updates every {days} days") - elif interval.endswith("h"): - hours = int(interval[:-1]) - schedule.every(hours).hours.do(self.run_update) - logger.info(f"Scheduling updates every {hours} hours") - elif interval.endswith("m"): - minutes = int(interval[:-1]) - schedule.every(minutes).minutes.do(self.run_update) - logger.info(f"Scheduling updates every {minutes} minutes") - else: - logger.warning(f"Invalid UPDATE_INTERVAL format: {interval}, defaulting to daily") - schedule.every().day.do(self.run_update) + logger.info( + f"Checking index age every {POLL_TICK_SECONDS}s (UPDATE_INTERVAL={config.UPDATE_INTERVAL}, " + f"retry backoff {POLL_BACKOFF_SECONDS}s)" + ) + schedule.every(POLL_TICK_SECONDS).seconds.do(self._maybe_update) def scheduler_loop(): while not self.should_exit: @@ -281,6 +288,18 @@ def scheduler_loop(): thread = threading.Thread(target=scheduler_loop, daemon=True) thread.start() + def _maybe_update(self): + if not self._is_update_due(): + return + + now = time.monotonic() + if now < self._next_poll_at: + logger.debug(f"Update attempt throttled for another {self._next_poll_at - now:.0f}s") + return + + self._next_poll_at = now + POLL_BACKOFF_SECONDS + self.run_update() + def _run_pending_jobs(self): try: schedule.run_pending() @@ -327,6 +346,8 @@ def run(self): logger.error("Failed to start Photon during initial startup") sys.exit(1) + send_notification("Photon started successfully") + self.schedule_updates() self.monitor_photon() diff --git a/src/utils/config.py b/src/utils/config.py index 1ea40cd3..11817aba 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -8,6 +8,7 @@ LANGUAGES = os.getenv("LANGUAGES") EXTRA_TAGS = os.getenv("EXTRA_TAGS") IMPORT_GEOMETRIES = os.getenv("IMPORT_GEOMETRIES", "False").lower() in ("true", "1", "t") +REVERSE_ONLY = os.getenv("REVERSE_ONLY", "False").lower() in ("true", "1", "t") FORCE_UPDATE = os.getenv("FORCE_UPDATE", "False").lower() in ("true", "1", "t") DOWNLOAD_MAX_RETRIES = os.getenv("DOWNLOAD_MAX_RETRIES", "3") CHECKSUM_MAX_RETRIES = os.getenv("CHECKSUM_MAX_RETRIES", "3") @@ -50,6 +51,14 @@ def get_jsonl_regions() -> list[str]: return _get_csv_values(REGION) or [] +def parse_interval(interval: str) -> int: + value = interval.strip().lower() + for suffix, seconds in (("d", 86400), ("h", 3600), ("m", 60)): + if value.endswith(suffix) and value[:-1].isdigit(): + return int(value[:-1]) * seconds + return 86400 + + def _get_csv_values(value: str | None) -> list[str] | None: if not value: return None diff --git a/src/utils/validate_config.py b/src/utils/validate_config.py index 588452f4..ac17fae7 100644 --- a/src/utils/validate_config.py +++ b/src/utils/validate_config.py @@ -7,6 +7,43 @@ logging = get_logger() +def _validate_db_mode() -> list[str]: + errors = [] + + if config.REGION and len(config.get_jsonl_regions()) > 1: + errors.append("DB mode supports exactly one region in REGION.") + elif config.REGION: + if not is_valid_region(config.REGION): + errors.append(f"Invalid REGION: '{config.REGION}'. Must be a valid continent, sub-region, or 'planet'.") + else: + region_info = get_region_info(config.REGION) + if region_info and not region_info.get("db_available", False): + errors.append(f"DB index is not available for REGION: '{config.REGION}'.") + + if config.REVERSE_ONLY: + errors.append("REVERSE_ONLY is only supported when IMPORT_MODE=jsonl, since it is an import-time option.") + + return errors + + +def _validate_jsonl_mode() -> list[str]: + errors = [] + + if config.FILE_URL: + errors.append("FILE_URL is not supported when IMPORT_MODE=jsonl.") + if config.MD5_URL: + errors.append("MD5_URL is not supported when IMPORT_MODE=jsonl.") + if not config.get_jsonl_regions(): + errors.append("REGION is required when IMPORT_MODE=jsonl.") + else: + try: + get_regions_for_jsonl(config.get_jsonl_regions()) + except ValueError as exc: + errors.append(str(exc)) + + return errors + + def validate_config(): logging.info("Validating environment variables...") error_messages = [] @@ -27,30 +64,10 @@ def validate_config(): ) if config.IMPORT_MODE == "db": - if config.REGION and len(config.get_jsonl_regions()) > 1: - error_messages.append("DB mode supports exactly one region in REGION.") - elif config.REGION: - if not is_valid_region(config.REGION): - error_messages.append( - f"Invalid REGION: '{config.REGION}'. Must be a valid continent, sub-region, or 'planet'." - ) - else: - region_info = get_region_info(config.REGION) - if region_info and not region_info.get("db_available", False): - error_messages.append(f"DB index is not available for REGION: '{config.REGION}'.") + error_messages.extend(_validate_db_mode()) if config.IMPORT_MODE == "jsonl": - if config.FILE_URL: - error_messages.append("FILE_URL is not supported when IMPORT_MODE=jsonl.") - if config.MD5_URL: - error_messages.append("MD5_URL is not supported when IMPORT_MODE=jsonl.") - if not config.get_jsonl_regions(): - error_messages.append("REGION is required when IMPORT_MODE=jsonl.") - else: - try: - get_regions_for_jsonl(config.get_jsonl_regions()) - except ValueError as exc: - error_messages.append(str(exc)) + error_messages.extend(_validate_jsonl_mode()) if error_messages: full_error_message = "Configuration validation failed:\n" + "\n".join(error_messages) diff --git a/tests/test_importer.py b/tests/test_importer.py index a70aa4a4..805f58f0 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -95,6 +95,64 @@ def fake_popen(cmd, cwd, stdin): ] +def test_start_photon_import_includes_reverse_only(monkeypatch): + commands = [] + + class DummyProcess: + def __init__(self): + self.stdin = io.BytesIO() + + def fake_popen(cmd, cwd, stdin): + commands.append({"cmd": cmd, "cwd": cwd, "stdin": stdin}) + return DummyProcess() + + monkeypatch.setattr(config, "JAVA_PARAMS", None) + monkeypatch.setattr(config, "LANGUAGES", None) + monkeypatch.setattr(config, "EXTRA_TAGS", None) + monkeypatch.setattr(config, "IMPORT_GEOMETRIES", False) + monkeypatch.setattr(config, "REVERSE_ONLY", True) + monkeypatch.setattr(importer.os, "makedirs", _noop_makedirs) + monkeypatch.setattr(importer.subprocess, "Popen", fake_popen) + + importer._start_photon_import("-") + + assert commands[0]["cmd"] == [ + "java", + "-jar", + "/photon/photon.jar", + "import", + "-import-file", + "-", + "-data-dir", + config.DATA_DIR, + "-reverse-only", + ] + + +def test_start_photon_import_omits_reverse_only_by_default(monkeypatch): + commands = [] + + class DummyProcess: + def __init__(self): + self.stdin = io.BytesIO() + + def fake_popen(cmd, cwd, stdin): + commands.append({"cmd": cmd, "cwd": cwd, "stdin": stdin}) + return DummyProcess() + + monkeypatch.setattr(config, "JAVA_PARAMS", None) + monkeypatch.setattr(config, "LANGUAGES", None) + monkeypatch.setattr(config, "EXTRA_TAGS", None) + monkeypatch.setattr(config, "IMPORT_GEOMETRIES", False) + monkeypatch.setattr(config, "REVERSE_ONLY", False) + monkeypatch.setattr(importer.os, "makedirs", _noop_makedirs) + monkeypatch.setattr(importer.subprocess, "Popen", fake_popen) + + importer._start_photon_import("-") + + assert "-reverse-only" not in commands[0]["cmd"] + + def test_run_jsonl_import_uses_parent_region_and_country_codes_for_multi_region(monkeypatch): process = RecordingProcess() download_args = [] diff --git a/tests/test_index.py b/tests/test_index.py index e653aceb..2e6ab13a 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,4 +1,5 @@ import os +import time from pathlib import Path from unittest.mock import patch @@ -52,6 +53,26 @@ def test_last_updated_returns_zero_when_nothing_exists(fake_dirs: Path): assert index.last_updated() == 0.0 +def test_age_seconds_is_infinite_when_nothing_exists(fake_dirs: Path): + assert index.age_seconds() == float("inf") + + +def test_age_seconds_measures_marker_age(fake_dirs: Path): + marker = fake_dirs / ".photon-index-updated" + marker.write_text("") + mtime = time.time() - 3600 + os.utime(marker, (mtime, mtime)) + assert index.age_seconds() == pytest.approx(3600, abs=5) + + +def test_age_seconds_never_negative_for_future_marker(fake_dirs: Path): + marker = fake_dirs / ".photon-index-updated" + marker.write_text("") + mtime = time.time() + 3600 + os.utime(marker, (mtime, mtime)) + assert index.age_seconds() == 0.0 + + def test_mark_updated_creates_marker(fake_dirs: Path): index.mark_updated() assert (fake_dirs / ".photon-index-updated").exists() diff --git a/tests/test_process_manager.py b/tests/test_process_manager.py index 9695faab..366e3326 100644 --- a/tests/test_process_manager.py +++ b/tests/test_process_manager.py @@ -1,5 +1,7 @@ +import os import signal import subprocess +import time from pathlib import Path from unittest.mock import MagicMock, patch @@ -398,9 +400,9 @@ def test_run_pending_jobs_survives_job_exception( manager._run_pending_jobs() -@pytest.mark.parametrize(("interval", "expected_unit"), [("3d", "days"), ("12h", "hours"), ("30m", "minutes")]) -def test_schedule_updates_parses_intervals( - manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, interval: str, expected_unit: str +@pytest.mark.parametrize("interval", ["3d", "12h", "30m"]) +def test_schedule_updates_ticks_on_a_fixed_cadence( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, interval: str ): monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") monkeypatch.setattr(config, "UPDATE_INTERVAL", interval) @@ -408,19 +410,61 @@ def test_schedule_updates_parses_intervals( manager.schedule_updates() jobs = schedule.get_jobs() assert len(jobs) == 1 - assert jobs[0].unit == expected_unit + assert jobs[0].unit == "seconds" + assert jobs[0].interval == process_manager.POLL_TICK_SECONDS -def test_schedule_updates_falls_back_to_daily_on_invalid_interval( +def test_is_update_due_when_no_index_timestamp( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) + monkeypatch.setattr(config, "OS_NODE_DIR", str(tmp_path / "photon_data" / "node_1")) + assert manager._is_update_due() is True + + +@pytest.mark.parametrize(("age_days", "due"), [(31, True), (1, False)]) +def test_is_update_due_compares_index_age_to_interval( + manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, age_days: int, due: bool +): + monkeypatch.setattr(config, "DATA_DIR", str(tmp_path)) + monkeypatch.setattr(config, "UPDATE_INTERVAL", "30d") + marker = tmp_path / ".photon-index-updated" + marker.touch() + mtime = time.time() - age_days * 86400 + os.utime(marker, (mtime, mtime)) + assert manager._is_update_due() is due + + +def test_maybe_update_runs_when_due_and_arms_throttle(manager: process_manager.PhotonManager): + with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run: + manager._maybe_update() + run.assert_called_once() + assert manager._next_poll_at > time.monotonic() + + +def test_maybe_update_skips_when_not_due(manager: process_manager.PhotonManager): + with patch.object(manager, "_is_update_due", return_value=False), patch.object(manager, "run_update") as run: + manager._maybe_update() + run.assert_not_called() + assert manager._next_poll_at == 0.0 + + +def test_maybe_update_throttles_repeated_attempts(manager: process_manager.PhotonManager): + with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run: + manager._maybe_update() + manager._maybe_update() + manager._maybe_update() + run.assert_called_once() + + +def test_maybe_update_retries_once_throttle_expires( manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch ): - monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL") - monkeypatch.setattr(config, "UPDATE_INTERVAL", "garbage") - monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None)) - manager.schedule_updates() - jobs = schedule.get_jobs() - assert len(jobs) == 1 - assert jobs[0].unit == "days" + with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run: + manager._maybe_update() + monkeypatch.setattr(process_manager.time, "monotonic", lambda: manager._next_poll_at + 1) + manager._maybe_update() + assert run.call_count == 2 def test_schedule_updates_skipped_when_disabled( diff --git a/tests/utils/test_config.py b/tests/utils/test_config.py new file mode 100644 index 00000000..0d04c778 --- /dev/null +++ b/tests/utils/test_config.py @@ -0,0 +1,16 @@ +import pytest + +from src.utils import config + + +@pytest.mark.parametrize( + ("interval", "expected"), + [("30d", 30 * 86400), ("12h", 12 * 3600), ("30m", 30 * 60), ("720H", 720 * 3600), (" 7d ", 7 * 86400)], +) +def test_parse_interval_returns_seconds(interval: str, expected: int): + assert config.parse_interval(interval) == expected + + +@pytest.mark.parametrize("interval", ["garbage", "", "d", "30", "30s", "-1d", "1.5d"]) +def test_parse_interval_falls_back_to_one_day_without_raising(interval: str): + assert config.parse_interval(interval) == 86400 diff --git a/tests/utils/test_validate_config.py b/tests/utils/test_validate_config.py index b1c18d6f..67339aba 100644 --- a/tests/utils/test_validate_config.py +++ b/tests/utils/test_validate_config.py @@ -11,6 +11,7 @@ def _set_base_config(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(config, "REGION", None) monkeypatch.setattr(config, "FILE_URL", None) monkeypatch.setattr(config, "MD5_URL", None) + monkeypatch.setattr(config, "REVERSE_ONLY", False) def test_validate_config_accepts_valid_configuration(monkeypatch: pytest.MonkeyPatch): @@ -77,6 +78,23 @@ def test_validate_config_accepts_multiple_jsonl_regions(monkeypatch: pytest.Monk validate_config() +def test_validate_config_accepts_reverse_only_in_jsonl_mode(monkeypatch: pytest.MonkeyPatch): + _set_base_config(monkeypatch) + monkeypatch.setattr(config, "IMPORT_MODE", "jsonl") + monkeypatch.setattr(config, "REGION", "de") + monkeypatch.setattr(config, "REVERSE_ONLY", True) + + validate_config() + + +def test_validate_config_rejects_reverse_only_in_db_mode(monkeypatch: pytest.MonkeyPatch): + _set_base_config(monkeypatch) + monkeypatch.setattr(config, "REVERSE_ONLY", True) + + with pytest.raises(ValueError, match="REVERSE_ONLY is only supported when IMPORT_MODE=jsonl"): + validate_config() + + def test_validate_config_rejects_multiple_db_regions(monkeypatch: pytest.MonkeyPatch): _set_base_config(monkeypatch) monkeypatch.setattr(config, "REGION", "germany,andorra") diff --git a/uv.lock b/uv.lock index db63d721..4bfe2343 100644 --- a/uv.lock +++ b/uv.lock @@ -169,6 +169,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] +[[package]] +name = "deptry" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "requirements-parser" }, + { name = "tomli", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -216,7 +239,7 @@ wheels = [ [[package]] name = "photon-docker" -version = "2.1.0" +version = "2.3.1" source = { editable = "." } dependencies = [ { name = "apprise" }, @@ -225,12 +248,12 @@ dependencies = [ { name = "requests" }, { name = "schedule" }, { name = "tqdm" }, - { name = "urllib3" }, { name = "zstandard" }, ] [package.dev-dependencies] dev = [ + { name = "deptry" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -247,12 +270,12 @@ requires-dist = [ { name = "requests", specifier = "==2.33.1" }, { name = "schedule", specifier = ">=1.2.2" }, { name = "tqdm", specifier = "==4.67.3" }, - { name = "urllib3", specifier = "==2.7.0" }, { name = "zstandard", specifier = ">=0.23.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "deptry", specifier = ">=0.25.1" }, { name = "prek", specifier = ">=0.3.4" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, @@ -427,6 +450,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "requirements-parser" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" }, +] + [[package]] name = "ruff" version = "0.14.14" @@ -471,6 +506,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "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/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/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tqdm" version = "4.67.3"