From 27e0eeba15b3073f36e9bf8bd0aacf22366477fa Mon Sep 17 00:00:00 2001 From: Ian Chan Date: Thu, 20 Aug 2026 14:51:35 -0400 Subject: [PATCH 1/2] add script for detecting unstable urls; add is_producer_url_unstable on realtime schema for future --- README.md | 1 + schemas/gtfs_realtime_source_schema.json | 5 + scripts/identify_unstable_urls.py | 556 +++++++++++++++++++++++ tests/test_identify_unstable_urls.py | 248 ++++++++++ tools/operations.py | 10 +- tools/representations.py | 7 + tools/tests/test_operations.py | 4 + tools/tests/test_representations.py | 13 + 8 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 scripts/identify_unstable_urls.py create mode 100644 tests/test_identify_unstable_urls.py diff --git a/README.md b/README.md index 0bdb39509..aeada992a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Contains the JSON schemas used to validate the feeds in the integration tests. | features | Array of Enums | Optional | An array of features which can be any of: | | status | Enum | Optional | Describes status of the feed. Should be one of: Feed is assumed to be `active` if status is not explicitly provided.| | | is_official | Enum | Optional | Flag indicating if the source comes from the agency itself or not. Feed's is_official flag is assumed to be `False` if it is not explicitly provided.| +| is_producer_url_unstable | Enum | Optional | Indicates whether the feed's producer URL is stable and unchanging over time. | |redirect| Object | Optional | When a feed is deprecated by a provider and replaced with a new URL, redirect information is provided to point to the new feed.| | - id | String | Optional | New feed ID that replaces the current feed that is out of date or no longer maintained by the provider. | | - comment | String | Optional | comment to explain redirect if needed (e.g new aggregate feed) | diff --git a/schemas/gtfs_realtime_source_schema.json b/schemas/gtfs_realtime_source_schema.json index f56f893a1..bd913faf2 100644 --- a/schemas/gtfs_realtime_source_schema.json +++ b/schemas/gtfs_realtime_source_schema.json @@ -176,6 +176,11 @@ "type": "string", "description": "True if a feed comes directly from the agency, False if a feed is created by researchers or partners unaffiliated with the agency or municipality.", "enum": ["True", "False"] + }, + "is_producer_url_unstable": { + "type": ["string", "null"], + "description": "True if the producer URL is known to be unstable (e.g. changes frequently), False otherwise.", + "enum": [null, "True", "False"] } }, "required": ["mdb_source_id", "data_type", "entity_type", "provider", "urls"] diff --git a/scripts/identify_unstable_urls.py b/scripts/identify_unstable_urls.py new file mode 100644 index 000000000..e8c3bc2dd --- /dev/null +++ b/scripts/identify_unstable_urls.py @@ -0,0 +1,556 @@ +# Find schedule and realtime feeds whose producer URL carries evidence of a date or a +# version, and flag them with is_producer_url_unstable = "True". +# +# A producer URL containing a date or a rotating version number will stop resolving once +# the producer publishes again, so the feed needs manual attention more than twice a year. +# See the is_producer_url_unstable row of README.md for the field definition. +# +# Dry run by default. Nothing is written unless --apply is passed, and the field is only +# ever set to "True" -- feeds without evidence are left untouched. +# +# python scripts/identify_unstable_urls.py --report unstable_urls.csv --weak +# python scripts/identify_unstable_urls.py --apply +# +# This script is intentionally standalone (standard library only). tools.helpers pulls in +# gtfs_kit, which needs GDAL, so scripts/ re-declares the handful of constants it needs +# instead of importing the tools package. Same convention as scripts/create_urls_matrix.py. +import argparse +import csv +import json +import os +import re +from urllib.parse import unquote, urlsplit + +# OS constants +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# tools.constants +GTFS_SCHEDULE_CATALOG_PATH_FROM_ROOT = "catalogs/sources/gtfs/schedule" +GTFS_REALTIME_CATALOG_PATH_FROM_ROOT = "catalogs/sources/gtfs/realtime" +GTFS = "gtfs" +GTFS_RT = "gtfs-rt" +ALL = "all" +MDB_SOURCE_ID = "mdb_source_id" +DATA_TYPE = "data_type" +PROVIDER = "provider" +STATUS = "status" +URLS = "urls" +DIRECT_DOWNLOAD = "direct_download" +IS_PRODUCER_URL_UNSTABLE = "is_producer_url_unstable" + +# Field constants +TRUE = "True" +DEPRECATED = "deprecated" + +# Report constants +REPORT_COLUMNS = [ + "mdb_source_id", + "data_type", + "provider", + "status", + "direct_download", + "rules", + "matched_text", +] + +# A sentinel that no pattern can match, used to blank out stable tokens before scanning. +SENTINEL = "\x00" + + +######################### +# STABLE TOKENS +######################### + +# Substrings that look like dates or versions but never change when the feed is +# republished. They are blanked out before the unstable patterns run, so they cannot +# contribute a match. Every entry here corresponds to a real cluster in the catalog. +STABLE_PATTERNS = [ + # CKAN dataset and resource keys. + ( + "uuid", + re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ), + ), + # ArcGIS item ids, Wix and hibu site keys, opendatasoft file ids. Deliberately only + # 32 hex characters: a 40 character run is a git commit SHA, which is not stable. + ("hex_id", re.compile(r"(?/ multisite id. + ( + "wordpress_upload_folder", + re.compile(r"wp-content/uploads/(?:[^/]+/){0,2}(?:19|20)\d{2}/\d{1,2}/"), + ), + # YYYYMMDD, YYYY-MM-DD, YYYY_MM_DD, YYYY.MM.DD, optionally carrying an HHMM or + # HHMMSS time, as in mvv_ohneShape_20241004095702.zip. + ( + "iso_date", + re.compile( + r"(?5} {row['rules']:<40} {row['matched_text']}") + print(f" {row['direct_download']}") + + print() + print(f"Scanned {scanned} sources, skipped {skipped_deprecated} deprecated.") + print(f"Unstable producer URLs: {len(flagged)}") + + counts = {} + for row in flagged: + for name in row["rules"].split("|"): + counts[name] = counts.get(name, 0) + 1 + for name, count in sorted(counts.items(), key=lambda item: -item[1]): + print(f" {count:>4} {name}") + + if already_set: + print() + print(f"Matched but left alone, the field is already set: {len(already_set)}") + for source, url, _ in already_set: + value = source.get(IS_PRODUCER_URL_UNSTABLE) + print(f" {source.get(MDB_SOURCE_ID):>5} {value:<6} {url}") + + if args.weak: + print() + print(f"Weak signals, never flagged, review by hand: {len(weak)}") + for source, url, weak_signals in weak: + rules, matched = format_signals(weak_signals) + print(f" {source.get(MDB_SOURCE_ID):>5} {rules:<40} {matched}") + print(f" {url}") + + if args.report: + write_report(args.report, flagged) + print() + print(f"Report written to {args.report}") + + if args.apply: + print() + print(f"Wrote is_producer_url_unstable to {len(flagged)} files.") + else: + print() + print("Dry run, nothing written. Pass --apply to write.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_identify_unstable_urls.py b/tests/test_identify_unstable_urls.py new file mode 100644 index 000000000..381d249b0 --- /dev/null +++ b/tests/test_identify_unstable_urls.py @@ -0,0 +1,248 @@ +import importlib.util +import os +from unittest import TestCase + +PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) +SCRIPT_PATH = os.path.join(PROJECT_ROOT, "scripts", "identify_unstable_urls.py") + +# The script is standalone by design, so that it can run without the GDAL dependencies +# that tools.helpers pulls in. Load it by path rather than turning scripts/ into a package. +spec = importlib.util.spec_from_file_location("identify_unstable_urls", SCRIPT_PATH) +identify_unstable_urls = importlib.util.module_from_spec(spec) +spec.loader.exec_module(identify_unstable_urls) + +is_unstable = identify_unstable_urls.is_unstable + +# Producer URLs that carry a date or a version, paired with the rule that must fire. +# Every URL here is taken from the catalog. +UNSTABLE_URLS = [ + ( + "wordpress_upload_folder", + "https://moosejaw.ca/wp-content/uploads/2023/11/moosejaw-GTFS.zip", + ), + ( + "wordpress_upload_folder", + "https://www.metrofor.ce.gov.br/wp-content/uploads/sites/32/2025/01/gtfs_metrofor.zip", + ), + ("iso_date", "https://www.dtpm.cl/descargas/gtfs/GTFS_20260704.zip"), + ("iso_date", "https://storage.googleapis.com/gtfs-estaticos/GTFS-2026-04-29.zip"), + ( + "iso_date", + "https://zdzit.olsztyn.eu/wp-content/uploads/2025/05/GTFS_2025_05_26.zip", + ), + ("iso_date", "https://mts.pt/imt/MTS-20240129.zip"), + ( + "iso_date", + "https://transport-data-gouv-fr-resource-history-prod.cellar-c2.services." + "clever-cloud.com/80742/80742.20241107.160916.535554.zip", + ), + # A date carrying a time. The trailing time must not defeat the rule. + ( + "iso_date", + "https://www.mvv-muenchen.de/fileadmin/mediapool/02-Fahrplanauskunft/" + "03-Downloads/openData/mvv_ohneShape_20241004095702.zip", + ), + ( + "year_month", + "https://dados.fortaleza.ce.gov.br/dataset/51afc610-d48b-4fa8-8dea-9c65747148c7/" + "resource/51e3d494-3b41-4328-a971-964e2cdd8a22/download/gtff-202311.zip", + ), + ( + "locale_date", + "https://datos.jalisco.gob.mx/sites/default/files/" + "rutas_mitransporte_puertovallarta_03.12.2021.zip", + ), + ( + "locale_date", + "https://www.circumetnea.it/download/" + "general-transit-feed-specification-fce-01-02-2025-28-02-2028/?wpdmdl=17798", + ), + ( + "dated_version_tag", + "https://github.com/aruneko/DonanbusGTFS/archive/refs/tags/2020.0401.1.zip", + ), + ( + "delimited_year", + "https://static.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_OP_2024_obb.zip", + ), + ("delimited_year", "https://www.santaeulaliaferry.com/gtfs_feed_2025.zip"), + ( + "delimited_year", + "https://data.opentransportdata.swiss/dataset/timetable-2026-gtfs2020/permalink", + ), + ("delimited_year", "https://maxtransit.org/GTFS/2026/google_transit_Working.zip"), + ( + "labelled_year", + "https://opendata.hamilton.ca/GTFS-Static/2025Winter_GTFSStatic.zip", + ), + ( + "labelled_year", + "http://go-rts.com/wp-content/uploads/2025/01/RTSGTFS_Spring2025.zip", + ), + ( + "labelled_year", + "https://www.fredericksburgva.gov/DocumentCenter/View/31122/FXBGO-GTFS---CY2026", + ), + ( + "labelled_year", + "https://www.fredericksburgva.gov/DocumentCenter/View/28958/FXBGO-GTFS-Q1-CY2025", + ), + ( + "month_name", + "https://data.opencity.in/dataset/88e2d145-7ec6-4666-88dd-6cf18b18312e/resource/" + "1b0d18bb-b2fb-4a79-8ed0-1e071da5790c/download/" + "telangana_opendata_gtfs_tgsrtc_08_february_2026.zip", + ), + ( + "month_name", + "https://dfef8f.p3cdn2.secureserver.net/wp-content/uploads/2023/08/" + "RMTD_GTFS_AUGUST_2023.zip", + ), + ("month_name", "https://www.dtpm.cl/descargas/gtfs/03%20GTFS_Final_03marzo.zip"), + ( + "month_abbreviation_with_digits", + "http://datos.gob.cl/dataset/c77c9a50-6dd1-449d-b5ab-947ec0139b31/resource/" + "a4edcf07-0657-456d-bbbc-54b2aec1de8d/download/coquimbo10feb16.zip", + ), + ( + "month_abbreviation_with_digits", + "http://datos.gob.cl/dataset/cef4c471-2837-412b-a78e-1d4c6a261bf9/resource/" + "7887a7e7-9af6-4fc8-b19f-8ff4ea474d1c/download/temuco24nov16.zip", + ), + ( + "dotted_version", + "https://dipl.nt.gov.au/data-feeds/bus-gtfs/google-transit-darwin.zip?v=0.16.0", + ), + # gateway.carris.pt really did move from v2.8 to v2.11, so a non-zero minor counts. + ("dotted_version", "https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS"), + ( + "dotted_version", + "https://github.com/shubhamvelani/VapiGTFS/releases/download/v1.0.0/Vapi_GTFS.zip", + ), + ( + "large_version_counter", + "https://www.dtpm.cl/descargas/gtfs/GTFS-V126-PO20241019.zip", + ), + ( + "version_query_parameter", + "https://solweb.tper.it/web/tools/open-data/open-data-download.aspx?" + "source=solweb.tper.it&filename=gommagtfsbo&version=20260122&format=zip", + ), + ( + "epoch_timestamp", + "https://www.wroclaw.pl/open-data/87b09b32-f076-4475-8ec9-6020ed1f9ac0/" + "1513602900.0_OtwartyWroclaw_rozklad_jazdy_GTFS.zip", + ), + ( + "dotnet_ticks", + "https://www.heleonbus.hawaiicounty.gov/home/showpublisheddocument/307470/" + "638458319827900000", + ), + ( + "pinned_git_sha", + "https://raw.githubusercontent.com/transitland/" + "gtfs-archives-not-hosted-elsewhere/" + "3db26c0092b6efeb1886a99b4fc0765122b0282b/delhi-bus.zip", + ), + ( + "cache_buster", + "https://www.circumetnea.it/download/" + "general-transit-feed-specification-fce-01-02-2025-28-02-2028/" + "?wpdmdl=17798&refresh=69c2e93fcbfb51774381375", + ), + ( + "shared_access_signature_window", + "https://paueasasskybusgtfs.blob.core.windows.net/gtfs-live/VIC/gtfs.zip?" + "sp=rl&st=2026-02-18T06:46:07Z&se=2027-02-28T15:01:07Z&sv=2024-11-04&sr=b&sig=x", + ), +] + +# Producer URLs whose digits look like a date or a version but never change. Every URL +# here is taken from the catalog, and each one stands for a whole cluster of feeds. +STABLE_URLS = [ + # Digits in the hostname, not the path. + "https://www3.septa.org/developer/google_bus.zip", + "http://apps2.saskatoon.ca/app/data/google_transit.zip", + # A raw IPv4 host reads as a version number. + "http://70.34.208.164/gtfs.zip", + "http://193.23.225.211:8002/export-gtfs-static", + # An explicit port reads as a year. + "https://cat.cadavl.com:4431/CAT/GTFS/GTFS_CAT.zip", + # The v2 is the GTFS-Flex spec version, not the feed's. + "https://data.trilliumtransit.com/gtfs/tracy-ca-us/tracy-ca-us--flex-v2.zip", + "https://data.trilliumtransit.com/gtfs/pueblo-co-us/pueblo-co-us--flex-v2.zip", + # Stable API version paths. + "https://api.transport.nsw.gov.au/v2/gtfs/alerts/all", + "https://transport.api.act.gov.au/gtfs/data/gtfs/v2/gtfs.zip", + "https://gitlab.com/api/v4/projects/vekejsn%2Fgtfs-generators/packages/generic/" + "nis-gtfs/latest/nis_gtfs.zip", + # A zero minor is decorative. data.waltti.fi serves eleven feeds from /v1.0/. + "https://data.waltti.fi/tampere/api/gtfsrealtime/v1.0/feed/tripupdate", + "https://stibmivb.opendatasoft.com/api/datasets/1.0/gtfs-files-production/" + "alternative_exports/gtfszip/", + # The numeric TransitFeeds feed key. + "https://transitfeeds.com/p/rodoviaria-de-lisboa/998/latest/download", + # A permanent ArcGIS item id. + "https://www.arcgis.com/sharing/rest/content/items/" + "1a25440bf66f499bae2657ec7fb40144/data", + # A 40 hex character Mecatran credential, not a pinned git commit. + "https://app.mecatran.com/utw/ws/gtfsfeed/static/lio" + "?apiKey=2b160d626f783808095373766f18714901325e45&type=gtfs_lio", + # A Google Drive file id. + "https://drive.usercontent.google.com/uc" + "?id=1l8BUIOaNZiu7hbO1UxMB1e9EaXm5s4Wa&export=download", + # Place and agency names that collide with month and season words. + "http://data.trilliumtransit.com/gtfs/winterpark-co-us/winterpark-co-us.zip", + "https://data.trilliumtransit.com/gtfs/cedarfalls-ia-us/cedarfalls-ia-us.zip", + "http://data.trilliumtransit.com/gtfs/centralmarylandrta-md-us/" + "centralmarylandrta-md-us.zip", + "https://s3.amazonaws.com/datatools-511ny/public/" + "Greater_Glens_Falls_Transit_System.zip", + # Stable numeric ids that contain a year-like run. + "https://addtransit.com/gtfsfile/42017/TheVictoriaClipper.zip", + "https://www.tib.org/documents/20124/478141/ctm-mallorca-es.zip", + # A plain producer URL with nothing rotating in it. + "http://www.wienerlinien.at/ogd_realtime/doku/ogd/gtfs/gtfs.zip", + # The MobilityData mirror template, which the script never scans but must not match. + "https://storage.googleapis.com/storage/v1/b/mdb-latest/o/" + "at-wien-wiener-lokalbahnen-wlb-gtfs-648.zip?alt=media", +] + + +class TestIsUnstable(TestCase): + def test_flags_dates_and_versions(self): + for expected_rule, url in UNSTABLE_URLS: + with self.subTest(url=url): + rules = [rule for rule, _ in is_unstable(url)] + self.assertTrue(rules, f"expected {url} to be flagged") + self.assertIn(expected_rule, rules) + + def test_leaves_stable_urls_alone(self): + for url in STABLE_URLS: + with self.subTest(url=url): + self.assertEqual(is_unstable(url), [], f"expected {url} to be stable") + + +class TestScannable(TestCase): + def test_drops_scheme_and_host(self): + under_test = identify_unstable_urls.scannable( + "https://www3.septa.org:8443/developer/google_bus.zip?a=b#c" + ) + self.assertEqual(under_test, "/developer/google_bus.zip?a=b#c") + + def test_decodes_percent_encoding(self): + under_test = identify_unstable_urls.scannable( + "https://www.dtpm.cl/descargas/gtfs/03%20GTFS_Final_03marzo.zip" + ) + self.assertIn("03 GTFS_Final_03marzo.zip", under_test) + + +class TestRedactStableTokens(TestCase): + def test_keeps_signature_window_but_drops_service_version(self): + under_test = identify_unstable_urls.redact_stable_tokens( + "/gtfs.zip?st=2026-02-18&se=2027-02-28&sv=2024-11-04&sig=abc" + ) + self.assertIn("2026-02-18", under_test) + self.assertIn("2027-02-28", under_test) + self.assertNotIn("2024-11-04", under_test) + self.assertNotIn("abc", under_test) diff --git a/tools/operations.py b/tools/operations.py index bae438dfa..b39e3ac00 100644 --- a/tools/operations.py +++ b/tools/operations.py @@ -50,6 +50,7 @@ def add_gtfs_realtime_source( status=None, features=None, is_official=None, + is_producer_url_unstable=None, ): """ Add a new GTFS Realtime source to the Mobility Catalogs. @@ -71,6 +72,7 @@ def add_gtfs_realtime_source( status (str, optional): The status of the GTFS Realtime source. Defaults to None. features (list, optional): A list of features of the GTFS Realtime source. Defaults to None. is_official (str, optional): Flag indicating if the source comes from the agency itself or not. Defaults to None. + is_producer_url_unstable (str, optional): Indicates if the producer URL is unstable. Possible values: "True", "False". Defaults to None. Returns: GtfsRealtimeSourcesCatalog: The catalog with the newly added GTFS Realtime source. @@ -89,7 +91,8 @@ def add_gtfs_realtime_source( LICENSE: license_url, STATUS: status, FEATURES: features, - IS_OFFICIAL: is_official + IS_OFFICIAL: is_official, + IS_PRODUCER_URL_UNSTABLE: is_producer_url_unstable, } catalog.add(**data) return catalog @@ -109,7 +112,8 @@ def update_gtfs_realtime_source( note=None, status=None, features=None, - is_official = None, + is_official=None, + is_producer_url_unstable=None, ): """ Update an existing GTFS Realtime source in the Mobility Catalogs. @@ -132,6 +136,7 @@ def update_gtfs_realtime_source( status (str, optional): The status of the GTFS Realtime source. Defaults to None. features (list, optional): A list of features of the GTFS Realtime source. Defaults to None. is_official (str, optional): Flag indicating if the source comes from the agency itself or not. Defaults to None. + is_producer_url_unstable (str, optional): Indicates if the producer URL is unstable. Possible values: "True", "False". Defaults to None. Returns: GtfsRealtimeSourcesCatalog: The catalog with the updated GTFS Realtime source. @@ -152,6 +157,7 @@ def update_gtfs_realtime_source( STATUS: status, FEATURES: features, IS_OFFICIAL: is_official, + IS_PRODUCER_URL_UNSTABLE: is_producer_url_unstable, } catalog.update(**data) return catalog diff --git a/tools/representations.py b/tools/representations.py index 8b4e423e6..4e7208036 100644 --- a/tools/representations.py +++ b/tools/representations.py @@ -905,6 +905,7 @@ def __str__(self): FEATURES: self.features, STATUS: self.status, IS_OFFICIAL: self.is_official, + IS_PRODUCER_URL_UNSTABLE: self.is_producer_url_unstable, } return json.dumps(self.schematize(**attributes), ensure_ascii=False) @@ -1017,6 +1018,9 @@ def update(self, **kwargs): is_official = kwargs.get(IS_OFFICIAL) if is_official is not None: self.is_official = is_official + is_producer_url_unstable = kwargs.get(IS_PRODUCER_URL_UNSTABLE) + if is_producer_url_unstable is not None: + self.is_producer_url_unstable = is_producer_url_unstable return self @classmethod @@ -1076,6 +1080,7 @@ def schematize(cls, **kwargs): LICENSE: kwargs.pop(LICENSE, None), }, IS_OFFICIAL: kwargs.pop(IS_OFFICIAL, None), + IS_PRODUCER_URL_UNSTABLE: kwargs.pop(IS_PRODUCER_URL_UNSTABLE, None), } if schema[NAME] is None: del schema[NAME] @@ -1097,4 +1102,6 @@ def schematize(cls, **kwargs): del schema[STATUS] if schema[IS_OFFICIAL] is None: del schema[IS_OFFICIAL] + if schema[IS_PRODUCER_URL_UNSTABLE] is None: + del schema[IS_PRODUCER_URL_UNSTABLE] return schema diff --git a/tools/tests/test_operations.py b/tools/tests/test_operations.py index 631bb9220..8eb050e18 100644 --- a/tools/tests/test_operations.py +++ b/tools/tests/test_operations.py @@ -34,6 +34,7 @@ def test_add_gtfs_realtime_source(self, mock_catalog): test_status = "active" test_features = ["fares"] test_is_official = "True" + test_is_producer_url_unstable = "True" under_test = add_gtfs_realtime_source( entity_type=test_entity_type, provider=test_provider, @@ -48,6 +49,7 @@ def test_add_gtfs_realtime_source(self, mock_catalog): status=test_status, features=test_features, is_official=test_is_official, + is_producer_url_unstable=test_is_producer_url_unstable, ) self.assertEqual(under_test, mock_catalog()) self.assertEqual(mock_catalog.call_count, 2) @@ -69,6 +71,7 @@ def test_update_gtfs_realtime_source(self, mock_catalog): test_status = "active" test_features = ["flex-v2"] test_is_official = "True" + test_is_producer_url_unstable = "False" under_test = update_gtfs_realtime_source( mdb_source_id=test_mdb_source_id, entity_type=test_entity_type, @@ -84,6 +87,7 @@ def test_update_gtfs_realtime_source(self, mock_catalog): status=test_status, features=test_features, is_official=test_is_official, + is_producer_url_unstable=test_is_producer_url_unstable, ) self.assertEqual(under_test, mock_catalog()) self.assertEqual(mock_catalog.call_count, 2) diff --git a/tools/tests/test_representations.py b/tools/tests/test_representations.py index 2c8739373..fe4fee563 100644 --- a/tools/tests/test_representations.py +++ b/tools/tests/test_representations.py @@ -701,6 +701,7 @@ def setUp(self): self.test_features = [self.test_feature] self.test_status = "some_status" self.test_is_official = "some_is_official" + self.test_is_producer_url_unstable = "some_is_producer_url_unstable" self.test_kwargs = { MDB_SOURCE_ID: self.test_mdb_source_id, DATA_TYPE: self.test_data_type, @@ -718,6 +719,7 @@ def setUp(self): FEATURES: self.test_features, STATUS: self.test_status, IS_OFFICIAL: self.test_is_official, + IS_PRODUCER_URL_UNSTABLE: self.test_is_producer_url_unstable, } self.test_schema = { MDB_SOURCE_ID: self.test_mdb_source_id, @@ -737,6 +739,7 @@ def setUp(self): LICENSE: self.test_license_url, }, IS_OFFICIAL: self.test_is_official, + IS_PRODUCER_URL_UNSTABLE: self.test_is_producer_url_unstable, } @patch("tools.representations.GtfsRealtimeSource.static_catalog") @@ -876,6 +879,16 @@ def test_has_is_official(self, mock_static_catalog): under_test = instance.has_is_official(is_official=test_another_is_official) self.assertFalse(under_test) + @patch("tools.representations.GtfsRealtimeSource.static_catalog") + def test_has_is_producer_url_unstable(self, mock_static_catalog): + test_is_producer_url_unstable = self.test_is_producer_url_unstable + test_another_is_producer_url_unstable = "some_other_is_producer_url_unstable" + instance = GtfsRealtimeSource(filename=self.test_filename, **self.test_schema) + under_test = instance.has_is_producer_url_unstable(is_producer_url_unstable=test_is_producer_url_unstable) + self.assertTrue(under_test) + under_test = instance.has_is_producer_url_unstable(is_producer_url_unstable=test_another_is_producer_url_unstable) + self.assertFalse(under_test) + @patch("tools.representations.GtfsRealtimeSource.static_catalog") def test_update(self, mock_static_catalog): instance = GtfsRealtimeSource(filename=self.test_filename, **self.test_schema) From 655927dfb05dc72d345f26a8fb31abbf26b49202 Mon Sep 17 00:00:00 2001 From: Ian Chan Date: Tue, 25 Aug 2026 16:01:52 -0400 Subject: [PATCH 2/2] remove several detection rules and the unnecessary sentinels --- scripts/identify_unstable_urls.py | 87 +--------------------------- tests/test_identify_unstable_urls.py | 52 +---------------- 2 files changed, 4 insertions(+), 135 deletions(-) diff --git a/scripts/identify_unstable_urls.py b/scripts/identify_unstable_urls.py index e8c3bc2dd..1a23bb8aa 100644 --- a/scripts/identify_unstable_urls.py +++ b/scripts/identify_unstable_urls.py @@ -53,57 +53,6 @@ "matched_text", ] -# A sentinel that no pattern can match, used to blank out stable tokens before scanning. -SENTINEL = "\x00" - - -######################### -# STABLE TOKENS -######################### - -# Substrings that look like dates or versions but never change when the feed is -# republished. They are blanked out before the unstable patterns run, so they cannot -# contribute a match. Every entry here corresponds to a real cluster in the catalog. -STABLE_PATTERNS = [ - # CKAN dataset and resource keys. - ( - "uuid", - re.compile( - r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", - re.IGNORECASE, - ), - ), - # ArcGIS item ids, Wix and hibu site keys, opendatasoft file ids. Deliberately only - # 32 hex characters: a 40 character run is a git commit SHA, which is not stable. - ("hex_id", re.compile(r"(?