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,447 @@
|
|
|
1
|
+
"""The source health check: a bounded liveness and citation probe.
|
|
2
|
+
|
|
3
|
+
The probe answers one question -- is this public source answering, and what exactly did it say --
|
|
4
|
+
and it answers it without downloading the source's payload. It does that by fetching a small
|
|
5
|
+
declared **citation target**: the bulk index, the CKAN ``package_show`` document, the STAC
|
|
6
|
+
collection document. For FAOSTAT that is a 109,120-byte index carrying an update timestamp for
|
|
7
|
+
every dataset, which is how cadence is learned without touching a 33,921,825-byte archive. If the
|
|
8
|
+
citation target itself exceeds the cap, the probe refuses. There is no fallback to an unbounded
|
|
9
|
+
fetch, and there is no ranged request anywhere on this path.
|
|
10
|
+
|
|
11
|
+
**The probe reports facts. It never reaches a verdict.** It does not import the fitness
|
|
12
|
+
adjudicator, the rights rule, or the catalog gate, and it never returns "selected", "rejected" or
|
|
13
|
+
"human_escalation_required". A probe that started issuing verdicts would be a second gate, and two
|
|
14
|
+
gates disagree. A test proves this structurally rather than trusting the sentence you just read --
|
|
15
|
+
and note that those three words appear in this docstring and nowhere else in the module, which is
|
|
16
|
+
exactly why that test strips docstrings before it looks.
|
|
17
|
+
|
|
18
|
+
Two consequences of that reporting discipline are worth stating.
|
|
19
|
+
|
|
20
|
+
*A blocked egress is not a dead source.* An allowlist, DNS, URL-policy or TLS failure propagates
|
|
21
|
+
untouched, because recording it as "unreachable" would write a local misconfiguration into the
|
|
22
|
+
catalog as a fact about the world. Only codes describing how the endpoint *answered* become an
|
|
23
|
+
unreachable probe.
|
|
24
|
+
|
|
25
|
+
*A refusal is still citable.* Every probe emits an ``EvidenceReference``, including the
|
|
26
|
+
unreachable case, where the citation attests to the refusal rather than to a document. A liveness
|
|
27
|
+
claim nobody can check later is not evidence.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from mostlyright.data_harness.acquisition.http import PinnedHttpsRetriever, RetrievalLimits
|
|
38
|
+
from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
|
|
39
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
40
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
41
|
+
EvidenceReference,
|
|
42
|
+
SourceContractError,
|
|
43
|
+
SourceObservation,
|
|
44
|
+
_CanonicalContract,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
HEALTH_PROBE_CONTRACT_VERSION = "harness-source-health-probe.v1"
|
|
48
|
+
|
|
49
|
+
MAX_CITATION_BYTES = 1 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
CITATION_PROBE_LIMITS = RetrievalLimits(
|
|
52
|
+
max_response_bytes=MAX_CITATION_BYTES,
|
|
53
|
+
max_redirects=2,
|
|
54
|
+
max_requests=3,
|
|
55
|
+
allowed_media_types=("application/json",),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Codes that describe how the endpoint answered. These become an unreachable probe. Every other
|
|
59
|
+
# acquisition code describes this side of the connection and propagates untouched.
|
|
60
|
+
ENDPOINT_FAULT_CODES = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"HTTP_STATUS",
|
|
63
|
+
"MEDIA_TYPE",
|
|
64
|
+
"RESPONSE_LIMIT",
|
|
65
|
+
"CONTENT_LENGTH",
|
|
66
|
+
"CONTENT_ENCODING",
|
|
67
|
+
"REDIRECT_LIMIT",
|
|
68
|
+
"REDIRECT_LOCATION",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# The parked item, named verbatim so a 401 reads as a scope boundary rather than a prompt.
|
|
73
|
+
AUTHENTICATED_ADAPTER_PARKED = (
|
|
74
|
+
"this endpoint requires authentication and is out of scope; authenticated source adapters are "
|
|
75
|
+
"parked, and their trigger is the first real credentialed need plus a broker trust review"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Ten years, matching the bound SourceObservation enforces on its second-valued fields.
|
|
79
|
+
MAX_DECLARED_DELAY_SECONDS = 10 * 365 * 24 * 60 * 60
|
|
80
|
+
|
|
81
|
+
_STATUS_TAIL = re.compile(r"status (\d{3})$")
|
|
82
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
83
|
+
_TIMESTAMP = re.compile(
|
|
84
|
+
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$"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def citation_probe_limits(*media_types: str) -> RetrievalLimits:
|
|
89
|
+
"""The citation-probe caps, narrowed to one or more media types.
|
|
90
|
+
|
|
91
|
+
STAC and SDMX citation targets are not JSON in every case, and they must not have to redefine
|
|
92
|
+
the caps to say so -- a second copy of the caps is a second thing to forget to tighten.
|
|
93
|
+
|
|
94
|
+
Which non-JSON types actually reach the wire is the transport's decision, not this function's.
|
|
95
|
+
A probe is narrowed onto the coordinator's retriever, so the type named here must be one that
|
|
96
|
+
retriever's allowlist carries or one of the transport's own ``METADATA_MEDIA_TYPES`` -- the
|
|
97
|
+
closed metadata channel that exists because an SDMX structure message is not a payload format
|
|
98
|
+
and so can never be in the format table a default retriever is built from.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
if not media_types:
|
|
102
|
+
raise SourceContractError(
|
|
103
|
+
"EMPTY",
|
|
104
|
+
"media_types",
|
|
105
|
+
"a citation probe must allow at least one media type",
|
|
106
|
+
)
|
|
107
|
+
return RetrievalLimits(
|
|
108
|
+
max_response_bytes=CITATION_PROBE_LIMITS.max_response_bytes,
|
|
109
|
+
max_redirects=CITATION_PROBE_LIMITS.max_redirects,
|
|
110
|
+
max_requests=CITATION_PROBE_LIMITS.max_requests,
|
|
111
|
+
allowed_media_types=tuple(media_types),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class SourceHealthProbe(_CanonicalContract):
|
|
117
|
+
"""What one bounded probe of one public source observed, and nothing more."""
|
|
118
|
+
|
|
119
|
+
source_id: str
|
|
120
|
+
citation_uri: str
|
|
121
|
+
probed_at: str
|
|
122
|
+
reachable: bool
|
|
123
|
+
response_status: int | None = None
|
|
124
|
+
media_type: str | None = None
|
|
125
|
+
content_sha256: str | None = None
|
|
126
|
+
content_bytes: int | None = None
|
|
127
|
+
refusal_code: str | None = None
|
|
128
|
+
refusal_detail: str | None = None
|
|
129
|
+
transport_evidence_digest: str | None = None
|
|
130
|
+
schema_version: str = HEALTH_PROBE_CONTRACT_VERSION
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
if self.schema_version != HEALTH_PROBE_CONTRACT_VERSION:
|
|
134
|
+
raise SourceContractError(
|
|
135
|
+
"VERSION",
|
|
136
|
+
"probe.schema_version",
|
|
137
|
+
f"must equal {HEALTH_PROBE_CONTRACT_VERSION!r}",
|
|
138
|
+
)
|
|
139
|
+
_identifier(self.source_id, "probe.source_id")
|
|
140
|
+
_https_uri(self.citation_uri, "probe.citation_uri")
|
|
141
|
+
_timestamp(self.probed_at, "probe.probed_at")
|
|
142
|
+
if type(self.reachable) is not bool:
|
|
143
|
+
raise SourceContractError("BOOLEAN", "probe.reachable", "must be a boolean")
|
|
144
|
+
# The two states are mutually exclusive by construction: a reachable probe carries what it
|
|
145
|
+
# saw, an unreachable one carries why it saw nothing. Neither can carry both.
|
|
146
|
+
if self.reachable:
|
|
147
|
+
_integer(self.response_status, "probe.response_status", minimum=200, maximum=299)
|
|
148
|
+
_media_type(self.media_type, "probe.media_type")
|
|
149
|
+
_digest(self.content_sha256, "probe.content_sha256")
|
|
150
|
+
_integer(
|
|
151
|
+
self.content_bytes,
|
|
152
|
+
"probe.content_bytes",
|
|
153
|
+
minimum=0,
|
|
154
|
+
maximum=MAX_CITATION_BYTES,
|
|
155
|
+
)
|
|
156
|
+
_digest(self.transport_evidence_digest, "probe.transport_evidence_digest")
|
|
157
|
+
if self.refusal_code is not None or self.refusal_detail is not None:
|
|
158
|
+
raise SourceContractError(
|
|
159
|
+
"PROBE_STATE",
|
|
160
|
+
"probe.refusal_code",
|
|
161
|
+
"a reachable probe carries no refusal",
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
if self.refusal_code not in ENDPOINT_FAULT_CODES:
|
|
165
|
+
raise SourceContractError(
|
|
166
|
+
"PROBE_REFUSAL",
|
|
167
|
+
"probe.refusal_code",
|
|
168
|
+
f"must be one of {sorted(ENDPOINT_FAULT_CODES)}",
|
|
169
|
+
)
|
|
170
|
+
_text(self.refusal_detail, "probe.refusal_detail", maximum=500)
|
|
171
|
+
for name in ("media_type", "content_sha256", "content_bytes"):
|
|
172
|
+
if getattr(self, name) is not None:
|
|
173
|
+
raise SourceContractError(
|
|
174
|
+
"PROBE_STATE",
|
|
175
|
+
f"probe.{name}",
|
|
176
|
+
"an unreachable probe retains no content facts",
|
|
177
|
+
)
|
|
178
|
+
if self.response_status is not None:
|
|
179
|
+
_integer(
|
|
180
|
+
self.response_status,
|
|
181
|
+
"probe.response_status",
|
|
182
|
+
minimum=100,
|
|
183
|
+
maximum=599,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def to_dict(self) -> dict[str, Any]:
|
|
187
|
+
return {
|
|
188
|
+
"schema_version": self.schema_version,
|
|
189
|
+
"source_id": self.source_id,
|
|
190
|
+
"citation_uri": self.citation_uri,
|
|
191
|
+
"probed_at": self.probed_at,
|
|
192
|
+
"reachable": self.reachable,
|
|
193
|
+
"response_status": self.response_status,
|
|
194
|
+
"media_type": self.media_type,
|
|
195
|
+
"content_sha256": self.content_sha256,
|
|
196
|
+
"content_bytes": self.content_bytes,
|
|
197
|
+
"refusal_code": self.refusal_code,
|
|
198
|
+
"refusal_detail": self.refusal_detail,
|
|
199
|
+
"transport_evidence_digest": self.transport_evidence_digest,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def probe_source_health(
|
|
204
|
+
*,
|
|
205
|
+
source_id: str,
|
|
206
|
+
citation_uri: str,
|
|
207
|
+
retriever: PinnedHttpsRetriever,
|
|
208
|
+
probed_at: str,
|
|
209
|
+
limits: RetrievalLimits = CITATION_PROBE_LIMITS,
|
|
210
|
+
) -> SourceHealthProbe:
|
|
211
|
+
"""Fetch one bounded citation target and record exactly what came back.
|
|
212
|
+
|
|
213
|
+
``limits`` is accepted so a caller can narrow the media types, and it governs the fetch: it is
|
|
214
|
+
threaded into the transport through ``retriever.narrowed`` rather than merely validated, so a
|
|
215
|
+
probe declaring ``application/json`` under a 1 MiB cap really does refuse an HTML interstitial
|
|
216
|
+
served by a retriever whose own allowlist would have accepted it.
|
|
217
|
+
|
|
218
|
+
Two independent ceilings apply, and both are needed. ``narrowed`` refuses anything looser than
|
|
219
|
+
what the *coordinator* configured, so a probe can never escape the transport it was handed.
|
|
220
|
+
That is not enough on its own: a coordinator's retriever is sized for harvesting, so limits
|
|
221
|
+
well inside it are still far outside what "a bounded citation probe" means. So every
|
|
222
|
+
transport-shape cap this function accepts is also checked against ``CITATION_PROBE_LIMITS``
|
|
223
|
+
before anything is fetched. Checking only the byte cap would have left the redirect and
|
|
224
|
+
request caps enforced by nothing but callers using ``citation_probe_limits`` -- a bound that
|
|
225
|
+
holds while everyone takes the sanctioned route is the pattern this package refuses everywhere
|
|
226
|
+
else.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
for name in ("max_response_bytes", "max_redirects", "max_requests"):
|
|
230
|
+
asked = getattr(limits, name)
|
|
231
|
+
if asked > getattr(CITATION_PROBE_LIMITS, name):
|
|
232
|
+
raise SourceContractError(
|
|
233
|
+
"PROBE_LIMITS",
|
|
234
|
+
f"limits.{name}",
|
|
235
|
+
f"a citation probe may not exceed {getattr(CITATION_PROBE_LIMITS, name)}; "
|
|
236
|
+
f"{asked} was asked for",
|
|
237
|
+
)
|
|
238
|
+
bounded = retriever.narrowed(limits)
|
|
239
|
+
try:
|
|
240
|
+
retrieved = bounded.retrieve(citation_uri)
|
|
241
|
+
except AcquisitionSecurityError as error:
|
|
242
|
+
if error.code not in ENDPOINT_FAULT_CODES:
|
|
243
|
+
# Egress, DNS, URL policy, TLS, concurrency and rate-limit failures are facts about
|
|
244
|
+
# this side of the connection. Recording one as a dead source would put a local
|
|
245
|
+
# misconfiguration into the catalog as a claim about the world.
|
|
246
|
+
raise
|
|
247
|
+
status = _status_from_detail(error.detail)
|
|
248
|
+
return SourceHealthProbe(
|
|
249
|
+
source_id=source_id,
|
|
250
|
+
citation_uri=citation_uri,
|
|
251
|
+
probed_at=probed_at,
|
|
252
|
+
reachable=False,
|
|
253
|
+
response_status=status,
|
|
254
|
+
refusal_code=error.code,
|
|
255
|
+
refusal_detail=_refusal_detail(error, status),
|
|
256
|
+
)
|
|
257
|
+
return SourceHealthProbe(
|
|
258
|
+
source_id=source_id,
|
|
259
|
+
citation_uri=citation_uri,
|
|
260
|
+
probed_at=probed_at,
|
|
261
|
+
reachable=True,
|
|
262
|
+
response_status=retrieved.hops[-1].status,
|
|
263
|
+
media_type=retrieved.media_type,
|
|
264
|
+
content_sha256=retrieved.content_sha256,
|
|
265
|
+
content_bytes=len(retrieved.content),
|
|
266
|
+
transport_evidence_digest=retrieved.transport_evidence_digest,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def probe_citation(probe: SourceHealthProbe) -> EvidenceReference:
|
|
271
|
+
"""The citation this probe leaves behind.
|
|
272
|
+
|
|
273
|
+
For a reachable probe the citation attests to a document: its uri, when it was read, and the
|
|
274
|
+
digest of its exact bytes. For an unreachable probe there are no bytes, so the citation
|
|
275
|
+
attests to the *refusal* -- a canonical digest over the refusal code, the uri and the probe
|
|
276
|
+
time. Either way the claim is checkable later, which is the point.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
if not isinstance(probe, SourceHealthProbe):
|
|
280
|
+
raise SourceContractError("TYPE", "probe", "must be a SourceHealthProbe")
|
|
281
|
+
if probe.reachable:
|
|
282
|
+
assert probe.content_sha256 is not None
|
|
283
|
+
assert probe.media_type is not None
|
|
284
|
+
return EvidenceReference(
|
|
285
|
+
uri=probe.citation_uri,
|
|
286
|
+
observed_at=probe.probed_at,
|
|
287
|
+
content_sha256=probe.content_sha256,
|
|
288
|
+
media_type=probe.media_type,
|
|
289
|
+
)
|
|
290
|
+
return EvidenceReference(
|
|
291
|
+
uri=probe.citation_uri,
|
|
292
|
+
observed_at=probe.probed_at,
|
|
293
|
+
content_sha256=canonical_sha256(
|
|
294
|
+
{
|
|
295
|
+
"refusal_code": probe.refusal_code,
|
|
296
|
+
"citation_uri": probe.citation_uri,
|
|
297
|
+
"probed_at": probe.probed_at,
|
|
298
|
+
}
|
|
299
|
+
),
|
|
300
|
+
media_type="application/json",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def build_probe_observation(
|
|
305
|
+
*,
|
|
306
|
+
probe: SourceHealthProbe,
|
|
307
|
+
declared_updated_at: str,
|
|
308
|
+
historical_start: str,
|
|
309
|
+
historical_end: str,
|
|
310
|
+
update_frequency_seconds: int,
|
|
311
|
+
event_time_field: str | None = None,
|
|
312
|
+
available_at_field: str | None = None,
|
|
313
|
+
) -> SourceObservation:
|
|
314
|
+
"""Turn one probe plus the publisher's own declared update time into a source observation.
|
|
315
|
+
|
|
316
|
+
This is the object a ``CatalogEntry`` embeds, which is what stops the probe's view of a source
|
|
317
|
+
and the entry's facts about it from drifting apart.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
if not isinstance(probe, SourceHealthProbe):
|
|
321
|
+
raise SourceContractError("TYPE", "probe", "must be a SourceHealthProbe")
|
|
322
|
+
probed = _timestamp(probe.probed_at, "probe.probed_at")
|
|
323
|
+
declared = _timestamp(declared_updated_at, "declared_updated_at")
|
|
324
|
+
delay = int((probed - declared).total_seconds())
|
|
325
|
+
if delay < 0:
|
|
326
|
+
# Validate ordering only; timestamp plausibility is outside this function.
|
|
327
|
+
delay = 0
|
|
328
|
+
if delay > MAX_DECLARED_DELAY_SECONDS:
|
|
329
|
+
raise SourceContractError(
|
|
330
|
+
"PROBE_STALE_DECLARATION",
|
|
331
|
+
"declared_updated_at",
|
|
332
|
+
f"the declared update is {delay} seconds old, beyond the "
|
|
333
|
+
f"{MAX_DECLARED_DELAY_SECONDS}-second bound; a decade-stale claim is a fact worth "
|
|
334
|
+
"surfacing rather than clamping into range",
|
|
335
|
+
)
|
|
336
|
+
_integer(
|
|
337
|
+
update_frequency_seconds,
|
|
338
|
+
"update_frequency_seconds",
|
|
339
|
+
minimum=0,
|
|
340
|
+
maximum=MAX_DECLARED_DELAY_SECONDS,
|
|
341
|
+
)
|
|
342
|
+
return SourceObservation(
|
|
343
|
+
source_id=probe.source_id,
|
|
344
|
+
observed_at=probe.probed_at,
|
|
345
|
+
event_time_field=event_time_field,
|
|
346
|
+
available_at_field=available_at_field,
|
|
347
|
+
historical_start=historical_start,
|
|
348
|
+
historical_end=historical_end,
|
|
349
|
+
live_status="live" if probe.reachable else "dead",
|
|
350
|
+
publication_delay_seconds=delay,
|
|
351
|
+
update_frequency_seconds=update_frequency_seconds,
|
|
352
|
+
response_status=probe.response_status,
|
|
353
|
+
evidence=(probe_citation(probe),),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def derive_update_frequency_seconds(earlier: str, later: str) -> int:
|
|
358
|
+
"""Whole seconds between two declared update timestamps of the same source."""
|
|
359
|
+
|
|
360
|
+
first = _timestamp(earlier, "earlier")
|
|
361
|
+
second = _timestamp(later, "later")
|
|
362
|
+
seconds = int((second - first).total_seconds())
|
|
363
|
+
if seconds <= 0:
|
|
364
|
+
raise SourceContractError(
|
|
365
|
+
"PROBE_INTERVAL",
|
|
366
|
+
"later",
|
|
367
|
+
"the later declared update must follow the earlier one",
|
|
368
|
+
)
|
|
369
|
+
if seconds > MAX_DECLARED_DELAY_SECONDS:
|
|
370
|
+
raise SourceContractError(
|
|
371
|
+
"PROBE_INTERVAL",
|
|
372
|
+
"later",
|
|
373
|
+
f"an update interval of {seconds} seconds exceeds the observation bound",
|
|
374
|
+
)
|
|
375
|
+
return seconds
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _status_from_detail(detail: str) -> int | None:
|
|
379
|
+
match = _STATUS_TAIL.search(detail)
|
|
380
|
+
if match is None:
|
|
381
|
+
return None
|
|
382
|
+
return int(match.group(1))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _refusal_detail(error: AcquisitionSecurityError, status: int | None) -> str:
|
|
386
|
+
if status in {401, 403}:
|
|
387
|
+
return f"HTTP {status}: {AUTHENTICATED_ADAPTER_PARKED}"
|
|
388
|
+
return error.detail[:500]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _text(value: Any, path: str, *, maximum: int) -> str:
|
|
392
|
+
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
393
|
+
raise SourceContractError(
|
|
394
|
+
"TEXT",
|
|
395
|
+
path,
|
|
396
|
+
f"must be a non-empty string no longer than {maximum} characters",
|
|
397
|
+
)
|
|
398
|
+
return value
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _identifier(value: Any, path: str) -> str:
|
|
402
|
+
text = _text(value, path, maximum=128)
|
|
403
|
+
if not re.fullmatch(r"[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*", text):
|
|
404
|
+
raise SourceContractError(
|
|
405
|
+
"IDENTIFIER",
|
|
406
|
+
path,
|
|
407
|
+
"must be a lowercase dotted/dashed/underscored identifier",
|
|
408
|
+
)
|
|
409
|
+
return text
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _https_uri(value: Any, path: str) -> str:
|
|
413
|
+
text = _text(value, path, maximum=2_048)
|
|
414
|
+
if not text.startswith("https://") or len(text) <= len("https://"):
|
|
415
|
+
raise SourceContractError("HTTPS_URI", path, "must be an HTTPS URI")
|
|
416
|
+
return text
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _timestamp(value: Any, path: str) -> datetime:
|
|
420
|
+
text = _text(value, path, maximum=32)
|
|
421
|
+
if not _TIMESTAMP.fullmatch(text):
|
|
422
|
+
raise SourceContractError("TIMESTAMP", path, "must be a canonical RFC 3339 UTC timestamp")
|
|
423
|
+
return datetime.fromisoformat(text[:-1] + "+00:00")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _media_type(value: Any, path: str) -> str:
|
|
427
|
+
text = _text(value, path, maximum=255)
|
|
428
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*", text):
|
|
429
|
+
raise SourceContractError("MEDIA_TYPE", path, "must be a lowercase type/subtype")
|
|
430
|
+
return text
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _digest(value: Any, path: str) -> str:
|
|
434
|
+
text = _text(value, path, maximum=64)
|
|
435
|
+
if not _SHA256.fullmatch(text):
|
|
436
|
+
raise SourceContractError("SHA256", path, "must be a lowercase 64-character SHA-256")
|
|
437
|
+
return text
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _integer(value: Any, path: str, *, minimum: int, maximum: int) -> int:
|
|
441
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
442
|
+
raise SourceContractError(
|
|
443
|
+
"INTEGER",
|
|
444
|
+
path,
|
|
445
|
+
f"must be an integer in [{minimum}, {maximum}]",
|
|
446
|
+
)
|
|
447
|
+
return value
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Fetch-at-start: giving one hosted worker the exact sealed catalogue its deployment pinned.
|
|
2
|
+
|
|
3
|
+
A local install of the catalogue is a thing a person did once and can see in their home folder. A
|
|
4
|
+
worker has neither -- it is a fresh container per attempt, and the only durable statement about
|
|
5
|
+
what it should be running is its own deploy-owned configuration. So the worker does at start what
|
|
6
|
+
the person did by hand: reads the pin, fetches the pinned manifest, installs the generation the
|
|
7
|
+
manifest names, and verifies it with the real store loader before anything can query it.
|
|
8
|
+
|
|
9
|
+
THREE OUTCOMES, ALL EXPLICIT.
|
|
10
|
+
|
|
11
|
+
* No pin configured -> ``None``. This deployment does not host a catalogue. It is the state every
|
|
12
|
+
deployment is in before the channel is rolled out to it, and it must not be an error.
|
|
13
|
+
* Pin configured and satisfied -> a receipt naming the generation now on disk.
|
|
14
|
+
* Pin configured and not satisfiable -> a refusal, and the worker does not start.
|
|
15
|
+
|
|
16
|
+
The third is the one worth being deliberate about. A worker that cannot get the catalogue it was
|
|
17
|
+
told to have could plausibly carry on -- discovery would simply record the catalogue lane as
|
|
18
|
+
unavailable, which is a state the product already models. It does not carry on, because the two
|
|
19
|
+
situations are indistinguishable from the outside and only one of them is intended: "this
|
|
20
|
+
deployment has no catalogue" is a decision, and "this deployment's catalogue fetch failed" is a
|
|
21
|
+
fault, and a fault that renders as a decision is how a fleet quietly stops using its catalogue.
|
|
22
|
+
|
|
23
|
+
CACHING. The generation lands under a writable path in the container -- ``/tmp`` by default, which
|
|
24
|
+
on Cloud Run is an in-memory filesystem, and the install is append-only, so a warm instance that
|
|
25
|
+
already has the pinned generation re-verifies it and downloads nothing. There is no shared mount:
|
|
26
|
+
the hosted worker image mounts no volume for any other read-only asset either, and introducing one
|
|
27
|
+
for the catalogue would make a per-instance cache into shared mutable state.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from mostlyright.data_harness.sources.catalog.channel import pin_from_environment
|
|
38
|
+
from mostlyright.data_harness.sources.catalog.channel_client import (
|
|
39
|
+
ChannelTransport,
|
|
40
|
+
install_from_channel,
|
|
41
|
+
metadata_access_token,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"CATALOG_ROOT_ENV",
|
|
46
|
+
"DEFAULT_WORKER_CATALOG_ROOT",
|
|
47
|
+
"HOSTED_CATALOG_RECEIPT_SCHEMA",
|
|
48
|
+
"ensure_hosted_catalog",
|
|
49
|
+
"worker_catalog_root",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
#: Where the worker keeps the generation it fetched. Overridable so a job image with a different
|
|
53
|
+
#: writable layout, and every test, can put it somewhere else.
|
|
54
|
+
CATALOG_ROOT_ENV = "MOSTLYRIGHT_WORKER_CATALOG_DIR"
|
|
55
|
+
DEFAULT_WORKER_CATALOG_ROOT = "/tmp/mostlyright-catalog"
|
|
56
|
+
|
|
57
|
+
HOSTED_CATALOG_RECEIPT_SCHEMA = "mostlyright-worker-catalog-fetch.v1"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def worker_catalog_root(environment: Mapping[str, str] | None = None) -> Path:
|
|
61
|
+
"""The folder this worker installs the pinned generation into."""
|
|
62
|
+
|
|
63
|
+
env = os.environ if environment is None else environment
|
|
64
|
+
return Path(env.get(CATALOG_ROOT_ENV) or DEFAULT_WORKER_CATALOG_ROOT)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_hosted_catalog(
|
|
68
|
+
environment: Mapping[str, str] | None = None,
|
|
69
|
+
*,
|
|
70
|
+
transport: ChannelTransport | None = None,
|
|
71
|
+
authorization: str | None = None,
|
|
72
|
+
) -> dict[str, Any] | None:
|
|
73
|
+
"""Put the pinned generation on this worker, or answer None when none is pinned.
|
|
74
|
+
|
|
75
|
+
``authorization`` is asked of the metadata server when the caller does not supply one, which
|
|
76
|
+
is what a real worker wants and what no test wants. Passing an empty string selects an
|
|
77
|
+
unauthenticated fetch explicitly, for a channel that is world-readable.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
env = os.environ if environment is None else environment
|
|
81
|
+
pin = pin_from_environment(env)
|
|
82
|
+
if pin is None:
|
|
83
|
+
return None
|
|
84
|
+
if authorization is None:
|
|
85
|
+
authorization = metadata_access_token(transport=transport)
|
|
86
|
+
elif authorization == "":
|
|
87
|
+
authorization = None
|
|
88
|
+
root = worker_catalog_root(env)
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
report = install_from_channel(
|
|
91
|
+
root, pin, transport=transport, authorization=authorization or None
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"schema_version": HOSTED_CATALOG_RECEIPT_SCHEMA,
|
|
95
|
+
"pin_selection_id": pin.selection_id,
|
|
96
|
+
"channel_url": pin.channel_url,
|
|
97
|
+
"manifest_sha256": pin.manifest_sha256,
|
|
98
|
+
"status": report.status,
|
|
99
|
+
"store_kind": report.state.store_kind,
|
|
100
|
+
"store_sha256": report.state.store_sha256,
|
|
101
|
+
"archive_sha256": report.state.archive_sha256,
|
|
102
|
+
"entry_count": report.state.entry_count,
|
|
103
|
+
"catalog_root": str(root),
|
|
104
|
+
"generation_root": os.path.join(str(root), report.state.generation_path),
|
|
105
|
+
}
|