mostlyright-data 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
"""The shared harvest boundary: fetch bounded bytes, admit them, then parse them.
|
|
2
|
+
|
|
3
|
+
Everything a harvester touches is attacker-influenceable. A public catalogue controls its own
|
|
4
|
+
status line, headers, size and body, and one of the instances this was tested against answers
|
|
5
|
+
HTTP 200 with a bot-wall HTML page. So the discipline here is uniform and not negotiable per
|
|
6
|
+
harvester:
|
|
7
|
+
|
|
8
|
+
* **Every fetch goes through the hardened retriever.** There is no ``urllib`` or ``requests`` call
|
|
9
|
+
anywhere in this package, which is what keeps DNS pinning, the egress allowlist and the per-hop
|
|
10
|
+
public-address check on the path.
|
|
11
|
+
* **Bytes are admitted before they are parsed.** ``admit_public_fact_bytes`` runs on every payload,
|
|
12
|
+
because this is the moment a stranger's document would become a durable shared fact.
|
|
13
|
+
* **Parsing takes bytes, never a URL.** That is what lets every harvester test run offline against
|
|
14
|
+
a recorded response, and it puts the untrusted-input boundary in one obvious place.
|
|
15
|
+
* **Caps sit at or below the admission cap**, so an oversized response is refused at the transport
|
|
16
|
+
rather than downloaded and then rejected. The observed Eurostat structure response is 37,166,239
|
|
17
|
+
bytes; that is the concrete reason this is enforced rather than advised.
|
|
18
|
+
|
|
19
|
+
A ``HarvestedRecord`` is deliberately weaker than a ``CatalogEntry``. It holds what a harvester can
|
|
20
|
+
honestly read off a response and nothing more -- no rights adjudication, no liveness observation.
|
|
21
|
+
Turning one into an entry is a separate, explicit step that runs the public-source checks.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Protocol
|
|
30
|
+
from urllib.parse import urlsplit
|
|
31
|
+
|
|
32
|
+
from mostlyright.data_harness.acquisition.http import (
|
|
33
|
+
DatagovRateObservation,
|
|
34
|
+
DatagovResponseRefused,
|
|
35
|
+
DatagovV4Authorization,
|
|
36
|
+
PinnedHttpsRetriever,
|
|
37
|
+
RetrievalLimits,
|
|
38
|
+
RetrievedBytes,
|
|
39
|
+
)
|
|
40
|
+
from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
|
|
41
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
42
|
+
from mostlyright.data_harness.sources.catalog.admission import (
|
|
43
|
+
MAX_CATALOG_FACT_BYTES,
|
|
44
|
+
admit_public_fact_bytes,
|
|
45
|
+
)
|
|
46
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
47
|
+
DATA_FORMATS,
|
|
48
|
+
EvidenceReference,
|
|
49
|
+
SourceContractError,
|
|
50
|
+
_CanonicalContract,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
HARVESTED_RECORD_CONTRACT_VERSION = "harness-source-harvested-record.v1"
|
|
54
|
+
HARVEST_PAGE_CONTRACT_VERSION = "harness-source-harvest-page.v1"
|
|
55
|
+
HARVEST_RESPONSE_EVIDENCE_VERSION = "harness-source-harvest-response-evidence.v1"
|
|
56
|
+
|
|
57
|
+
HARVEST_PROTOCOLS = ("ckan", "datagov_v4", "stac", "sdmx")
|
|
58
|
+
DATAGOV_V4_HARVESTER_ID = "datagov_v4"
|
|
59
|
+
DATAGOV_V4_HARVESTER_VERSION = "1.0.0"
|
|
60
|
+
DATAGOV_V4_HARVESTER_COORDINATE = f"{DATAGOV_V4_HARVESTER_ID}@{DATAGOV_V4_HARVESTER_VERSION}"
|
|
61
|
+
|
|
62
|
+
MAX_RECORD_ID = 200
|
|
63
|
+
MAX_RECORD_TEXT = 4_000
|
|
64
|
+
MAX_RECORD_TITLE = 500
|
|
65
|
+
MAX_RECORD_PUBLISHER = 500
|
|
66
|
+
MAX_RECORD_LICENSE_ID = 200
|
|
67
|
+
MAX_RECORD_URI = 2_048
|
|
68
|
+
MAX_RECORD_FORMATS = 16
|
|
69
|
+
|
|
70
|
+
# Named verbatim so a 401 reads as a scope boundary rather than an invitation to add credentials.
|
|
71
|
+
AUTHENTICATED_ADAPTER_PARKED = (
|
|
72
|
+
"this endpoint is not open; authenticated source adapters are parked, and their trigger is the "
|
|
73
|
+
"first real credentialed need plus a broker trust review"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_STATUS_TAIL = re.compile(r"status (\d{3})$")
|
|
77
|
+
# Publisher id characters, deliberately wider than the harness identifier grammar and still
|
|
78
|
+
# closed. The parentheses are there because an SDMX dataflow's natural coordinate is
|
|
79
|
+
# `ESTAT:APRO_CPSH1(1.0)`; no whitespace, quote, angle bracket or control character is admitted.
|
|
80
|
+
_RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+()-]*$")
|
|
81
|
+
_TIMESTAMP = re.compile(
|
|
82
|
+
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CatalogHarvestError(SourceContractError):
|
|
87
|
+
"""A harvest refusal: the endpoint is not open, not what it claimed, or not bounded."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DatagovFetchRefused(CatalogHarvestError):
|
|
91
|
+
"""A v4 refusal after an HTTP response, carrying only transport accounting."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
code: str,
|
|
96
|
+
path: str,
|
|
97
|
+
detail: str,
|
|
98
|
+
*,
|
|
99
|
+
response_evidence: DatagovRefusedResponseEvidence,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.response_evidence = response_evidence
|
|
102
|
+
super().__init__(code, path, detail)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class DatagovRefusedResponseEvidence:
|
|
107
|
+
"""Secret-free accounting for a response whose bytes may not contribute any digest."""
|
|
108
|
+
|
|
109
|
+
final_url: str
|
|
110
|
+
status: int
|
|
111
|
+
network_bytes: int
|
|
112
|
+
rate_observation: DatagovRateObservation | None
|
|
113
|
+
requests: int = 1
|
|
114
|
+
responses: int = 1
|
|
115
|
+
content_bytes: int = 0
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"final_url": self.final_url,
|
|
120
|
+
"status": self.status,
|
|
121
|
+
"network_bytes": self.network_bytes,
|
|
122
|
+
"requests": self.requests,
|
|
123
|
+
"responses": self.responses,
|
|
124
|
+
"content_bytes": self.content_bytes,
|
|
125
|
+
"rate_observation": (
|
|
126
|
+
self.rate_observation.to_dict() if self.rate_observation is not None else None
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class HarvesterDescriptor:
|
|
133
|
+
"""The immutable identity of one harvester."""
|
|
134
|
+
|
|
135
|
+
protocol: str
|
|
136
|
+
harvester_id: str
|
|
137
|
+
harvester_version: str
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if self.protocol not in HARVEST_PROTOCOLS:
|
|
141
|
+
raise CatalogHarvestError(
|
|
142
|
+
"ENUM",
|
|
143
|
+
"harvester.protocol",
|
|
144
|
+
f"must be one of {list(HARVEST_PROTOCOLS)}",
|
|
145
|
+
)
|
|
146
|
+
_identifier(self.harvester_id, "harvester.harvester_id")
|
|
147
|
+
if not re.fullmatch(r"[1-9][0-9]*\.[0-9]+\.[0-9]+", str(self.harvester_version)):
|
|
148
|
+
raise CatalogHarvestError(
|
|
149
|
+
"SEMVER",
|
|
150
|
+
"harvester.harvester_version",
|
|
151
|
+
"must be a positive-major semantic version",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def coordinate(self) -> str:
|
|
156
|
+
return f"{self.harvester_id}@{self.harvester_version}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class HarvestCursor(_CanonicalContract):
|
|
161
|
+
"""A closed protocol continuation coordinate, never an executable request."""
|
|
162
|
+
|
|
163
|
+
protocol: str
|
|
164
|
+
kind: str
|
|
165
|
+
value: str
|
|
166
|
+
|
|
167
|
+
def __post_init__(self) -> None:
|
|
168
|
+
if self.protocol not in HARVEST_PROTOCOLS:
|
|
169
|
+
raise CatalogHarvestError("ENUM", "cursor.protocol", "must name a harvest protocol")
|
|
170
|
+
expected = {
|
|
171
|
+
"ckan": "offset",
|
|
172
|
+
"datagov_v4": "after",
|
|
173
|
+
"stac": "next",
|
|
174
|
+
"sdmx": "terminal",
|
|
175
|
+
}[self.protocol]
|
|
176
|
+
if self.kind != expected:
|
|
177
|
+
raise CatalogHarvestError(
|
|
178
|
+
"HARVEST_CURSOR",
|
|
179
|
+
"cursor.kind",
|
|
180
|
+
f"{self.protocol} cursors must use {expected!r}",
|
|
181
|
+
)
|
|
182
|
+
_text(self.value, "cursor.value", maximum=MAX_RECORD_URI)
|
|
183
|
+
if self.protocol == "ckan":
|
|
184
|
+
match = re.fullmatch(r"(0|[1-9][0-9]*):([1-9][0-9]*)", self.value)
|
|
185
|
+
if match is None or int(match.group(2)) > 1_000:
|
|
186
|
+
raise CatalogHarvestError(
|
|
187
|
+
"HARVEST_CURSOR", "cursor.value", "CKAN cursor must be start:rows"
|
|
188
|
+
)
|
|
189
|
+
elif self.protocol == "stac":
|
|
190
|
+
_https_uri(self.value, "cursor.value")
|
|
191
|
+
elif self.protocol == "datagov_v4":
|
|
192
|
+
validate_datagov_cursor_text(self.value, "cursor.value")
|
|
193
|
+
|
|
194
|
+
def to_dict(self) -> dict[str, str]:
|
|
195
|
+
return {"protocol": self.protocol, "kind": self.kind, "value": self.value}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class HarvestResponseEvidence(_CanonicalContract):
|
|
200
|
+
"""Exact transport work behind one admitted response."""
|
|
201
|
+
|
|
202
|
+
final_url: str
|
|
203
|
+
media_type: str
|
|
204
|
+
content_sha256: str
|
|
205
|
+
content_bytes: int
|
|
206
|
+
requests: int
|
|
207
|
+
responses: int
|
|
208
|
+
network_bytes: int
|
|
209
|
+
transport_evidence_digest: str
|
|
210
|
+
status: int = 200
|
|
211
|
+
rate_observation: DatagovRateObservation | None = None
|
|
212
|
+
schema_version: str = HARVEST_RESPONSE_EVIDENCE_VERSION
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
if self.schema_version != HARVEST_RESPONSE_EVIDENCE_VERSION:
|
|
216
|
+
raise CatalogHarvestError("VERSION", "response.schema_version", "unsupported version")
|
|
217
|
+
_https_uri(self.final_url, "response.final_url")
|
|
218
|
+
if not re.fullmatch(r"[0-9a-f]{64}", self.content_sha256):
|
|
219
|
+
raise CatalogHarvestError("DIGEST", "response.content_sha256", "must be SHA-256")
|
|
220
|
+
if not re.fullmatch(r"[0-9a-f]{64}", self.transport_evidence_digest):
|
|
221
|
+
raise CatalogHarvestError(
|
|
222
|
+
"DIGEST", "response.transport_evidence_digest", "must be SHA-256"
|
|
223
|
+
)
|
|
224
|
+
for name, value, minimum in (
|
|
225
|
+
("content_bytes", self.content_bytes, 0),
|
|
226
|
+
("requests", self.requests, 1),
|
|
227
|
+
("responses", self.responses, 1),
|
|
228
|
+
("network_bytes", self.network_bytes, 0),
|
|
229
|
+
):
|
|
230
|
+
if type(value) is not int or value < minimum:
|
|
231
|
+
raise CatalogHarvestError("LIMIT", f"response.{name}", "is outside its bound")
|
|
232
|
+
if self.content_bytes > self.network_bytes:
|
|
233
|
+
raise CatalogHarvestError(
|
|
234
|
+
"HARVEST_TRANSPORT_EVIDENCE",
|
|
235
|
+
"response.network_bytes",
|
|
236
|
+
"network bytes may not be smaller than final content bytes",
|
|
237
|
+
)
|
|
238
|
+
if type(self.status) is not int or not 100 <= self.status <= 599:
|
|
239
|
+
raise CatalogHarvestError("HARVEST_STATUS", "response.status", "status is invalid")
|
|
240
|
+
if self.rate_observation is not None and not isinstance(
|
|
241
|
+
self.rate_observation, DatagovRateObservation
|
|
242
|
+
):
|
|
243
|
+
raise CatalogHarvestError(
|
|
244
|
+
"DATAGOV_RATE", "response.rate_observation", "rate observation is invalid"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def from_retrieval(cls, retrieved: RetrievedBytes) -> HarvestResponseEvidence:
|
|
249
|
+
requests = sum(max(1, len(hop.peer_attempts)) for hop in retrieved.hops)
|
|
250
|
+
return cls(
|
|
251
|
+
final_url=retrieved.final_url,
|
|
252
|
+
media_type=retrieved.media_type,
|
|
253
|
+
content_sha256=retrieved.content_sha256,
|
|
254
|
+
content_bytes=len(retrieved.content),
|
|
255
|
+
requests=requests,
|
|
256
|
+
responses=len(retrieved.hops),
|
|
257
|
+
network_bytes=retrieved.total_response_body_size_bytes,
|
|
258
|
+
transport_evidence_digest=retrieved.transport_evidence_digest,
|
|
259
|
+
status=retrieved.hops[-1].status,
|
|
260
|
+
rate_observation=retrieved.rate_observation,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> dict[str, Any]:
|
|
264
|
+
result = {
|
|
265
|
+
"schema_version": self.schema_version,
|
|
266
|
+
"final_url": self.final_url,
|
|
267
|
+
"media_type": self.media_type,
|
|
268
|
+
"content_sha256": self.content_sha256,
|
|
269
|
+
"content_bytes": self.content_bytes,
|
|
270
|
+
"requests": self.requests,
|
|
271
|
+
"responses": self.responses,
|
|
272
|
+
"network_bytes": self.network_bytes,
|
|
273
|
+
"transport_evidence_digest": self.transport_evidence_digest,
|
|
274
|
+
}
|
|
275
|
+
if self.status != 200 or self.rate_observation is not None:
|
|
276
|
+
result["status"] = self.status
|
|
277
|
+
result["rate_observation"] = (
|
|
278
|
+
self.rate_observation.to_dict() if self.rate_observation is not None else None
|
|
279
|
+
)
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class HarvestPage(_CanonicalContract):
|
|
285
|
+
"""One strict page envelope returned by every supported harvester."""
|
|
286
|
+
|
|
287
|
+
protocol: str
|
|
288
|
+
records: tuple[Any, ...]
|
|
289
|
+
next_cursor: HarvestCursor | None
|
|
290
|
+
provider_count: int | None
|
|
291
|
+
count_basis: str
|
|
292
|
+
skipped_record_ids: tuple[str, ...]
|
|
293
|
+
evidence: EvidenceReference
|
|
294
|
+
response_evidence: HarvestResponseEvidence | None
|
|
295
|
+
schema_version: str = HARVEST_PAGE_CONTRACT_VERSION
|
|
296
|
+
|
|
297
|
+
def __post_init__(self) -> None:
|
|
298
|
+
if self.schema_version != HARVEST_PAGE_CONTRACT_VERSION:
|
|
299
|
+
raise CatalogHarvestError("VERSION", "page.schema_version", "unsupported version")
|
|
300
|
+
if self.protocol not in HARVEST_PROTOCOLS:
|
|
301
|
+
raise CatalogHarvestError("ENUM", "page.protocol", "must name a harvest protocol")
|
|
302
|
+
if not isinstance(self.records, tuple) or any(
|
|
303
|
+
not _same_protocol_record(record, self.protocol) for record in self.records
|
|
304
|
+
):
|
|
305
|
+
raise CatalogHarvestError("TYPE", "page.records", "must be same-protocol records")
|
|
306
|
+
if self.next_cursor is not None and self.next_cursor.protocol != self.protocol:
|
|
307
|
+
raise CatalogHarvestError("HARVEST_CURSOR", "page.next_cursor", "protocol mismatch")
|
|
308
|
+
if self.provider_count is not None and (
|
|
309
|
+
type(self.provider_count) is not int or self.provider_count < 0
|
|
310
|
+
):
|
|
311
|
+
raise CatalogHarvestError("HARVEST_COUNT", "page.provider_count", "must be nonnegative")
|
|
312
|
+
allowed_basis = {"reported_total", "not_reported", "one_shot_response"}
|
|
313
|
+
if self.count_basis not in allowed_basis:
|
|
314
|
+
raise CatalogHarvestError("ENUM", "page.count_basis", "unsupported count basis")
|
|
315
|
+
if self.count_basis == "reported_total" and self.provider_count is None:
|
|
316
|
+
raise CatalogHarvestError("HARVEST_COUNT", "page.provider_count", "count is required")
|
|
317
|
+
if not isinstance(self.skipped_record_ids, tuple):
|
|
318
|
+
raise CatalogHarvestError("TYPE", "page.skipped_record_ids", "must be a tuple")
|
|
319
|
+
if tuple(sorted(self.skipped_record_ids)) != self.skipped_record_ids:
|
|
320
|
+
raise CatalogHarvestError("ORDER", "page.skipped_record_ids", "must be sorted")
|
|
321
|
+
if not self.records and self.next_cursor is not None:
|
|
322
|
+
raise CatalogHarvestError(
|
|
323
|
+
"HARVEST_EMPTY_PAGE", "page.records", "an empty non-terminal page is refused"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def to_dict(self) -> dict[str, Any]:
|
|
327
|
+
return {
|
|
328
|
+
"schema_version": self.schema_version,
|
|
329
|
+
"protocol": self.protocol,
|
|
330
|
+
"records": [record.to_dict() for record in self.records],
|
|
331
|
+
"next_cursor": self.next_cursor.to_dict() if self.next_cursor else None,
|
|
332
|
+
"provider_count": self.provider_count,
|
|
333
|
+
"count_basis": self.count_basis,
|
|
334
|
+
"skipped_record_ids": list(self.skipped_record_ids),
|
|
335
|
+
"evidence": self.evidence.to_dict(),
|
|
336
|
+
"response_evidence": (
|
|
337
|
+
self.response_evidence.to_dict() if self.response_evidence is not None else None
|
|
338
|
+
),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True)
|
|
343
|
+
class HarvestedRecord(_CanonicalContract):
|
|
344
|
+
"""One dataset as a public catalogue describes it, read and nothing more."""
|
|
345
|
+
|
|
346
|
+
protocol: str
|
|
347
|
+
record_id: str
|
|
348
|
+
title: str
|
|
349
|
+
description: str
|
|
350
|
+
publisher: str
|
|
351
|
+
landing_uri: str
|
|
352
|
+
data_formats: tuple[str, ...]
|
|
353
|
+
evidence: EvidenceReference
|
|
354
|
+
license_id: str | None = None
|
|
355
|
+
license_uri: str | None = None
|
|
356
|
+
declared_updated_at: str | None = None
|
|
357
|
+
schema_version: str = HARVESTED_RECORD_CONTRACT_VERSION
|
|
358
|
+
|
|
359
|
+
def __post_init__(self) -> None:
|
|
360
|
+
if self.schema_version != HARVESTED_RECORD_CONTRACT_VERSION:
|
|
361
|
+
raise CatalogHarvestError(
|
|
362
|
+
"VERSION",
|
|
363
|
+
"record.schema_version",
|
|
364
|
+
f"must equal {HARVESTED_RECORD_CONTRACT_VERSION!r}",
|
|
365
|
+
)
|
|
366
|
+
if self.protocol not in HARVEST_PROTOCOLS:
|
|
367
|
+
raise CatalogHarvestError(
|
|
368
|
+
"ENUM",
|
|
369
|
+
"record.protocol",
|
|
370
|
+
f"must be one of {list(HARVEST_PROTOCOLS)}",
|
|
371
|
+
)
|
|
372
|
+
# A publisher's dataset id is not ours to shape, so it is bounded and character-checked
|
|
373
|
+
# rather than forced through the harness identifier regex.
|
|
374
|
+
record_id = _text(self.record_id, "record.record_id", maximum=MAX_RECORD_ID)
|
|
375
|
+
if not _RECORD_ID.fullmatch(record_id):
|
|
376
|
+
raise CatalogHarvestError(
|
|
377
|
+
"RECORD_ID",
|
|
378
|
+
"record.record_id",
|
|
379
|
+
"must contain only unreserved catalogue-id characters",
|
|
380
|
+
)
|
|
381
|
+
_text(self.title, "record.title", maximum=MAX_RECORD_TITLE)
|
|
382
|
+
_text(self.description, "record.description", maximum=MAX_RECORD_TEXT)
|
|
383
|
+
_text(self.publisher, "record.publisher", maximum=MAX_RECORD_PUBLISHER)
|
|
384
|
+
_https_uri(self.landing_uri, "record.landing_uri")
|
|
385
|
+
if not isinstance(self.data_formats, tuple):
|
|
386
|
+
raise CatalogHarvestError("TYPE", "record.data_formats", "must be an immutable tuple")
|
|
387
|
+
if len(self.data_formats) > MAX_RECORD_FORMATS:
|
|
388
|
+
raise CatalogHarvestError(
|
|
389
|
+
"LIMIT",
|
|
390
|
+
"record.data_formats",
|
|
391
|
+
f"must contain at most {MAX_RECORD_FORMATS} formats",
|
|
392
|
+
)
|
|
393
|
+
for position, value in enumerate(self.data_formats):
|
|
394
|
+
if value not in DATA_FORMATS:
|
|
395
|
+
raise CatalogHarvestError(
|
|
396
|
+
"ENUM",
|
|
397
|
+
f"record.data_formats[{position}]",
|
|
398
|
+
f"must be one of {sorted(DATA_FORMATS)}",
|
|
399
|
+
)
|
|
400
|
+
if len(set(self.data_formats)) != len(self.data_formats):
|
|
401
|
+
raise CatalogHarvestError("DUPLICATE", "record.data_formats", "formats must be unique")
|
|
402
|
+
if not isinstance(self.evidence, EvidenceReference):
|
|
403
|
+
raise CatalogHarvestError("TYPE", "record.evidence", "must be an EvidenceReference")
|
|
404
|
+
if self.license_id is not None:
|
|
405
|
+
_text(self.license_id, "record.license_id", maximum=MAX_RECORD_LICENSE_ID)
|
|
406
|
+
if self.license_uri is not None:
|
|
407
|
+
_https_uri(self.license_uri, "record.license_uri")
|
|
408
|
+
if self.declared_updated_at is not None:
|
|
409
|
+
if not _TIMESTAMP.fullmatch(str(self.declared_updated_at)):
|
|
410
|
+
raise CatalogHarvestError(
|
|
411
|
+
"TIMESTAMP",
|
|
412
|
+
"record.declared_updated_at",
|
|
413
|
+
"must be a canonical RFC 3339 UTC timestamp",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def to_dict(self) -> dict[str, Any]:
|
|
417
|
+
return {
|
|
418
|
+
"schema_version": self.schema_version,
|
|
419
|
+
"protocol": self.protocol,
|
|
420
|
+
"record_id": self.record_id,
|
|
421
|
+
"title": self.title,
|
|
422
|
+
"description": self.description,
|
|
423
|
+
"publisher": self.publisher,
|
|
424
|
+
"landing_uri": self.landing_uri,
|
|
425
|
+
"data_formats": list(self.data_formats),
|
|
426
|
+
"evidence": self.evidence.to_dict(),
|
|
427
|
+
"license_id": self.license_id,
|
|
428
|
+
"license_uri": self.license_uri,
|
|
429
|
+
"declared_updated_at": self.declared_updated_at,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def facts_sha256(self) -> str:
|
|
434
|
+
"""The digest of everything the provider *published*, with the observation excluded.
|
|
435
|
+
|
|
436
|
+
A rights decision binds to this rather than to ``digest``, and it has to: ``digest``
|
|
437
|
+
covers the evidence wrapper -- the page URL, the caller's observation timestamp, and
|
|
438
|
+
the digest of the whole response page -- so a byte-identical record re-observed at a
|
|
439
|
+
new time, or sharing a page with any unrelated changed record, would void every
|
|
440
|
+
standing decision and train the operator to re-stamp digests mechanically. Excluding
|
|
441
|
+
the evidence makes the binding mean what the person meant: these published facts,
|
|
442
|
+
whoever's page they arrived on, whenever they were fetched.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
payload = self.to_dict()
|
|
446
|
+
del payload["evidence"]
|
|
447
|
+
return canonical_sha256(payload)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def declared_timestamp(value: str | None) -> str | None:
|
|
451
|
+
"""A harvester's normalised update time, or ``None`` when it is not one.
|
|
452
|
+
|
|
453
|
+
One grammar, checked in one place. Each harvester normalises the spelling its publishers use
|
|
454
|
+
and hands the result here; anything that does not come out as the canonical RFC 3339 UTC
|
|
455
|
+
timestamp ``HarvestedRecord`` requires becomes an undeclared update time.
|
|
456
|
+
|
|
457
|
+
Returning ``None`` rather than raising is what keeps one record from denying a whole page.
|
|
458
|
+
``HarvestedRecord.__post_init__`` raises ``TIMESTAMP`` on a bad value and that exception
|
|
459
|
+
escapes the enclosing ``parse`` call, so a single publisher field spelled
|
|
460
|
+
``2026-07-31T23:59:59+02:00`` -- legal RFC 3339, and not the UTC spelling STAC requires -- or
|
|
461
|
+
``not-a-real-timestamp-value`` would refuse every other record in the same response, in a
|
|
462
|
+
document a stranger controls. An undeclared update time is already a supported state that
|
|
463
|
+
both harvesters produce for an open-ended or absent value, so degrading this one field on this
|
|
464
|
+
one record is strictly less lossy than degrading the page.
|
|
465
|
+
|
|
466
|
+
A zone-bearing spelling is left undeclared rather than converted. Converting would need a zone
|
|
467
|
+
arithmetic path this boundary does not otherwise have, and the two protocols harvested here
|
|
468
|
+
both specify UTC, so an offset is a publisher deviation rather than a case to support. The rule
|
|
469
|
+
is only as good as what reaches this funnel, which is why :func:`carries_a_zone_offset` exists
|
|
470
|
+
and why both pre-normalisers call it before they touch the spelling -- see there.
|
|
471
|
+
|
|
472
|
+
The record-level check stays where it is. This is the funnel every harvester goes through; that
|
|
473
|
+
is the backstop for a record built by hand.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
if value is None:
|
|
477
|
+
return None
|
|
478
|
+
return value if _TIMESTAMP.fullmatch(value) else None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def carries_a_zone_offset(value: str) -> bool:
|
|
482
|
+
"""True when a timestamp spelling names a UTC offset rather than UTC itself.
|
|
483
|
+
|
|
484
|
+
Both pre-normalisers cut a fractional second down to six digits before handing the result to
|
|
485
|
+
:func:`declared_timestamp`. The offset must be identified first because it rides on the
|
|
486
|
+
fractional second and would otherwise go with the part that
|
|
487
|
+
was cut: ``2026-07-31T23:59:59.123456+02:00`` came out as ``2026-07-31T23:59:59.123456Z``, and
|
|
488
|
+
``2026-07-31T23:59:59.123+02:00`` -- whose fraction is not six digits, so the whole remainder
|
|
489
|
+
was dropped -- came out as ``2026-07-31T23:59:59Z``. Both are canonical, both are accepted, and
|
|
490
|
+
both are a local time relabelled as UTC, by up to fourteen hours. Only the fraction-less
|
|
491
|
+
spelling was rejected, which is the one every test used.
|
|
492
|
+
|
|
493
|
+
That value becomes ``HarvestedRecord.declared_updated_at``, which feeds the health probe's
|
|
494
|
+
publication delay and the derived update frequency, which the facts gate then decides a
|
|
495
|
+
source's cadence on. A silently wrong hour is therefore a wrong catalog fact, not a wrong
|
|
496
|
+
display string, so this is checked before any spelling is normalised rather than after.
|
|
497
|
+
|
|
498
|
+
Everything after the date is inspected, because the date's own hyphens are not offsets and
|
|
499
|
+
every canonical UTC spelling this boundary accepts carries neither a plus nor a minus in its
|
|
500
|
+
time of day.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
_, separator, time_of_day = value.partition("T")
|
|
504
|
+
if not separator:
|
|
505
|
+
return False
|
|
506
|
+
return "+" in time_of_day or "-" in time_of_day
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def bounded_record_text(value: str, *, maximum: int) -> str:
|
|
510
|
+
"""A publisher's free text, cut to the record bound rather than refused at it.
|
|
511
|
+
|
|
512
|
+
Same rule as :func:`declared_timestamp`, applied to the four fields it was never applied to.
|
|
513
|
+
``HarvestedRecord.__post_init__`` raises ``TEXT`` on an over-length ``title``, ``description``
|
|
514
|
+
or ``publisher``, and that exception escapes the enclosing ``parse`` call -- so one publisher's
|
|
515
|
+
long prose refused every other record in the same response, in a document a stranger controls.
|
|
516
|
+
CKAN's ``notes`` has no server-side length limit and multi-thousand-character descriptions are
|
|
517
|
+
ordinary on real portals, so this was a first-contact failure rather than an adversarial edge.
|
|
518
|
+
|
|
519
|
+
Truncating is the smaller loss, and it is the loss the two other harvesters were already
|
|
520
|
+
taking on ``description`` before this was stated in one place. A cut description is still the
|
|
521
|
+
publisher's description; a refused page is every sibling record gone. This is a bound on a
|
|
522
|
+
descriptive field, never on an identifier: see :func:`usable_record_id` for those.
|
|
523
|
+
|
|
524
|
+
Trailing whitespace left by the cut is stripped, because a title ending mid-space is a worse
|
|
525
|
+
artefact than one ending mid-word, and an all-whitespace tail would leave the field empty --
|
|
526
|
+
which the record contract refuses. A value that is empty after stripping is returned as-is so
|
|
527
|
+
the caller's own fallback (``or record_id``, ``or "No description published."``) applies.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
text = value[:maximum].rstrip()
|
|
531
|
+
return text if text else value[:maximum]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def usable_record_id(value: str | None) -> str | None:
|
|
535
|
+
"""A publisher's dataset id, or ``None`` when it is not one this boundary can carry.
|
|
536
|
+
|
|
537
|
+
An identifier is not truncatable. Cutting one to the bound would mint a different dataset's
|
|
538
|
+
coordinate out of this dataset's, and every citation built on it afterwards would name
|
|
539
|
+
something the publisher never published -- which is worse than not carrying the record.
|
|
540
|
+
|
|
541
|
+
So the degradation for an id is the record, not the field: the caller records it in ``skipped``
|
|
542
|
+
and keeps the rest of the page, exactly as it already does for a package with no id at all and
|
|
543
|
+
for one whose landing page is not HTTPS. ``skipped`` is reported in the refusal message when a
|
|
544
|
+
response yields nothing usable, so a page lost this way is never lost silently.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
if not isinstance(value, str):
|
|
548
|
+
return None
|
|
549
|
+
text = value.strip()
|
|
550
|
+
if not text or len(text) > MAX_RECORD_ID or not _RECORD_ID.fullmatch(text):
|
|
551
|
+
return None
|
|
552
|
+
return text
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def usable_https_uri(value: str | None) -> str | None:
|
|
556
|
+
"""An HTTPS URI within the record bound, or ``None``.
|
|
557
|
+
|
|
558
|
+
A URI is an identifier too, so it degrades the same way an id does rather than being cut. For
|
|
559
|
+
``landing_uri`` the caller skips the record; for ``license_uri``, which is optional, ``None``
|
|
560
|
+
is the supported state and the licence remains readable from ``license_id``.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
if not isinstance(value, str):
|
|
564
|
+
return None
|
|
565
|
+
text = value.strip()
|
|
566
|
+
if not text.startswith("https://") or len(text) <= len("https://"):
|
|
567
|
+
return None
|
|
568
|
+
return text if len(text) <= MAX_RECORD_URI else None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def usable_license_id(value: str | None) -> str | None:
|
|
572
|
+
"""A licence identifier within the record bound, or ``None``, which reads as unclear rights.
|
|
573
|
+
|
|
574
|
+
Not truncated, for the reason ids are not truncated: a cut licence id is a different licence
|
|
575
|
+
id. Dropping it maps to ``unclear``, which the facts gate escalates to a human rather than
|
|
576
|
+
guessing at -- the correct outcome for terms this boundary could not read.
|
|
577
|
+
"""
|
|
578
|
+
|
|
579
|
+
if not isinstance(value, str):
|
|
580
|
+
return None
|
|
581
|
+
text = value.strip()
|
|
582
|
+
if not text or len(text) > MAX_RECORD_LICENSE_ID:
|
|
583
|
+
return None
|
|
584
|
+
return text
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class SourceHarvester(Protocol):
|
|
588
|
+
"""One endpoint family, read into records. Takes bytes; never fetches."""
|
|
589
|
+
|
|
590
|
+
@property
|
|
591
|
+
def descriptor(self) -> HarvesterDescriptor:
|
|
592
|
+
"""Return the immutable harvester identity."""
|
|
593
|
+
|
|
594
|
+
def parse(
|
|
595
|
+
self,
|
|
596
|
+
payload: bytes,
|
|
597
|
+
*,
|
|
598
|
+
uri: str,
|
|
599
|
+
observed_at: str,
|
|
600
|
+
evidence: EvidenceReference,
|
|
601
|
+
) -> tuple[HarvestedRecord, ...]:
|
|
602
|
+
"""Read this exact recorded response into records, or refuse it."""
|
|
603
|
+
|
|
604
|
+
def parse_page(
|
|
605
|
+
self,
|
|
606
|
+
payload: bytes,
|
|
607
|
+
*,
|
|
608
|
+
uri: str,
|
|
609
|
+
observed_at: str,
|
|
610
|
+
evidence: EvidenceReference,
|
|
611
|
+
response_evidence: HarvestResponseEvidence | None,
|
|
612
|
+
cursor: HarvestCursor | None,
|
|
613
|
+
) -> HarvestPage:
|
|
614
|
+
"""Read a response plus its closed continuation coordinate."""
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class HarvestRegistry:
|
|
618
|
+
"""Allowlist of harvesters, in the shape of the coordinator's adapter allowlist."""
|
|
619
|
+
|
|
620
|
+
def __init__(self, harvesters: tuple[SourceHarvester, ...] = ()) -> None:
|
|
621
|
+
self._harvesters: dict[tuple[str, str], SourceHarvester] = {}
|
|
622
|
+
for harvester in harvesters:
|
|
623
|
+
self.register(harvester)
|
|
624
|
+
|
|
625
|
+
def register(self, harvester: SourceHarvester) -> None:
|
|
626
|
+
descriptor = harvester.descriptor
|
|
627
|
+
if not isinstance(descriptor, HarvesterDescriptor):
|
|
628
|
+
raise CatalogHarvestError(
|
|
629
|
+
"HARVESTER_DESCRIPTOR",
|
|
630
|
+
"harvester.descriptor",
|
|
631
|
+
"harvester descriptor must use the strict contract",
|
|
632
|
+
)
|
|
633
|
+
key = (descriptor.harvester_id, descriptor.harvester_version)
|
|
634
|
+
if key in self._harvesters:
|
|
635
|
+
raise CatalogHarvestError(
|
|
636
|
+
"HARVESTER_DUPLICATE",
|
|
637
|
+
"registry",
|
|
638
|
+
f"harvester {descriptor.coordinate} is already registered",
|
|
639
|
+
)
|
|
640
|
+
self._harvesters[key] = harvester
|
|
641
|
+
|
|
642
|
+
def resolve(self, harvester_id: str, harvester_version: str) -> SourceHarvester:
|
|
643
|
+
try:
|
|
644
|
+
return self._harvesters[(harvester_id, harvester_version)]
|
|
645
|
+
except KeyError:
|
|
646
|
+
raise CatalogHarvestError(
|
|
647
|
+
"HARVESTER_UNAVAILABLE",
|
|
648
|
+
"harvester_id",
|
|
649
|
+
f"harvester {harvester_id}@{harvester_version} is not allowlisted",
|
|
650
|
+
) from None
|
|
651
|
+
|
|
652
|
+
def inventory(self) -> tuple[HarvesterDescriptor, ...]:
|
|
653
|
+
return tuple(self._harvesters[key].descriptor for key in sorted(self._harvesters))
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def harvest_limits(*media_types: str, max_response_bytes: int) -> RetrievalLimits:
|
|
657
|
+
"""Transport caps for one harvester, never looser than the catalog admission cap."""
|
|
658
|
+
|
|
659
|
+
if not media_types:
|
|
660
|
+
raise CatalogHarvestError(
|
|
661
|
+
"EMPTY",
|
|
662
|
+
"media_types",
|
|
663
|
+
"a harvester must allow at least one media type",
|
|
664
|
+
)
|
|
665
|
+
if type(max_response_bytes) is not int or not 1 <= max_response_bytes <= MAX_CATALOG_FACT_BYTES:
|
|
666
|
+
raise CatalogHarvestError(
|
|
667
|
+
"HARVEST_RESPONSE_LIMIT",
|
|
668
|
+
"max_response_bytes",
|
|
669
|
+
f"must be an integer in [1, {MAX_CATALOG_FACT_BYTES}]",
|
|
670
|
+
)
|
|
671
|
+
return RetrievalLimits(
|
|
672
|
+
max_response_bytes=max_response_bytes,
|
|
673
|
+
max_redirects=2,
|
|
674
|
+
max_requests=3,
|
|
675
|
+
allowed_media_types=tuple(media_types),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def fetch_harvest_payload(
|
|
680
|
+
*,
|
|
681
|
+
retriever: PinnedHttpsRetriever,
|
|
682
|
+
url: str,
|
|
683
|
+
observed_at: str,
|
|
684
|
+
limits: RetrievalLimits,
|
|
685
|
+
) -> tuple[bytes, EvidenceReference]:
|
|
686
|
+
"""Fetch one bounded catalogue response and admit it as public fact bytes.
|
|
687
|
+
|
|
688
|
+
Returns the exact bytes and the citation for them. The caller parses; this function does not,
|
|
689
|
+
because a parser that also fetches cannot be tested against a recorded response.
|
|
690
|
+
|
|
691
|
+
``limits`` governs the fetch. It is threaded into the transport through ``retriever.narrowed``
|
|
692
|
+
rather than merely checked here, because a cap that is validated and then not applied is worse
|
|
693
|
+
than no cap: ``CKAN_LIMITS`` would read as the control that refuses a bot-wall HTML page while
|
|
694
|
+
a retriever configured with a looser allowlist accepted it. Narrowing can only tighten what the
|
|
695
|
+
coordinator configured, so this never widens egress.
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
if limits.max_response_bytes > MAX_CATALOG_FACT_BYTES:
|
|
699
|
+
raise CatalogHarvestError(
|
|
700
|
+
"HARVEST_RESPONSE_LIMIT",
|
|
701
|
+
"limits.max_response_bytes",
|
|
702
|
+
f"a harvest fetch may not exceed {MAX_CATALOG_FACT_BYTES} bytes",
|
|
703
|
+
)
|
|
704
|
+
retrieved = _retrieve_harvest(
|
|
705
|
+
retriever=retriever,
|
|
706
|
+
url=url,
|
|
707
|
+
limits=limits,
|
|
708
|
+
)
|
|
709
|
+
evidence = EvidenceReference(
|
|
710
|
+
uri=retrieved.final_url,
|
|
711
|
+
observed_at=observed_at,
|
|
712
|
+
content_sha256=retrieved.content_sha256,
|
|
713
|
+
media_type=retrieved.media_type,
|
|
714
|
+
)
|
|
715
|
+
return retrieved.content, evidence
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def fetch_harvest_response(
|
|
719
|
+
*,
|
|
720
|
+
retriever: PinnedHttpsRetriever,
|
|
721
|
+
url: str,
|
|
722
|
+
observed_at: str,
|
|
723
|
+
limits: RetrievalLimits,
|
|
724
|
+
) -> tuple[bytes, EvidenceReference, HarvestResponseEvidence]:
|
|
725
|
+
"""Fetch like :func:`fetch_harvest_payload`, retaining exact transport accounting."""
|
|
726
|
+
|
|
727
|
+
if limits.max_response_bytes > MAX_CATALOG_FACT_BYTES:
|
|
728
|
+
raise CatalogHarvestError(
|
|
729
|
+
"HARVEST_RESPONSE_LIMIT",
|
|
730
|
+
"limits.max_response_bytes",
|
|
731
|
+
f"a harvest fetch may not exceed {MAX_CATALOG_FACT_BYTES} bytes",
|
|
732
|
+
)
|
|
733
|
+
retrieved = _retrieve_harvest(retriever=retriever, url=url, limits=limits)
|
|
734
|
+
evidence = EvidenceReference(
|
|
735
|
+
uri=retrieved.final_url,
|
|
736
|
+
observed_at=observed_at,
|
|
737
|
+
content_sha256=retrieved.content_sha256,
|
|
738
|
+
media_type=retrieved.media_type,
|
|
739
|
+
)
|
|
740
|
+
return retrieved.content, evidence, HarvestResponseEvidence.from_retrieval(retrieved)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def fetch_datagov_v4_response(
|
|
744
|
+
*,
|
|
745
|
+
retriever: PinnedHttpsRetriever,
|
|
746
|
+
url: str,
|
|
747
|
+
observed_at: str,
|
|
748
|
+
limits: RetrievalLimits,
|
|
749
|
+
authorization: DatagovV4Authorization,
|
|
750
|
+
on_request_started: Callable[[], None] | None = None,
|
|
751
|
+
on_response_observed: (
|
|
752
|
+
Callable[[HarvestResponseEvidence | DatagovRefusedResponseEvidence], None] | None
|
|
753
|
+
) = None,
|
|
754
|
+
on_response_received: (
|
|
755
|
+
Callable[[bytes, EvidenceReference, HarvestResponseEvidence], None] | None
|
|
756
|
+
) = None,
|
|
757
|
+
) -> tuple[bytes, EvidenceReference, HarvestResponseEvidence]:
|
|
758
|
+
"""Fetch the exact authenticated v4 response without widening legacy admission caps."""
|
|
759
|
+
|
|
760
|
+
if not 1 <= limits.max_response_bytes <= 16 * 1024 * 1024 or limits.max_redirects != 0:
|
|
761
|
+
raise CatalogHarvestError(
|
|
762
|
+
"DATAGOV_RESPONSE_LIMIT",
|
|
763
|
+
"limits",
|
|
764
|
+
"Data.gov v4 requires the exact 16 MiB, zero-redirect transport boundary",
|
|
765
|
+
)
|
|
766
|
+
if not isinstance(authorization, DatagovV4Authorization):
|
|
767
|
+
raise CatalogHarvestError(
|
|
768
|
+
"DATAGOV_AUTHORIZATION", "authorization", "strict authorization is required"
|
|
769
|
+
)
|
|
770
|
+
bounded = retriever.narrowed(limits)
|
|
771
|
+
redacted_observation: DatagovRefusedResponseEvidence | None = None
|
|
772
|
+
|
|
773
|
+
def observe_transport(
|
|
774
|
+
status: int, network_bytes: int, rate: DatagovRateObservation | None
|
|
775
|
+
) -> None:
|
|
776
|
+
nonlocal redacted_observation
|
|
777
|
+
redacted_observation = DatagovRefusedResponseEvidence(
|
|
778
|
+
final_url=url,
|
|
779
|
+
status=status,
|
|
780
|
+
network_bytes=network_bytes,
|
|
781
|
+
rate_observation=rate,
|
|
782
|
+
)
|
|
783
|
+
if on_response_observed is not None:
|
|
784
|
+
on_response_observed(redacted_observation)
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
if on_request_started is not None:
|
|
788
|
+
on_request_started()
|
|
789
|
+
if isinstance(bounded, PinnedHttpsRetriever):
|
|
790
|
+
retrieved = bounded.retrieve_datagov_v4(
|
|
791
|
+
url, authorization, on_response_observed=observe_transport
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
retrieved = bounded.retrieve_datagov_v4(url, authorization)
|
|
795
|
+
except DatagovResponseRefused as error:
|
|
796
|
+
redacted = DatagovRefusedResponseEvidence(
|
|
797
|
+
final_url=url,
|
|
798
|
+
status=error.status,
|
|
799
|
+
network_bytes=error.network_bytes,
|
|
800
|
+
rate_observation=None,
|
|
801
|
+
)
|
|
802
|
+
if on_response_observed is not None:
|
|
803
|
+
on_response_observed(redacted)
|
|
804
|
+
raise DatagovFetchRefused(
|
|
805
|
+
error.code,
|
|
806
|
+
"harvest.response",
|
|
807
|
+
error.detail,
|
|
808
|
+
response_evidence=redacted,
|
|
809
|
+
) from None
|
|
810
|
+
except AcquisitionSecurityError as error:
|
|
811
|
+
if redacted_observation is not None:
|
|
812
|
+
raise DatagovFetchRefused(
|
|
813
|
+
error.code,
|
|
814
|
+
"harvest.response",
|
|
815
|
+
error.detail,
|
|
816
|
+
response_evidence=redacted_observation,
|
|
817
|
+
) from None
|
|
818
|
+
raise _translate(error, url) from None
|
|
819
|
+
response_evidence = HarvestResponseEvidence.from_retrieval(retrieved)
|
|
820
|
+
redacted = redacted_observation or DatagovRefusedResponseEvidence(
|
|
821
|
+
final_url=response_evidence.final_url,
|
|
822
|
+
status=response_evidence.status,
|
|
823
|
+
network_bytes=response_evidence.network_bytes,
|
|
824
|
+
rate_observation=response_evidence.rate_observation,
|
|
825
|
+
)
|
|
826
|
+
if redacted_observation is None and on_response_observed is not None:
|
|
827
|
+
on_response_observed(redacted)
|
|
828
|
+
try:
|
|
829
|
+
authorization.reject_echoed_response(retrieved.content)
|
|
830
|
+
except AcquisitionSecurityError as error:
|
|
831
|
+
raise DatagovFetchRefused(
|
|
832
|
+
error.code,
|
|
833
|
+
"harvest.response",
|
|
834
|
+
error.detail,
|
|
835
|
+
response_evidence=redacted,
|
|
836
|
+
) from None
|
|
837
|
+
if on_response_observed is not None:
|
|
838
|
+
on_response_observed(response_evidence)
|
|
839
|
+
evidence = EvidenceReference(
|
|
840
|
+
uri=retrieved.final_url,
|
|
841
|
+
observed_at=observed_at,
|
|
842
|
+
content_sha256=retrieved.content_sha256,
|
|
843
|
+
media_type=retrieved.media_type,
|
|
844
|
+
)
|
|
845
|
+
if on_response_received is not None:
|
|
846
|
+
on_response_received(retrieved.content, evidence, response_evidence)
|
|
847
|
+
return retrieved.content, evidence, response_evidence
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _retrieve_harvest(
|
|
851
|
+
*, retriever: PinnedHttpsRetriever, url: str, limits: RetrievalLimits
|
|
852
|
+
) -> RetrievedBytes:
|
|
853
|
+
bounded = retriever.narrowed(limits)
|
|
854
|
+
try:
|
|
855
|
+
retrieved = bounded.retrieve(url)
|
|
856
|
+
except AcquisitionSecurityError as error:
|
|
857
|
+
raise _translate(error, url) from None
|
|
858
|
+
admit_public_fact_bytes(retrieved.content)
|
|
859
|
+
return retrieved
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _translate(error: AcquisitionSecurityError, url: str) -> CatalogHarvestError:
|
|
863
|
+
host = _hostname(url)
|
|
864
|
+
if error.code == "MEDIA_TYPE":
|
|
865
|
+
# The bot-wall case: a 200 carrying an interstitial HTML page. Naming the host turns a
|
|
866
|
+
# generic transport error into a durable fact about that instance.
|
|
867
|
+
return CatalogHarvestError(
|
|
868
|
+
"HARVEST_MEDIA_TYPE",
|
|
869
|
+
"harvest.response",
|
|
870
|
+
f"{host} answered with a media type this harvester does not accept: {error.detail}",
|
|
871
|
+
)
|
|
872
|
+
# A cycle survey treats 404 as an absent unpublished run. A catalog harvest still treats that
|
|
873
|
+
# same response as an endpoint that is not open;
|
|
874
|
+
# the type split must not bypass the catalog boundary's existing translation.
|
|
875
|
+
if error.code in {"HTTP_STATUS", "HTTP_NOT_FOUND"}:
|
|
876
|
+
match = _STATUS_TAIL.search(error.detail)
|
|
877
|
+
status = int(match.group(1)) if match else None
|
|
878
|
+
if status in {401, 403}:
|
|
879
|
+
return CatalogHarvestError(
|
|
880
|
+
"HARVEST_ENDPOINT_NOT_OPEN",
|
|
881
|
+
"harvest.response",
|
|
882
|
+
f"{host} answered HTTP {status}: {AUTHENTICATED_ADAPTER_PARKED}",
|
|
883
|
+
)
|
|
884
|
+
return CatalogHarvestError(
|
|
885
|
+
"HARVEST_ENDPOINT_NOT_OPEN",
|
|
886
|
+
"harvest.response",
|
|
887
|
+
f"{host} answered {error.detail}",
|
|
888
|
+
)
|
|
889
|
+
if error.code in {"RESPONSE_LIMIT", "CONTENT_LENGTH"}:
|
|
890
|
+
return CatalogHarvestError(
|
|
891
|
+
"HARVEST_RESPONSE_LIMIT",
|
|
892
|
+
"harvest.response",
|
|
893
|
+
f"{host} answered outside the harvest byte bound: {error.detail}",
|
|
894
|
+
)
|
|
895
|
+
# Egress, DNS, URL policy, TLS, concurrency and rate-limit failures are facts about this side
|
|
896
|
+
# of the connection, not about the catalogue. They propagate untouched.
|
|
897
|
+
raise error
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _hostname(url: str) -> str:
|
|
901
|
+
return (urlsplit(url).hostname or "the endpoint").lower()
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _same_protocol_record(record: Any, protocol: str) -> bool:
|
|
905
|
+
if isinstance(record, HarvestedRecord):
|
|
906
|
+
return record.protocol == protocol
|
|
907
|
+
return (
|
|
908
|
+
protocol == "datagov_v4"
|
|
909
|
+
and record.__class__.__name__ == "DatagovV4Record"
|
|
910
|
+
and record.__class__.__module__.endswith(".harvest.datagov_v4")
|
|
911
|
+
and getattr(record, "protocol", None) == protocol
|
|
912
|
+
and callable(getattr(record, "to_dict", None))
|
|
913
|
+
and isinstance(getattr(record, "digest", None), str)
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _text(value: Any, path: str, *, maximum: int) -> str:
|
|
918
|
+
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
919
|
+
raise CatalogHarvestError(
|
|
920
|
+
"TEXT",
|
|
921
|
+
path,
|
|
922
|
+
f"must be a non-empty string no longer than {maximum} characters",
|
|
923
|
+
)
|
|
924
|
+
return value
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def validate_datagov_cursor_text(value: Any, path: str) -> str:
|
|
928
|
+
"""Validate the one closed Unicode/control boundary for v4 continuation text."""
|
|
929
|
+
|
|
930
|
+
text = _text(value, path, maximum=MAX_RECORD_URI)
|
|
931
|
+
try:
|
|
932
|
+
text.encode("utf-8", errors="strict")
|
|
933
|
+
except UnicodeEncodeError:
|
|
934
|
+
raise CatalogHarvestError(
|
|
935
|
+
"HARVEST_CURSOR", path, "Data.gov cursor must be valid Unicode"
|
|
936
|
+
) from None
|
|
937
|
+
if any(
|
|
938
|
+
ord(character) < 0x20
|
|
939
|
+
or 0x7F <= ord(character) <= 0x9F
|
|
940
|
+
or (character.isspace() and character != " ")
|
|
941
|
+
for character in text
|
|
942
|
+
):
|
|
943
|
+
raise CatalogHarvestError(
|
|
944
|
+
"HARVEST_CURSOR", path, "Data.gov cursor contains unsafe control or whitespace text"
|
|
945
|
+
)
|
|
946
|
+
return text
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _identifier(value: Any, path: str) -> str:
|
|
950
|
+
text = _text(value, path, maximum=128)
|
|
951
|
+
if not re.fullmatch(r"[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*", text):
|
|
952
|
+
raise CatalogHarvestError(
|
|
953
|
+
"IDENTIFIER",
|
|
954
|
+
path,
|
|
955
|
+
"must be a lowercase dotted/dashed/underscored identifier",
|
|
956
|
+
)
|
|
957
|
+
return text
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _https_uri(value: Any, path: str) -> str:
|
|
961
|
+
text = _text(value, path, maximum=2_048)
|
|
962
|
+
if not text.startswith("https://") or len(text) <= len("https://"):
|
|
963
|
+
raise CatalogHarvestError("HTTPS_URI", path, "must be an HTTPS URI")
|
|
964
|
+
return text
|