diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 43f85d0..0000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 160 -max-complexity = 10 -extend-exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,.venv -extend-ignore = E501 diff --git a/.gcloudignore b/.gcloudignore index 03708cd..a36ce21 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -22,6 +22,7 @@ CHANGELOG.md dist/ Dockerfile.* docs/ +gpxsamples/ junit/ LICENSE README.rst diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a6ea3a9..4602eee 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index baa3196..3a4c636 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true python-version: "3.12" @@ -39,7 +39,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v8.2.0 + with: + # the test suite reads its GPX inputs from the gpxsamples submodule + submodules: true + # gpxsamples is a separate private repo, and the default GITHUB_TOKEN is + # scoped to this repo alone -- it cannot clone it (GitHub reports that as + # "Repository not found"). GPXSAMPLES_TOKEN is a PAT with read access to + # both repos; checkout applies it to submodule fetches as well. + # Falls back to the default token so forks still check out the main repo. + token: ${{ secrets.GPXSAMPLES_TOKEN || github.token }} + - uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true python-version: "3.12" diff --git a/CLAUDE.md b/CLAUDE.md index 28c361d..d98afe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,9 @@ GPXtable converts GPX files (routes and tracks with waypoints) into Markdown or # Install for development (all extras: gunicorn, tests, lint/mypy) pip install -e ".[dev]" +# Check out the GPX test inputs (gpxsamples submodule) — required by the tests +git submodule update --init + # Run all tests TZ=America/Los_Angeles pytest @@ -24,19 +27,19 @@ pytest tests/test_gpxtable.py::test_print_header # Regenerate expected CLI output files in samples/ python tests/test_cli.py --generate -# Lint (ruff only — replaces flake8) +# Lint (ruff only — replaces flake8; rule set is pinned in [tool.ruff.lint]) ruff check . # Type check mypy src/ # Run the CLI -gpxtable samples/basecamp-route.gpx -gpxtable --departure "07/30/2023 09:15:00" samples/basecamp-tracks.gpx +gpxtable gpxsamples/basecamp-route.gpx +gpxtable --departure "07/30/2023 09:15:00" gpxsamples/basecamp-tracks.gpx # Dump/customize waypoint classifier config gpxtable --dump-config > myconfig.json -gpxtable --config myconfig.json samples/basecamp-route.gpx +gpxtable --config myconfig.json gpxsamples/basecamp-route.gpx # Run the Flask dev server flask --app gpxtable.wsgi:create_app run @@ -89,6 +92,8 @@ The package has four modules under `src/gpxtable/`: ### Testing approach +Sample data is split in two: the large `.gpx` **inputs** live in the `gpxsamples` git submodule (checked out at `gpxsamples/`), while the small expected-output fixtures (`*.txt`, `default-config.json`) stay versioned in `samples/`. `tests/conftest.py` owns both paths — use its `gpx_sample()` / `expected_output()` helpers rather than hardcoding directories, and it fails collection with an actionable message if the submodule was never initialized. + CLI tests in `test_cli.py` use golden-file comparison: they run the CLI subprocess and diff against `samples/*.txt` files. When changing output format, regenerate these with `python tests/test_cli.py --generate`. The test environment sets `TZ=America/Los_Angeles` for reproducibility. Unit tests construct `GPX` objects directly (no file I/O) and assert on `StringIO` output or return values. @@ -103,6 +108,12 @@ lint + test → deploy-to-gae → release-please release-please only runs after a successful GAE deployment. +### Required Actions secrets + +**`GPXSAMPLES_TOKEN`** — a PAT with read access to *both* `pleasantone/gpxtable` and the private `pleasantone/gpxsamples` submodule repo. The `test` job passes it to `actions/checkout`, which applies it to submodule fetches too. Without it the default `GITHUB_TOKEN` is scoped to this repo alone and the submodule clone fails with the misleading `Repository not found`. The workflow falls back to the default token when the secret is absent, so forks still check out the main repo (their `test` job will fail on the submodule). + +**`MY_RELEASE_PLEASE_TOKEN`** — used by the release-please job. + **`requirements.txt`** lists the production runtime deps (flask, gunicorn, gpxpy, etc.) and is required for GAE — the platform reads this file to install dependencies, it does not read `pyproject.toml`. Keep it in sync with the `web`/`gunicorn` extras in `pyproject.toml`. **`app.yaml`** entrypoint uses `--pythonpath src "gpxtable.wsgi:create_app()"` so the module address matches the installed package name. diff --git a/Makefile b/Makefile index 32063bf..96e9608 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev test test-unit test-cli lint typecheck \ +.PHONY: help install install-dev submodules test test-unit test-cli lint typecheck \ generate-samples run-web run-web-gunicorn \ demo-route demo-tracks demo-basecamp demo-config \ build clean @@ -10,6 +10,7 @@ help: @echo "Setup" @echo " install Install package (core deps only)" @echo " install-dev Install package with all extras (dev = gunicorn + tests + lint)" + @echo " submodules Check out the gpxsamples submodule (GPX test inputs)" @echo "" @echo "Testing" @echo " test Run all tests" @@ -44,18 +45,24 @@ install: install-dev: pip install -e ".[dev]" +# GPX inputs used by the tests live in the gpxsamples submodule. +submodules: + git submodule update --init --recursive + # ── Testing ────────────────────────────────────────────────────────────────── -test: +test: submodules TZ=America/Los_Angeles pytest -test-unit: +# test-unit needs no GPX inputs itself, but conftest's submodule check is +# deliberately global (see tests/conftest.py), so every pytest run requires it. +test-unit: submodules TZ=America/Los_Angeles pytest tests/test_gpxtable.py -test-cli: +test-cli: submodules TZ=America/Los_Angeles pytest tests/test_cli.py -generate-samples: +generate-samples: submodules TZ=America/Los_Angeles python tests/test_cli.py --generate # ── Linting ────────────────────────────────────────────────────────────────── @@ -68,20 +75,20 @@ typecheck: # ── CLI demos ──────────────────────────────────────────────────────────────── -demo-route: - gpxtable samples/basecamp-route.gpx +demo-route: submodules + gpxtable gpxsamples/basecamp-route.gpx -demo-tracks: - gpxtable --departure "07/30/2023 09:15:00" samples/basecamp-tracks.gpx +demo-tracks: submodules + gpxtable --departure "07/30/2023 09:15:00" gpxsamples/basecamp-tracks.gpx -demo-basecamp: - gpxtable samples/basecamp.gpx +demo-basecamp: submodules + gpxtable gpxsamples/basecamp.gpx demo-config: gpxtable --dump-config -demo-custom: /tmp/myconfig.json - gpxtable --config /tmp/myconfig.json samples/basecamp-route.gpx +demo-custom: submodules /tmp/myconfig.json + gpxtable --config /tmp/myconfig.json gpxsamples/basecamp-route.gpx /tmp/myconfig.json: gpxtable --dump-config > /tmp/myconfig.json diff --git a/docs/usage.rst b/docs/usage.rst index ae1892b..286811d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -71,7 +71,7 @@ route configuration itself in Basecamp. These may be overridden with the flag based upon the `--departure` and `--speed` options. .. literalinclude:: ../samples/basecamp-route.txt - :caption: gpxtable samples/basecamp-route.gpx + :caption: gpxtable gpxsamples/basecamp-route.gpx :language: text Building a table from a track and waypoints @@ -96,7 +96,7 @@ symbol type or by including keywords in the waypoint name. Scenic Area: 5 minutes .. literalinclude:: ../samples/basecamp-route.txt - :caption: gpxtable --departure "07/30/2022 09:15:00" samples/basecamp-route.gpx + :caption: gpxtable --departure "07/30/2022 09:15:00" gpxsamples/basecamp-route.gpx :language: text Limitations: diff --git a/pyproject.toml b/pyproject.toml index 1f61b7f..e26c832 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,15 +37,15 @@ gunicorn = [ "gunicorn>=26.0.0", ] tests = [ - "pytest", - "pytest-flask", - "responses", + "pytest>=9.1.1", + "pytest-flask>=1.3.0", + "responses>=0.26.2", ] lint = [ - "mypy", - "ruff", - "types-python-dateutil", - "types-requests", + "mypy>=2.3.0", + "ruff>=0.16.0", + "types-python-dateutil>=2.9.0.20260716", + "types-requests>=2.33.0.20260712", ] dev = [ "gpxtable[gunicorn,tests,lint]", @@ -66,6 +66,31 @@ version = {attr = "gpxtable.__version__"} [tool.setuptools.package-data] gpxtable = ["static/*", "templates/*"] +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +# Pin the rule set explicitly. Ruff's implicit default set grows with each +# release, so relying on it turns a routine ruff upgrade into a broken build. +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RUF", # ruff-specific rules +] +ignore = [ + "E501", # line length is handled by the formatter +] + +[tool.ruff.lint.isort] +# "conftest" is the test suite's own shared-helper module, not a third-party package. +known-first-party = ["gpxtable", "conftest"] + [tool.mypy] python_version = "3.10" warn_return_any = true diff --git a/requirements.txt b/requirements.txt index 957ec59..b431ec8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ python-dateutil>=2.9 flask>=3.1.3 requests>=2.34.2 gunicorn>=26.0.0 -click>=8.4.1 +click>=8.4.2 diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..212bca6 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,15 @@ +# samples + +Expected output fixtures, versioned in this repository: + +- `*.txt` — golden CLI output used by the `tests/test_cli.py` comparison tests. + Regenerate with `make generate-samples` after an intentional output change. +- `default-config.json` — the default waypoint classifier, included in the docs. + +The GPX **inputs** that produce these outputs are not here. They live in the +[`gpxsamples`](https://github.com/pleasantone/gpxsamples) submodule, checked out +at `gpxsamples/` in the repository root: + +```bash +git submodule update --init # or: make submodules +``` diff --git a/samples/basecamp-route.txt b/samples/basecamp-route.txt new file mode 100644 index 0000000..f4caf1f --- /dev/null +++ b/samples/basecamp-route.txt @@ -0,0 +1,17 @@ +* Garmin Desktop App +* Default speed: 30.00 mph + +## Route: Fort Ross Run + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee Northgate Mall | 0 | | 09:15 | Restaurant +| Nicasio Square | 12 | | 09:39 | Restroom (+0:15) +| Pat's International | 65 | L | 11:41 | Restaurant (+1:00) +| 76 Gureneville | 65/65 | G | 12:41 | Gas Station (+0:15) +| Willy's America | 79 | | 13:23 | Scenic Area (+0:05) +| 76 Bodega Bay | 67/132 | G | 15:14 | Gas Station (+0:15) +| Point Reyes Station | 165 | | 16:36 | Restroom (+0:05) +| Starbucks Strawberry Village | 63/195 | | 17:41 | Restaurant + +* 07/30/23: Sunrise: 06:11, Starts: 09:15, Ends: 17:41, Sunset: 20:20 diff --git a/samples/basecamp-tracks.txt b/samples/basecamp-tracks.txt new file mode 100644 index 0000000..c370226 --- /dev/null +++ b/samples/basecamp-tracks.txt @@ -0,0 +1,19 @@ +* Garmin Desktop App +* Departure at Sat Jul 30 09:15:00 2022 PDT +* Total distance: 196 mi +* Default speed: 30.00 mph + +## Track: Fort Ross Run tk + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee Northgate Mall | 0 | | 09:15 | Restaurant +| Nicasio Square | 12 | | 09:39 | Restroom (+0:15) +| Pat's International | 65 | L | 11:40 | Restaurant (+1:00) +| 76 Guerneville | 65/65 | G | 12:40 | Gas Station (+0:15) +| Willy's America | 79 | | 13:22 | Scenic Area (+0:05) +| 76 Bodega Bay | 67/132 | G | 15:14 | Gas Station (+0:15) +| Point Reyes Station | 165 | | 16:35 | Restroom (+0:15) +| Starbucks Strawberry Village | 63/196 | | 17:51 | Restaurant + +* 07/30/22: Sunrise: 06:11, Starts: 09:15, Ends: 17:51, Sunset: 20:20 diff --git a/samples/basecamp.txt b/samples/basecamp.txt new file mode 100644 index 0000000..685d84a --- /dev/null +++ b/samples/basecamp.txt @@ -0,0 +1,34 @@ +* Garmin Desktop App +* Departure at Sun Jul 30 09:15:00 2023 PDT +* Total distance: 196 mi +* Default speed: 30.00 mph + +## Track: Fort Ross Run tk + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee Northgate Mall | 0 | | 09:15 | Restaurant +| Nicasio Square | 12 | | 09:39 | Restroom (+0:15) +| Pat's International | 65 | L | 11:40 | Restaurant (+1:00) +| 76 Guerneville | 65/65 | G | 12:40 | Gas Station (+0:15) +| Willy's America | 79 | | 13:22 | Scenic Area (+0:05) +| 76 Bodega Bay | 67/132 | G | 15:14 | Gas Station (+0:15) +| Point Reyes Station | 165 | | 16:35 | Restroom (+0:15) +| Starbucks Strawberry Village | 63/196 | | 17:51 | Restaurant + +* 07/30/23: Sunrise: 06:11, Starts: 09:15, Ends: 17:51, Sunset: 20:20 + +## Route: Fort Ross Run + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee Northgate Mall | 0 | | 09:15 | Restaurant +| Nicasio Square | 12 | | 09:39 | Restroom (+0:15) +| Pat's International | 65 | L | 11:40 | Restaurant (+1:00) +| 76 Guerneville | 65/65 | G | 12:40 | Gas Station (+0:15) +| Willy's America | 79 | | 13:22 | Scenic Area (+0:05) +| 76 Bodega Bay | 67/132 | G | 15:14 | Gas Station (+0:15) +| Point Reyes Station | 165 | | 16:35 | Restroom (+0:05) +| Starbucks Strawberry Village | 63/195 | | 17:40 | Restaurant + +* 07/30/23: Sunrise: 06:11, Starts: 09:15, Ends: 17:40, Sunset: 20:20 diff --git a/samples/default-config.json b/samples/default-config.json new file mode 100644 index 0000000..ab68bec --- /dev/null +++ b/samples/default-config.json @@ -0,0 +1,36 @@ +[ + { + "delay": 75, + "fuel_reset": true, + "marker": "GL", + "search": "(?=.*\\b(Gas|Fuel)\\b)(?=.*\\b(Lunch|Meal)\\b)", + "symbol": "Gas/Restaurant" + }, + { + "delay": 15, + "fuel_reset": true, + "marker": "G", + "search": "\\bGas\\b|\\bFuel\\b|\\b\\(G\\)\\b", + "symbol": "Gas Station" + }, + { + "delay": 60, + "marker": "L", + "search": "\\bRestaurant\\b|\\bLunch\\b|\\bBreakfast\\b|\\bDinner\\b|\\b\\(L\\)\\b", + "symbol": "Restaurant" + }, + { + "delay": 15, + "search": "\\bRestroom\\b|\\bBreak\\b|\\b\\(R\\)\\b", + "symbol": "Restroom" + }, + { + "delay": 5, + "symbol": "Scenic Area" + }, + { + "delay": 5, + "search": "\\bPhotos?\\b|\\b\\(P\\)\\b", + "symbol": "Photo" + } +] \ No newline at end of file diff --git a/samples/ich-north-fixed.txt b/samples/ich-north-fixed.txt new file mode 100644 index 0000000..be08379 --- /dev/null +++ b/samples/ich-north-fixed.txt @@ -0,0 +1,59 @@ +* Departure at Sun Jul 30 09:15:00 2023 PDT +* Total distance: 1175 mi +* Default speed: 30.00 mph + +## Track: D1 Arcata 361 miles + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| 101 Los Altos | 0 | | 09:15 | Circle, Green +| 102 Santa Rosa | 97/97 | G | 12:29 | Gas Station (+0:15) +| 103 Cloverdale | 129 | | 13:48 | Waypoint +| 104 Boonville | 159 | L | 14:48 | Restaurant (+1:00) +| 105 Fort Bragg | 111/208 | G | 17:26 | Gas Station (+0:15) +| 106 tree | 253 | | 19:11 | Waypoint +| 107 Garberville | 278 | | 20:00 | Waypoint +| 108 Fortuna | 123/331 | G | 21:47 | Gas Station (+0:15) +| 109 Arcata motels | 29/360 | | 23:00 | Lodging + +* 07/30/23: Sunrise: 06:11, Starts: 09:15, Sunset: 20:34, Ends: 23:00 + +## Track: D2 Oregon 376 miles + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| 109 Arcata motels | 0/0 | | 09:15 | Lodging +| 301 Titlow Hill | 28 | | 10:11 | Waypoint +| 209 Willow Creek | 38/38 | G | 10:30 | Gas Station (+0:15) +| 201 Hoopa | 12/50 | G | 11:10 | Gas Station (+0:15) +| 208 Pectah | 61 | | 11:46 | Waypoint +| 202 Happy Camp | 70/120 | G | 13:44 | Gas Station (+0:15) +| 203 border | 141 | | 14:41 | Waypoint +| 202 Happy Camp | 41/161 | G | 15:22 | Gas Station (+0:15) +| 204 Seiad | 180 | | 16:14 | Waypoint +| 205 Fort Jones | 60/221 | G | 17:37 | Gas Station (+0:15) +| 206 Etna | 235 | | 18:19 | Waypoint +| 207 Salmon River | 260 | | 19:10 | Waypoint +| 208 Pectah | 313 | | 20:56 | Waypoint +| 201 Hoopa | 102/323 | G | 21:16 | Gas Station (+0:15) +| 209 Willow Creek | 13/336 | G | 21:56 | Gas Station (+0:15) +| 301 Titlow Hill | 345 | | 22:30 | Waypoint +| 109 Arcata motels | 38/373 | | 23:26 | Lodging + +* 07/31/23: Sunrise: 06:11, Starts: 09:15, Sunset: 20:33, Ends: 23:26 + +## Track: D3 home 443 miles + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| 109 Arcata motels | 0 | | 09:15 | Lodging +| 301 Titlow Hill | 28 | | 10:11 | Waypoint +| 302 NF-1 | 49 | | 10:53 | Waypoint +| 303 Hwy 36 | 81 | | 11:56 | Waypoint +| 304 Dinsmore | 88/88 | G | 12:10 | Gas Station (+0:15) +| 303 Hwy 36 | 94 | | 12:38 | Waypoint +| 305 Red Bluff | 97/185 | G | 15:39 | Gas Station (+0:15) +| 306 Stonyford | 72/256 | G | 18:17 | Gas Station (+0:15) +| 307 Cache Creek | 59/316 | | 20:31 | Gas Station + +* 08/01/23: Sunrise: 06:12, Starts: 09:15, Ends: 00:42, Sunset: 20:15 diff --git a/samples/scenic2.txt b/samples/scenic2.txt new file mode 100644 index 0000000..b25fc29 --- /dev/null +++ b/samples/scenic2.txt @@ -0,0 +1,65 @@ +* Scenic Motorcycle Navigation App +* Departure at Sun Jul 30 09:15:00 2023 PDT +* Total moving time: 06:27:52 +* Total distance: 228 mi +* Default speed: 30.00 mph + +## Track: Jun15 (Track) +* Scenic Motorcycle Navigation App + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee | 0 | | 09:15 | +| Riverboat Marina | 77 | | 12:16 | +| Isleton | 84 | | 12:26 | +| Locke | 99 | | 12:46 | +| Freeport Bar & Grill (Lunch / | 119/119 | GL | 13:13 | Gas/Restaurant (+1:15) +| Rio Vista Shell | 150 | | 15:17 | +| Starbucks | 109/228 | | 17:27 | + +* 07/30/23: Sunrise: 06:10, Starts: 09:15, Ends: 17:27, Sunset: 20:20 + +## Route: Jun15 (plain GPX) +* Scenic Motorcycle Navigation App + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee - Route Start | 0 | | 09:15 | +| Riverboat Marina - Stop 1 (Via | 48 | | 10:50 | +| Isleton (Lunch) - Stop 2 (Via | 53 | L | 11:00 | Restaurant (+1:00) +| Locke - Stop 3 (Via 14) | 67 | | 12:29 | +| Freeport Bar & Grill (Restroom | 77 | | 12:49 | Restroom (+0:15) +| Rio Vista Shell Gas - Stop 5 ( | 104/104 | G | 13:58 | Gas Station (+0:15) +| Starbucks - Route End | 60/164 | | 16:13 | + +* 07/30/23: Sunrise: 06:10, Starts: 09:15, Ends: 16:13, Sunset: 20:20 + +## Route: Jun15 (Garmin Trip Extension) +* Scenic Motorcycle Navigation App + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee - Route Start | 0 | | 09:15 | +| Riverboat Marina - Stop 1 (Via | 48 | | 10:50 | +| Isleton (Lunch) - Stop 2 (Via | 53 | L | 11:00 | Restaurant (+1:00) +| Locke - Stop 3 (Via 14) | 67 | | 12:29 | +| Freeport Bar & Grill (Restroom | 77 | | 12:49 | Restroom (+0:15) +| Rio Vista Shell Gas - Stop 5 ( | 104/104 | G | 13:58 | Gas Station (+0:15) +| Starbucks - Route End | 60/164 | | 16:13 | + +* 07/30/23: Sunrise: 06:10, Starts: 09:15, Ends: 16:13, Sunset: 20:20 + +## Route: Jun15 (Garmin RoutePoint Extension) +* Scenic Motorcycle Navigation App + +| Name | Dist. | GL | ETA | Notes +| :----------------------------- | ------: | -- | ----: | :---- +| Peet's Coffee - Route Start | 0 | | 09:15 | +| Riverboat Marina - Stop 1 (Via | 48 | | 10:50 | +| Isleton (Lunch) - Stop 2 (Via | 53 | L | 11:00 | Restaurant (+1:00) +| Locke - Stop 3 (Via 14) | 68 | | 12:31 | +| Freeport Bar & Grill (Restroom | 80 | | 12:55 | Restroom (+0:15) +| Rio Vista Shell Gas - Stop 5 ( | 102/102 | G | 13:53 | Gas Station (+0:15) +| Starbucks - Route End | 63/164 | | 16:13 | + +* 07/30/23: Sunrise: 06:10, Starts: 09:15, Ends: 16:13, Sunset: 20:20 diff --git a/src/gpxtable/__init__.py b/src/gpxtable/__init__.py index 0d02f99..01dfc24 100644 --- a/src/gpxtable/__init__.py +++ b/src/gpxtable/__init__.py @@ -2,8 +2,8 @@ gpxtable - Create a markdown template from a Garmin GPX file for route information """ -from .gpxtable import GPXTableCalculator, GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER +from .gpxtable import GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER, GPXTableCalculator __version__ = "2.2.1" -__all__ = ["GPXTableCalculator", "GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER"] +__all__ = ["GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER", "GPXTableCalculator"] __author__ = "Paul Traina" diff --git a/src/gpxtable/cli.py b/src/gpxtable/cli.py index bd15139..b35eb7d 100644 --- a/src/gpxtable/cli.py +++ b/src/gpxtable/cli.py @@ -11,12 +11,12 @@ import dateutil.parser import dateutil.tz -import gpxpy.gpx import gpxpy.geo +import gpxpy.gpx import gpxpy.utils import markdown2 -from gpxtable import GPXTableCalculator, GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER +from gpxtable import GPXTABLE_DEFAULT_WAYPOINT_CLASSIFIER, GPXTableCalculator logger = logging.getLogger(__name__) diff --git a/src/gpxtable/gpxtable.py b/src/gpxtable/gpxtable.py index 49e7a9d..479d70a 100644 --- a/src/gpxtable/gpxtable.py +++ b/src/gpxtable/gpxtable.py @@ -15,9 +15,9 @@ import gpxpy.geo from gpxpy.gpx import ( GPX, - GPXWaypoint, GPXRoutePoint, GPXTrackPoint, + GPXWaypoint, PointData, ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c5c353 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared paths and helpers for the gpxtable test suite. + +Sample data is split across two locations: + +* ``gpxsamples/`` — a git submodule holding the (large) ``.gpx`` inputs. +* ``samples/`` — the small expected-output fixtures versioned in this repo. +""" + +from pathlib import Path + +import pytest + +BASE_DIR = Path(__file__).resolve().parent.parent + +#: GPX inputs, provided by the ``gpxsamples`` submodule. +GPX_DIR = BASE_DIR / "gpxsamples" + +#: Expected CLI output and config fixtures, versioned in this repository. +EXPECTED_DIR = BASE_DIR / "samples" + + +def gpx_sample(name: str) -> Path: + """Path to a ``.gpx`` input file in the samples submodule.""" + return GPX_DIR / f"{name}.gpx" + + +def expected_output(name: str) -> Path: + """Path to the golden ``.txt`` output for a sample.""" + return EXPECTED_DIR / f"{name}.txt" + + +def pytest_configure(config: pytest.Config) -> None: + """Fail fast with an actionable message if the submodule is not checked out. + + This is deliberately global rather than scoped to the tests that call + ``gpx_sample()``. Skipping those tests when the inputs are missing would let + a broken checkout look like a passing run — which is exactly how the sample + move went unnoticed. An unusable checkout should be loud, not quiet, so a + missing submodule stops the whole run. + """ + if not any(GPX_DIR.glob("*.gpx")): + raise pytest.UsageError( + f"No GPX sample data found in {GPX_DIR}. " + "Run 'git submodule update --init' to populate the gpxsamples submodule." + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index da921f3..44e8b6c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,23 +1,28 @@ import argparse -import pytest -import subprocess import os -from typing import Tuple, List +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import EXPECTED_DIR, expected_output, gpx_sample # Define the paths -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CLI_SCRIPT_PATH = os.path.join(BASE_DIR, "src", "gpxtable", "cli.py") +BASE_DIR = Path(__file__).resolve().parent.parent +CLI_SCRIPT_PATH = BASE_DIR / "src" / "gpxtable" / "cli.py" +DEFAULT_CONFIG_FILE = EXPECTED_DIR / "default-config.json" -def _run_cli(args: List[str]): +def _run_cli(args: list[str]) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["TZ"] = "America/Los_Angeles" return subprocess.run( - ["python", CLI_SCRIPT_PATH] + args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + [sys.executable, str(CLI_SCRIPT_PATH), *args], + capture_output=True, text=True, env=env, + check=False, ) @@ -47,23 +52,19 @@ def test_cli_invalid_file(run_cli): ] -def input_output_names(filename: str) -> Tuple[str, str]: - # sourcery skip: use-fstring-for-concatenation - return ( - os.path.join(BASE_DIR, "samples", filename + ".gpx"), - os.path.join(BASE_DIR, "samples", filename + ".txt"), - ) +def input_output_names(filename: str) -> tuple[str, str]: + return str(gpx_sample(filename)), str(expected_output(filename)) @pytest.mark.parametrize(("test_case", "arguments"), file_test_cases) def test_cli_files_parm(run_cli, test_case: str, arguments: list): input_file, expected_file = input_output_names(test_case) - args = arguments + [input_file] + args = [*arguments, input_file] result = run_cli(args) assert result.returncode == 0 - with open(expected_file, "r") as f: - expected_output = f.read() - assert result.stdout == expected_output + with open(expected_file) as f: + expected = f.read() + assert result.stdout == expected def test_bad_xml(run_cli) -> None: @@ -87,16 +88,28 @@ def test_cli_invalid_timezone(run_cli) -> None: assert "invalid timezone" in result.stderr.lower() +def test_dump_config_matches_sample(run_cli) -> None: + """samples/default-config.json is included in the docs — keep it current.""" + result = run_cli(["--dump-config"]) + assert result.returncode == 0 + assert result.stdout == DEFAULT_CONFIG_FILE.read_text() + + def generate_sample_output() -> None: for test_case, arguments in file_test_cases: input_file, output_file = input_output_names(test_case) - args = arguments + [input_file] + args = [*arguments, input_file] print(f"gpxtable {' '.join(args)} > {output_file}...") result = _run_cli(args) assert result.returncode == 0 with open(output_file, "w") as f: f.write(result.stdout) + print(f"gpxtable --dump-config > {DEFAULT_CONFIG_FILE}...") + result = _run_cli(["--dump-config"]) + assert result.returncode == 0 + DEFAULT_CONFIG_FILE.write_text(result.stdout) + if __name__ == "__main__": parser = argparse.ArgumentParser() diff --git a/tests/test_gpxtable.py b/tests/test_gpxtable.py index a03b4c0..4933643 100644 --- a/tests/test_gpxtable.py +++ b/tests/test_gpxtable.py @@ -1,18 +1,18 @@ -import pytest from datetime import datetime, timedelta, timezone from io import StringIO -from typing import Tuple +import pytest +from gpxpy.geo import Location from gpxpy.gpx import ( GPX, GPXRoute, GPXRoutePoint, - GPXTrackSegment, - GPXTrackPoint, GPXTrack, + GPXTrackPoint, + GPXTrackSegment, GPXWaypoint, ) -from gpxpy.geo import Location + from gpxtable.gpxtable import ( GPXTableCalculator, GPXTrackExt, @@ -21,7 +21,7 @@ @pytest.fixture -def gpx_data() -> Tuple[GPX, StringIO]: +def gpx_data() -> tuple[GPX, StringIO]: gpx = GPX() gpx.author_name = "John Doe" gpx.author_email = "unittest@example.com" @@ -88,7 +88,7 @@ def gpx_data() -> Tuple[GPX, StringIO]: return gpx, output -def test_print_header(gpx_data: Tuple[GPX, StringIO]) -> None: +def test_print_header(gpx_data: tuple[GPX, StringIO]) -> None: gpx, output = gpx_data calculator = GPXTableCalculator(gpx, output) calculator.print_header() @@ -103,14 +103,14 @@ def test_print_header(gpx_data: Tuple[GPX, StringIO]) -> None: ) -def test_print_waypoints(gpx_data: Tuple[GPX, StringIO]) -> None: +def test_print_waypoints(gpx_data: tuple[GPX, StringIO]) -> None: gpx, output = gpx_data calculator = GPXTableCalculator(gpx, output) calculator.print_waypoints() assert "## Track:" in output.getvalue() -def test_print_routes(gpx_data: Tuple[GPX, StringIO]) -> None: +def test_print_routes(gpx_data: tuple[GPX, StringIO]) -> None: gpx, output = gpx_data calculator = GPXTableCalculator(gpx, output) calculator.print_routes() @@ -129,14 +129,14 @@ def test_print_routes(gpx_data: Tuple[GPX, StringIO]) -> None: ) -def test_get_points_data(gpx_data: Tuple[GPX, StringIO]) -> None: +def test_get_points_data(gpx_data: tuple[GPX, StringIO]) -> None: gpx, _ = gpx_data track_ext = GPXTrackExt(gpx.tracks[0]) points_data = track_ext.get_points_data() assert len(points_data) == 2 -def test_get_nearest_locations(gpx_data: Tuple[GPX, StringIO]) -> None: +def test_get_nearest_locations(gpx_data: tuple[GPX, StringIO]) -> None: gpx, _ = gpx_data location = Location(48.2081744, 16.3638188) track_ext = GPXTrackExt(gpx.tracks[0]) diff --git a/tests/test_gpxtable_sourcery.py b/tests/test_gpxtable_sourcery.py index f3bf69c..e280590 100644 --- a/tests/test_gpxtable_sourcery.py +++ b/tests/test_gpxtable_sourcery.py @@ -1,16 +1,17 @@ -import pytest from datetime import datetime, timedelta, timezone - from io import StringIO + +import pytest from gpxpy.gpx import ( GPX, - GPXTrack, - GPXTrackSegment, - GPXTrackPoint, GPXRoute, GPXRoutePoint, + GPXTrack, + GPXTrackPoint, + GPXTrackSegment, GPXWaypoint, ) + from gpxtable import GPXTableCalculator diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index d8f5fd3..981caec 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -1,21 +1,28 @@ -import os -from flask.testing import FlaskClient -from flask import url_for +import io + import pytest import responses -from gpxtable.wsgi import create_app, create_table, InvalidSubmission +from flask import url_for +from flask.testing import FlaskClient + +from conftest import gpx_sample +from gpxtable.wsgi import InvalidSubmission, create_app, create_table TEST_FILE_URL = "http://mock.api/basecamp.gpx" -TEST_FILE = "samples/basecamp.gpx" +TEST_FILE = gpx_sample("basecamp") TEST_RESPONSE = b"Garmin Desktop App" -BAD_XML_FILE = "samples/bad-xml.gpx" +BAD_XML_FILE = gpx_sample("bad-xml") + + +def upload(path, filename: str = "test.gpx") -> tuple[io.BytesIO, str]: + """Build a multipart upload tuple from a sample file, without leaking a handle.""" + return io.BytesIO(path.read_bytes()), filename @pytest.fixture(scope="session") def app(): # add our fake responses - with open(TEST_FILE, "rb") as f: - responses.add(responses.GET, TEST_FILE_URL, status=200, body=f.read()) + responses.add(responses.GET, TEST_FILE_URL, status=200, body=TEST_FILE.read_bytes()) app = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False @@ -31,7 +38,7 @@ def test_index(client: FlaskClient) -> None: def test_upload_file(client: FlaskClient) -> None: """Test file upload.""" - data = {"file": (open(TEST_FILE, "rb"), os.path.dirname(TEST_FILE))} + data = {"file": upload(TEST_FILE, "basecamp.gpx")} response = client.post( url_for("gpxtable.upload_file"), data=data, content_type="multipart/form-data" ) @@ -53,7 +60,7 @@ def test_upload_url(client: FlaskClient) -> None: def test_bad_xml(client: FlaskClient) -> None: - data = {"file": (open(BAD_XML_FILE, "rb"), os.path.dirname(BAD_XML_FILE))} + data = {"file": upload(BAD_XML_FILE, "bad-xml.gpx")} response = client.post( url_for("gpxtable.upload_file"), data=data, @@ -81,10 +88,9 @@ def test_missing_file_and_url(client: FlaskClient) -> None: assert b"Missing URL" in response.data - def test_invalid_speed(client: FlaskClient) -> None: """Non-numeric speed value should redirect with error.""" - data = {"file": (open(TEST_FILE, "rb"), "test.gpx"), "speed": "fast"} + data = {"file": upload(TEST_FILE), "speed": "fast"} response = client.post( url_for("gpxtable.upload_file"), data=data, @@ -97,7 +103,7 @@ def test_invalid_speed(client: FlaskClient) -> None: def test_invalid_timezone(client: FlaskClient) -> None: """Unknown timezone should redirect with error.""" - data = {"file": (open(TEST_FILE, "rb"), "test.gpx"), "tz": "Fake/Zone"} + data = {"file": upload(TEST_FILE), "tz": "Fake/Zone"} response = client.post( url_for("gpxtable.upload_file"), data=data, @@ -110,7 +116,7 @@ def test_invalid_timezone(client: FlaskClient) -> None: def test_invalid_departure(client: FlaskClient) -> None: """Unparseable departure time should redirect with error.""" - data = {"file": (open(TEST_FILE, "rb"), "test.gpx"), "departure": "notadate"} + data = {"file": upload(TEST_FILE), "departure": "notadate"} response = client.post( url_for("gpxtable.upload_file"), data=data, @@ -193,9 +199,11 @@ def test_create_table_htmlcode() -> None: def test_create_table_invalid_departure() -> None: """Unparseable departure string should raise InvalidSubmission.""" - with open(TEST_FILE, "rb") as f: - with pytest.raises(InvalidSubmission, match="Invalid departure"): - create_table(f, departure="notadate") + with ( + open(TEST_FILE, "rb") as f, + pytest.raises(InvalidSubmission, match="Invalid departure"), + ): + create_table(f, departure="notadate") if __name__ == "__main__":