From d58ea62b602973abf681f8635ba4caff293abac7 Mon Sep 17 00:00:00 2001 From: Tabish Mustufa Date: Sat, 5 Sep 2026 19:09:15 -0700 Subject: [PATCH] Skip text directly on a record or lap element instead of crashing _row_from_fields indexed the empty path yielded for an element's own text, raising IndexError on input the parser otherwise tolerates. Co-Authored-By: Claude Sonnet 5 --- src/activity_parser/parse_tcx_gpx.py | 4 ++-- tests/files/gpx/direct_text.gpx | 14 ++++++++++++++ tests/files/tcx/direct_text.tcx | 21 +++++++++++++++++++++ tests/test_parse_tcx_gpx.py | 21 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/files/gpx/direct_text.gpx create mode 100644 tests/files/tcx/direct_text.tcx diff --git a/src/activity_parser/parse_tcx_gpx.py b/src/activity_parser/parse_tcx_gpx.py index fee9049..5168e34 100644 --- a/src/activity_parser/parse_tcx_gpx.py +++ b/src/activity_parser/parse_tcx_gpx.py @@ -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, ()) @@ -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("*"): diff --git a/tests/files/gpx/direct_text.gpx b/tests/files/gpx/direct_text.gpx new file mode 100644 index 0000000..7484338 --- /dev/null +++ b/tests/files/gpx/direct_text.gpx @@ -0,0 +1,14 @@ + + + + + + stray-trkpt-text + 10.0 + + + + + diff --git a/tests/files/tcx/direct_text.tcx b/tests/files/tcx/direct_text.tcx new file mode 100644 index 0000000..0e292b6 --- /dev/null +++ b/tests/files/tcx/direct_text.tcx @@ -0,0 +1,21 @@ + + + + + + 2026-01-05T08:00:00Z + stray-lap-text + 1.0 + 5.0 + + stray-trackpoint-text + + 10.0 + + + + + + diff --git a/tests/test_parse_tcx_gpx.py b/tests/test_parse_tcx_gpx.py index cea4787..853da25 100644 --- a/tests/test_parse_tcx_gpx.py +++ b/tests/test_parse_tcx_gpx.py @@ -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" @@ -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) @@ -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)