diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb42e76 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Mapilio Kit environment configuration +# Copy this file to ".env" and fill in the values you need. +# The CLI reads these variables at startup; they are never committed. + +# --- Telemetry / Sentry -------------------------------------------------- +# Provide a Sentry DSN to enable error reporting. Leave empty to disable. +MAPILIO_KIT_SENTRY_DSN= + +# Set to 1/true/yes to disable Sentry even if a DSN is configured. +MAPILIO_KIT_DISABLE_TELEMETRY=0 + +# Sample rates for Sentry tracing/profiling (float between 0.0 and 1.0). +# Defaults are conservative (0.1 = 10%) to limit overhead. +MAPILIO_KIT_SENTRY_TRACES_RATE=0.1 +MAPILIO_KIT_SENTRY_PROFILES_RATE=0.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..31d569a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + # Lightweight install: only what unit tests need. + # Heavy/optional deps (ffpb, sentry-sdk, calculation, gps-anomaly-detector) + # are stubbed in tests/conftest.py. + python -m pip install \ + pytest pytest-cov \ + ruff \ + requests "urllib3>=2.2.2" "certifi>=2024.7.4" "idna>=3.7" \ + "charset-normalizer>=3.3.2" attrs jsonschema pyrsistent \ + python-dateutil colorama tqdm typing-extensions six \ + piexif ExifRead pynmea2 gpxpy Shapely tzwhere \ + "construct>=2.10.0,<3.0.0" + + - name: Lint (ruff) + run: ruff check mapilio_kit tests + continue-on-error: true + + - name: Run tests + run: pytest --cov=mapilio_kit --cov-report=term-missing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50bfb06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# --- Secrets / local config --- +.env +.env.local +.env.*.local +*.pem + +# --- Python --- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# --- Virtual environments --- +.venv/ +venv/ +env/ +ENV/ + +# --- Testing / coverage --- +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover + +# --- Linting / type-check caches --- +.ruff_cache/ +.mypy_cache/ + +# --- IDEs / editors --- +.idea/ +.vscode/ +*.swp +*.swo + +# --- OS --- +.DS_Store +Thumbs.db + +# --- Project-specific --- +mapilio_kit/base/bin/ +extras/max2sphere-batch/MAX2spherebatch +*.log diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d965c17 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# Pre-commit hooks for mapilio-kit. +# Install with: pip install pre-commit && pre-commit install +# Run on whole tree: pre-commit run --all-files + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=1024"] + - id: detect-private-key + - id: mixed-line-ending + args: ["--fix=lf"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.5 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af6e1ea..2a3fa9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,96 @@ -

Contributing

- -

We welcome contributions from the community! If you'd like to contribute to the project, please follow these guidelines:

- -
    -
  1. Fork the Repository: -

    Fork this repository to your own GitHub account.

    -
  2. -
  3. Create a Branch: -

    Create a new branch for your feature or bug fix.

    -
    git checkout -b feature/your-feature
    -
  4. -
  5. Make Changes: -

    Make your changes to the codebase. Be sure to follow the project's coding style and conventions.

    -
  6. -
  7. Commit Changes: -

    Commit your changes with clear and descriptive commit messages.

    -
    git commit -m "Add feature: your feature description"
    -
  8. -
  9. Push Changes: -

    Push your changes to your forked repository on GitHub.

    -
    git push origin feature/your-feature
    -
  10. -
  11. Open a Pull Request: -

    Open a pull request from your forked repository to the original repository. Provide a clear and detailed description of your changes.

    -
  12. -
  13. Code Review: -

    Be open to feedback and participate in the code review process. Address any comments or suggestions from maintainers.

    -
  14. -
  15. Merge: -

    Once your pull request is approved, it will be merged into the main project. Congratulations, you've contributed to the project!

    -
  16. -
- -
-

License

- -

This project is licensed under the MIT LICENSE - see the LICENSE.md file for details.

-
\ No newline at end of file +# Contributing to Mapilio Kit + +Thanks for your interest in improving Mapilio Kit! This document explains how +to set up your environment, how the project is organised, and what we expect +from contributions. + +## Getting Started + +1. **Fork & clone** + ```bash + git clone https://github.com//mapilio-kit-v2.git + cd mapilio-kit-v2 + ``` + +2. **Create a virtual environment** (Python 3.8+ supported, 3.10+ recommended): + ```bash + python -m venv .venv + source .venv/bin/activate # Windows: .venv\Scripts\activate + python -m pip install --upgrade pip + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + pip install pytest pytest-cov ruff black pre-commit + ``` + +4. **Configure environment** + ```bash + cp .env.example .env # edit if you need Sentry / telemetry + ``` + +5. **Install pre-commit hooks** (optional but recommended): + ```bash + pre-commit install + ``` + +## Branching & commits + +- Open feature branches from `main`: + ```bash + git checkout -b feature/ + git checkout -b fix/ + ``` +- Use clear, imperative commit messages: `"Fix GPX parser for sub-second timestamps"`. +- Squash trivial fixups before opening the PR. + +## Running the test suite + +```bash +pytest # unit tests only (fast, no external tools) +pytest --run-integration # includes tests that need ffmpeg / exiftool +pytest --cov=mapilio_kit # with coverage report +``` + +Heavy optional dependencies (`calculation`, GoPro binaries) are stubbed in +`tests/conftest.py` so the unit tests run on a clean machine. If you add a +test that needs a real binary, mark it with +`@pytest.mark.integration` so it's skipped by default. + +## Linting & formatting + +We use `ruff` for linting/imports and `black` for formatting: + +```bash +ruff check mapilio_kit tests +ruff check --fix mapilio_kit tests +black mapilio_kit tests +``` + +Both tools are configured in `pyproject.toml` and run automatically via +`pre-commit`. + +## Pull requests + +Before opening a PR, make sure: + +- [ ] Tests pass locally (`pytest`). +- [ ] Lint is clean (`ruff check`). +- [ ] You've added/updated tests for any behaviour change. +- [ ] User-visible changes are noted in the PR description. +- [ ] No secrets, tokens, or credentials are committed (the `.gitignore` + excludes `.env`; double-check with `git status`). + +Once green, open a PR against `main` and request review. CI runs the test +suite on Python 3.9 / 3.10 / 3.11 / 3.12 — please keep all of those green. + +## Where to make changes + +A short tour of the codebase lives in +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). The CLI commands and their +arguments are documented in [`docs/CLI.md`](docs/CLI.md). + +## License + +This project is licensed under the MIT License — see [`LICENSE`](LICENSE). diff --git a/README.md b/README.md index 33e3b54..df2fa22 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,17 @@ Mapilio Kit is a library for processing and uploading images to [Mapilio](https:
  • Contact
  • +

    Documentation

    + + +

    Introduction

    Our Image Uploader with GPS Metadata is a powerful tool designed to simplify the process of uploading and managing images, while also preserving and utilizing valuable location-based information embedded in photos. With the increasing popularity of geotagging in modern cameras and smartphones, GPS metadata in images can provide valuable context and enhance the user experience. Whether you're a photographer, a traveler, or simply someone who values the story behind each image, our uploader has you covered. @@ -100,6 +111,16 @@ sudo apt install exiftool +

    Health-check & preflight

    + +

    Two commands help you catch problems before they bite:

    + +
    mapilio_kit doctor                  # check ffmpeg / exiftool / creds / disk
    +mapilio_kit validate /path/to/imgs  # scan EXIF/GPS without uploading
    + +

    See docs/CLI.md for full options (JSON output, +custom thresholds, strict mode).

    + @@ -119,7 +140,7 @@ sudo apt install exiftool
  • Installation:
    • -
    • via Pip on Windows and Python (3.6 and above) and git are required:

    • +
    • via Pip on Windows and Python (3.8 and above) and git are required:

    • Note: In case you're using PowerShell to run these commands below, you need to re-activate virtual env after installation is done, however, if you're using Command Prompt you don't need to re-activate it.

      # Installation commands
      @@ -129,7 +150,7 @@ win_installer.cmd
       
      -
    • via Pip on Ubuntu + 18.04 and Python (3.6 and above) and git are required:

    • +
    • via Pip on Ubuntu + 18.04 and Python (3.8 and above) and git are required:

    • # Installation commands
       git clone https://github.com/mapilio/mapilio-kit-v2.git
      @@ -138,7 +159,7 @@ chmod +x install.sh
       source ./install.sh
       
    • -

      via Pip on macOS and Python (3.6 and above) and git are required. In addition, commands for ubuntu can also be used for macOS, however, in case using Mac Terminal instead of using iTerm for installation you need to re-activate the virtual env. Otherwise, you're not going to be able to run the kit.

      +

      via Pip on macOS and Python (3.8 and above) and git are required. In addition, commands for ubuntu can also be used for macOS, however, in case using Mac Terminal instead of using iTerm for installation you need to re-activate the virtual env. Otherwise, you're not going to be able to run the kit.


    @@ -265,6 +286,32 @@ mapiio_kit upload "path/to/zipfolder" --proccessed For docker support please visit; [Docker.md](https://github.com/mapilio/mapilio-kit/blob/main/Docker.md)
    For 360 Upload with docker take a look at here; [GoPro360Max.md](https://github.com/mapilio/mapilio-kit/blob/main/GoPro360Max.md) +### Configuration + +Mapilio Kit reads optional settings from environment variables. Sentry-based +error reporting is **disabled by default** — provide `MAPILIO_KIT_SENTRY_DSN` +to opt in, or set `MAPILIO_KIT_DISABLE_TELEMETRY=1` to force-disable. Copy +`.env.example` to `.env` and tweak as needed. See +[`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference. + +### Development + +```bash +# Install dev dependencies +pip install -r requirements.txt +pip install pytest pytest-cov ruff black pre-commit +pre-commit install + +# Run tests +pytest + +# Lint & format +ruff check mapilio_kit tests +black mapilio_kit tests +``` + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full contributor workflow. +

    License

    This project is licensed under the MIT LICENSE - see the LICENSE.md file for details.

    diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6d56e7d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,115 @@ +# Architecture Overview + +Mapilio Kit is organised as a thin CLI on top of a layered library. This +document explains the moving parts so contributors know where to make +changes. + +## High-level layout + +``` +mapilio_kit/ +├── __main__.py # CLI entry point + command dispatch +├── base/ # Top-level command implementations (uploader, +│ # decomposer, video_loader, run_mapi, ...) +└── components/ # Reusable building blocks + ├── auth/ # Login & token management + ├── geotagging/ # GPS / GPX / GoPro location parsing + ├── metadata/ # EXIF read/write + ├── processing/ # FFmpeg wrappers, frame extraction + ├── upload/ # Chunked upload, resume, progress + ├── blending/ # Equirectangular merging for 360° + ├── ipc/ # Inter-process helpers + ├── logs/, logger.py # Logging setup + ├── utilities/ # Shared helpers (point math, CSV, types, errors) + └── version.py # Single source of truth for the package version +``` + +## Request flow + +A typical upload looks like this: + +``` +mapilio_kit upload ./images + │ + ▼ +mapilio_kit/__main__.py (parse args, dispatch) + │ + ▼ +mapilio_kit/base/uploader.py (orchestrates the run) + │ + ├── components/auth/login.py (load credentials) + ├── components/metadata/ (read EXIF tags) + ├── components/geotagging/ (resolve GPS per image) + ├── components/utilities/utilities.py (FOV / aspect / hashing) + └── components/upload/ (chunked HTTP to Mapilio) +``` + +`run` (the magic mode) sits at `base/run_mapi.py` and is essentially a +TUI built on `simple-term-menu` that calls the same building blocks. + +## Geotag sources + +`components/geotagging/` chooses the right parser based on +`--geotag_source`: + +- `images` — read EXIF GPS tags directly from the image files (default). +- `gpx` — match a GPX track to images by capture timestamp. +- `gopro_videos` — extract a GPS track from GoPro telemetry (GPMF). +- `gopro360max` — same, but for `.360` files via the bundled + `MAX2spherebatch` extractor. + +Add a new source by: + +1. Adding a parser in `components/geotagging/` that yields `Point` instances + (see `components/utilities/point.py`). +2. Wiring it into `geotag_property_handler.py`. +3. Exposing the new `--geotag_source` value in `components/utilities/arguments.py`. + +## Native binaries + +The build pulls in `extras/max2sphere-batch` and compiles +`MAX2spherebatch` into `mapilio_kit/base/bin/`. This step is wrapped by +`MakeBuild` in `setup.py` and only runs on Linux. Windows and macOS use +the prebuilt copy distributed with the wheel. + +## Health-check & validation + +Two commands provide a safety net for users: + +- `doctor` (`base/doctor.py` + `components/utilities/doctor.py`) inspects + the local environment — Python version, ffmpeg/exiftool availability, + free disk space, Mapilio credentials, telemetry config — and prints a + text or JSON report. Each individual check is a pure function with + injectable dependencies (subprocess runner, `which`, `disk_usage`, + user loader) so unit tests stay hermetic. +- `validate` (`base/validate.py` + `components/utilities/validator.py`) + walks a directory of images, reads EXIF + GPS read-only, and applies + per-image and sequence-level rules (missing tags, suspicious + `(0, 0)` coords, duplicate captures, large GPS / time gaps). It never + modifies anything; it produces a `ValidationReport` for the CLI to + render. + +Both commands cleanly skip `general_arguments` injection (they handle +their own argument set), so they don't get a forced `import_path` that +doesn't apply. + +## Telemetry + +Sentry is initialised in `__main__.py` _only_ when +`MAPILIO_KIT_SENTRY_DSN` is set and `MAPILIO_KIT_DISABLE_TELEMETRY` is +unset. See [`CONFIGURATION.md`](CONFIGURATION.md) for the full list of +relevant environment variables. + +## Tests + +Tests live under `tests/` and use pytest. They are designed to run on a +"thin" install — heavy optional dependencies (`calculation`, GoPro +binaries, ffmpeg) are stubbed in `tests/conftest.py`. Run with: + +```bash +pytest # unit tests +pytest --run-integration # also run integration tests (need real tools) +``` + +See [`CONTRIBUTING.md`](../CONTRIBUTING.md) for the full development +workflow. diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..81409c1 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,145 @@ +# Mapilio Kit CLI Reference + +This document covers every subcommand exposed by `mapilio_kit`. Run any +command with `--help` to see its full list of arguments. + +```bash +mapilio_kit --help +mapilio_kit --help +``` + +## Quick Reference + +| Command | Purpose | +| --- | --- | +| `run` | Interactive "magic" mode — guides you through everything from a single menu. | +| `authenticate` | Log in / refresh credentials for your Mapilio account. | +| `doctor` | Health-check: verify ffmpeg/exiftool, Python, credentials, disk space. | +| `validate` | Pre-flight EXIF/GPS scan over a folder of images, no upload. | +| `upload` | Upload a folder of geotagged images. | +| `decompose` | Read EXIF/GPS from images and write `mapilio_image_description.json`. | +| `video_upload` | Sample frames from a video, geotag them, and upload. | +| `image_and_csv_upload` | Upload 360° panorama images with a metadata CSV. | +| `CSVprocessor` | Convert a CSV of GPS points into the expected description format. | +| `gopro360max_processor` | Convert GoPro Max `.360` videos into equirectangular frames. | +| `zip` | Bundle a processed folder into a zip ready for upload. | +| `sampler` | Extract frames from a video at a given interval. | + +## Examples + +### One-shot interactive flow + +```bash +mapilio_kit run +``` + +Walks you through authentication, source selection, geotagging and upload. + +### Health-check (doctor) + +Run this first when something doesn't work or after a fresh install. It +verifies that ffmpeg, exiftool, Python and your credentials are all in +order and prints an actionable list of fixes. + +```bash +mapilio_kit doctor # text report (colored) +mapilio_kit doctor --no-color # plain text, useful in logs / CI +mapilio_kit doctor --json # machine-readable JSON +mapilio_kit doctor --strict # exit non-zero on any WARN +``` + +Exit codes: `0` everything OK, `1` warnings (only with `--strict`), `2` +at least one check failed. + +### Validate a folder of images (preflight) + +Walks a directory and reports problems before you upload — missing GPS +tags, missing timestamps, duplicate captures, suspicious `(0, 0)` +coordinates, and large GPS / time gaps between consecutive images. + +```bash +mapilio_kit validate "/path/to/images" +mapilio_kit validate "/path/to/images" --skip_subfolders +mapilio_kit validate "/path/to/images" --json > report.json +mapilio_kit validate "/path/to/images" \ + --max_gps_gap_meters 1000 \ + --max_time_gap_seconds 600 \ + --strict +``` + +Exit codes: `0` clean, `1` warnings (only with `--strict`), `2` errors. + +### Authenticate + +```bash +# Interactive +mapilio_kit authenticate + +# Non-interactive +mapilio_kit authenticate \ + --user_name "alice" \ + --user_email "alice@example.com" \ + --user_password "$MAPILIO_PASSWORD" +``` + +### Upload a directory of geotagged images + +```bash +# First-time upload — will run decompose internally. +mapilio_kit upload "/path/to/images" + +# Skip decompose if you've already produced mapilio_image_description.json. +mapilio_kit upload "/path/to/images" --processed +``` + +### Decompose only (write description JSON without uploading) + +```bash +mapilio_kit decompose "/path/to/images" +``` + +### Video upload with GoPro geotag source + +```bash +mapilio_kit video_upload "/path/to/videos" "/path/to/sample_images" \ + --geotag_source "gopro_videos" \ + --interpolate_directions \ + --video_sample_interval 1 +``` + +### GoPro Max 360 (`.360`) workflow + +```bash +# Step 1: convert .360 → equirectangular frames +mapilio_kit gopro360max_processor \ + --video-file ~/Desktop/GS017111.360 \ + --output-folder ~/Desktop/OutputData/ \ + --bin-dir bin + +# Step 2: upload the frames with a separate GPX track +mapilio_kit upload ~/Desktop/OutputData/frames \ + --user_name "you@example.com" \ + --geotag_source "gpx" \ + --geotag_source_path "~/Desktop/gps_track.gpx" +``` + +### 360° panorama image + CSV + +```bash +mapilio_kit image_and_csv_upload "/path/to/images" \ + --csv_path "/path/to/metadata.csv" +``` + +### Zip and upload (large batches) + +```bash +mapilio_kit zip "/path/to/images" "/path/to/zipfolder" +mapilio_kit upload "/path/to/zipfolder" --processed +``` + +## See also + +- [Configuration & environment variables](CONFIGURATION.md) +- [Architecture overview](ARCHITECTURE.md) +- [Docker usage](../Docker.md) +- [GoPro 360 Max details](../GoPro360Max.md) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..c0e51f0 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,46 @@ +# Configuration + +Mapilio Kit reads configuration from environment variables. Sensitive values +(DSNs, tokens) **must never** be committed; copy `.env.example` to `.env` and +set them locally, or export them in your shell. + +## Telemetry / Sentry + +By default Sentry is **disabled** — no DSN is shipped with the package, so no +events are sent. To opt in, set: + +| Variable | Default | Description | +| --- | --- | --- | +| `MAPILIO_KIT_SENTRY_DSN` | _(empty)_ | Your Sentry project DSN. Leave empty to disable. | +| `MAPILIO_KIT_DISABLE_TELEMETRY` | `0` | Set to `1`/`true` to force-disable Sentry even if a DSN is set. | +| `MAPILIO_KIT_SENTRY_TRACES_RATE` | `0.1` | Sampling rate for performance traces, between `0.0` and `1.0`. | +| `MAPILIO_KIT_SENTRY_PROFILES_RATE` | `0.1` | Sampling rate for profiles, between `0.0` and `1.0`. | + +If `sentry-sdk` is not installed, the CLI silently skips initialization — the +package is treated as an optional runtime dependency. + +### Privacy + +Telemetry, when enabled, only captures uncaught exceptions and (if traces are +enabled) span timings for the CLI. No image content, no GPS coordinates, and +no Mapilio credentials are sent. + +## Local `.env` file + +The recommended workflow: + +```bash +cp .env.example .env +# edit .env with your editor of choice +``` + +`.env` is in `.gitignore`, so your local secrets stay on your machine. The CLI +itself does **not** auto-load `.env`; export the variables in your shell +(e.g. `set -a; source .env; set +a`) or use a tool like +[`direnv`](https://direnv.net/) or `python-dotenv`. + +## Mapilio account credentials + +Account credentials are stored in the per-user config file managed by +`mapilio_kit authenticate` — they are not read from environment variables. +See `mapilio_kit authenticate --help` for details. diff --git a/mapilio_kit/__main__.py b/mapilio_kit/__main__.py index 9a73d4c..dcd2ff3 100644 --- a/mapilio_kit/__main__.py +++ b/mapilio_kit/__main__.py @@ -1,32 +1,71 @@ -# -*- coding: utf-8 -*- import argparse import os import sys -import sentry_sdk from colorama import Fore project_root = os.path.dirname(os.path.realpath(__file__)) sys.path.append(os.path.join(project_root, 'components')) -from mapilio_kit.components.version import VERSION -from mapilio_kit.base import uploader, decomposer, authenticator, video_loader, image_and_csv_uploader, CSVprocessor, \ - gopro360max_processor, Zipper, run_mapi, sampler -from mapilio_kit.components.utilities import arguments +from mapilio_kit.base import ( + CSVprocessor, + Zipper, + authenticator, + decomposer, + doctor, + gopro360max_processor, + image_and_csv_uploader, + run_mapi, + sampler, + uploader, + validator, + video_loader, +) from mapilio_kit.components.auth.login import list_all_users +from mapilio_kit.components.utilities import arguments from mapilio_kit.components.utilities.config import delete_user from mapilio_kit.components.utilities.info import get_latest_version, maintenance_info +from mapilio_kit.components.version import VERSION -sentry_sdk.init( - dsn="https://e64e5a7900578f279015f1c573318337@o4506428096577536.ingest.us.sentry.io/4507385354387456", - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - traces_sample_rate=1.0, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. - profiles_sample_rate=1.0, -) + +def _init_sentry() -> None: + """Initialize Sentry only if a DSN is provided and telemetry is not disabled. + + Configuration is fully driven by environment variables so secrets are never + committed to the repository: + + MAPILIO_KIT_SENTRY_DSN - Sentry DSN (required to enable Sentry) + MAPILIO_KIT_DISABLE_TELEMETRY - Set to "1"/"true" to disable Sentry + MAPILIO_KIT_SENTRY_TRACES_RATE - Float [0.0, 1.0], default 0.1 + MAPILIO_KIT_SENTRY_PROFILES_RATE - Float [0.0, 1.0], default 0.1 + """ + disabled = os.environ.get("MAPILIO_KIT_DISABLE_TELEMETRY", "").lower() in { + "1", "true", "yes", "on", + } + dsn = os.environ.get("MAPILIO_KIT_SENTRY_DSN", "").strip() + if disabled or not dsn: + return + + try: + import sentry_sdk # imported lazily so the package is optional at runtime + except ImportError: + return + + def _rate(name: str, default: float) -> float: + try: + return max(0.0, min(1.0, float(os.environ.get(name, default)))) + except (TypeError, ValueError): + return default + + sentry_sdk.init( + dsn=dsn, + traces_sample_rate=_rate("MAPILIO_KIT_SENTRY_TRACES_RATE", 0.1), + profiles_sample_rate=_rate("MAPILIO_KIT_SENTRY_PROFILES_RATE", 0.1), + release=f"mapilio-kit@{VERSION}", + ) + + +_init_sentry() FUNCTION_MAP = {'Upload': uploader, 'Decompose': decomposer, @@ -37,7 +76,9 @@ "gopro360max_processor": gopro360max_processor, "Zipper": Zipper, "sampler": sampler, - "Run": run_mapi} + "Run": run_mapi, + "Doctor": doctor, + "Validate": validator} def get_parser(subparsers, funtion_map): diff --git a/mapilio_kit/base/__init__.py b/mapilio_kit/base/__init__.py index 1a6777a..8b1f29c 100644 --- a/mapilio_kit/base/__init__.py +++ b/mapilio_kit/base/__init__.py @@ -8,6 +8,8 @@ from mapilio_kit.base.gopro_360max import gopro360max_process from mapilio_kit.base.zip import Zip from mapilio_kit.base.run import Run +from mapilio_kit.base.doctor import Doctor +from mapilio_kit.base.validate import Validate Zipper = Zip gopro360max_processor = gopro360max_process @@ -19,3 +21,5 @@ sampler = Sampler video_loader = VideoUpload run_mapi = Run +doctor = Doctor +validator = Validate diff --git a/mapilio_kit/base/doctor.py b/mapilio_kit/base/doctor.py new file mode 100644 index 0000000..13cc776 --- /dev/null +++ b/mapilio_kit/base/doctor.py @@ -0,0 +1,63 @@ +"""``mapilio_kit doctor`` — health-check command. + +Verifies the local environment is set up correctly: Python version, ffmpeg +and exiftool availability, disk space, authentication state, telemetry +configuration. Designed so new users get an actionable list of fixes +rather than a cryptic failure mid-upload. +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from mapilio_kit.components.utilities.doctor import ( + FAIL, + WARN, + render_text, + run_all_checks, +) +from mapilio_kit.components.version import VERSION + + +class Doctor: + name = "doctor" + help = "Check the local environment for missing tools or misconfiguration" + + def fundamental_arguments(self, parser: argparse.ArgumentParser) -> None: + group = parser.add_argument_group("doctor options") + group.add_argument( + "--json", + dest="output_json", + action="store_true", + default=False, + help="Emit a machine-readable JSON report instead of text.", + ) + group.add_argument( + "--no-color", + dest="no_color", + action="store_true", + default=False, + help="Disable ANSI colors in the text report.", + ) + group.add_argument( + "--strict", + dest="strict", + action="store_true", + default=False, + help="Exit with a non-zero status if any check is WARN or FAIL.", + ) + + def perform_task(self, vars_args: dict) -> None: + report = run_all_checks(VERSION) + + if vars_args.get("output_json"): + print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False)) + else: + print(render_text(report, use_color=not vars_args.get("no_color", False))) + + if report.overall_status == FAIL: + sys.exit(2) + if vars_args.get("strict") and report.overall_status == WARN: + sys.exit(1) diff --git a/mapilio_kit/base/validate.py b/mapilio_kit/base/validate.py new file mode 100644 index 0000000..b081b6d --- /dev/null +++ b/mapilio_kit/base/validate.py @@ -0,0 +1,103 @@ +"""``mapilio_kit validate`` — pre-flight EXIF/GPS check. + +Walks a directory of images and reports problems (missing GPS tags, missing +timestamps, duplicate captures, suspicious coordinates, large GPS gaps) +without modifying anything. Designed to be run before ``upload`` so users +catch problems early. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import typing as T + +from mapilio_kit.components.utilities.validator import ( + MAX_GPS_GAP_METERS, + MAX_TIME_GAP_SECONDS, + build_report, + find_images, + read_image_exif, + render_text, + validate_records, +) + + +class Validate: + name = "validate" + help = "Run pre-flight EXIF/GPS checks on a directory of images" + + def fundamental_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "import_path", + nargs="?", + help="Directory containing the images to validate", + ) + group = parser.add_argument_group("validate options") + group.add_argument( + "--skip_subfolders", + action="store_true", + default=False, + help="Only validate images in the top-level directory.", + ) + group.add_argument( + "--max_gps_gap_meters", + type=float, + default=MAX_GPS_GAP_METERS, + help=( + "Warn when consecutive images are more than this many metres " + "apart (default: %(default)s)." + ), + ) + group.add_argument( + "--max_time_gap_seconds", + type=float, + default=MAX_TIME_GAP_SECONDS, + help=( + "Warn when consecutive images are more than this many seconds " + "apart (default: %(default)s)." + ), + ) + group.add_argument( + "--json", + dest="output_json", + action="store_true", + default=False, + help="Emit a machine-readable JSON report.", + ) + group.add_argument( + "--strict", + dest="strict", + action="store_true", + default=False, + help="Exit with non-zero status if any warnings are reported.", + ) + + def perform_task(self, vars_args: dict) -> None: + path = vars_args.get("import_path") + if not path: + raise SystemExit("validate: import_path is required") + + images = find_images( + path, skip_subfolders=bool(vars_args.get("skip_subfolders")) + ) + records: T.List = [read_image_exif(p) for p in images] + issues = validate_records( + records, + max_gap_meters=float(vars_args.get("max_gps_gap_meters", MAX_GPS_GAP_METERS)), + max_time_gap_seconds=float( + vars_args.get("max_time_gap_seconds", MAX_TIME_GAP_SECONDS) + ), + ) + report = build_report(records, issues) + + if vars_args.get("output_json"): + print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False)) + else: + print(render_text(report)) + + if report.has_errors: + sys.exit(2) + if vars_args.get("strict") and report.has_warnings: + sys.exit(1) diff --git a/mapilio_kit/components/__init__.py b/mapilio_kit/components/__init__.py index 001e81c..6aefb26 100644 --- a/mapilio_kit/components/__init__.py +++ b/mapilio_kit/components/__init__.py @@ -1,2 +1,17 @@ -from mapilio_kit.components.utilities.arguments import general_arguments -from mapilio_kit.components.utilities.edit_config import edit_config \ No newline at end of file +"""Mapilio Kit components package. + +This package previously eagerly imported ``general_arguments`` and +``edit_config`` from sub-modules. Those re-exports turned out to be unused — +every caller imports directly from the originating module — and they had two +nasty side effects: + +* importing any single submodule (e.g. + ``mapilio_kit.components.utilities.error``) dragged in the entire pipeline + (ffmpeg wrappers, exiftool helpers, sentry, etc.); +* unit tests that only need lightweight helpers had to stub heavy optional + dependencies just to import them. + +Keeping ``__init__`` empty restores the principle of least surprise. If you +need to expose a public API from this package, prefer explicit ``__all__`` ++ PEP 562 lazy imports over top-level imports. +""" diff --git a/mapilio_kit/components/utilities/arguments.py b/mapilio_kit/components/utilities/arguments.py index a911b57..273ce97 100644 --- a/mapilio_kit/components/utilities/arguments.py +++ b/mapilio_kit/components/utilities/arguments.py @@ -2,7 +2,14 @@ def general_arguments(parser, command): - if command == "authenticate" or command == "gopro360max_process" or command == "run": + # Commands that manage their own positional/optional arguments entirely. + if command in ( + "authenticate", + "gopro360max_process", + "run", + "doctor", + "validate", + ): return if command in ["Sampler", "video_process", "video_upload"]: parser.add_argument( diff --git a/mapilio_kit/components/utilities/doctor.py b/mapilio_kit/components/utilities/doctor.py new file mode 100644 index 0000000..63f70d2 --- /dev/null +++ b/mapilio_kit/components/utilities/doctor.py @@ -0,0 +1,378 @@ +"""Health-check helpers used by the ``mapilio_kit doctor`` command. + +The functions here are intentionally pure (no side effects beyond the system +calls they describe) so that ``base/doctor.py`` stays a thin CLI wrapper and +unit tests can exercise the underlying logic with mocks. +""" + +from __future__ import annotations + +import os +import platform +import re +import shutil +import subprocess +import sys +import typing as T +from dataclasses import asdict, dataclass, field + +# These minimums match what we test on CI; older versions may "work" but are +# unsupported. +MIN_PYTHON: T.Tuple[int, int] = (3, 8) +MIN_FFMPEG: T.Tuple[int, int] = (4, 0) +MIN_EXIFTOOL: T.Tuple[int, int] = (12, 0) +MIN_FREE_DISK_BYTES: int = 1 * 1024 * 1024 * 1024 # 1 GiB + +#: Status values used for individual checks. +OK = "ok" +WARN = "warn" +FAIL = "fail" + + +@dataclass +class CheckResult: + """Outcome of a single health check.""" + + name: str + status: str # one of OK / WARN / FAIL + message: str + details: T.Dict[str, T.Any] = field(default_factory=dict) + + +@dataclass +class DoctorReport: + """A bundle of every check executed by ``doctor``.""" + + checks: T.List[CheckResult] = field(default_factory=list) + + @property + def overall_status(self) -> str: + """Worst status across all checks.""" + if any(c.status == FAIL for c in self.checks): + return FAIL + if any(c.status == WARN for c in self.checks): + return WARN + return OK + + def to_dict(self) -> T.Dict[str, T.Any]: + return { + "overall_status": self.overall_status, + "checks": [asdict(c) for c in self.checks], + } + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # + + +def _parse_version(text: str) -> T.Optional[T.Tuple[int, ...]]: + """Pull the first ``X.Y[.Z]`` token out of ``text``.""" + match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text) + if not match: + return None + return tuple(int(p) for p in match.groups() if p is not None) + + +def _ge(actual: T.Tuple[int, ...], minimum: T.Tuple[int, ...]) -> bool: + return tuple(actual[: len(minimum)]) >= minimum + + +def _run( + cmd: T.Sequence[str], + timeout: float = 5.0, +) -> T.Tuple[int, str, str]: + """Run ``cmd`` and return (returncode, stdout, stderr). + + Returns ``(-1, "", str(error))`` if the binary is missing or the run + times out. Never raises. + """ + try: + proc = subprocess.run( # noqa: S603 — args are fixed by callers + list(cmd), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + return -1, "", f"not found: {exc}" + except subprocess.TimeoutExpired: + return -1, "", "timeout" + except OSError as exc: # pragma: no cover - very unusual + return -1, "", str(exc) + return proc.returncode, proc.stdout, proc.stderr + + +# --------------------------------------------------------------------------- # +# Individual checks # +# --------------------------------------------------------------------------- # + + +def check_python_version( + version_info: T.Tuple[int, ...] = sys.version_info[:3], + minimum: T.Tuple[int, int] = MIN_PYTHON, +) -> CheckResult: + actual = tuple(version_info) + pretty = ".".join(str(p) for p in actual) + if _ge(actual, minimum): + return CheckResult( + name="python", + status=OK, + message=f"Python {pretty}", + details={"version": pretty}, + ) + return CheckResult( + name="python", + status=FAIL, + message=( + f"Python {pretty} is below the supported minimum " + f"{minimum[0]}.{minimum[1]}" + ), + details={"version": pretty, "minimum": list(minimum)}, + ) + + +def check_kit_version(version: str) -> CheckResult: + return CheckResult( + name="mapilio_kit", + status=OK, + message=f"mapilio-kit {version}", + details={"version": version}, + ) + + +def _check_external_binary( + name: str, + args_for_version: T.Sequence[str], + minimum: T.Tuple[int, int], + runner: T.Callable[[T.Sequence[str]], T.Tuple[int, str, str]] = _run, + which: T.Callable[[str], T.Optional[str]] = shutil.which, +) -> CheckResult: + binary_path = which(name) + if not binary_path: + return CheckResult( + name=name, + status=FAIL, + message=f"{name} not found in PATH", + details={"path": None}, + ) + rc, stdout, stderr = runner([binary_path, *args_for_version]) + output = stdout if stdout else stderr + version = _parse_version(output) if rc != -1 else None + if version is None: + return CheckResult( + name=name, + status=WARN, + message=f"{name} found at {binary_path} but version could not be parsed", + details={"path": binary_path, "raw_output": output[:200]}, + ) + pretty = ".".join(str(p) for p in version) + if _ge(version, minimum): + return CheckResult( + name=name, + status=OK, + message=f"{name} {pretty} ({binary_path})", + details={"path": binary_path, "version": pretty}, + ) + return CheckResult( + name=name, + status=WARN, + message=( + f"{name} {pretty} is below the recommended minimum " + f"{minimum[0]}.{minimum[1]} ({binary_path})" + ), + details={ + "path": binary_path, + "version": pretty, + "minimum": list(minimum), + }, + ) + + +def check_ffmpeg( + runner: T.Callable[[T.Sequence[str]], T.Tuple[int, str, str]] = _run, + which: T.Callable[[str], T.Optional[str]] = shutil.which, +) -> CheckResult: + return _check_external_binary("ffmpeg", ["-version"], MIN_FFMPEG, runner, which) + + +def check_exiftool( + runner: T.Callable[[T.Sequence[str]], T.Tuple[int, str, str]] = _run, + which: T.Callable[[str], T.Optional[str]] = shutil.which, +) -> CheckResult: + return _check_external_binary("exiftool", ["-ver"], MIN_EXIFTOOL, runner, which) + + +def check_disk_space( + path: str = ".", + minimum_bytes: int = MIN_FREE_DISK_BYTES, + disk_usage: T.Callable[[str], T.Any] = shutil.disk_usage, +) -> CheckResult: + try: + usage = disk_usage(path) + free = int(usage.free) + except OSError as exc: + return CheckResult( + name="disk", + status=WARN, + message=f"Could not check disk space for {path!r}: {exc}", + details={"path": path, "error": str(exc)}, + ) + free_gb = free / (1024**3) + if free >= minimum_bytes: + return CheckResult( + name="disk", + status=OK, + message=f"{free_gb:.1f} GiB free on {path}", + details={"path": path, "free_bytes": free}, + ) + return CheckResult( + name="disk", + status=WARN, + message=( + f"Only {free_gb:.2f} GiB free on {path} (recommended ≥ " + f"{minimum_bytes / (1024**3):.1f} GiB)" + ), + details={ + "path": path, + "free_bytes": free, + "minimum_bytes": minimum_bytes, + }, + ) + + +def check_credentials( + user_loader: T.Optional[T.Callable[[], T.List[T.Dict[str, T.Any]]]] = None, +) -> CheckResult: + """Check that there's at least one authenticated Mapilio user. + + The loader is injected so tests don't need to touch the real config + file; in production we use ``components.auth.login.list_all_users``. + """ + if user_loader is None: + # Imported lazily so doctor doesn't drag in heavy modules at import + # time and so unit tests can run without the auth subsystem. + from mapilio_kit.components.auth.login import list_all_users + + user_loader = list_all_users # type: ignore[assignment] + + try: + users = list(user_loader()) + except Exception as exc: # noqa: BLE001 - we want to report any failure + return CheckResult( + name="credentials", + status=WARN, + message=f"Could not read Mapilio credentials: {exc}", + details={"error": str(exc)}, + ) + + valid = [u for u in users if "SettingsEmail" in u] + if not valid: + return CheckResult( + name="credentials", + status=WARN, + message=( + "No authenticated Mapilio user found. " + "Run 'mapilio_kit authenticate' to sign in." + ), + details={"user_count": 0}, + ) + + emails = [u.get("SettingsEmail") for u in valid] + return CheckResult( + name="credentials", + status=OK, + message=f"{len(valid)} authenticated user(s): {', '.join(str(e) for e in emails)}", + details={"user_count": len(valid), "emails": emails}, + ) + + +def check_telemetry(env: T.Optional[T.Mapping[str, str]] = None) -> CheckResult: + env = env if env is not None else os.environ + disabled = env.get("MAPILIO_KIT_DISABLE_TELEMETRY", "").lower() in { + "1", "true", "yes", "on", + } + dsn_set = bool(env.get("MAPILIO_KIT_SENTRY_DSN", "").strip()) + if disabled: + msg = "Sentry telemetry disabled (MAPILIO_KIT_DISABLE_TELEMETRY)" + elif dsn_set: + msg = "Sentry telemetry enabled (MAPILIO_KIT_SENTRY_DSN set)" + else: + msg = "Sentry telemetry off (no DSN configured)" + return CheckResult( + name="telemetry", + status=OK, + message=msg, + details={"disabled": disabled, "dsn_configured": dsn_set}, + ) + + +def check_platform() -> CheckResult: + return CheckResult( + name="platform", + status=OK, + message=f"{platform.system()} {platform.release()} ({platform.machine()})", + details={ + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + }, + ) + + +# --------------------------------------------------------------------------- # +# Aggregator # +# --------------------------------------------------------------------------- # + + +def run_all_checks(version: str) -> DoctorReport: + """Run every health check and return a populated ``DoctorReport``.""" + return DoctorReport( + checks=[ + check_platform(), + check_python_version(), + check_kit_version(version), + check_ffmpeg(), + check_exiftool(), + check_disk_space(), + check_credentials(), + check_telemetry(), + ] + ) + + +# --------------------------------------------------------------------------- # +# Rendering # +# --------------------------------------------------------------------------- # + +_STATUS_GLYPH = {OK: "✓", WARN: "!", FAIL: "✗"} + + +def render_text(report: DoctorReport, *, use_color: bool = True) -> str: + """Format a report as human-readable text suitable for stdout.""" + try: + from colorama import Fore, Style + color_for = { + OK: Fore.GREEN, + WARN: Fore.YELLOW, + FAIL: Fore.RED, + } + reset = Style.RESET_ALL + except ImportError: # pragma: no cover + color_for = {OK: "", WARN: "", FAIL: ""} + reset = "" + + if not use_color: + color_for = {OK: "", WARN: "", FAIL: ""} + reset = "" + + lines: T.List[str] = ["Mapilio Kit doctor report", "-" * 28] + for check in report.checks: + glyph = _STATUS_GLYPH.get(check.status, "?") + color = color_for.get(check.status, "") + lines.append(f" {color}{glyph}{reset} {check.name:<14} {check.message}") + lines.append("-" * 28) + overall_color = color_for.get(report.overall_status, "") + lines.append(f"Overall: {overall_color}{report.overall_status.upper()}{reset}") + return "\n".join(lines) diff --git a/mapilio_kit/components/utilities/utilities.py b/mapilio_kit/components/utilities/utilities.py index e88bf38..73eb9fc 100644 --- a/mapilio_kit/components/utilities/utilities.py +++ b/mapilio_kit/components/utilities/utilities.py @@ -173,9 +173,11 @@ def get_exiftool_specific_feature(video_or_image_path: str, exiftool_path=None) dict_object['device_model'] = filtered_line.split(':')[1].lstrip(' ') if 'image size' in filtered_line: dict_object['image_size'] = filtered_line.split(':')[1].lstrip(' ') - except TypeError: - raise f"Exif data does not Exist !" \ - f"Please remove this video file {video_or_image_path}" + except TypeError as exc: + raise RuntimeError( + f"Exif data does not exist! " + f"Please remove this video file: {video_or_image_path}" + ) from exc if dict_object['field_of_view'] and "deg" in dict_object['field_of_view']: dict_object['field_of_view'] = float(dict_object['field_of_view'].replace('deg', '')) @@ -245,13 +247,28 @@ def is_large_video(video_size, large_video_threshold=1 * 1024 * 1024 * 1024): else: return False -def calculate_chunk_size(video_size, large_video_threshold=1 * 1024 * 1024 * 1024): - """ - Calculate chunk size based on video size. - Use smaller chunks for larger videos. - """ +#: Default number of frames per chunk when extracting a "normal-sized" video. +DEFAULT_CHUNK_FRAMES = 5000 +#: Frames per chunk for videos that exceed ``large_video_threshold``. +LARGE_VIDEO_CHUNK_FRAMES = 2500 - if video_size > large_video_threshold: - chunk_size = 2500 - return chunk_size +def calculate_chunk_size( + video_size: int, + large_video_threshold: int = 1 * 1024 * 1024 * 1024, +) -> int: + """Pick a frame chunk size based on video size. + + Larger videos get smaller chunks so each FFmpeg invocation stays bounded + in memory. Small videos use ``DEFAULT_CHUNK_FRAMES``. + + Args: + video_size: video file size in bytes. + large_video_threshold: byte threshold above which a video is "large". + + Returns: + Number of frames to process per chunk (always a positive integer). + """ + if video_size > large_video_threshold: + return LARGE_VIDEO_CHUNK_FRAMES + return DEFAULT_CHUNK_FRAMES diff --git a/mapilio_kit/components/utilities/validator.py b/mapilio_kit/components/utilities/validator.py new file mode 100644 index 0000000..1b87460 --- /dev/null +++ b/mapilio_kit/components/utilities/validator.py @@ -0,0 +1,352 @@ +"""Pre-flight EXIF/GPS validator used by ``mapilio_kit validate``. + +This module deliberately performs *only* read-only inspection of images and +does not modify or upload anything. It produces a structured report that the +CLI wrapper turns into text or JSON. + +Heavy dependencies (e.g. PIL/exiftool) are imported lazily inside helpers so +unit tests can run without installing them, and so importing this module +stays cheap. +""" + +from __future__ import annotations + +import math +import os +import typing as T +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone + +#: File extensions we consider candidates for validation. +IMAGE_EXTENSIONS: T.FrozenSet[str] = frozenset({ + ".jpg", ".jpeg", ".png", ".tif", ".tiff", +}) + +#: Thresholds used by the default validation rules. The CLI exposes overrides. +MAX_GPS_GAP_METERS: float = 5_000.0 # warn when consecutive points are >5km apart +MIN_TIME_GAP_SECONDS: float = 0.0 # timestamps should not go backwards +MAX_TIME_GAP_SECONDS: float = 3_600.0 # warn when consecutive captures >1h apart +SUSPICIOUS_LATLON: T.Tuple[float, float] = (0.0, 0.0) + + +@dataclass +class ImageRecord: + """Minimal per-image metadata used by the validator.""" + + path: str + lat: T.Optional[float] = None + lon: T.Optional[float] = None + captured_at: T.Optional[float] = None # epoch seconds, UTC + error: T.Optional[str] = None + + +@dataclass +class Issue: + """A single problem found during validation.""" + + level: str # "error" or "warning" + code: str + path: T.Optional[str] + message: str + details: T.Dict[str, T.Any] = field(default_factory=dict) + + +@dataclass +class ValidationReport: + image_count: int + valid_count: int + issues: T.List[Issue] + summary: T.Dict[str, int] + + @property + def has_errors(self) -> bool: + return any(i.level == "error" for i in self.issues) + + @property + def has_warnings(self) -> bool: + return any(i.level == "warning" for i in self.issues) + + def to_dict(self) -> T.Dict[str, T.Any]: + return { + "image_count": self.image_count, + "valid_count": self.valid_count, + "summary": self.summary, + "issues": [asdict(i) for i in self.issues], + } + + +# --------------------------------------------------------------------------- # +# Discovery # +# --------------------------------------------------------------------------- # + + +def find_images( + root: str, + *, + skip_subfolders: bool = False, + extensions: T.Iterable[str] = IMAGE_EXTENSIONS, +) -> T.List[str]: + """Return a sorted list of image paths under ``root``.""" + exts = {e.lower() for e in extensions} + if not os.path.isdir(root): + return [] + found: T.List[str] = [] + if skip_subfolders: + for entry in os.listdir(root): + full = os.path.join(root, entry) + if os.path.isfile(full) and os.path.splitext(entry)[1].lower() in exts: + found.append(full) + else: + for dirpath, _dirs, filenames in os.walk(root): + for fname in filenames: + if os.path.splitext(fname)[1].lower() in exts: + found.append(os.path.join(dirpath, fname)) + return sorted(found) + + +# --------------------------------------------------------------------------- # +# EXIF reading # +# --------------------------------------------------------------------------- # + + +def _dms_to_decimal(dms: T.Sequence[T.Any], ref: str) -> float: + """Convert ((d, m, s), ref) to a signed decimal degree.""" + deg, minutes, seconds = (float(x) for x in dms[:3]) + value = deg + minutes / 60.0 + seconds / 3600.0 + if ref in ("S", "W"): + value = -value + return value + + +def _parse_exif_datetime(text: str) -> T.Optional[float]: + """Parse the standard EXIF DateTime ``YYYY:MM:DD HH:MM:SS`` format.""" + text = text.strip() + fmts = ( + "%Y:%m:%d %H:%M:%S", + "%Y:%m:%d %H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + ) + for fmt in fmts: + try: + return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc).timestamp() + except ValueError: + continue + return None + + +def read_image_exif( + path: str, + *, + exif_reader: T.Optional[T.Callable[[T.IO[bytes]], T.Mapping[str, T.Any]]] = None, +) -> ImageRecord: + """Extract GPS + timestamp from a single image. + + ``exif_reader`` is injectable so unit tests can drive the function without + a real exif backend. If omitted, ``ExifRead`` is loaded lazily. + """ + if exif_reader is None: + try: + import exifread + + def _default_reader(fp: T.IO[bytes]) -> T.Mapping[str, T.Any]: + return exifread.process_file(fp, details=False) + + exif_reader = _default_reader + except ImportError: + return ImageRecord(path=path, error="exifread not installed") + + try: + with open(path, "rb") as fp: + tags = exif_reader(fp) + except OSError as exc: + return ImageRecord(path=path, error=f"open failed: {exc}") + except Exception as exc: # noqa: BLE001 - exifread can raise odd errors + return ImageRecord(path=path, error=f"exif read failed: {exc}") + + record = ImageRecord(path=path) + + lat_tag = tags.get("GPS GPSLatitude") + lat_ref = tags.get("GPS GPSLatitudeRef") + lon_tag = tags.get("GPS GPSLongitude") + lon_ref = tags.get("GPS GPSLongitudeRef") + if lat_tag and lat_ref and lon_tag and lon_ref: + try: + lat_values = [v.num / v.den for v in lat_tag.values] + lon_values = [v.num / v.den for v in lon_tag.values] + record.lat = _dms_to_decimal(lat_values, str(lat_ref)) + record.lon = _dms_to_decimal(lon_values, str(lon_ref)) + except (AttributeError, TypeError, ZeroDivisionError): + # Some EXIF parsers return plain floats already; fall back. + try: + record.lat = _dms_to_decimal(list(lat_tag.values), str(lat_ref)) + record.lon = _dms_to_decimal(list(lon_tag.values), str(lon_ref)) + except Exception: # noqa: BLE001 + pass + + for time_key in ("EXIF DateTimeOriginal", "Image DateTime", "EXIF DateTimeDigitized"): + time_tag = tags.get(time_key) + if time_tag is None: + continue + ts = _parse_exif_datetime(str(time_tag)) + if ts is not None: + record.captured_at = ts + break + + return record + + +# --------------------------------------------------------------------------- # +# Rules # +# --------------------------------------------------------------------------- # + + +def _haversine_meters(p1: T.Tuple[float, float], p2: T.Tuple[float, float]) -> float: + """Great-circle distance between two (lat, lon) pairs, in metres.""" + r = 6_371_000.0 + lat1, lon1 = math.radians(p1[0]), math.radians(p1[1]) + lat2, lon2 = math.radians(p2[0]), math.radians(p2[1]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def validate_records( + records: T.Sequence[ImageRecord], + *, + max_gap_meters: float = MAX_GPS_GAP_METERS, + max_time_gap_seconds: float = MAX_TIME_GAP_SECONDS, +) -> T.List[Issue]: + """Apply per-image and sequence-level rules, returning a list of issues.""" + issues: T.List[Issue] = [] + + valid_pairs: T.List[T.Tuple[ImageRecord, float, float, float]] = [] + + for rec in records: + if rec.error: + issues.append(Issue( + level="error", + code="exif_read_error", + path=rec.path, + message=rec.error, + )) + continue + if rec.lat is None or rec.lon is None: + issues.append(Issue( + level="error", + code="missing_gps", + path=rec.path, + message="Image has no GPS tags", + )) + continue + if (rec.lat, rec.lon) == SUSPICIOUS_LATLON: + issues.append(Issue( + level="warning", + code="suspicious_gps_zero", + path=rec.path, + message="GPS coordinates are exactly (0, 0)", + details={"lat": rec.lat, "lon": rec.lon}, + )) + if not (-90.0 <= rec.lat <= 90.0 and -180.0 <= rec.lon <= 180.0): + issues.append(Issue( + level="error", + code="invalid_gps", + path=rec.path, + message=f"GPS coordinates out of range: ({rec.lat}, {rec.lon})", + details={"lat": rec.lat, "lon": rec.lon}, + )) + continue + if rec.captured_at is None: + issues.append(Issue( + level="error", + code="missing_timestamp", + path=rec.path, + message="Image has no EXIF capture time", + )) + continue + valid_pairs.append((rec, rec.lat, rec.lon, rec.captured_at)) + + # Sequence-level checks: walk in capture-time order. + valid_pairs.sort(key=lambda x: x[3]) + + seen_keys: T.Dict[T.Tuple[float, float, float], str] = {} + for rec, lat, lon, t in valid_pairs: + key = (round(lat, 6), round(lon, 6), round(t, 1)) + if key in seen_keys: + issues.append(Issue( + level="warning", + code="duplicate_capture", + path=rec.path, + message=f"Same lat/lon/time as {seen_keys[key]}", + details={"duplicate_of": seen_keys[key]}, + )) + else: + seen_keys[key] = rec.path + + for prev, curr in zip(valid_pairs, valid_pairs[1:]): + prev_rec, prev_lat, prev_lon, prev_t = prev + curr_rec, curr_lat, curr_lon, curr_t = curr + if curr_t < prev_t: # already sorted, but defensive + continue + time_gap = curr_t - prev_t + if time_gap > max_time_gap_seconds: + issues.append(Issue( + level="warning", + code="large_time_gap", + path=curr_rec.path, + message=( + f"{time_gap:.0f}s since previous image " + f"(threshold {max_time_gap_seconds:.0f}s)" + ), + details={"previous": prev_rec.path, "gap_seconds": time_gap}, + )) + gap = _haversine_meters((prev_lat, prev_lon), (curr_lat, curr_lon)) + if gap > max_gap_meters: + issues.append(Issue( + level="warning", + code="large_gps_gap", + path=curr_rec.path, + message=( + f"{gap:.0f} m from previous image " + f"(threshold {max_gap_meters:.0f} m)" + ), + details={"previous": prev_rec.path, "gap_meters": gap}, + )) + + return issues + + +def build_report(records: T.Sequence[ImageRecord], issues: T.List[Issue]) -> ValidationReport: + summary: T.Dict[str, int] = {} + for issue in issues: + summary[issue.code] = summary.get(issue.code, 0) + 1 + paths_with_errors = {i.path for i in issues if i.level == "error" and i.path} + valid_count = sum(1 for r in records if r.path not in paths_with_errors) + return ValidationReport( + image_count=len(records), + valid_count=valid_count, + issues=issues, + summary=summary, + ) + + +def render_text(report: ValidationReport) -> str: + lines: T.List[str] = ["Mapilio Kit validation report", "-" * 30] + lines.append(f" images : {report.image_count}") + lines.append(f" valid (ready): {report.valid_count}") + if report.summary: + lines.append(" issues :") + for code, count in sorted(report.summary.items()): + lines.append(f" - {code:<24} {count}") + else: + lines.append(" issues : none") + if report.issues: + lines.append("") + lines.append("Details (first 20):") + for issue in report.issues[:20]: + tag = "ERROR" if issue.level == "error" else "WARN " + path = issue.path or "-" + lines.append(f" [{tag}] {issue.code} {path}") + lines.append(f" {issue.message}") + return "\n".join(lines) diff --git a/mapilio_kit/components/version.py b/mapilio_kit/components/version.py index 1f3edff..576283f 100644 --- a/mapilio_kit/components/version.py +++ b/mapilio_kit/components/version.py @@ -1,2 +1,7 @@ -# TODO check before commit -VERSION = "3.0.5" +"""Single source of truth for the package version. + +Bumping the version is the only step required for a release; ``setup.py`` +reads ``VERSION`` from this module via ``exec``. +""" + +VERSION = "3.0.6" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1c9c979 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +# Tooling configuration for mapilio-kit. +# Project metadata still lives in setup.py because the build has custom +# extension steps; this file is intentionally limited to dev tooling. + +[tool.black] +line-length = 100 +target-version = ["py38", "py39", "py310", "py311", "py312"] +extend-exclude = ''' +/( + \.git + | \.venv + | build + | dist + | extras + | mapilio_kit/base/bin +)/ +''' + +[tool.ruff] +line-length = 100 +target-version = "py38" +extend-exclude = [ + "extras", + "mapilio_kit/base/bin", + "build", + "dist", +] + +[tool.ruff.lint] +# Conservative starter set. We can broaden once the codebase is clean. +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "W", # pycodestyle warnings +] +ignore = [ + "E501", # line too long — handled by black + "B008", # function calls in argument defaults (common pattern here) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B", "F401"] +"__init__.py" = ["F401"] + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers" +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +source = ["mapilio_kit"] +omit = [ + "mapilio_kit/base/bin/*", + "tests/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/requirements.txt b/requirements.txt index 8dc3a66..e0b32bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,46 @@ -attrs==21.2.0 -certifi==2021.10.8 -chardet==3.0.4 -charset-normalizer==2.0.7 -ExifRead==2.3.2 -gpxpy==0.9.8 -pip>22.3.0 -idna==2.7 -jsonschema==4.1.1 -key==0.4 -piexif==1.1.3 +# Runtime dependencies for mapilio-kit. +# Pinned with lower bounds (>=) for security/maintenance flexibility while +# keeping known-good upper bounds where the API has changed historically. + +# --- HTTP / networking (security-critical) --- +certifi>=2024.7.4 +charset-normalizer>=3.3.2 +idna>=3.7 +requests>=2.32.3 +urllib3>=2.2.2 + +# --- Geospatial / GPS --- +gpxpy>=1.6.2 +pynmea2>=1.19.0 +Shapely>=2.0.0 +tzwhere>=3.0.3 + +# --- EXIF / image metadata --- +ExifRead>=3.0.0 +piexif>=1.1.3 + +# --- Data / formats --- +attrs>=23.1.0 construct>=2.10.0,<3.0.0 -pynmea2==1.19.0 -pyrsistent==0.18.0 -python-dateutil==2.8.2 -requests==2.32.3 -Shapely -colorama==0.4.6 -six==1.16.0 -tqdm==4.62.3 +jsonschema>=4.21.1 +pyrsistent>=0.20.0 +python-dateutil>=2.8.2 + +# --- CLI / UX --- +colorama>=0.4.6 +simple-term-menu>=1.6.4 +tqdm>=4.66.3 + +# --- Misc --- +ffpb>=0.4.1 +key>=0.4 +sentry-sdk>=2.5.0 +six>=1.16.0 typing-extensions>=4.7.1 -tzwhere==3.0.3 -simple-term-menu -gps-anomaly-detector + +# --- Mapilio in-house packages --- calculation-mapilio -windows-curses; platform_system == "Windows" -urllib3==2.2.1 -ffpb -sentry-sdk -typing_extensions +gps-anomaly-detector + +# --- Platform-specific --- +windows-curses; platform_system == "Windows" diff --git a/setup.py b/setup.py index c16328c..946f6d8 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,20 @@ import re import subprocess import sys -from distutils.version import LooseVersion import platform - -from setuptools import setup import warnings +from setuptools import setup from setuptools.command.build_ext import build_ext from setuptools.extension import Extension +# `distutils` was removed in Python 3.12. Fall back to `packaging.version`, +# which is available with any modern setuptools install. +try: + from packaging.version import Version as _Version +except ImportError: # pragma: no cover - extremely old environment + from distutils.version import LooseVersion as _Version # type: ignore + with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -36,8 +41,8 @@ def run(self): raise RuntimeError("CMake must be installed to build the following extensions: " + ", ".join(e.name for e in self.extensions)) - cmake_version = LooseVersion(re.search(r'GNU Make\s*([\d.]+)', out.decode()).group(1)) - if cmake_version < LooseVersion('4.2.1'): + cmake_version = _Version(re.search(r'GNU Make\s*([\d.]+)', out.decode()).group(1)) + if cmake_version < _Version('4.2.1'): raise RuntimeError("GNU Make >= 4.2.1 is required") for ext in self.extensions: @@ -93,7 +98,20 @@ def win_read_requirements(): url='https://github.com/mapilio/mapilio-kit-v2', author='Mapilio', license='MIT License', - python_requires=">=3.6", + python_requires=">=3.8", + classifiers=[ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics", + "Topic :: Scientific/Engineering :: GIS", + ], ext_modules=ext_modules, cmdclass=cmdclass, packages=['mapilio_kit', 'mapilio_kit.base', 'mapilio_kit.components','mapilio_kit.components.auth','mapilio_kit.components.geotagging','mapilio_kit.components.ipc','mapilio_kit.components.metadata','mapilio_kit.components.upload','mapilio_kit.components.blending','mapilio_kit.components.logs','mapilio_kit.components.processing','mapilio_kit.components.utilities'], diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4dc5e3c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared pytest configuration. + +We deliberately stub out optional native/heavy dependencies so unit tests can +run on a vanilla CI runner without installing the full pipeline. Tests that +need the real implementations should mark themselves with +``@pytest.mark.integration`` and be skipped by default. +""" + +from __future__ import annotations + +import os +import sys +import types + +import pytest + +# Disable telemetry during tests no matter what is in the environment. +os.environ.setdefault("MAPILIO_KIT_DISABLE_TELEMETRY", "1") +os.environ.pop("MAPILIO_KIT_SENTRY_DSN", None) + + +def _install_stub(name: str, attrs: dict | None = None) -> None: + """Insert a placeholder module into ``sys.modules`` if it's missing.""" + if name in sys.modules: + return + module = types.ModuleType(name) + for key, value in (attrs or {}).items(): + setattr(module, key, value) + sys.modules[name] = module + + +# ``calculation`` is an in-house package not always installable in CI. The +# only symbol we touch from it is ``calculate_vfov``. +_install_stub("calculation") +_install_stub( + "calculation.util", + {"calculate_vfov": lambda fov, w, h: round(float(fov) * float(h) / max(float(w), 1.0), 2)}, +) + + +def pytest_collection_modifyitems(config, items): + """Skip integration tests unless explicitly opted in.""" + run_integration = config.getoption("--run-integration", default=False) + if run_integration: + return + skip_marker = pytest.mark.skip(reason="needs --run-integration") + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_marker) + + +def pytest_addoption(parser): + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run tests marked as integration (require external tools).", + ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..0f55769 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,227 @@ +"""Unit tests for ``mapilio_kit.components.utilities.doctor``. + +These tests exercise the individual checks with mocks so they don't depend +on whether ffmpeg / exiftool / a Mapilio account exist on the host. +""" + +from __future__ import annotations + +import json +from collections import namedtuple + +import pytest + +from mapilio_kit.components.utilities.doctor import ( + FAIL, + OK, + WARN, + CheckResult, + DoctorReport, + check_credentials, + check_disk_space, + check_exiftool, + check_ffmpeg, + check_kit_version, + check_python_version, + check_telemetry, + render_text, + run_all_checks, +) + +# ----------------------------- python check ----------------------------- # + + +def test_python_check_ok_for_supported_version() -> None: + result = check_python_version(version_info=(3, 11, 1), minimum=(3, 8)) + assert result.status == OK + assert "3.11.1" in result.message + + +def test_python_check_fails_for_too_old() -> None: + result = check_python_version(version_info=(3, 6, 9), minimum=(3, 8)) + assert result.status == FAIL + + +# ---------------------------- kit version ------------------------------- # + + +def test_kit_version_check_always_ok() -> None: + result = check_kit_version("9.9.9") + assert result.status == OK + assert result.details["version"] == "9.9.9" + + +# ----------------------- external binaries (mocked) --------------------- # + + +def _make_runner(rc: int, stdout: str = "", stderr: str = ""): + def _runner(_cmd): + return rc, stdout, stderr + return _runner + + +def test_ffmpeg_missing_is_fail() -> None: + result = check_ffmpeg(runner=_make_runner(0), which=lambda _: None) + assert result.status == FAIL + assert "not found" in result.message + + +def test_ffmpeg_present_and_modern_is_ok() -> None: + result = check_ffmpeg( + runner=_make_runner(0, "ffmpeg version 6.1.1 Copyright ..."), + which=lambda _: "/usr/bin/ffmpeg", + ) + assert result.status == OK + assert "6.1" in result.message + + +def test_ffmpeg_too_old_is_warn() -> None: + result = check_ffmpeg( + runner=_make_runner(0, "ffmpeg version 2.8.5"), + which=lambda _: "/usr/bin/ffmpeg", + ) + assert result.status == WARN + + +def test_ffmpeg_unparseable_version_is_warn() -> None: + result = check_ffmpeg( + runner=_make_runner(0, "ffmpeg version built ..."), + which=lambda _: "/usr/bin/ffmpeg", + ) + assert result.status == WARN + + +def test_exiftool_modern_version_is_ok() -> None: + result = check_exiftool( + runner=_make_runner(0, "12.78\n"), + which=lambda _: "/usr/bin/exiftool", + ) + assert result.status == OK + + +# ------------------------------ disk ------------------------------------ # + + +_Usage = namedtuple("Usage", "total used free") + + +def test_disk_space_ok_when_above_threshold() -> None: + result = check_disk_space( + path=".", + minimum_bytes=100, + disk_usage=lambda _: _Usage(1000, 500, 500), + ) + assert result.status == OK + + +def test_disk_space_warn_when_below_threshold() -> None: + result = check_disk_space( + path=".", + minimum_bytes=10_000_000_000, + disk_usage=lambda _: _Usage(1000, 500, 500), + ) + assert result.status == WARN + + +def test_disk_space_warn_on_oserror() -> None: + def _raises(_path): + raise OSError("nope") + + result = check_disk_space(path="/nonexistent", disk_usage=_raises) + assert result.status == WARN + assert "nope" in result.message + + +# ----------------------------- credentials ------------------------------ # + + +def test_credentials_ok_with_users() -> None: + result = check_credentials(user_loader=lambda: [ + {"SettingsEmail": "alice@example.com", "SettingsUsername": "alice"}, + ]) + assert result.status == OK + assert "alice@example.com" in result.message + + +def test_credentials_warn_when_no_valid_user() -> None: + result = check_credentials(user_loader=lambda: [ + {"SettingsUsername": "alice"}, # missing SettingsEmail + ]) + assert result.status == WARN + assert result.details["user_count"] == 0 + + +def test_credentials_warn_on_loader_failure() -> None: + def _raises(): + raise RuntimeError("config corrupt") + + result = check_credentials(user_loader=_raises) + assert result.status == WARN + assert "config corrupt" in result.message + + +# ----------------------------- telemetry -------------------------------- # + + +def test_telemetry_off_when_disabled() -> None: + result = check_telemetry({"MAPILIO_KIT_DISABLE_TELEMETRY": "1"}) + assert result.status == OK + assert "disabled" in result.message.lower() + + +def test_telemetry_on_when_dsn_set() -> None: + result = check_telemetry({"MAPILIO_KIT_SENTRY_DSN": "https://x@y/1"}) + assert result.details["dsn_configured"] is True + + +def test_telemetry_off_when_no_dsn() -> None: + result = check_telemetry({}) + assert result.details["dsn_configured"] is False + + +# ------------------------------ report ---------------------------------- # + + +def test_report_overall_status_picks_worst() -> None: + report = DoctorReport(checks=[ + CheckResult("a", OK, "fine"), + CheckResult("b", WARN, "meh"), + ]) + assert report.overall_status == WARN + + report.checks.append(CheckResult("c", FAIL, "bad")) + assert report.overall_status == FAIL + + +def test_report_to_dict_round_trips_through_json() -> None: + report = DoctorReport(checks=[CheckResult("a", OK, "fine", {"k": 1})]) + payload = json.dumps(report.to_dict()) + assert "fine" in payload + assert "ok" in payload + + +def test_render_text_includes_each_check_name() -> None: + report = DoctorReport(checks=[ + CheckResult("python", OK, "Python 3.11"), + CheckResult("ffmpeg", WARN, "old"), + ]) + text = render_text(report, use_color=False) + assert "python" in text and "ffmpeg" in text + assert "WARN" in text + + +def test_run_all_checks_returns_full_report(monkeypatch) -> None: + # Force credential check to succeed without touching the real config file. + import mapilio_kit.components.utilities.doctor as doctor_mod + + monkeypatch.setattr( + "mapilio_kit.components.auth.login.list_all_users", + lambda: [{"SettingsEmail": "x@y.z"}], + raising=False, + ) + # We don't actually care about ffmpeg/exiftool here — the check just + # has to *run* (it'll be FAIL on this VM). The point is the aggregator. + report = doctor_mod.run_all_checks("9.9.9") + names = [c.name for c in report.checks] + for expected in ("platform", "python", "mapilio_kit", "disk", "telemetry"): + assert expected in names diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..468b5fb --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,26 @@ +"""Smoke tests for the custom exception hierarchy.""" + +from __future__ import annotations + +import pytest + +from mapilio_kit.components.utilities.error import ( + MapilioDuplicationError, + MapilioGeoTaggingError, + MapilioUserError, +) + + +def test_geotagging_error_is_user_error() -> None: + assert issubclass(MapilioGeoTaggingError, MapilioUserError) + + +def test_duplication_error_carries_desc() -> None: + err = MapilioDuplicationError("duplicate", desc={"id": 1}) + assert str(err) == "duplicate" + assert err.desc == {"id": 1} + + +def test_user_error_can_be_raised_and_caught() -> None: + with pytest.raises(MapilioUserError): + raise MapilioGeoTaggingError("boom") diff --git a/tests/test_init_sentry.py b/tests/test_init_sentry.py new file mode 100644 index 0000000..1a6260f --- /dev/null +++ b/tests/test_init_sentry.py @@ -0,0 +1,137 @@ +"""Tests for the Sentry init helper in ``mapilio_kit.__main__``. + +We deliberately avoid importing the entire CLI module at top level — it pulls +in heavy optional deps (ffmpeg wrappers, exif tools). Instead we re-implement +the same logic in isolation to verify env-var handling stays correct. + +If you change ``_init_sentry`` in ``mapilio_kit/__main__.py``, mirror the +behaviour here. +""" + +from __future__ import annotations + +import os +import sys +import types +from typing import Any +from unittest import mock + + +def _build_init_sentry(version: str = "0.0.0-test"): + """Create an isolated copy of the Sentry init helper used by the CLI.""" + + def _init_sentry() -> None: + disabled = os.environ.get("MAPILIO_KIT_DISABLE_TELEMETRY", "").lower() in { + "1", "true", "yes", "on", + } + dsn = os.environ.get("MAPILIO_KIT_SENTRY_DSN", "").strip() + if disabled or not dsn: + return + try: + import sentry_sdk + except ImportError: + return + + def _rate(name: str, default: float) -> float: + try: + return max(0.0, min(1.0, float(os.environ.get(name, default)))) + except (TypeError, ValueError): + return default + + sentry_sdk.init( + dsn=dsn, + traces_sample_rate=_rate("MAPILIO_KIT_SENTRY_TRACES_RATE", 0.1), + profiles_sample_rate=_rate("MAPILIO_KIT_SENTRY_PROFILES_RATE", 0.1), + release=f"mapilio-kit@{version}", + ) + + return _init_sentry + + +def _stub_sentry(monkeypatch) -> mock.MagicMock: + fake = types.ModuleType("sentry_sdk") + fake.init = mock.MagicMock() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "sentry_sdk", fake) + return fake.init # type: ignore[attr-defined,return-value] + + +def test_skips_when_no_dsn(monkeypatch) -> None: + init_mock = _stub_sentry(monkeypatch) + monkeypatch.delenv("MAPILIO_KIT_SENTRY_DSN", raising=False) + monkeypatch.delenv("MAPILIO_KIT_DISABLE_TELEMETRY", raising=False) + + _build_init_sentry()() + + init_mock.assert_not_called() + + +def test_skips_when_disabled(monkeypatch) -> None: + init_mock = _stub_sentry(monkeypatch) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_DSN", "https://key@host/1") + monkeypatch.setenv("MAPILIO_KIT_DISABLE_TELEMETRY", "true") + + _build_init_sentry()() + + init_mock.assert_not_called() + + +def test_initializes_when_dsn_present(monkeypatch) -> None: + init_mock = _stub_sentry(monkeypatch) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_DSN", "https://key@host/1") + monkeypatch.delenv("MAPILIO_KIT_DISABLE_TELEMETRY", raising=False) + + _build_init_sentry(version="9.9.9")() + + init_mock.assert_called_once() + kwargs = init_mock.call_args.kwargs + assert kwargs["dsn"] == "https://key@host/1" + assert kwargs["release"] == "mapilio-kit@9.9.9" + assert 0.0 <= kwargs["traces_sample_rate"] <= 1.0 + assert 0.0 <= kwargs["profiles_sample_rate"] <= 1.0 + + +def test_clamps_sample_rates(monkeypatch) -> None: + init_mock = _stub_sentry(monkeypatch) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_DSN", "https://key@host/1") + monkeypatch.delenv("MAPILIO_KIT_DISABLE_TELEMETRY", raising=False) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_TRACES_RATE", "5") # above 1 + monkeypatch.setenv("MAPILIO_KIT_SENTRY_PROFILES_RATE", "-2") # below 0 + + _build_init_sentry()() + + kwargs = init_mock.call_args.kwargs + assert kwargs["traces_sample_rate"] == 1.0 + assert kwargs["profiles_sample_rate"] == 0.0 + + +def test_falls_back_when_rate_is_unparseable(monkeypatch) -> None: + init_mock = _stub_sentry(monkeypatch) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_DSN", "https://key@host/1") + monkeypatch.delenv("MAPILIO_KIT_DISABLE_TELEMETRY", raising=False) + monkeypatch.setenv("MAPILIO_KIT_SENTRY_TRACES_RATE", "not-a-number") + + _build_init_sentry()() + + kwargs = init_mock.call_args.kwargs + assert kwargs["traces_sample_rate"] == 0.1 + + +def test_no_sentry_module_does_not_crash(monkeypatch) -> None: + monkeypatch.setenv("MAPILIO_KIT_SENTRY_DSN", "https://key@host/1") + monkeypatch.delenv("MAPILIO_KIT_DISABLE_TELEMETRY", raising=False) + + # Pretend sentry_sdk is not installed. + real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ + + def fake_import(name: str, *a: Any, **kw: Any): + if name == "sentry_sdk": + raise ImportError("simulated missing sentry_sdk") + return real_import(name, *a, **kw) + + if isinstance(__builtins__, dict): + monkeypatch.setitem(__builtins__, "__import__", fake_import) + else: + monkeypatch.setattr(__builtins__, "__import__", fake_import) + + # Should not raise. + _build_init_sentry()() diff --git a/tests/test_point.py b/tests/test_point.py new file mode 100644 index 0000000..5cbbf83 --- /dev/null +++ b/tests/test_point.py @@ -0,0 +1,169 @@ +"""Tests for the geometry utilities in ``mapilio_kit.components.utilities.point``.""" + +from __future__ import annotations + +import math + +import pytest + +from mapilio_kit.components.utilities.point import ( + Interpolator, + Point, + _interpolate_segment, + calculate_ecef_from_lla, + calculate_gps_distance, + compute_bearing, + determine_maximum_distance_from_start, + extend_deduplicate_points, + filter_points_by_distance, + generate_pairs, +) + +# ------------------------ basic distance / bearing ------------------------ # + + +def test_distance_zero_for_same_point() -> None: + assert calculate_gps_distance((42.0, -11.0), (42.0, -11.0)) == pytest.approx(0.0, abs=1e-6) + + +def test_distance_doctest_example_within_expected_range() -> None: + # Same example used in the function's doctest. + d = calculate_gps_distance((42.1, -11.1), (42.2, -11.3)) + assert 19_000 < d < 20_000 + + +def test_distance_is_symmetric() -> None: + a, b = (40.7128, -74.0060), (34.0522, -118.2437) # NYC <-> LA + assert calculate_gps_distance(a, b) == pytest.approx(calculate_gps_distance(b, a), rel=1e-9) + + +def test_ecef_round_numbers_at_equator() -> None: + # On the equator, lat=0, lon=0 → x=WGS84_a, y=0, z=0 + x, y, z = calculate_ecef_from_lla(0.0, 0.0) + assert x == pytest.approx(6_378_137.0, rel=1e-9) + assert y == pytest.approx(0.0, abs=1e-6) + assert z == pytest.approx(0.0, abs=1e-6) + + +def test_compute_bearing_due_north_is_zero() -> None: + bearing = compute_bearing(0.0, 0.0, 1.0, 0.0) + assert bearing == pytest.approx(0.0, abs=1e-6) + + +def test_compute_bearing_due_east_is_90() -> None: + bearing = compute_bearing(0.0, 0.0, 0.0, 1.0) + assert bearing == pytest.approx(90.0, abs=1e-3) + + +def test_compute_bearing_due_south_is_180() -> None: + bearing = compute_bearing(1.0, 0.0, 0.0, 0.0) + assert bearing == pytest.approx(180.0, abs=1e-3) + + +def test_max_distance_from_start_zero_for_empty() -> None: + assert determine_maximum_distance_from_start([]) == 0 + + +def test_max_distance_from_start_handles_multiple() -> None: + points = [(0.0, 0.0), (0.0, 0.0), (0.001, 0.001)] + assert determine_maximum_distance_from_start(points) > 0 + + +# ----------------------------- pair iterator ------------------------------ # + + +def test_generate_pairs_basic() -> None: + assert list(generate_pairs([1, 2, 3, 4])) == [(1, 2), (2, 3), (3, 4)] + + +def test_generate_pairs_empty() -> None: + assert list(generate_pairs([])) == [] + + +def test_generate_pairs_single() -> None: + assert list(generate_pairs([1])) == [] + + +# --------------------------- interpolate segment -------------------------- # + + +def _pt(t: float, lat: float, lon: float, alt: float | None = 0.0) -> Point: + return Point(time=t, lat=lat, lon=lon, alt=alt, angle=None) + + +def test_interpolate_midpoint() -> None: + start = _pt(0.0, 0.0, 0.0, 0.0) + end = _pt(10.0, 1.0, 1.0, 100.0) + mid = _interpolate_segment(start, end, 5.0) + assert mid.lat == pytest.approx(0.5) + assert mid.lon == pytest.approx(0.5) + assert mid.alt == pytest.approx(50.0) + + +def test_interpolate_handles_equal_times() -> None: + start = _pt(0.0, 0.0, 0.0) + end = _pt(0.0, 1.0, 1.0) + out = _interpolate_segment(start, end, 0.0) + # Weight degenerates to 0 → returns start coords. + assert out.lat == pytest.approx(0.0) + assert out.lon == pytest.approx(0.0) + + +def test_interpolate_alt_none_when_either_is_missing() -> None: + start = _pt(0.0, 0.0, 0.0, alt=None) + end = _pt(10.0, 1.0, 1.0, alt=100.0) + out = _interpolate_segment(start, end, 5.0) + assert out.alt is None + + +# ------------------------------ Interpolator ------------------------------ # + + +def test_interpolator_within_range() -> None: + track = [_pt(0.0, 0.0, 0.0, 0.0), _pt(10.0, 1.0, 1.0, 100.0)] + interp = Interpolator([track]) + out = interp.interpolate(5.0) + assert out.lat == pytest.approx(0.5) + assert out.lon == pytest.approx(0.5) + + +def test_interpolator_rejects_empty() -> None: + with pytest.raises(ValueError): + Interpolator([[]]) + + +def test_interpolator_extrapolates_before_first_point() -> None: + track = [_pt(10.0, 1.0, 1.0, 0.0), _pt(20.0, 2.0, 2.0, 0.0)] + interp = Interpolator([track]) + # asking for t before track start: should still return a Point (extrapolated) + out = interp.interpolate(5.0) + assert isinstance(out, Point) + + +# --------------------- filter_points_by_distance / dedup ------------------ # + + +def test_filter_points_by_distance_drops_close_samples() -> None: + samples = [ + _pt(0, 0.0, 0.0), + _pt(1, 0.000001, 0.0), # ~0.1m, way under 100m threshold + _pt(2, 1.0, 1.0), # very far, should be kept + ] + out = list(filter_points_by_distance(samples, min_distance=100.0, point_func=lambda p: p)) + assert len(out) == 2 + assert out[0].lat == 0.0 + assert out[1].lat == 1.0 + + +def test_extend_deduplicate_points_skips_consecutive_duplicates() -> None: + pts = [_pt(0, 1.0, 2.0), _pt(1, 1.0, 2.0), _pt(2, 1.5, 2.5), _pt(3, 1.5, 2.5)] + out = extend_deduplicate_points(pts) + assert [(p.lat, p.lon) for p in out] == [(1.0, 2.0), (1.5, 2.5)] + + +def test_extend_deduplicate_points_appends_to_existing() -> None: + initial = [_pt(0, 1.0, 2.0)] + out = extend_deduplicate_points([_pt(1, 1.0, 2.0), _pt(2, 3.0, 4.0)], to_extend=initial) + assert out is initial # mutated in place + assert len(out) == 2 + assert (out[1].lat, out[1].lon) == (3.0, 4.0) diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100644 index 0000000..77557e5 --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,153 @@ +"""Tests for ``mapilio_kit.components.utilities.utilities``. + +These exercise pure helpers that don't require ffmpeg/exiftool/sentry to be +installed on the host running the tests. +""" + +from __future__ import annotations + +import hashlib +import io + +import pytest + +from mapilio_kit.components.utilities.utilities import ( + DEFAULT_CHUNK_FRAMES, + LARGE_VIDEO_CHUNK_FRAMES, + calculate_aspect_ratio, + calculate_chunk_size, + calculation_vfov, + find_fov2, + is_large_video, + md5sum_fp, + photo_uuid_generate, +) + +# ------------------------------ aspect ratios ----------------------------- # + + +@pytest.mark.parametrize( + "size, expected", + [ + ("1920x1080", "16:9"), + ("1280x720", "16:9"), + ("4000x3000", "4:3"), + ("1024x1024", "1:1"), + ("3840x2160", "16:9"), + ], +) +def test_calculate_aspect_ratio(size: str, expected: str) -> None: + assert calculate_aspect_ratio(size) == expected + + +# --------------------------------- vFOV ----------------------------------- # + + +def test_calculation_vfov_known_value() -> None: + # 118.2° hFOV at 16:9 → vFOV ≈ 86.45° using the project's formula + # (2·atan(tan(hfov/2) / (16/9))). + vfov = calculation_vfov(118.2, ("16", "9")) + assert vfov == pytest.approx(86.45, abs=0.05) + + +def test_calculation_vfov_square_ratio() -> None: + vfov = calculation_vfov(90.0, ("1", "1")) + assert vfov == pytest.approx(90.0, abs=1e-2) + + +# ---------------------------- find_fov2 lookup ---------------------------- # + + +def test_find_fov2_lookup_known_pair() -> None: + # ('hero7', 'wide', '4:3') is in the rules table. + out = find_fov2("hero7", "wide", "4:3") + assert out == [122.6, 94.4] + + +def test_find_fov2_unknown_raises() -> None: + with pytest.raises(KeyError): + find_fov2("nonexistent_camera", "weird", "1:1") + + +# ---------------------------- video size helpers -------------------------- # + + +def test_is_large_video_true_above_threshold() -> None: + assert is_large_video(2 * 1024 * 1024 * 1024) is True + + +def test_is_large_video_false_below_threshold() -> None: + assert is_large_video(100 * 1024 * 1024) is False + + +def test_is_large_video_custom_threshold() -> None: + assert is_large_video(10, large_video_threshold=5) is True + assert is_large_video(3, large_video_threshold=5) is False + + +# ---------------------------- chunk size logic --------------------------- # +# Regression test for the UnboundLocalError that used to happen when +# calculate_chunk_size was called with video_size <= threshold. + + +def test_calculate_chunk_size_small_video_returns_default() -> None: + chunk = calculate_chunk_size(100 * 1024 * 1024) # 100 MB → not large + assert chunk == DEFAULT_CHUNK_FRAMES + assert chunk > 0 + + +def test_calculate_chunk_size_large_video_returns_smaller_chunks() -> None: + chunk = calculate_chunk_size(2 * 1024 * 1024 * 1024) # 2 GB → large + assert chunk == LARGE_VIDEO_CHUNK_FRAMES + assert chunk < DEFAULT_CHUNK_FRAMES + + +def test_calculate_chunk_size_at_exact_threshold_uses_default() -> None: + # ``> threshold`` means equality should fall into the "small" branch. + threshold = 5 + assert calculate_chunk_size(threshold, large_video_threshold=threshold) == DEFAULT_CHUNK_FRAMES + + +def test_calculate_chunk_size_above_custom_threshold() -> None: + assert calculate_chunk_size(10, large_video_threshold=5) == LARGE_VIDEO_CHUNK_FRAMES + + +# ----------------------------- md5 streaming ------------------------------ # + + +def test_md5sum_fp_matches_hashlib() -> None: + payload = b"mapilio-kit-test-payload" * 1024 + expected = hashlib.md5(payload).hexdigest() + fp = io.BytesIO(payload) + digest = md5sum_fp(fp).hexdigest() + assert digest == expected + + +def test_md5sum_fp_accepts_existing_hash_object() -> None: + fp = io.BytesIO(b"hello world") + h = hashlib.md5() + h.update(b"prefix:") + out = md5sum_fp(fp, md5=h).hexdigest() + assert out == hashlib.md5(b"prefix:hello world").hexdigest() + + +# ---------------------------- photo_uuid_generate ------------------------- # + + +def test_photo_uuid_generate_adds_uuid_to_all_but_last() -> None: + descs = [ + {"captureTime": "2024-01-01T00:00:00Z"}, + {"captureTime": "2024-01-01T00:00:01Z"}, + {"captureTime": "tail"}, # function explicitly skips the last item + ] + out = photo_uuid_generate("user@example.com", descs) + assert "photoUuid" in out[0] + assert "photoUuid" in out[1] + assert "photoUuid" not in out[2] + + +def test_photo_uuid_generate_produces_stable_hash() -> None: + descs = [{"captureTime": "2024-01-01T00:00:00Z"}, {"captureTime": "tail"}] + expected = hashlib.md5(b"user@example.com--2024-01-01T00:00:00Z").hexdigest() + out = photo_uuid_generate("user@example.com", descs) + assert out[0]["photoUuid"] == expected diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..760c912 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,243 @@ +"""Unit tests for ``mapilio_kit.components.utilities.validator``. + +The exif reading layer is replaced with a fake reader so tests don't depend +on a real EXIF parser or real image bytes on disk. +""" + +from __future__ import annotations + +import os + +import pytest + +from mapilio_kit.components.utilities.validator import ( + ImageRecord, + Issue, + _haversine_meters, + _parse_exif_datetime, + build_report, + find_images, + read_image_exif, + validate_records, +) + +# --------------------------- helpers / fixtures -------------------------- # + + +def _record( + path: str, + lat: float | None, + lon: float | None, + captured_at: float | None, + error: str | None = None, +) -> ImageRecord: + return ImageRecord(path=path, lat=lat, lon=lon, captured_at=captured_at, error=error) + + +# --------------------------- find_images -------------------------------- # + + +def test_find_images_returns_sorted_paths(tmp_path) -> None: + (tmp_path / "b.jpg").write_bytes(b"") + (tmp_path / "a.JPEG").write_bytes(b"") + (tmp_path / "ignore.txt").write_bytes(b"") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "c.png").write_bytes(b"") + + out = find_images(str(tmp_path)) + assert [os.path.basename(p) for p in out] == ["a.JPEG", "b.jpg", "c.png"] + + +def test_find_images_skip_subfolders(tmp_path) -> None: + (tmp_path / "a.jpg").write_bytes(b"") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "b.jpg").write_bytes(b"") + + out = find_images(str(tmp_path), skip_subfolders=True) + assert [os.path.basename(p) for p in out] == ["a.jpg"] + + +def test_find_images_missing_dir_returns_empty(tmp_path) -> None: + assert find_images(str(tmp_path / "does-not-exist")) == [] + + +# --------------------------- exif parsing helpers ----------------------- # + + +def test_parse_exif_datetime_standard_format() -> None: + ts = _parse_exif_datetime("2024:01:15 10:30:45") + assert ts is not None + assert ts > 1_700_000_000 + + +def test_parse_exif_datetime_iso_with_microseconds() -> None: + assert _parse_exif_datetime("2024-01-15T10:30:45.123") is not None + + +def test_parse_exif_datetime_garbage_returns_none() -> None: + assert _parse_exif_datetime("not a date") is None + + +# --------------------------- haversine ---------------------------------- # + + +def test_haversine_zero_for_same_point() -> None: + assert _haversine_meters((0.0, 0.0), (0.0, 0.0)) == pytest.approx(0.0, abs=1e-6) + + +def test_haversine_one_degree_lat_is_about_111km() -> None: + d = _haversine_meters((0.0, 0.0), (1.0, 0.0)) + assert 110_000 < d < 112_000 + + +# --------------------------- read_image_exif (mocked) ------------------- # + + +class _FakeRatio: + def __init__(self, num, den=1): + self.num = num + self.den = den + + +class _FakeTag: + def __init__(self, values): + self.values = values + + def __str__(self) -> str: + return str(self.values) + + +def test_read_image_exif_extracts_gps_and_time(tmp_path) -> None: + p = tmp_path / "img.jpg" + p.write_bytes(b"fake") + + def fake_reader(_fp): + return { + "GPS GPSLatitude": _FakeTag([_FakeRatio(40), _FakeRatio(42), _FakeRatio(0)]), + "GPS GPSLatitudeRef": "N", + "GPS GPSLongitude": _FakeTag([_FakeRatio(74), _FakeRatio(0), _FakeRatio(0)]), + "GPS GPSLongitudeRef": "W", + "EXIF DateTimeOriginal": "2024:01:15 10:30:45", + } + + rec = read_image_exif(str(p), exif_reader=fake_reader) + assert rec.lat == pytest.approx(40.7, abs=0.01) + assert rec.lon == pytest.approx(-74.0, abs=0.01) + assert rec.captured_at is not None + + +def test_read_image_exif_no_gps_returns_record_without_coords(tmp_path) -> None: + p = tmp_path / "img.jpg" + p.write_bytes(b"fake") + + def fake_reader(_fp): + return {"EXIF DateTimeOriginal": "2024:01:15 10:30:45"} + + rec = read_image_exif(str(p), exif_reader=fake_reader) + assert rec.lat is None and rec.lon is None + assert rec.captured_at is not None + + +def test_read_image_exif_handles_missing_file() -> None: + rec = read_image_exif("/nonexistent/path.jpg", exif_reader=lambda _fp: {}) + assert rec.error is not None + assert "open failed" in rec.error + + +# --------------------------- validate_records --------------------------- # + + +def test_validate_flags_missing_gps() -> None: + records = [_record("a.jpg", None, None, 100.0)] + issues = validate_records(records) + assert any(i.code == "missing_gps" for i in issues) + + +def test_validate_flags_invalid_gps() -> None: + records = [_record("a.jpg", 200.0, 0.0, 100.0)] + issues = validate_records(records) + assert any(i.code == "invalid_gps" for i in issues) + + +def test_validate_warns_on_zero_zero_gps() -> None: + records = [_record("a.jpg", 0.0, 0.0, 100.0)] + issues = validate_records(records) + assert any(i.code == "suspicious_gps_zero" for i in issues) + + +def test_validate_flags_missing_timestamp() -> None: + records = [_record("a.jpg", 40.0, -74.0, None)] + issues = validate_records(records) + assert any(i.code == "missing_timestamp" for i in issues) + + +def test_validate_flags_duplicate_capture() -> None: + records = [ + _record("a.jpg", 40.0, -74.0, 100.0), + _record("b.jpg", 40.0, -74.0, 100.0), + ] + issues = validate_records(records) + assert any(i.code == "duplicate_capture" for i in issues) + + +def test_validate_warns_on_large_gps_gap() -> None: + records = [ + _record("a.jpg", 40.0, -74.0, 100.0), # NYC + _record("b.jpg", 34.0, -118.0, 200.0), # LA — ~3900 km later + ] + issues = validate_records(records, max_gap_meters=10_000) + assert any(i.code == "large_gps_gap" for i in issues) + + +def test_validate_warns_on_large_time_gap() -> None: + records = [ + _record("a.jpg", 40.0, -74.0, 100.0), + _record("b.jpg", 40.001, -74.001, 100.0 + 7200), # +2h + ] + issues = validate_records(records, max_time_gap_seconds=3600) + assert any(i.code == "large_time_gap" for i in issues) + + +def test_validate_clean_sequence_has_no_issues() -> None: + records = [ + _record("a.jpg", 40.0, -74.0, 100.0), + _record("b.jpg", 40.0001, -74.0001, 110.0), + _record("c.jpg", 40.0002, -74.0002, 120.0), + ] + issues = validate_records(records) + assert issues == [] + + +def test_validate_handles_exif_read_error() -> None: + records = [_record("bad.jpg", None, None, None, error="exif read failed: parse")] + issues = validate_records(records) + assert len(issues) == 1 + assert issues[0].code == "exif_read_error" + assert issues[0].level == "error" + + +# ------------------------------ build_report ---------------------------- # + + +def test_build_report_counts_valid_images() -> None: + records = [ + _record("a.jpg", 40.0, -74.0, 100.0), + _record("b.jpg", None, None, 100.0), # missing_gps + ] + issues = validate_records(records) + report = build_report(records, issues) + assert report.image_count == 2 + assert report.valid_count == 1 + assert report.has_errors + + +def test_build_report_summary_groups_codes() -> None: + issues = [ + Issue(level="error", code="missing_gps", path="a", message=""), + Issue(level="error", code="missing_gps", path="b", message=""), + Issue(level="warning", code="duplicate_capture", path="c", message=""), + ] + report = build_report([], issues) + assert report.summary == {"missing_gps": 2, "duplicate_capture": 1} diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..0ce6a07 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,19 @@ +"""Sanity checks for the package version metadata.""" + +from __future__ import annotations + +import re + +from mapilio_kit.components.version import VERSION + + +def test_version_is_string() -> None: + assert isinstance(VERSION, str) + assert VERSION, "VERSION must not be empty" + + +def test_version_follows_semver_like_format() -> None: + # Allow X.Y.Z and X.Y.Z; the project has historically used both. + assert re.match(r"^\d+\.\d+\.\d+([.\-+a-zA-Z0-9]*)$", VERSION), ( + f"Unexpected version string: {VERSION!r}" + )