Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fb956f7
Only preserve namespace declarations from the root element
sgraaf Sep 13, 2026
9d0f316
Resolve model type hints once per class instead of per element
sgraaf Sep 13, 2026
1204647
Deduplicate GeoJSON bbox computation for routes and tracks
sgraaf Sep 13, 2026
3ce64b9
Omit GeoJSON bbox for routes and tracks without points
sgraaf Sep 13, 2026
9999b84
Skip short segments when computing track max/min speed
sgraaf Sep 13, 2026
2f963c5
Reject `NaN`` and `Infinity`` coordinates instead of crashing
sgraaf Sep 13, 2026
12decd8
Exclude extensions from GeoJSON properties
sgraaf Sep 13, 2026
d07f8b8
Add required properties member to wrapped GeoJSON features
sgraaf Sep 13, 2026
745f68b
Handle M dimensions and SRID in WKB parsing
sgraaf Sep 13, 2026
92b4f02
Never treat content as a file path in strict string parsing
sgraaf Sep 13, 2026
12b1163
Support legacy Google Earth KML namespaces
sgraaf Sep 13, 2026
4f07726
Honor the declared XML encoding when reading GPX files
sgraaf Sep 13, 2026
9e725b6
Raise on unsupported geometries instead of dropping them
sgraaf Sep 13, 2026
f49ddcc
Raise `ValueError` for truncated WKB and incomplete coordinates
sgraaf Sep 13, 2026
b6ec5ad
Write timestamps without truncating microseconds
sgraaf Sep 13, 2026
2b25232
Write and validate decimals in fixed-point notation
sgraaf Sep 13, 2026
7cd41cc
Ignore surrounding whitespace when parsing non-string values
sgraaf Sep 13, 2026
12e0387
Recompute metadata bounds after operations that change points
sgraaf Sep 13, 2026
4e808f4
Avoid sharing lists and instances between operation input and output
sgraaf Sep 13, 2026
dfe8513
Match empty elements in `Extensions` membership checks
sgraaf Sep 13, 2026
52f621e
Parse CLI datetimes with `fromisoformat`
sgraaf Sep 13, 2026
80a7f50
Run CI and the test suite before publishing to PyPI
sgraaf Sep 13, 2026
56100a5
Parse copyright years as `xsd:gYear`, preserving the timezone
sgraaf Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
# CI on any tag starting with the format "YYYY.N.N"
- "[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+*"
pull_request:
workflow_call:
workflow_dispatch:

env:
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ on:
- "[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+*"

jobs:
# Only publish when the full test suite and the pre-commit hooks pass
ci:
uses: ./.github/workflows/ci.yml
permissions:
contents: read

test:
uses: ./.github/workflows/test.yml

publish:
needs: [ci, test]
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
# Test on any tag starting with the format "YYYY.N.N"
- "[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+*"
pull_request:
workflow_call:
workflow_dispatch:

jobs:
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ The **third number** is for emergencies when we need to start branches for older
- Both are importable directly from the top-level package (e.g. `from gpx import convert_file`).
- New `validation` module that validates GPX data against the GPX 1.1 schema (`gpx.xsd`) without any extra dependencies:
- `validate()`: Validate a file path, a string of GPX content, or a `GPX` instance and return a `ValidationResult`.
- `validate_text()`: Validate a string that is always treated as GPX content (never as a file path), e.g. for untrusted input.
- `ValidationResult`: Holds all issues, with `is_valid`, `errors` and `warnings` properties.
- `ValidationIssue`: A single issue with a `severity`, `message`, `path` (e.g. `gpx > trk[0] > trkseg[2] > trkpt[14]`) and source `line` (when available).
- `Severity`: Enum of `ERROR` and `WARNING`.
- `InvalidGPXError`: Raised by strict parsing; carries the full `ValidationResult`.
- Detects, among others: wrong root element / namespace (with a GPX 1.0 hint), missing required attributes, unknown elements (with "did you mean …?" suggestions), duplicate single-occurrence elements, out-of-order children, `<extensions>` children that are not in a foreign namespace (e.g. unprefixed elements that inherit the default GPX namespace), and invalid values (latitude/longitude/degrees ranges, `fix`, `dgpsid`, `sat`, copyright `year`, `time`).
- All names are importable directly from the top-level package (e.g. `from gpx import validate`).
- New `strict` keyword argument on `read_gpx()` and `from_string()`. When `strict=True`, the input is validated against the GPX 1.1 schema first and an `InvalidGPXError` is raised if any errors are found. The default (`strict=False`) keeps the existing lenient behavior.
- New `Year` type (an `int` subclass) for `xsd:gYear` values such as `2004`, `2004Z` or `2004+02:00`. The optional timezone is kept in `Year.timezone`, and `str()` returns valid `xsd:gYear` text. It's importable from the top-level package (`from gpx import Year`).
- New `gpx validate` CLI options:
- `--strict`: Treat warnings as failures (non-zero exit code).
- `--json`: Output a machine-readable validation report.
Expand All @@ -54,6 +56,31 @@ The **third number** is for emergencies when we need to start branches for older

- The CLI (`gpx edit`, `gpx merge` and `gpx convert`) now uses the new `operations` module and `io` conversion functions internally (behavior is unchanged).
- The `gpx validate` CLI command is now a real GPX 1.1 schema validator. It reports all errors and warnings (with source line numbers) instead of only checking whether the file can be parsed, and exits non-zero when errors are found (or, with `--strict`, when warnings are found).
- `Copyright.year` is now typed as `Year | None` instead of `int | None`. `Year` is an `int` subclass, so existing code keeps working at runtime, but type checkers now expect `Year(2004)` instead of `2004`.
- Parsing and serializing GPX data is ~20× faster: the type annotations of each model are now resolved once per class instead of once per XML element.

### Fixed

- Writing a parsed GPX file no longer moves the document into the wrong XML namespace when an element deeper in the file (e.g. a Garmin extension) redeclares the default namespace, or when text content contains `xmlns="..."`. Only the namespace declarations on the root element are now preserved.
- `__geo_interface__`, `write_geojson()` and `gpx convert` to GeoJSON no longer crash on routes and tracks without any points. Their geometries are now written without a `bbox`.
- `Track.max_speed` and `Track.min_speed` no longer raise when a track segment has fewer than two points. Such segments are now skipped.
- `NaN` and `Infinity` values are now rejected with a `ValueError` by `Latitude`, `Longitude` and `Degrees` (instead of an unhelpful `decimal.InvalidOperation`). `validate()` now reports them as errors instead of crashing.
- GeoJSON output (`__geo_interface__`, `write_geojson()` and `gpx convert`) no longer contains Python object representations (with memory addresses) for extensions. Extensions have no GeoJSON representation and are now left out of the properties.
- GeoJSON Features created for waypoints, routes and tracks without any properties now have `"properties": null`, as required by RFC 7946. They previously had no `properties` member, which strict GeoJSON readers reject.
- `from_wkb()` now correctly reads geometries with M values (ISO `M`/`ZM` types and EWKB M flag) and EWKB geometries with an embedded SRID (e.g. PostGIS `ST_AsEWKB` output). These previously produced wrong coordinates without any error.
- `from_string(..., strict=True)` no longer treats content that doesn't start with `<` as a file path to read. Such content is now reported as not well-formed XML.
- `read_kml()` and `gpx convert` now read KML files in the legacy Google Earth namespaces (`http://earth.google.com/kml/2.0`, `2.1` and `2.2`), which previously produced an empty GPX without any error. Documents whose root element is not `<kml>` in a supported namespace (or no namespace) now raise a `ValueError`.
- `read_gpx()`, `validate()` and the CLI now decode GPX files according to the encoding in their XML declaration (e.g. `ISO-8859-1`). They previously always decoded files as UTF-8 and failed with a `UnicodeDecodeError` on other encodings. `from_string()` and `validate_text()` now also accept encoded `bytes`.
- `from_geo_interface()`, `read_geojson()`, `from_wkt()` and `from_wkb()` now raise a `ValueError` for geometries without a GPX equivalent (e.g. `Polygon`), including ones nested in a `FeatureCollection` or `GeometryCollection`. They were previously dropped without any error, so e.g. `from_wkt("POLYGON (...)")` returned an empty GPX.
- `from_wkb()`, `from_wkt()` and `from_geo_interface()` now raise a `ValueError`, as documented, for truncated WKB data and coordinates with fewer than two values. They previously raised `struct.error` or `IndexError`. An empty WKT point (`POINT EMPTY`) now produces no waypoint instead of raising.
- Timestamps with sub-millisecond precision are no longer cut off at milliseconds when writing GPX files. For example, `12:00:00.123456` was written as `12:00:00.123`.
- Decimal values (e.g. coordinates very close to the equator or prime meridian) are now always written in fixed-point notation. Values such as `lat="1E-8"` were not valid `xsd:decimal` values. `validate()` now also reports decimals in exponent notation as errors.
- Parsing now ignores surrounding whitespace in non-string values (e.g. `<time> 2024-01-01T00:00:00Z </time>`), just like `validate()`. Such documents previously passed validation but then failed to parse. Whitespace-only values are now treated as missing.
- `crop()`, `trim()`, `filter_points()`, `reduce_precision()`, `simplify()`, `smooth()` and `gpx edit` now recompute the metadata bounds (if present) from the remaining points. The bounds are removed if no points remain. The original, stale bounds were previously kept.
- `reverse()` with `routes=False` or `tracks=False` no longer returns a GPX that shares its route or track list with the input. `strip_metadata()` on a GPX without metadata now returns a new instance instead of the input itself.
- `in` checks on `Extensions` (e.g. `"hr" in extensions`) now return `True` for matching elements without text content.
- Copyright years with a timezone (e.g. `<year>2004Z</year>`) are now parsed, and written back with their timezone. They previously passed `validate()` but then failed to parse. Years before 1000 are now written with four digits (e.g. `0999`), as `xsd:gYear` requires.
- `gpx edit --start/--end` now accept any ISO 8601 datetime, including fractional seconds (e.g. `2024-01-01T10:00:00.5Z`). Datetimes without a timezone are still interpreted as UTC.

## [2026.3.0](https://github.com/sgraaf/gpx/compare/2026.2.0...2026.3.0) - 2026-05-17

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ gpx/
│ ├── copyright.py # Copyright dataclass model
│ ├── extensions.py # Extensions dataclass model for GPX extensions
│ ├── mixins.py # Mixins for shared functionality (e.g., PointsMixin)
│ ├── types.py # Custom types: Latitude, Longitude, Degrees, Fix, DGPSStation
│ ├── types.py # Custom types: Latitude, Longitude, Degrees, Fix, DGPSStation, Year
│ ├── utils.py # Utility functions for XML parsing and serialization
│ ├── convert.py # Conversion functions: from_geo_interface, from_wkb, from_wkt
│ ├── io.py # I/O functions: read_gpx, read_geojson, read_kml, convert_file, detect_format
Expand Down Expand Up @@ -187,6 +187,7 @@ GPXModel (base.py)
- `Degrees`: For bearing/heading (0-360)
- `Fix`: GPS fix type enum-like string
- `DGPSStation`: DGPS station ID (0-1023)
- `Year`: Copyright year (`xsd:gYear`, preserving an optional timezone)

1. **GeoJSON Support**:

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,12 +411,16 @@ merged = merge([read_gpx("one.gpx"), read_gpx("two.gpx")])
*gpx* can validate GPX data against the GPX 1.1 schema, catching problems that lenient parsing silently ignores (unknown/misspelled elements, duplicates, out-of-order children, out-of-range values, and more):

```python
from gpx import read_gpx, validate
from gpx import read_gpx, validate, validate_text


# Validate a file path, a string of GPX content, or a GPX instance
result = validate("path/to/file.gpx")

# Validate a string that must be treated as GPX content, never as a file path
# (e.g. untrusted input)
result = validate_text(gpx_string)

if not result.is_valid:
for issue in result.errors:
# e.g. "ERROR line 8 gpx > wpt[0]: unknown element <nmae> (did you mean <name>?)"
Expand Down
5 changes: 4 additions & 1 deletion src/gpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@
from .route import Route
from .track import Track
from .track_segment import TrackSegment
from .types import Degrees, DGPSStation, Fix, Latitude, Longitude
from .types import Degrees, DGPSStation, Fix, Latitude, Longitude, Year
from .validation import (
InvalidGPXError,
Severity,
ValidationIssue,
ValidationResult,
validate,
validate_text,
)
from .waypoint import Waypoint

Expand Down Expand Up @@ -62,6 +63,7 @@
"ValidationIssue",
"ValidationResult",
"Waypoint",
"Year",
"convert_file",
"crop",
"detect_format",
Expand All @@ -84,4 +86,5 @@
"strip_metadata",
"trim",
"validate",
"validate_text",
]
41 changes: 14 additions & 27 deletions src/gpx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
trim,
)
from .operations import merge as merge_op
from .utils import from_isoformat
from .validation import InvalidGPXError, ValidationResult
from .validation import validate as validate_gpx

Expand Down Expand Up @@ -191,8 +192,8 @@ def _validate_text_output(
print()

if result.is_valid:
# The document is schema-valid, so it is guaranteed to parse; guard the
# read anyway so the CLI reports cleanly instead of raising a traceback.
# The document is schema-valid, so it should parse; guard the read anyway
# so the CLI reports cleanly instead of raising a traceback.
try:
gpx = read_gpx(file_path)
except Exception as e: # noqa: BLE001
Expand Down Expand Up @@ -840,31 +841,17 @@ def _apply_strip_metadata(gpx: GPX, args: argparse.Namespace) -> GPX:


def _parse_datetime(dt_str: str) -> dt.datetime:
"""Parse an ISO 8601 datetime string."""
# Try various ISO 8601 formats
formats = [
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d",
]

for fmt in formats:
try:
dt_ = dt.datetime.strptime(dt_str, fmt) # noqa: DTZ007
# Add dt.UTC timezone if none specified
if dt_.tzinfo is None:
dt_ = dt_.replace(tzinfo=dt.UTC)
except ValueError:
continue
else:
return dt_

msg = (
f"Invalid datetime format: {dt_str}. "
"Use ISO 8601 format (e.g., 2024-01-01T10:00:00)"
)
raise ValueError(msg)
"""Parse an ISO 8601 datetime string, assuming UTC if it has no timezone."""
try:
dt_ = from_isoformat(dt_str)
except ValueError as e:
# Replace the generic parser message with a hint for the CLI user
msg = (
f"Invalid datetime format: {dt_str}. "
"Use ISO 8601 format (e.g., 2024-01-01T10:00:00)"
)
raise ValueError(msg) from e
return dt_ if dt_.tzinfo is not None else dt_.replace(tzinfo=dt.UTC)


def _add_merge_parser(
Expand Down
Loading