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,495 @@
|
|
|
1
|
+
"""The content half of slice fetching: sidecar parsing, range planning, structural checks.
|
|
2
|
+
|
|
3
|
+
Everything here is pure and I/O-free. It parses an untrusted index sidecar into an ordered
|
|
4
|
+
message table, turns a recipe-named selection into exact byte ranges emitted in index order,
|
|
5
|
+
and proves a fetched slice is a well-formed GRIB message before anything else touches it.
|
|
6
|
+
|
|
7
|
+
The division of labour with ``acquisition.http`` is deliberate and no single part is sufficient
|
|
8
|
+
alone. HTTP proves the *span*: exactly 206, a ``Content-Range`` parsed strictly and equal to
|
|
9
|
+
what was ordered, three lengths that agree. ``check_grib_message`` proves the delivered bytes
|
|
10
|
+
are a whole, well-formed message rather than a shifted, truncated or spliced window of one --
|
|
11
|
+
the shape a wrong offset produces.
|
|
12
|
+
|
|
13
|
+
Neither proves *which object* the message came from. Two well-formed messages of equal length
|
|
14
|
+
are indistinguishable to both, and for GRIB2 at a fixed grid and a fixed bits-per-value, equal
|
|
15
|
+
length across a re-issued or superseded run is the ordinary case rather than a contrived one.
|
|
16
|
+
``check_grib_reference_time`` is the control for that case: it reads Section 1's reference time
|
|
17
|
+
out of the delivered message and refuses anything that did not come from the run the order
|
|
18
|
+
named. It is in force only when the order names a run -- ``expected_reference_time`` on the
|
|
19
|
+
range plan -- because a slice order that names no run has nothing to be held to.
|
|
20
|
+
|
|
21
|
+
What remains uncovered, stated rather than implied, in two parts.
|
|
22
|
+
|
|
23
|
+
First, a *different object belonging to the same run* -- a neighbouring field's file at the same
|
|
24
|
+
cycle, of equal message length -- passes every check here. Closing that would need per-object
|
|
25
|
+
evidence the index sidecar does not carry.
|
|
26
|
+
|
|
27
|
+
Second, and cheaper: *the index chooses which message answers a descriptor, and nothing verifies
|
|
28
|
+
that pairing*. ``plan_ranges`` turns a descriptor into a byte span using nothing but the
|
|
29
|
+
sidecar's own descriptor->offset mapping, and no control anywhere binds a delivered message back
|
|
30
|
+
to the descriptor that named it. All three controls -- the span, ``check_grib_message`` and
|
|
31
|
+
``check_grib_reference_time`` -- are properties of the bytes alone, so a sidecar that keeps the
|
|
32
|
+
object's real, strictly ascending offsets and attaches each descriptor to its neighbour's span
|
|
33
|
+
delivers a whole, well-formed message from the correct model run at exactly the span the index
|
|
34
|
+
named. The sidecar is untrusted input for its *shape*; it is trusted for the descriptor->offset
|
|
35
|
+
mapping, and this is where that trust is spent. Unlike the case above it needs no second file,
|
|
36
|
+
no mirror lag and no redirect -- only the small text ``.idx`` beside the object, which is a
|
|
37
|
+
separate, far cheaper cache or edge object than the binary it describes. Both are pinned by
|
|
38
|
+
``known_gap`` tests in ``tests/h3/test_ranges.py`` and ``tests/h3/test_sandbox.py``.
|
|
39
|
+
|
|
40
|
+
This module is a leaf on purpose: it imports the transport's typed range and its range budget
|
|
41
|
+
and nothing else from the package, so the sandbox worker can import it without dragging in a
|
|
42
|
+
recipe, a pipeline or a source registry.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import re
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from datetime import date
|
|
50
|
+
|
|
51
|
+
from mostlyright.data_harness.acquisition.http import ByteRange, require_range_plan_within_budget
|
|
52
|
+
from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
|
|
53
|
+
|
|
54
|
+
# A message number or a byte offset, in one canonical spelling: no sign, no whitespace, no
|
|
55
|
+
# leading zeros, and at most sixteen digits. The same shape ``acquisition.http`` accepts in a
|
|
56
|
+
# ``Content-Range``, for the same reason -- one delivered span must have one spelling, and a
|
|
57
|
+
# sidecar must not be able to name an offset no object could have.
|
|
58
|
+
_SIDECAR_DECIMAL = re.compile(r"0|[1-9][0-9]{0,15}")
|
|
59
|
+
|
|
60
|
+
GRIB_MAGIC = b"GRIB"
|
|
61
|
+
GRIB_EDITION = 2
|
|
62
|
+
GRIB_EDITION_OFFSET = 7
|
|
63
|
+
GRIB_TRAILER = b"7777"
|
|
64
|
+
# Section 0 is sixteen bytes: ``GRIB``, two reserved bytes, the discipline, the edition, then
|
|
65
|
+
# the whole message's length as a big-endian eight-byte integer. With the four-byte trailer,
|
|
66
|
+
# the shortest byte string that could be a message at all is twenty bytes.
|
|
67
|
+
GRIB_SECTION_ZERO_BYTES = 16
|
|
68
|
+
GRIB_LENGTH_SLICE = slice(8, 16)
|
|
69
|
+
GRIB_MINIMUM_BYTES = GRIB_SECTION_ZERO_BYTES + len(GRIB_TRAILER)
|
|
70
|
+
|
|
71
|
+
# Section 1, the identification section, begins where Section 0 ends and is at least twenty-one
|
|
72
|
+
# octets. Its octets are numbered from one, so octet ``n`` sits at index ``15 + n`` of the
|
|
73
|
+
# message. Only the four fields below are read and nothing here decodes: the reference time,
|
|
74
|
+
# and the significance byte that says what the reference time means.
|
|
75
|
+
GRIB_SECTION_ONE_OFFSET = GRIB_SECTION_ZERO_BYTES
|
|
76
|
+
GRIB_SECTION_ONE_MINIMUM_BYTES = 21
|
|
77
|
+
GRIB_SECTION_ONE_NUMBER = 1
|
|
78
|
+
GRIB_SECTION_ONE_LENGTH_SLICE = slice(16, 20)
|
|
79
|
+
GRIB_SECTION_ONE_NUMBER_OFFSET = 20
|
|
80
|
+
# Code table 1.2. Value 1 is "start of forecast", which is the model run itself. Any other
|
|
81
|
+
# significance names a different instant -- a verifying time, an observation time -- and is
|
|
82
|
+
# refused rather than compared against a cycle it does not denote.
|
|
83
|
+
GRIB_REFERENCE_SIGNIFICANCE_OFFSET = 27
|
|
84
|
+
GRIB_REFERENCE_START_OF_FORECAST = 1
|
|
85
|
+
GRIB_REFERENCE_YEAR_SLICE = slice(28, 30)
|
|
86
|
+
GRIB_REFERENCE_MONTH_OFFSET = 30
|
|
87
|
+
GRIB_REFERENCE_DAY_OFFSET = 31
|
|
88
|
+
GRIB_REFERENCE_HOUR_OFFSET = 32
|
|
89
|
+
GRIB_REFERENCE_MINUTE_OFFSET = 33
|
|
90
|
+
GRIB_REFERENCE_SECOND_OFFSET = 34
|
|
91
|
+
|
|
92
|
+
# One spelling for a model run, everywhere it crosses a boundary: a UTC instant to the second,
|
|
93
|
+
# zero-padded, with the literal ``Z``. The pattern is the shape; ``date`` below is what rejects
|
|
94
|
+
# a shape-valid instant that is not a day, so ``2026-02-30T00:00:00Z`` cannot be ordered.
|
|
95
|
+
_REFERENCE_TIME_SPELLING = re.compile(
|
|
96
|
+
r"([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class SidecarLimits:
|
|
102
|
+
"""Coordinator-owned ceilings for parsing one index sidecar.
|
|
103
|
+
|
|
104
|
+
A real NOAA ``.idx`` for a ~518 MiB GFS file is a few hundred lines of a few hundred bytes.
|
|
105
|
+
The defaults leave that an order of magnitude of headroom and refuse anything past it, so a
|
|
106
|
+
pathological or hostile sidecar is bounded before it is looked at rather than after.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
max_bytes: int = 1024 * 1024
|
|
110
|
+
max_lines: int = 4096
|
|
111
|
+
max_line_bytes: int = 512
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
bounded = (
|
|
115
|
+
("max_bytes", self.max_bytes, 1, 16 * 1024 * 1024),
|
|
116
|
+
("max_lines", self.max_lines, 1, 65_536),
|
|
117
|
+
("max_line_bytes", self.max_line_bytes, 1, 8192),
|
|
118
|
+
)
|
|
119
|
+
for name, value, minimum, maximum in bounded:
|
|
120
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
121
|
+
raise AcquisitionSecurityError(
|
|
122
|
+
"SIDECAR_LIMIT",
|
|
123
|
+
f"{name} must be an integer in [{minimum}, {maximum}]",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class SidecarMessage:
|
|
129
|
+
"""One line of an index sidecar, after parsing: where a message starts and what it is.
|
|
130
|
+
|
|
131
|
+
A sidecar value is never allowed to become a length, an allocation size or a URL. The
|
|
132
|
+
offset is a proposal about an object, checked against what actually arrives.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
index: int
|
|
136
|
+
offset: int
|
|
137
|
+
descriptor: str
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if type(self.index) is not int or self.index < 1:
|
|
141
|
+
raise AcquisitionSecurityError(
|
|
142
|
+
"SIDECAR_MESSAGE",
|
|
143
|
+
"message index must be an integer of at least 1",
|
|
144
|
+
)
|
|
145
|
+
if type(self.offset) is not int or self.offset < 0:
|
|
146
|
+
raise AcquisitionSecurityError(
|
|
147
|
+
"SIDECAR_MESSAGE",
|
|
148
|
+
"message offset must be a non-negative integer",
|
|
149
|
+
)
|
|
150
|
+
if not isinstance(self.descriptor, str):
|
|
151
|
+
raise AcquisitionSecurityError(
|
|
152
|
+
"SIDECAR_MESSAGE",
|
|
153
|
+
"message descriptor must be text",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_index_sidecar(content: bytes, *, limits: SidecarLimits) -> tuple[SidecarMessage, ...]:
|
|
158
|
+
"""Parse an untrusted ``.idx`` sidecar into an ordered message table, or refuse it.
|
|
159
|
+
|
|
160
|
+
The sidecar is untrusted input and is treated as such throughout: it is bounded before it
|
|
161
|
+
is decoded, decoded strictly, and accepted only in one closed shape. Message numbers must
|
|
162
|
+
run exactly ``1, 2, 3, ...`` and offsets must ascend strictly. Strict ascent is what makes
|
|
163
|
+
each message's end computable -- a message ends where the next one begins -- and what stops
|
|
164
|
+
a crafted sidecar from proposing an inverted or overlapping span.
|
|
165
|
+
|
|
166
|
+
No refusal here quotes the sidecar's own text. A refusal crosses the sandbox boundary and
|
|
167
|
+
must not become a channel for whatever the fetched bytes happened to contain.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
if not isinstance(content, bytes):
|
|
171
|
+
raise AcquisitionSecurityError("SIDECAR_INPUT", "sidecar input must be exact bytes")
|
|
172
|
+
if len(content) > limits.max_bytes:
|
|
173
|
+
raise AcquisitionSecurityError(
|
|
174
|
+
"SIDECAR_BYTES",
|
|
175
|
+
f"sidecar exceeds the {limits.max_bytes} byte budget",
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
text = content.decode("ascii", errors="strict")
|
|
179
|
+
except UnicodeDecodeError:
|
|
180
|
+
raise AcquisitionSecurityError("SIDECAR_ENCODING", "sidecar must be strict ASCII") from None
|
|
181
|
+
|
|
182
|
+
lines = text.split("\n")
|
|
183
|
+
# Exactly one trailing empty line is a line terminator on the final record. Every other
|
|
184
|
+
# blank line is a malformed record and falls through to the field check below.
|
|
185
|
+
if lines and lines[-1] == "":
|
|
186
|
+
lines = lines[:-1]
|
|
187
|
+
if not lines:
|
|
188
|
+
raise AcquisitionSecurityError("SIDECAR_EMPTY", "sidecar names no messages")
|
|
189
|
+
if len(lines) > limits.max_lines:
|
|
190
|
+
raise AcquisitionSecurityError(
|
|
191
|
+
"SIDECAR_LINES",
|
|
192
|
+
f"sidecar exceeds the {limits.max_lines} line budget",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
messages: list[SidecarMessage] = []
|
|
196
|
+
for line_number, line in enumerate(lines, start=1):
|
|
197
|
+
if len(line) > limits.max_line_bytes:
|
|
198
|
+
raise AcquisitionSecurityError(
|
|
199
|
+
"SIDECAR_LINE",
|
|
200
|
+
f"sidecar line {line_number} exceeds the {limits.max_line_bytes} byte budget",
|
|
201
|
+
)
|
|
202
|
+
fields = line.split(":")
|
|
203
|
+
if len(fields) < 3:
|
|
204
|
+
raise AcquisitionSecurityError(
|
|
205
|
+
"SIDECAR_FIELD",
|
|
206
|
+
f"sidecar line {line_number} has fewer than three fields",
|
|
207
|
+
)
|
|
208
|
+
if not _SIDECAR_DECIMAL.fullmatch(fields[0]) or not _SIDECAR_DECIMAL.fullmatch(fields[1]):
|
|
209
|
+
raise AcquisitionSecurityError(
|
|
210
|
+
"SIDECAR_FIELD",
|
|
211
|
+
f"sidecar line {line_number} does not begin with a plain number and offset",
|
|
212
|
+
)
|
|
213
|
+
index = int(fields[0])
|
|
214
|
+
offset = int(fields[1])
|
|
215
|
+
if index != line_number:
|
|
216
|
+
raise AcquisitionSecurityError(
|
|
217
|
+
"SIDECAR_ORDER",
|
|
218
|
+
f"sidecar line {line_number} is numbered {index}, breaking the message sequence",
|
|
219
|
+
)
|
|
220
|
+
if messages and offset <= messages[-1].offset:
|
|
221
|
+
raise AcquisitionSecurityError(
|
|
222
|
+
"SIDECAR_ORDER",
|
|
223
|
+
f"sidecar line {line_number} does not advance past the previous offset",
|
|
224
|
+
)
|
|
225
|
+
descriptor = ":".join(fields[2:])
|
|
226
|
+
if descriptor.endswith(":"):
|
|
227
|
+
descriptor = descriptor[:-1]
|
|
228
|
+
messages.append(SidecarMessage(index=index, offset=offset, descriptor=descriptor))
|
|
229
|
+
|
|
230
|
+
return tuple(messages)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class PlannedRange:
|
|
235
|
+
"""One message the acquisition ordered: which message it is and exactly which bytes."""
|
|
236
|
+
|
|
237
|
+
index: int
|
|
238
|
+
descriptor: str
|
|
239
|
+
byte_range: ByteRange
|
|
240
|
+
|
|
241
|
+
def __post_init__(self) -> None:
|
|
242
|
+
if type(self.index) is not int or self.index < 1:
|
|
243
|
+
raise AcquisitionSecurityError(
|
|
244
|
+
"PLANNED_RANGE",
|
|
245
|
+
"planned message index must be an integer of at least 1",
|
|
246
|
+
)
|
|
247
|
+
if not isinstance(self.descriptor, str):
|
|
248
|
+
raise AcquisitionSecurityError("PLANNED_RANGE", "planned descriptor must be text")
|
|
249
|
+
if not isinstance(self.byte_range, ByteRange):
|
|
250
|
+
raise AcquisitionSecurityError(
|
|
251
|
+
"PLANNED_RANGE",
|
|
252
|
+
"a planned range must carry the transport's own byte range type",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def plan_ranges(
|
|
257
|
+
messages: tuple[SidecarMessage, ...],
|
|
258
|
+
*,
|
|
259
|
+
selectors: tuple[str, ...],
|
|
260
|
+
max_ranges: int,
|
|
261
|
+
) -> tuple[PlannedRange, ...]:
|
|
262
|
+
"""Turn a recipe-named selection into exact byte ranges, emitted in message-index order.
|
|
263
|
+
|
|
264
|
+
Matching is exact string equality against ``SidecarMessage.descriptor``. There is no
|
|
265
|
+
substring match, no glob and no regular expression: a selector that matched by prefix would
|
|
266
|
+
silently pick up a different variable the day a producer adds a level to its descriptors,
|
|
267
|
+
and the acquisition would keep succeeding while fetching the wrong field.
|
|
268
|
+
|
|
269
|
+
The result is sorted by message index before it is returned, and that sort is what makes the
|
|
270
|
+
sealed digest reproducible. Slices may be fetched with any concurrency, so responses arrive
|
|
271
|
+
in whatever order the network produced; emitting in arrival order would make the receipt a
|
|
272
|
+
function of network timing rather than of the recipe.
|
|
273
|
+
|
|
274
|
+
Selecting the last message in the sidecar is refused. Its end is the end of the object and
|
|
275
|
+
this module does not learn object lengths -- a guessed end is exactly the "correct span,
|
|
276
|
+
wrong bytes" failure the structural check below exists to catch, and guessing it here would
|
|
277
|
+
manufacture that failure rather than detect it.
|
|
278
|
+
|
|
279
|
+
No refusal quotes a selector or a descriptor: both are text that came from outside.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
if not isinstance(messages, tuple) or not messages:
|
|
283
|
+
raise AcquisitionSecurityError(
|
|
284
|
+
"SIDECAR_EMPTY",
|
|
285
|
+
"a range plan needs a non-empty parsed message table",
|
|
286
|
+
)
|
|
287
|
+
if not all(isinstance(message, SidecarMessage) for message in messages):
|
|
288
|
+
raise AcquisitionSecurityError(
|
|
289
|
+
"SIDECAR_MESSAGE",
|
|
290
|
+
"a range plan may only be built from parsed sidecar messages",
|
|
291
|
+
)
|
|
292
|
+
if not isinstance(selectors, tuple) or not all(
|
|
293
|
+
isinstance(selector, str) for selector in selectors
|
|
294
|
+
):
|
|
295
|
+
raise AcquisitionSecurityError("SELECTOR_TYPE", "selectors must be a tuple of text")
|
|
296
|
+
|
|
297
|
+
# Bound the plan before doing any of its work, and bound it in exactly one place: the
|
|
298
|
+
# transport owns the range budget, so this module calls it rather than restating it.
|
|
299
|
+
require_range_plan_within_budget(len(selectors), max_ranges=max_ranges)
|
|
300
|
+
|
|
301
|
+
if len(set(selectors)) != len(selectors):
|
|
302
|
+
raise AcquisitionSecurityError(
|
|
303
|
+
"SELECTOR_DUPLICATE",
|
|
304
|
+
"a selector is named more than once; a repeated order is not a plan",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
by_descriptor: dict[str, list[int]] = {}
|
|
308
|
+
for position, message in enumerate(messages):
|
|
309
|
+
by_descriptor.setdefault(message.descriptor, []).append(position)
|
|
310
|
+
|
|
311
|
+
planned: list[PlannedRange] = []
|
|
312
|
+
for selector in selectors:
|
|
313
|
+
positions = by_descriptor.get(selector, [])
|
|
314
|
+
if not positions:
|
|
315
|
+
raise AcquisitionSecurityError(
|
|
316
|
+
"SELECTOR_MISS",
|
|
317
|
+
"a selector names no message in this sidecar",
|
|
318
|
+
)
|
|
319
|
+
if len(positions) > 1:
|
|
320
|
+
raise AcquisitionSecurityError(
|
|
321
|
+
"SELECTOR_AMBIGUOUS",
|
|
322
|
+
f"a selector names {len(positions)} messages in this sidecar",
|
|
323
|
+
)
|
|
324
|
+
position = positions[0]
|
|
325
|
+
if position == len(messages) - 1:
|
|
326
|
+
raise AcquisitionSecurityError(
|
|
327
|
+
"SELECTOR_TERMINAL",
|
|
328
|
+
"the last message in a sidecar has no computable end and is not selectable",
|
|
329
|
+
)
|
|
330
|
+
message = messages[position]
|
|
331
|
+
planned.append(
|
|
332
|
+
PlannedRange(
|
|
333
|
+
index=message.index,
|
|
334
|
+
descriptor=message.descriptor,
|
|
335
|
+
byte_range=ByteRange(
|
|
336
|
+
first_byte=message.offset,
|
|
337
|
+
last_byte=messages[position + 1].offset - 1,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return tuple(sorted(planned, key=lambda entry: entry.index))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def check_grib_message(payload: bytes) -> None:
|
|
346
|
+
"""Prove a fetched slice is one whole, well-formed GRIB message, or refuse it.
|
|
347
|
+
|
|
348
|
+
The division of labour, stated plainly and no wider than it is. ``acquisition.http``
|
|
349
|
+
establishes that the server answered 206 with a ``Content-Range`` equal to what was ordered
|
|
350
|
+
and that three lengths agree; that proves the *span*. This function proves the *framing*:
|
|
351
|
+
a message carries its own magic, its own edition, its own self-declared length and its own
|
|
352
|
+
trailer, so a window taken at the wrong offset, a window one message short or long, and a
|
|
353
|
+
body that is not a message at all each fail at least one of the four.
|
|
354
|
+
|
|
355
|
+
What this function does **not** prove is which object the bytes came from. Every check
|
|
356
|
+
below is a property of the message alone, so two well-formed messages of equal length are
|
|
357
|
+
indistinguishable here however different their contents -- which is exactly the shape a
|
|
358
|
+
stale cache, a re-issued file or a mirror running behind produces. The control for that is
|
|
359
|
+
``check_grib_reference_time``, and it is in force only when the order named a model run.
|
|
360
|
+
|
|
361
|
+
Nothing here decodes. Section 1 and beyond, the grid and packing allowlist and the
|
|
362
|
+
known-good samples belong to the decoding family.
|
|
363
|
+
|
|
364
|
+
A refusal names the check that failed and never the payload. These bytes are untrusted and
|
|
365
|
+
the refusal crosses the sandbox boundary, so it must not become a channel out.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
if not isinstance(payload, bytes):
|
|
369
|
+
raise AcquisitionSecurityError("GRIB_PAYLOAD", "a slice must be checked as exact bytes")
|
|
370
|
+
# Length first, so no comparison below can index past the end of a short payload.
|
|
371
|
+
if len(payload) < GRIB_MINIMUM_BYTES:
|
|
372
|
+
raise AcquisitionSecurityError(
|
|
373
|
+
"GRIB_TRUNCATED",
|
|
374
|
+
f"a GRIB message is at least {GRIB_MINIMUM_BYTES} bytes",
|
|
375
|
+
)
|
|
376
|
+
if payload[: len(GRIB_MAGIC)] != GRIB_MAGIC:
|
|
377
|
+
raise AcquisitionSecurityError("GRIB_MAGIC", "the slice does not begin with GRIB")
|
|
378
|
+
if payload[GRIB_EDITION_OFFSET] != GRIB_EDITION:
|
|
379
|
+
raise AcquisitionSecurityError(
|
|
380
|
+
"GRIB_EDITION",
|
|
381
|
+
f"only GRIB edition {GRIB_EDITION} is admitted",
|
|
382
|
+
)
|
|
383
|
+
if int.from_bytes(payload[GRIB_LENGTH_SLICE], "big") != len(payload):
|
|
384
|
+
raise AcquisitionSecurityError(
|
|
385
|
+
"GRIB_LENGTH",
|
|
386
|
+
"the message's self-declared length is not the number of bytes fetched",
|
|
387
|
+
)
|
|
388
|
+
if payload[-len(GRIB_TRAILER) :] != GRIB_TRAILER:
|
|
389
|
+
raise AcquisitionSecurityError("GRIB_TRAILER", "the slice does not end with the trailer")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def require_reference_time_spelling(value: str) -> str:
|
|
393
|
+
"""Return a model-run instant in the one spelling this package accepts, or refuse it.
|
|
394
|
+
|
|
395
|
+
Shared by the coordinator that builds an order and the worker that parses one, so a run can
|
|
396
|
+
have only one spelling on either side of the boundary. The refusal never quotes the value:
|
|
397
|
+
it is text that came from outside this process and the refusal crosses the sandbox boundary.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
if not isinstance(value, str):
|
|
401
|
+
raise AcquisitionSecurityError(
|
|
402
|
+
"REFERENCE_TIME",
|
|
403
|
+
"a model run must be named as text",
|
|
404
|
+
)
|
|
405
|
+
matched = _REFERENCE_TIME_SPELLING.fullmatch(value)
|
|
406
|
+
if matched is None:
|
|
407
|
+
raise AcquisitionSecurityError(
|
|
408
|
+
"REFERENCE_TIME",
|
|
409
|
+
"a model run must be spelled YYYY-MM-DDTHH:MM:SSZ, in UTC, zero-padded",
|
|
410
|
+
)
|
|
411
|
+
year, month, day = (int(part) for part in matched.group(1, 2, 3))
|
|
412
|
+
hour, minute, second = (int(part) for part in matched.group(4, 5, 6))
|
|
413
|
+
try:
|
|
414
|
+
date(year, month, day)
|
|
415
|
+
except ValueError:
|
|
416
|
+
raise AcquisitionSecurityError(
|
|
417
|
+
"REFERENCE_TIME",
|
|
418
|
+
"a model run must name a real calendar day",
|
|
419
|
+
) from None
|
|
420
|
+
if hour > 23 or minute > 59 or second > 59:
|
|
421
|
+
raise AcquisitionSecurityError(
|
|
422
|
+
"REFERENCE_TIME",
|
|
423
|
+
"a model run must name a real time of day",
|
|
424
|
+
)
|
|
425
|
+
return value
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def check_grib_reference_time(payload: bytes, *, expected: str) -> None:
|
|
429
|
+
"""Prove a delivered message came from the model run that was ordered, or refuse it.
|
|
430
|
+
|
|
431
|
+
This is the control ``check_grib_message`` cannot be: every check there is a property of
|
|
432
|
+
the message alone, so a re-issued run, a stale cache or a mirror running behind can answer
|
|
433
|
+
the ordered span with a well-formed message of the same length from a *different* run and
|
|
434
|
+
pass. Section 1 carries the reference time, and for a forecast product that reference time
|
|
435
|
+
is the run -- so a message from another run says so about itself, in its own bytes.
|
|
436
|
+
|
|
437
|
+
Read, never decoded, and read defensively: ``check_grib_message`` runs first and has already
|
|
438
|
+
established framing, but the section header is re-measured here so a message whose Section 1
|
|
439
|
+
is shorter than its fields cannot index past its own end.
|
|
440
|
+
|
|
441
|
+
The significance byte is required to say "start of forecast". A message whose reference
|
|
442
|
+
time means a verifying time or an observation time is refused rather than compared against a
|
|
443
|
+
cycle it does not denote -- an approximate comparison here is worse than none, because it
|
|
444
|
+
would report a run binding that is not one.
|
|
445
|
+
|
|
446
|
+
Scope, stated so it is not read wider: this binds a slice to a *run*, not to an object. A
|
|
447
|
+
different object belonging to the same run still passes.
|
|
448
|
+
|
|
449
|
+
A refusal names the check that failed and quotes neither the payload nor the expected run.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
expected = require_reference_time_spelling(expected)
|
|
453
|
+
if not isinstance(payload, bytes):
|
|
454
|
+
raise AcquisitionSecurityError("GRIB_PAYLOAD", "a slice must be checked as exact bytes")
|
|
455
|
+
if len(payload) < GRIB_SECTION_ONE_OFFSET + GRIB_SECTION_ONE_MINIMUM_BYTES + len(GRIB_TRAILER):
|
|
456
|
+
raise AcquisitionSecurityError(
|
|
457
|
+
"GRIB_SECTION_ONE",
|
|
458
|
+
"the slice is too short to carry an identification section",
|
|
459
|
+
)
|
|
460
|
+
section_length = int.from_bytes(payload[GRIB_SECTION_ONE_LENGTH_SLICE], "big")
|
|
461
|
+
if section_length < GRIB_SECTION_ONE_MINIMUM_BYTES:
|
|
462
|
+
raise AcquisitionSecurityError(
|
|
463
|
+
"GRIB_SECTION_ONE",
|
|
464
|
+
f"an identification section is at least {GRIB_SECTION_ONE_MINIMUM_BYTES} bytes",
|
|
465
|
+
)
|
|
466
|
+
if GRIB_SECTION_ONE_OFFSET + section_length > len(payload) - len(GRIB_TRAILER):
|
|
467
|
+
raise AcquisitionSecurityError(
|
|
468
|
+
"GRIB_SECTION_ONE",
|
|
469
|
+
"the identification section declares more bytes than the message holds",
|
|
470
|
+
)
|
|
471
|
+
if payload[GRIB_SECTION_ONE_NUMBER_OFFSET] != GRIB_SECTION_ONE_NUMBER:
|
|
472
|
+
raise AcquisitionSecurityError(
|
|
473
|
+
"GRIB_SECTION_ONE",
|
|
474
|
+
f"the section after the header is not section {GRIB_SECTION_ONE_NUMBER}",
|
|
475
|
+
)
|
|
476
|
+
if payload[GRIB_REFERENCE_SIGNIFICANCE_OFFSET] != GRIB_REFERENCE_START_OF_FORECAST:
|
|
477
|
+
raise AcquisitionSecurityError(
|
|
478
|
+
"GRIB_REFERENCE_SIGNIFICANCE",
|
|
479
|
+
"the message's reference time does not denote the start of a forecast run",
|
|
480
|
+
)
|
|
481
|
+
# Rendered into the one spelling and compared as text rather than built into an instant: a
|
|
482
|
+
# hostile month of 13 or day of 99 must fail this comparison, not raise out of it.
|
|
483
|
+
delivered = "{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}Z".format(
|
|
484
|
+
int.from_bytes(payload[GRIB_REFERENCE_YEAR_SLICE], "big"),
|
|
485
|
+
payload[GRIB_REFERENCE_MONTH_OFFSET],
|
|
486
|
+
payload[GRIB_REFERENCE_DAY_OFFSET],
|
|
487
|
+
payload[GRIB_REFERENCE_HOUR_OFFSET],
|
|
488
|
+
payload[GRIB_REFERENCE_MINUTE_OFFSET],
|
|
489
|
+
payload[GRIB_REFERENCE_SECOND_OFFSET],
|
|
490
|
+
)
|
|
491
|
+
if delivered != expected:
|
|
492
|
+
raise AcquisitionSecurityError(
|
|
493
|
+
"GRIB_REFERENCE_TIME",
|
|
494
|
+
"the delivered message belongs to a model run other than the one ordered",
|
|
495
|
+
)
|