Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/activity_parser/parse_tcx_gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def walk_fields(element: etree._Element) -> Iterator[tuple[FieldPath, str]]:
"""Yields (path, value) for every attribute and leaf element under ``element``.

``path`` matches the keys of the field tables in ``xml_fields``. XML comments are
skipped.
skipped. ``path`` is never empty.
"""
yield from _walk(element, ())

Expand All @@ -116,7 +116,7 @@ def _walk(element: etree._Element, path: FieldPath) -> Iterator[tuple[FieldPath,
yield path + ((namespace, "@" + localname),), cast(str, value)

text = element.text
if text is not None and not text.isspace():
if path and text is not None and not text.isspace():
yield path, text
# "*" matches only true elements, so comments are skipped without special-casing.
for child in element.iterchildren("*"):
Expand Down
14 changes: 14 additions & 0 deletions tests/files/gpx/direct_text.gpx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Text directly inside the trkpt element itself, not a child's text. Used to raise
IndexError; must be skipped without dropping the fields around it. -->
<gpx creator="activity_parser tests" version="1.1"
xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="37.0000" lon="-122.0000">stray-trkpt-text
<ele>10.0</ele>
<time>2026-01-05T08:00:00Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
21 changes: 21 additions & 0 deletions tests/files/tcx/direct_text.tcx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Text directly inside the Lap and Trackpoint elements themselves, not a child's text.
Both used to raise IndexError; both must be skipped without dropping the fields
around them. -->
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Biking">
<Id>2026-01-05T08:00:00Z</Id>
<Lap StartTime="2026-01-05T08:00:00Z">stray-lap-text
<TotalTimeSeconds>1.0</TotalTimeSeconds>
<DistanceMeters>5.0</DistanceMeters>
<Track>
<Trackpoint>stray-trackpoint-text
<Time>2026-01-05T08:00:00Z</Time>
<AltitudeMeters>10.0</AltitudeMeters>
</Trackpoint>
</Track>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
21 changes: 21 additions & 0 deletions tests/test_parse_tcx_gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
CADENCE_COLLISION_TCX = TCX_FILES / "cadence_collision.tcx"
UNKNOWN_COLLISION_TCX = TCX_FILES / "unknown_collision.tcx"
MIXED_CONTENT_TCX = TCX_FILES / "mixed_content.tcx"
DIRECT_TEXT_TCX = TCX_FILES / "direct_text.tcx"
DIRECT_TEXT_GPX = GPX_FILES / "direct_text.gpx"
VENDOR_TYPE_ATTRIBUTE_TCX = TCX_FILES / "vendor_type_attribute.tcx"
DUPLICATE_CADENCE_TCX = TCX_FILES / "duplicate_cadence.tcx"
LAPS_ONLY_TCX = TCX_FILES / "laps_only.tcx"
Expand Down Expand Up @@ -389,6 +391,15 @@ def test_gpx_unknown_extension_kept_as_namespaced_string():
assert column not in normalized.columns


def test_gpx_direct_text_on_trkpt_is_skipped():
# Text directly inside trkpt itself used to raise IndexError.
records, _, _ = parse_gpx(DIRECT_TEXT_GPX)
assert records["latitude"].tolist() == pytest.approx([37.0000])
assert records["longitude"].tolist() == pytest.approx([-122.0000])
assert records["altitude"].tolist() == [10.0]
assert not records.isin(["stray-trkpt-text"]).any().any()


def test_gpx_1_0_base_fields():
# GPX 1.0 exposes course/speed directly; GPS-fix diagnostics aren't fitness data.
records, _, _ = ActivityParser().parse(GPX10)
Expand Down Expand Up @@ -493,6 +504,16 @@ def test_tcx_mixed_content_keeps_both_text_and_child():
assert records[f"{{{ns}}}Nested"].tolist() == ["should-not-be-lost"]


def test_tcx_direct_text_on_lap_and_trackpoint_is_skipped():
# Text directly inside Lap/Trackpoint themselves used to raise IndexError.
records, laps, _ = parse_tcx(DIRECT_TEXT_TCX)
assert records["altitude"].tolist() == [10.0]
assert laps["total_elapsed_time"].tolist() == [1.0]
assert laps["total_distance"].tolist() == pytest.approx([0.005])
assert not records.isin(["stray-trackpoint-text"]).any().any()
assert not laps.isin(["stray-lap-text"]).any().any()


def test_tcx_type_attribute_only_skipped_for_xsi_namespace():
# Only genuine xsi:type is metadata; a same-named attribute elsewhere is real data.
records, _, _ = parse_tcx(VENDOR_TYPE_ATTRIBUTE_TCX)
Expand Down