From 3d2803c3a1557f28c34f37e70963e0cb37a1f297 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:37:40 +0000 Subject: [PATCH 1/8] test: repoint the test suite at the gpxsamples submodule The "Samples moved to submodule" commit deleted samples/ but nothing was updated to read from gpxsamples/, leaving 14 failures and 13 collection errors. Sample data is now split by size and role: * gpxsamples/ (submodule) - the large .gpx inputs * samples/ (this repo) - the small golden .txt outputs and default-config.json, which the docs include tests/conftest.py owns both paths and fails collection with an actionable message when the submodule was never initialized, instead of a pile of FileNotFoundErrors. Also make the suite independent of the invoking environment: use sys.executable rather than whatever "python" resolves to on PATH, and absolute sample paths rather than cwd-relative ones. samples/default-config.json had drifted from the code (it still carried a "\Dinner" typo fixed since), so --dump-config is now regenerated by --generate and asserted by a test to keep the documented config honest. The golden .txt files regenerate byte-identical, confirming no behavior change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- samples/README.md | 15 +++++++++ samples/basecamp-route.txt | 17 ++++++++++ samples/basecamp-tracks.txt | 19 +++++++++++ samples/basecamp.txt | 34 +++++++++++++++++++ samples/default-config.json | 36 ++++++++++++++++++++ samples/ich-north-fixed.txt | 59 +++++++++++++++++++++++++++++++++ samples/scenic2.txt | 65 +++++++++++++++++++++++++++++++++++++ tests/conftest.py | 38 ++++++++++++++++++++++ tests/test_cli.py | 53 ++++++++++++++++++------------ tests/test_wsgi.py | 42 ++++++++++++++---------- 10 files changed, 341 insertions(+), 37 deletions(-) create mode 100644 samples/README.md create mode 100644 samples/basecamp-route.txt create mode 100644 samples/basecamp-tracks.txt create mode 100644 samples/basecamp.txt create mode 100644 samples/default-config.json create mode 100644 samples/ich-north-fixed.txt create mode 100644 samples/scenic2.txt create mode 100644 tests/conftest.py 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8466ea1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +"""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.""" + 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_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__": From bf238115cfab19fd4905350090503bf6ebf249a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:37:51 +0000 Subject: [PATCH 2/8] ci: check out the gpxsamples submodule for the test job actions/checkout does not fetch submodules by default, so the test job had no GPX inputs to run against. Only the test job needs them; lint and the GAE deploy do not. Add a "make submodules" target that the test targets depend on, so a local checkout populates itself, and exclude gpxsamples/ from the GAE upload. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- .gcloudignore | 1 + .github/workflows/python-app.yml | 3 +++ Makefile | 21 +++++++++++++-------- 3 files changed, 17 insertions(+), 8 deletions(-) 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/python-app.yml b/.github/workflows/python-app.yml index baa3196..058e450 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -39,6 +39,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # the test suite reads its GPX inputs from the gpxsamples submodule + submodules: true - uses: astral-sh/setup-uv@v8.2.0 with: enable-cache: true diff --git a/Makefile b/Makefile index 32063bf..9b4a8cd 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,22 @@ 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: 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 ────────────────────────────────────────────────────────────────── @@ -69,19 +74,19 @@ typecheck: # ── CLI demos ──────────────────────────────────────────────────────────────── demo-route: - gpxtable samples/basecamp-route.gpx + gpxtable gpxsamples/basecamp-route.gpx demo-tracks: - gpxtable --departure "07/30/2023 09:15:00" samples/basecamp-tracks.gpx + gpxtable --departure "07/30/2023 09:15:00" gpxsamples/basecamp-tracks.gpx demo-basecamp: - gpxtable samples/basecamp.gpx + gpxtable gpxsamples/basecamp.gpx demo-config: gpxtable --dump-config demo-custom: /tmp/myconfig.json - gpxtable --config /tmp/myconfig.json samples/basecamp-route.gpx + gpxtable --config /tmp/myconfig.json gpxsamples/basecamp-route.gpx /tmp/myconfig.json: gpxtable --dump-config > /tmp/myconfig.json From 7a7254f67d6af42d1b41e4d20bfcfbbcc8669fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:37:51 +0000 Subject: [PATCH 3/8] chore(lint): pin the ruff rule set and fix the resulting violations Ruff's implicit default rule set grows with every release, so the project was effectively linting against a moving target -- ruff 0.16 turned up 28 violations that no code change introduced. Declare the rule set explicitly in [tool.ruff.lint] (E, W, F, I, UP, B, C4, SIM, RUF, ignoring E501) so a routine ruff upgrade stops breaking the build, and fix everything it flags: import ordering, __all__ sorting, deprecated typing.Tuple/List aliases, subprocess argument style, and leaked file handles in the tests. Tell isort that "conftest" is the suite's own shared-helper module rather than a third-party package. Add version floors for the lint and test tooling so CI resolves the versions these rules were checked against, and drop .flake8, which has been dead since ruff replaced flake8. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- .flake8 | 5 ----- pyproject.toml | 39 +++++++++++++++++++++++++++------ src/gpxtable/__init__.py | 4 ++-- src/gpxtable/cli.py | 4 ++-- src/gpxtable/gpxtable.py | 2 +- tests/test_gpxtable.py | 22 +++++++++---------- tests/test_gpxtable_sourcery.py | 11 +++++----- 7 files changed, 54 insertions(+), 33 deletions(-) delete mode 100644 .flake8 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/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/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/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 From 66db4ce4785419c6eeefd41380d65c0c1494b341 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:38:00 +0000 Subject: [PATCH 4/8] build(deps): bump the click floor to 8.4.2 Brings requirements.txt, which is what Google App Engine installs from, back in line with the current release. Every other runtime dependency floor already matches its latest version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e72e63da392fc4680135bbbda6b587514ba71087 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:38:00 +0000 Subject: [PATCH 5/8] docs: point sample invocations at gpxsamples/ The .gpx inputs shown in the usage examples now live in the gpxsamples submodule. The included output fixtures are unchanged and still resolve from samples/, so Read the Docs builds without needing the submodule. Also record the samples/ vs gpxsamples/ split and the conftest helpers in CLAUDE.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- CLAUDE.md | 13 +++++++++---- docs/usage.rst | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28c361d..dc685fb 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. 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: From 6d8043cf0c19539ff3337bafd9a3f5e5442d5673 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:47:08 +0000 Subject: [PATCH 6/8] build(deps): bump astral-sh/setup-uv from 8.2.0 to 9.0.0 Picks up the pending dependabot update (#38) across all three jobs that use it: lint, test, and the PyPI publish build. v9.0.0 is a major release solely because it flips the "prune-cache" default from true to false, to reduce load on PyPI's infrastructure. The new default is the intended behavior, so it is taken as-is; the only effect here is a somewhat larger Actions cache. Every Python dependency floor in pyproject.toml, requirements.txt, and docs/requirements.txt was checked against PyPI and already matches the current release, so no other bumps are outstanding. Dependabot's other open PR (#34, click >=8.4.2) is already carried by 66db4ce. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- .github/workflows/publish.yaml | 2 +- .github/workflows/python-app.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 058e450..84504cc 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" @@ -42,7 +42,7 @@ jobs: with: # the test suite reads its GPX inputs from the gpxsamples submodule submodules: true - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true python-version: "3.12" From cefa280001eb8b48efc797977765af3d3ef97d86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 22:14:41 +0000 Subject: [PATCH 7/8] ci: authenticate the gpxsamples submodule clone with a PAT gpxsamples is a separate private repository. A workflow's default GITHUB_TOKEN is scoped to this repo alone, so it cannot clone it -- GitHub reports the refusal as "Repository not found", which reads like the repo is missing rather than unauthorized. Pass a GPXSAMPLES_TOKEN secret to actions/checkout in the test job. checkout applies its token to submodule fetches as well, so one token input covers both repos and no extra step is needed. The expression falls back to github.token when the secret is absent, so a fork still checks out the main repo (its test job will fail on the submodule, as before) rather than failing outright on the primary checkout. Requires a PAT with read access to both repos, stored as GPXSAMPLES_TOKEN in the repository's Actions secrets. Documented in CLAUDE.md alongside the release-please token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- .github/workflows/python-app.yml | 6 ++++++ CLAUDE.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 84504cc..3a4c636 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -42,6 +42,12 @@ jobs: 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 diff --git a/CLAUDE.md b/CLAUDE.md index dc685fb..d98afe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,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. From cf4bcaa928e86d981c061c9c4678f81489d17ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:30:43 +0000 Subject: [PATCH 8/8] fix(make): initialize submodules for the demo and unit-test targets The demo-* targets read GPX inputs from gpxsamples/, so they failed on a fresh clone where the submodule had never been initialized. Give them the same "submodules" prerequisite the test targets already have. demo-config is left alone -- --dump-config reads no sample data. test-unit gets the prerequisite too. Its tests build GPX objects in memory and need no inputs, but conftest's submodule check is global, so every pytest invocation requires the submodule regardless. Aligning the target removes the mismatch between what the Makefile implied and what pytest actually enforces. Document why that check is global rather than scoped to the tests that call gpx_sample(): skipping those tests when inputs are missing would let a broken checkout look like a passing run, which is precisely how the sample move went unnoticed in the first place. Raised in review by sourcery-ai on #39. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xk6gQpcHHxmQ4KBUjYhKr9 --- Makefile | 12 +++++++----- tests/conftest.py | 9 ++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 9b4a8cd..96e9608 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,9 @@ submodules: 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: submodules @@ -73,19 +75,19 @@ typecheck: # ── CLI demos ──────────────────────────────────────────────────────────────── -demo-route: +demo-route: submodules gpxtable gpxsamples/basecamp-route.gpx -demo-tracks: +demo-tracks: submodules gpxtable --departure "07/30/2023 09:15:00" gpxsamples/basecamp-tracks.gpx -demo-basecamp: +demo-basecamp: submodules gpxtable gpxsamples/basecamp.gpx demo-config: gpxtable --dump-config -demo-custom: /tmp/myconfig.json +demo-custom: submodules /tmp/myconfig.json gpxtable --config /tmp/myconfig.json gpxsamples/basecamp-route.gpx /tmp/myconfig.json: diff --git a/tests/conftest.py b/tests/conftest.py index 8466ea1..4c5c353 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,14 @@ def expected_output(name: str) -> Path: def pytest_configure(config: pytest.Config) -> None: - """Fail fast with an actionable message if the submodule is not checked out.""" + """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}. "