diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8fcca88..ee22c8f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = +current_version = 0.1.0 commit = False tag = False allow_dirty = True diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 50d9ab4..b321c7a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,6 +6,8 @@ on: - main tags: - "v*.*.*" + repository_dispatch: + types: [new-tag-created] workflow_dispatch: inputs: variant: @@ -39,7 +41,7 @@ jobs: uses: actions/checkout@v4 # Only build on main if the merged PR has a "BUILD" label. - # Tag pushes and manual dispatch always build. + # Tag pushes, repository_dispatch, and manual dispatch always build. - name: Check for BUILD label on main branch id: check-build if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -97,7 +99,11 @@ jobs: github.event_name != 'push' || steps.check-build.outputs.result == 'true' run: | - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + if [[ "${{ github.event_name }}" == "repository_dispatch" ]]; then + VERSION="${{ github.event.client_payload.tag }}" + LATEST="latest" + VERSION_TAG=true + elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then VERSION="${{ github.ref_name }}" LATEST="latest" VERSION_TAG=true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..fec4b8c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,52 @@ +name: Documentation + +on: + push: + tags: ["v*.*.*"] + repository_dispatch: + types: [new-tag-created] + workflow_dispatch: + +# Builds the zensical docs and deploys them to GitHub Pages on release. +# Requires Pages to be enabled in repo settings with Source = GitHub Actions. + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.12 + + # Sync the project (so generate-cli-docs can import cartoload) + docs group + - name: Install dependencies + run: uv sync --group docs + + - name: Generate CLI docs + run: uv run python scripts/generate-cli-docs.py + + - name: Build docs + run: uv run --group docs zensical build -f docs/zensical.toml + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/new-version.yml b/.github/workflows/new-version.yml new file mode 100644 index 0000000..a33f813 --- /dev/null +++ b/.github/workflows/new-version.yml @@ -0,0 +1,60 @@ +name: New Version + +# After CI passes on main, check whether the version in pyproject.toml has a +# matching git tag. If not, a new version was merged: create + push the tag, +# then dispatch 'new-tag-created' so the downstream publish/release/docker +# workflows run (a tag pushed with GITHUB_TOKEN does not re-trigger them). +on: + workflow_run: + workflows: ["CI"] + branches: [main] + types: [completed] + +jobs: + tag: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + + - name: Check if tag exists + id: check + run: | + VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2) + TAG="v${VERSION}" + if git tag -l "$TAG" | grep -q .; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists, skipping." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "New version detected: $TAG" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "${{ steps.check.outputs.tag }}" + git push origin "${{ steps.check.outputs.tag }}" + + - name: Trigger downstream workflows + if: steps.check.outputs.exists == 'false' + uses: actions/github-script@v7 + with: + script: | + github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'new-tag-created', + client_payload: { + tag: '${{ steps.check.outputs.tag }}' + } + }) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3fd5000..d90555e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,8 @@ on: push: tags: - "v*" + repository_dispatch: + types: [new-tag-created] jobs: publish: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26f44b8..ced9754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: tags: - "v*.*.*" + repository_dispatch: + types: [new-tag-created] jobs: create-release: @@ -26,15 +28,24 @@ jobs: - name: Install dependencies run: uv sync --only-group dev + - name: Determine tag + id: tag + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + else + echo "name=${{ github.event.client_payload.tag }}" >> "$GITHUB_OUTPUT" + fi + - name: Get changelog for this version id: changelog run: | - VERSION="${{ github.ref_name }}" - echo "version=$VERSION" >> "$GITHUB_ENV" + TAG="${{ steps.tag.outputs.name }}" + echo "version=$TAG" >> "$GITHUB_ENV" echo "body<> "$GITHUB_ENV" # Extract changelog for this version from CHANGELOG.md if [ -f CHANGELOG.md ]; then - BODY=$(uv run git-cliff --tag "$VERSION" --strip all | tail -n +2 || true) + BODY=$(uv run git-cliff --tag "$TAG" --strip all | tail -n +2 || true) else BODY="" fi @@ -44,7 +55,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ env.version }} + tag_name: ${{ steps.tag.outputs.name }} name: ${{ env.version }} body: ${{ env.body }} draft: false diff --git a/.gitignore b/.gitignore index 0a500af..0157fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ htmlcov/ cache/ output/ docs/site/ +# Generated from src/cartoload/cli.py by scripts/generate-cli-docs.py +docs/cli.md # IDE .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb841b0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ + + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-06-26 + +- Initial release +- Convert WMTS and GeoTIFF raster geodata into Garmin GPS raster maps (`*.img`) +- Unified YAML configuration for sources, layers, and styles +- WMTS tile downloading with on-disk caching and JPEG optimization (mozjpeg trellis quantization) +- Custom Garmin IMG binary writer (GMP container with TRE/RGN/LBL sections and RGN2 raster records) +- CLI (`cartoload build`, `cartoload analyze img`) and reusable Python library (`build_layer`, `SourceConfig`, `LayerConfig`) +- `analyze img` tool for inspecting IMG files and exporting a GeoTIFF mosaic for validation +- Docker image bundling GDAL, mozjpeg, and GMapTool (`gmt`) + +[0.1.0]: https://github.com/burgdev/cartoload/releases/tag/v0.1.0 diff --git a/docs/cli.md b/docs/cli.md deleted file mode 100644 index 1aee8b8..0000000 --- a/docs/cli.md +++ /dev/null @@ -1,443 +0,0 @@ -# CLI Reference - -cartoload — convert geodata into GPS device maps. - -**Usage:** `cartoload COMMAND [ARGS]` - -**Subcommands:** - -`analyze` -: Analyze geodata files. - -`build` -: Build one or more layers into output files. - -`download` -: Download source data only (no build). - -`split` -: Split an oversized .img into region files. - -`list` -: List all layers from the provided config files. - -`cache` -: Inspect and manage the tile cache. - -`watermark` -: Read and write forensic watermarks in Garmin IMG files. - ---- - -### `cartoload analyze` - -Analyze geodata files. - -**Usage:** `cartoload analyze COMMAND [ARGS]` - -**Subcommands:** - -`img` -: Analyze Garmin IMG binary files. - -### `cartoload analyze img` - -Analyze Garmin IMG binary files. - -**Usage:** `cartoload analyze img COMMAND [ARGS]` - -**Subcommands:** - -`info` -: Analyze a Garmin IMG file. - -`compare` -: Compare two IMG files: structure, headers, and RGN2 raster tiles. - -`export` -: Export IMG raster tiles to GeoTIFF format. - -### `cartoload analyze img info` - -Analyze a Garmin IMG file. - -**Usage:** `cartoload analyze img info [OPTIONS] IMG_FILE` - -**Arguments:** - -`IMG_FILE` -: Path - - -**Options:** - -`-s, --subfile TEXT` -: Subfile name (e.g. '00355951') - -`-n, --section TEXT` -: Show only one section (TRE, TRE7, RGN, RGN2, LBL, NET, etc.) - -`--limit INTEGER` -: Max entries per section (default: 20, 0 = unlimited) - -`-x, --hex TEXT` -: Dump hex of section - -`-d, --dump TEXT` -: Full hex dump of section with ASCII - -`-l, --list` -: List subfiles only - -`-a, --all` -: Dump all sections - -`--raw-offset INTEGER` -: Read raw bytes at offset - -`--raw-size INTEGER` -: Size for raw read (default: 64) - -`-r, --rgn2` -: Show annotated RGN2 analysis. RGN2 contains raster tile records (E0) and polyline/polygon preambles that describe bitmap placement per zoom level. - -`-g, --segments` -: Segment RGN2 by zoom level using TRE7 offsets. Shows how raster tiles are grouped into zoom levels within the RGN2 data section. - -`-m, --summary` -: Show concise summary (bounds, bitmaps, encoding, map name) - -`-q, --no-descriptions` -: Hide section descriptions - -`--tile-details` -: Validate coordinate encoding and show per-tile decoded coordinates - -`--no-color` -: Disable colored output - -### `cartoload analyze img compare` - -Compare two IMG files: structure, headers, and RGN2 raster tiles. - -**Usage:** `cartoload analyze img compare [OPTIONS] FILE1 FILE2` - -**Arguments:** - -`FILE1` -: Path - -`FILE2` -: Path - - -**Options:** - -`--no-color` -: Disable colored output - -`--headers-only` -: Only compare headers, skip RGN2 samples - -`--sample-size INTEGER` -: Number of RGN2 records to compare (default: 10) - -`--full` -: Full raw dump mode (legacy verbose output) - -### `cartoload analyze img export` - -Export IMG raster tiles to GeoTIFF format. - -**Usage:** `cartoload analyze img export [OPTIONS] IMG_FILE` - -**Arguments:** - -`IMG_FILE` -: Path - - -**Options:** - -`-o, --output PATH` -: Output GeoTIFF file path - -`--bbox TEXT` -: Bounding box filter: west,south,east,north (e.g., '7.0,46.0,8.0,47.0') - -`--zoom TEXT` -: Zoom level filter: single level or range (e.g., '14' or '12-16') - -`--max-tiles INTEGER` -: Maximum tiles to export (0 = all, useful for testing) - ---- - -### `cartoload build` - -Build one or more layers into output files. - -**Usage:** `cartoload build [OPTIONS]` - -**Options:** - -`-c, --config PATH ...` -: Config file(s) (repeatable) - -`-l, --layer TEXT` -: Layer ID to build (required) - -`-b, --bbox FLOAT` -: Override bounding box: W S E N - -`-x, --lng FLOAT` -: Center longitude for extent (use with --lat/--width/--height) - -`-y, --lat FLOAT` -: Center latitude for extent (use with --lng/--width/--height) - -`-W, --width FLOAT` -: Extent width in km (use with --lng/--lat/--height) - -`-H, --height FLOAT` -: Extent height in km (use with --lng/--lat/--width) - -`-z, --zoom TEXT` -: Override zoom levels: 10,12,14 - -`-o, --output-dir TEXT` -: Default: ./output - -`-C, --cache-dir TEXT` -: Default: ./cache - -`--no-download` -: Use existing cache only - -`--offline` -: Skip freshness checks, use cached files as-is - -`--update` -: Check cache freshness via HTTP HEAD - -`--ago INTEGER` -: Only update if cached file is older than N days - -`-f, --force` -: Overwrite existing output files - -`--dry-run` -: Show build plan without executing - -`--cache-warmup` -: Download and cache tiles only, skip IMG build - -`--preview` -: Generate preview images after build - -`-P, --preview-tiles INTEGER` -: Max tiles per preview mosaic (default: 9) - -`--preview-center FLOAT` -: Override preview center: LNG LAT - -`-q, --quality INTEGER RANGE` -: JPEG quality 1-100 (default: passthrough, no re-encoding) - -`--qtables {raster,default}` -: Custom quantization tables: 'raster' (map-optimized) or 'default' (standard) - -`--executor {process,thread}` -: Parallel executor mode: 'process' (default, fastest) or 'thread' (less memory) - -`--fast` -: Fast build: skip mirror-padding and cjpeg trellis optimization (larger output) - -`-v, --verbose` -: Show detailed tracebacks on errors - ---- - -### `cartoload download` - -Download source data only (no build). - -**Usage:** `cartoload download [OPTIONS]` - -**Options:** - -`-c, --config PATH ...` -: Config file(s) (repeatable) - -`-l, --layer TEXT` -: Layer ID to download (required) - -`-b, --bbox FLOAT` -: Override bounding box: W S E N - -`-x, --lng FLOAT` -: Center longitude for extent (use with --lat/--width/--height) - -`-y, --lat FLOAT` -: Center latitude for extent (use with --lng/--width/--height) - -`-W, --width FLOAT` -: Extent width in km (use with --lng/--lat/--height) - -`-H, --height FLOAT` -: Extent height in km (use with --lng/--lat/--width) - -`-z, --zoom TEXT` -: Override zoom levels: 10,12,14 - -`-C, --cache-dir TEXT` -: Default: ./cache - ---- - -### `cartoload split` - -Split an oversized .img into region files. - -**Usage:** `cartoload split [OPTIONS] IMG_FILE` - -**Arguments:** - -`IMG_FILE` -: Path - - -**Options:** - -`-o, --output-dir TEXT` -: Output directory (default: same as input) - ---- - -### `cartoload list` - -List all layers from the provided config files. - -**Usage:** `cartoload list [OPTIONS]` - -**Options:** - -`-c, --config PATH ...` -: Config file(s) (repeatable) - ---- - -### `cartoload cache` - -Inspect and manage the tile cache. - -**Usage:** `cartoload cache [OPTIONS] COMMAND [ARGS]` - -**Options:** - -`-C, --cache-dir TEXT` -: Default: ./cache - - -**Subcommands:** - -`status` -: Report cache size and tile counts per source. - -`clean` -: Remove cached tiles. - -### `cartoload cache status` - -Report cache size and tile counts per source. - -**Usage:** `cartoload cache status` -### `cartoload cache clean` - -Remove cached tiles. - -**Usage:** `cartoload cache clean [OPTIONS]` - -**Options:** - -`--source TEXT` -: Clean only a specific source's cache - -`-f, --force` -: Skip confirmation prompt - ---- - -### `cartoload watermark` - -Read and write forensic watermarks in Garmin IMG files. - -**Usage:** `cartoload watermark COMMAND [ARGS]` - -**Subcommands:** - -`write` -: Write a watermark string into a Garmin IMG file. - -`read` -: Read and print the watermark from a Garmin IMG file. - -`read-header` -: Read the cleartext header from a Garmin IMG file (no key required). - -### `cartoload watermark write` - -Write a watermark string into a Garmin IMG file. - -**Usage:** `cartoload watermark write [OPTIONS] IMG_FILE PAYLOAD` - -**Arguments:** - -`IMG_FILE` -: Path - -`PAYLOAD` -: Text - - -**Options:** - -`--key TEXT` -: Encryption key - -`--key-file PATH` -: Read key from file - -`--header TEXT` -: Cleartext header string (e.g. order=ID) - -### `cartoload watermark read` - -Read and print the watermark from a Garmin IMG file. - -**Usage:** `cartoload watermark read [OPTIONS] IMG_FILE` - -**Arguments:** - -`IMG_FILE` -: Path - - -**Options:** - -`--key TEXT` -: Encryption key - -`--key-file PATH` -: Read key from file - -### `cartoload watermark read-header` - -Read the cleartext header from a Garmin IMG file (no key required). - -**Usage:** `cartoload watermark read-header IMG_FILE` - -**Arguments:** - -`IMG_FILE` -: Path diff --git a/pyproject.toml b/pyproject.toml index 5a88af3..79e367f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cartoload" -version = "0.1.1" +version = "0.1.0" description = "Convert raster geodata into GPS raser device maps" readme = "README.md" requires-python = ">=3.11" diff --git a/src/cartoload/__init__.py b/src/cartoload/__init__.py index 485f44a..3dc1f76 100644 --- a/src/cartoload/__init__.py +++ b/src/cartoload/__init__.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.0" diff --git a/src/cartoload/cli.py b/src/cartoload/cli.py index 2848556..9df5182 100644 --- a/src/cartoload/cli.py +++ b/src/cartoload/cli.py @@ -20,6 +20,7 @@ TimeRemainingColumn, ) +from . import __version__ from .analysis.cli import analyze from .config import ( load_config, @@ -169,7 +170,8 @@ def _handle_unexpected_error(error: Exception) -> None: ) -@click.group() +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(version=__version__, prog_name="cartoload") def main() -> None: """cartoload — convert geodata into GPS device maps.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 77020f9..cdad327 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ import pytest import yaml +from cartoload import __version__ from cartoload.cli import ( _compute_bounds_from_center, _human_size, @@ -68,6 +69,19 @@ def runner() -> click.testing.CliRunner: return click.testing.CliRunner() +# --------------------------------------------------------------------------- +# main group: --version +# --------------------------------------------------------------------------- + + +class TestVersion: + def test_version_flag(self, runner): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.output + assert "cartoload" in result.output + + # --------------------------------------------------------------------------- # Helper function tests # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 6dae4a6..4a8dbee 100644 --- a/uv.lock +++ b/uv.lock @@ -35,7 +35,7 @@ wheels = [ [[package]] name = "cartoload" -version = "0.1.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "click" },