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,406 @@
|
|
|
1
|
+
"""The hosted lane behind the command names people already type.
|
|
2
|
+
|
|
3
|
+
Each function here returns one payload mapping and prints nothing. That is the convention
|
|
4
|
+
``ux.commands.CommandSpec`` already establishes for every auto-discovered subcommand, and it is
|
|
5
|
+
what lets :mod:`mostlyright.data_harness.thin.router` decide plain lines or ``--json`` in one
|
|
6
|
+
place for the whole lane.
|
|
7
|
+
|
|
8
|
+
WHAT THE PAYLOADS PROMISE. Every fact in them comes out of one Studio response or out of the
|
|
9
|
+
submission this command just made. Nothing is derived from a clock, no percentage is manufactured,
|
|
10
|
+
and no progress record is presented as evidence -- the unsealed stream says what it measured and
|
|
11
|
+
the sealed candidate evidence says what was proved, and the two are reported under separate keys
|
|
12
|
+
so a reader cannot mistake one for the other.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from uuid import UUID
|
|
23
|
+
|
|
24
|
+
from mostlyright.data_harness.formats import FORMAT_MEDIA_TYPES, FORMAT_SUFFIXES
|
|
25
|
+
from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
|
|
26
|
+
from mostlyright.data_harness.thin.download import download_signed_artifact
|
|
27
|
+
from mostlyright.data_harness.thin.runs import RESOURCE_CLASSES, StudioRunClient
|
|
28
|
+
from mostlyright.data_harness.thin.session import (
|
|
29
|
+
StudioSession,
|
|
30
|
+
open_studio_session,
|
|
31
|
+
run_dashboard_url,
|
|
32
|
+
)
|
|
33
|
+
from mostlyright.data_harness.thin.stream import TERMINAL_RUN_STATUSES, RunEventStream
|
|
34
|
+
from mostlyright.data_harness.thin.transport import ThinLaneError
|
|
35
|
+
from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
|
|
36
|
+
|
|
37
|
+
SUBMIT_SCHEMA = f"{THIN_SCHEMA_PREFIX}-submission.v1"
|
|
38
|
+
WATCH_SCHEMA = f"{THIN_SCHEMA_PREFIX}-watch.v1"
|
|
39
|
+
STATUS_SCHEMA = f"{THIN_SCHEMA_PREFIX}-run-status.v1"
|
|
40
|
+
DOWNLOAD_SCHEMA = f"{THIN_SCHEMA_PREFIX}-download.v1"
|
|
41
|
+
|
|
42
|
+
#: The artifact kinds ``deploy-dataset`` brings back by default: the dataset itself, and nothing
|
|
43
|
+
#: else. A run also produces evidence, previews, profiles and lineage; those are fetched only when
|
|
44
|
+
#: a caller names them, because a folder that quietly filled with nine files is not a dataset.
|
|
45
|
+
DEFAULT_DOWNLOAD_KINDS = ("table_package", "candidate_parquet")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _session(args: argparse.Namespace) -> StudioSession:
|
|
49
|
+
"""Resolve this machine's credential and open a short-lived Studio session."""
|
|
50
|
+
|
|
51
|
+
credentials = resolve_cloud_credentials()
|
|
52
|
+
return open_studio_session(credentials)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _client(args: argparse.Namespace) -> StudioRunClient:
|
|
56
|
+
return StudioRunClient(_session(args))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_id(value: str) -> str:
|
|
60
|
+
"""Refuse anything that is not a run identifier before it reaches a URL path."""
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return str(UUID(value))
|
|
64
|
+
except ValueError as error:
|
|
65
|
+
raise ThinLaneError("THIN_REQUEST_INVALID", f"{value!r} is not a run identifier") from error
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_receipt(run: Mapping[str, Any]) -> dict[str, Any]:
|
|
69
|
+
"""The Run fields a person reads, exactly as Studio reported them."""
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
key: run[key]
|
|
73
|
+
for key in (
|
|
74
|
+
"run_id",
|
|
75
|
+
"status",
|
|
76
|
+
"kind",
|
|
77
|
+
"resource_class",
|
|
78
|
+
"table_id",
|
|
79
|
+
"dataset_id",
|
|
80
|
+
"recipe_digest",
|
|
81
|
+
"recipe_version",
|
|
82
|
+
"created_at",
|
|
83
|
+
"started_at",
|
|
84
|
+
"completed_at",
|
|
85
|
+
"failure_code",
|
|
86
|
+
)
|
|
87
|
+
if key in run
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def submit(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
|
|
92
|
+
"""Queue one hosted initial build and say where to watch it.
|
|
93
|
+
|
|
94
|
+
Backs ``mr-data build`` and ``mr-data run`` in the hosted lane. The two names stay distinct
|
|
95
|
+
everywhere else in this product and mean the same thing here, because a hosted submission is
|
|
96
|
+
one act: there is no local workbench to build in and no local plan to run.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
selected = client or _client(args)
|
|
100
|
+
try:
|
|
101
|
+
run = selected.start_initial_build(
|
|
102
|
+
expected_recipe_digest=args.recipe_digest,
|
|
103
|
+
recipe_proposal_id=UUID(args.recipe_proposal),
|
|
104
|
+
resource_class=getattr(args, "resource_class", None),
|
|
105
|
+
)
|
|
106
|
+
except ThinLaneError as refusal:
|
|
107
|
+
started = _already_started(selected, args, refusal)
|
|
108
|
+
if started is None:
|
|
109
|
+
raise
|
|
110
|
+
return started
|
|
111
|
+
run_id = run.get("run_id")
|
|
112
|
+
if not isinstance(run_id, str):
|
|
113
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "Studio queued a run without an identifier")
|
|
114
|
+
return {
|
|
115
|
+
"schema_version": SUBMIT_SCHEMA,
|
|
116
|
+
"status": "run_queued",
|
|
117
|
+
"lane": "hosted",
|
|
118
|
+
"dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
|
|
119
|
+
"watch_command": f"mr-data watch {run_id} --hosted",
|
|
120
|
+
"run": run_receipt(run),
|
|
121
|
+
"studio": selected.session.to_receipt(),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _already_started(
|
|
126
|
+
client: StudioRunClient, args: argparse.Namespace, refusal: ThinLaneError
|
|
127
|
+
) -> dict[str, Any] | None:
|
|
128
|
+
"""The run an approval already started, when that is why the submission was refused.
|
|
129
|
+
|
|
130
|
+
Since Studio #124 a person confirming a Table recipe in the dashboard can start its first
|
|
131
|
+
hosted Build in the same transaction. The proposal is then ``activation_started``, and
|
|
132
|
+
``POST /v3/runs`` refuses it with ``RELEASE_GATE_BLOCKED`` -- before it reaches the activation
|
|
133
|
+
receipt that would otherwise replay the run. So a build command printed before the person
|
|
134
|
+
decided is not wrong, it is late: the run it asked for exists. This reads the proposal, and
|
|
135
|
+
if the approval started the Build, reads that Build back and reports it as already queued. Any
|
|
136
|
+
other refusal, or a proposal in any other state, is the refusal it was.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
if refusal.code != "THIN_STUDIO_RELEASE_GATE_BLOCKED":
|
|
140
|
+
return None
|
|
141
|
+
proposal = client.recipe_proposal(args.recipe_proposal)
|
|
142
|
+
approval_request_id = proposal.get("approval_request_id")
|
|
143
|
+
if proposal.get("status") != "activation_started" or not isinstance(approval_request_id, str):
|
|
144
|
+
return None
|
|
145
|
+
if proposal.get("recipe_digest") != args.recipe_digest:
|
|
146
|
+
# The run that exists was started from a different recipe than the one asked for, which
|
|
147
|
+
# is the refusal Studio gave, not a run to hand back.
|
|
148
|
+
return None
|
|
149
|
+
started = client.approval_start(approval_request_id)
|
|
150
|
+
if started is None:
|
|
151
|
+
return None
|
|
152
|
+
run = started.get("run")
|
|
153
|
+
run_id = run.get("run_id") if isinstance(run, dict) else None
|
|
154
|
+
if not isinstance(run_id, str):
|
|
155
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "Studio named a started Build without a run")
|
|
156
|
+
return {
|
|
157
|
+
"schema_version": SUBMIT_SCHEMA,
|
|
158
|
+
"status": "run_already_queued",
|
|
159
|
+
"lane": "hosted",
|
|
160
|
+
"approval_request_id": approval_request_id,
|
|
161
|
+
"dashboard_url": run_dashboard_url(client.session.cloud_url, run_id),
|
|
162
|
+
"watch_command": f"mr-data watch {run_id} --hosted",
|
|
163
|
+
"run": run_receipt(run),
|
|
164
|
+
"note": (
|
|
165
|
+
"The approval that confirmed this recipe started its first hosted Build in the same "
|
|
166
|
+
"act, so there was nothing left for this command to queue. This is that Build."
|
|
167
|
+
),
|
|
168
|
+
"studio": client.session.to_receipt(),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def watch(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
|
|
173
|
+
"""Stream one run's event log to the terminal, resuming across stream cuts.
|
|
174
|
+
|
|
175
|
+
Lines are written as they arrive rather than collected and printed at the end -- the point of
|
|
176
|
+
a live view is that it is live -- and the returned payload is the summary of what was seen, so
|
|
177
|
+
``--json`` gets one object and a person gets a timeline followed by that object.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
|
|
181
|
+
# a run id should cost a sentence, not a network round trip and a Studio 404.
|
|
182
|
+
run_id = _run_id(args.run_id)
|
|
183
|
+
selected = client or _client(args)
|
|
184
|
+
stream = RunEventStream(selected.session, run_id)
|
|
185
|
+
announce = getattr(args, "json", False) is False
|
|
186
|
+
|
|
187
|
+
def _write(line: str) -> None:
|
|
188
|
+
if announce:
|
|
189
|
+
print(line, flush=True)
|
|
190
|
+
|
|
191
|
+
if announce:
|
|
192
|
+
print(f"watching {run_id}", flush=True)
|
|
193
|
+
print(run_dashboard_url(selected.session.cloud_url, run_id), flush=True)
|
|
194
|
+
progress = stream.follow(
|
|
195
|
+
from_seq=int(getattr(args, "from_seq", 0) or 0),
|
|
196
|
+
last_event_id=getattr(args, "last_event_id", None),
|
|
197
|
+
on_line=_write,
|
|
198
|
+
)
|
|
199
|
+
run = selected.get_run(run_id)
|
|
200
|
+
return {
|
|
201
|
+
"schema_version": WATCH_SCHEMA,
|
|
202
|
+
"status": "run_terminal" if run.get("status") in TERMINAL_RUN_STATUSES else "watch_ended",
|
|
203
|
+
"lane": "hosted",
|
|
204
|
+
"dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
|
|
205
|
+
"run": run_receipt(run),
|
|
206
|
+
"stream": progress.to_receipt(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def status(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
|
|
211
|
+
"""Report Studio's own reading of one run, with its receipts when it has any.
|
|
212
|
+
|
|
213
|
+
The execution summary and the candidate evidence are read only once the run has something to
|
|
214
|
+
say: asking for candidate evidence on a queued run is a 404 by design, and reporting that 404
|
|
215
|
+
as a failure would be reporting the ordinary state of every new run as an error.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
# The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
|
|
219
|
+
# a run id should cost a sentence, not a network round trip and a Studio 404.
|
|
220
|
+
run_id = _run_id(args.run_id)
|
|
221
|
+
selected = client or _client(args)
|
|
222
|
+
run = selected.get_run(run_id)
|
|
223
|
+
payload: dict[str, Any] = {
|
|
224
|
+
"schema_version": STATUS_SCHEMA,
|
|
225
|
+
"status": "run_reported",
|
|
226
|
+
"lane": "hosted",
|
|
227
|
+
"dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
|
|
228
|
+
"run": run_receipt(run),
|
|
229
|
+
}
|
|
230
|
+
if not getattr(args, "receipts", False):
|
|
231
|
+
return payload
|
|
232
|
+
unavailable: dict[str, str] = {}
|
|
233
|
+
payload["execution_summary"] = optional_receipt(
|
|
234
|
+
"execution_summary", lambda: selected.execution_summary(run_id), unavailable
|
|
235
|
+
)
|
|
236
|
+
evidence = optional_receipt(
|
|
237
|
+
"candidate_evidence", lambda: selected.candidate_evidence(run_id), unavailable
|
|
238
|
+
)
|
|
239
|
+
if isinstance(evidence, Mapping):
|
|
240
|
+
payload["candidate_evidence"] = {
|
|
241
|
+
key: evidence[key]
|
|
242
|
+
for key in ("execution_scope_digest", "release_policy_digest", "admission")
|
|
243
|
+
if key in evidence
|
|
244
|
+
}
|
|
245
|
+
payload["artifacts"] = [
|
|
246
|
+
{
|
|
247
|
+
key: artifact[key]
|
|
248
|
+
for key in (
|
|
249
|
+
"artifact_id",
|
|
250
|
+
"kind",
|
|
251
|
+
"purpose",
|
|
252
|
+
"media_type",
|
|
253
|
+
"size_bytes",
|
|
254
|
+
"content_digest",
|
|
255
|
+
"classification",
|
|
256
|
+
)
|
|
257
|
+
if key in artifact
|
|
258
|
+
}
|
|
259
|
+
for artifact in artifact_records(evidence)
|
|
260
|
+
]
|
|
261
|
+
else:
|
|
262
|
+
payload["candidate_evidence"] = None
|
|
263
|
+
payload["artifacts"] = []
|
|
264
|
+
payload["receipts_unavailable"] = unavailable
|
|
265
|
+
return payload
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def download(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
|
|
269
|
+
"""Bring one released run's dataset back to this machine, verified.
|
|
270
|
+
|
|
271
|
+
Backs ``mr-data deploy-dataset`` in the hosted lane, which is the command that already means
|
|
272
|
+
"bring the Build a deployment released back here". Under the thin profile it brings back the
|
|
273
|
+
bytes and their proof; it does not write a dataset notebook, because rendering one is
|
|
274
|
+
server-side work under ADR 0021, and a locally rendered notebook beside a hosted build is
|
|
275
|
+
exactly the kind of second answer this architecture exists to remove.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
# The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
|
|
279
|
+
# a run id should cost a sentence, not a network round trip and a Studio 404.
|
|
280
|
+
run_id = _run_id(args.run_id)
|
|
281
|
+
selected = client or _client(args)
|
|
282
|
+
evidence = selected.candidate_evidence(run_id)
|
|
283
|
+
wanted = tuple(getattr(args, "kind", None) or DEFAULT_DOWNLOAD_KINDS)
|
|
284
|
+
output = Path(getattr(args, "output", None) or "hosted-dataset").expanduser()
|
|
285
|
+
selectedartifact_records = [
|
|
286
|
+
artifact for artifact in artifact_records(evidence) if artifact.get("kind") in wanted
|
|
287
|
+
]
|
|
288
|
+
if not selectedartifact_records:
|
|
289
|
+
available = sorted({str(item.get("kind")) for item in artifact_records(evidence)})
|
|
290
|
+
raise ThinLaneError(
|
|
291
|
+
"THIN_NO_MATCHING_ARTIFACT",
|
|
292
|
+
f"this run published no artifact of kind {', '.join(wanted)}"
|
|
293
|
+
+ (f"; it published {', '.join(available)}" if available else ""),
|
|
294
|
+
)
|
|
295
|
+
downloaded = []
|
|
296
|
+
for artifact in selectedartifact_records:
|
|
297
|
+
artifact_id = artifact.get("artifact_id")
|
|
298
|
+
if not isinstance(artifact_id, str):
|
|
299
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "an artifact carried no identifier")
|
|
300
|
+
session = selected.artifact_download_session(artifact_id)
|
|
301
|
+
destination = output / f"{artifact_id}{suffix_for(artifact.get('media_type'))}"
|
|
302
|
+
downloaded.append(download_signed_artifact(session, destination).to_receipt())
|
|
303
|
+
return {
|
|
304
|
+
"schema_version": DOWNLOAD_SCHEMA,
|
|
305
|
+
"status": "artifacts_downloaded",
|
|
306
|
+
"lane": "hosted",
|
|
307
|
+
"dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
|
|
308
|
+
"run_id": run_id,
|
|
309
|
+
"output": str(output),
|
|
310
|
+
"artifacts": downloaded,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def artifact_records(evidence: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
315
|
+
artifacts = evidence.get("artifacts")
|
|
316
|
+
if not isinstance(artifacts, list):
|
|
317
|
+
return []
|
|
318
|
+
return [item for item in artifacts if isinstance(item, Mapping)]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
#: Media type to file extension, derived from the one format table rather than restated. Both
|
|
322
|
+
#: halves come from :mod:`mostlyright.data_harness.formats`, so a format added there reaches a
|
|
323
|
+
#: downloaded file's name without anybody remembering this line exists.
|
|
324
|
+
SUFFIX_BY_MEDIA_TYPE = {
|
|
325
|
+
media_type: min(FORMAT_SUFFIXES[data_format])
|
|
326
|
+
for data_format, media_types in FORMAT_MEDIA_TYPES.items()
|
|
327
|
+
if data_format in FORMAT_SUFFIXES
|
|
328
|
+
for media_type in media_types
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def media_type_essence(media_type: Any) -> str:
|
|
333
|
+
"""The type/subtype half of a media type, with its parameters and its casing removed.
|
|
334
|
+
|
|
335
|
+
⚠ A MEDIA TYPE IS NOT A STRING TO COMPARE. RFC 9110 §8.3 makes everything after the first
|
|
336
|
+
``;`` a parameter list, and Studio sends ``text/html; charset=utf-8`` where this repository
|
|
337
|
+
had written ``text/html``. An equality test against the whole header therefore missed, the
|
|
338
|
+
rendered notebook was named ``.bin``, and nothing failed loudly -- which is exactly the shape
|
|
339
|
+
of defect a comparison against a parsed value cannot have. Type and subtype are
|
|
340
|
+
case-insensitive in that same section, so they are lowered here rather than at each caller.
|
|
341
|
+
|
|
342
|
+
Only the essence is returned. No caller in this lane acts on a parameter, and returning them
|
|
343
|
+
would invite the next comparison to be made against the whole string again.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
if not isinstance(media_type, str):
|
|
347
|
+
return ""
|
|
348
|
+
return media_type.partition(";")[0].strip().lower()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def suffix_for(media_type: Any) -> str:
|
|
352
|
+
"""A file extension chosen from the media type, and ``.bin`` when it is not one we name.
|
|
353
|
+
|
|
354
|
+
Deliberately the project's own table rather than ``mimetypes``: the stdlib mapping differs
|
|
355
|
+
between hosts, and a dataset whose extension depends on the machine that downloaded it is a
|
|
356
|
+
support question waiting to happen.
|
|
357
|
+
|
|
358
|
+
The lookup is on the parsed essence, so ``text/csv`` and ``text/csv; charset=utf-8`` name the
|
|
359
|
+
same file. The table's own keys carry no parameters, so this widens what is recognised and
|
|
360
|
+
can never change what an already-recognised type resolves to.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
return SUFFIX_BY_MEDIA_TYPE.get(media_type_essence(media_type), ".bin")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
#: The two refusals a receipt read may legitimately answer with, and what each one means.
|
|
367
|
+
#: ``THIN_NOT_FOUND`` is the ordinary state of every run that has not produced that record yet;
|
|
368
|
+
#: ``THIN_AUTHORIZATION_DENIED`` is a real answer too -- candidate evidence needs `review:read`,
|
|
369
|
+
#: which a viewer may not hold. Neither is reported as absence, because "there is nothing" and
|
|
370
|
+
#: "you may not see it" are different sentences and a reader must be told which one they got.
|
|
371
|
+
RECEIPT_UNAVAILABLE_CODES = ("THIN_AUTHORIZATION_DENIED", "THIN_NOT_FOUND")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def optional_receipt(name: str, call: Any, unavailable: dict[str, str]) -> Any:
|
|
375
|
+
"""Return what ``call`` answers, recording WHY it could not be read when it could not."""
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
return call()
|
|
379
|
+
except ThinLaneError as refusal:
|
|
380
|
+
if refusal.code in RECEIPT_UNAVAILABLE_CODES:
|
|
381
|
+
unavailable[name] = refusal.code
|
|
382
|
+
return None
|
|
383
|
+
raise
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def print_stderr(text: str) -> None:
|
|
387
|
+
print(text, file=sys.stderr, flush=True)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
__all__ = [
|
|
391
|
+
"DEFAULT_DOWNLOAD_KINDS",
|
|
392
|
+
"DOWNLOAD_SCHEMA",
|
|
393
|
+
"RECEIPT_UNAVAILABLE_CODES",
|
|
394
|
+
"RESOURCE_CLASSES",
|
|
395
|
+
"STATUS_SCHEMA",
|
|
396
|
+
"SUBMIT_SCHEMA",
|
|
397
|
+
"WATCH_SCHEMA",
|
|
398
|
+
"artifact_records",
|
|
399
|
+
"download",
|
|
400
|
+
"optional_receipt",
|
|
401
|
+
"run_receipt",
|
|
402
|
+
"status",
|
|
403
|
+
"submit",
|
|
404
|
+
"suffix_for",
|
|
405
|
+
"watch",
|
|
406
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Fetch one artifact through a signed URL, and refuse the bytes unless they are the bytes.
|
|
2
|
+
|
|
3
|
+
The signed URL is a short-lived bearer capability that points straight at object storage, so two
|
|
4
|
+
things are true at once: nothing here needs a Studio credential, and nothing here may trust what
|
|
5
|
+
comes back. Studio hands over ``expected_content_digest`` and ``expected_size_bytes`` alongside the
|
|
6
|
+
URL precisely so the client can settle that itself, and this module treats both as mandatory
|
|
7
|
+
rather than advisory -- a download whose digest does not match is deleted, not written with a
|
|
8
|
+
warning.
|
|
9
|
+
|
|
10
|
+
The file is written with ``O_EXCL``: a hosted download never overwrites something already on this
|
|
11
|
+
machine, because the one thing worse than not having the dataset is silently replacing a different
|
|
12
|
+
one with it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from mostlyright.data_harness.thin.transport import (
|
|
26
|
+
REQUEST_TIMEOUT_SECONDS,
|
|
27
|
+
ThinLaneError,
|
|
28
|
+
Transport,
|
|
29
|
+
UrlLibTransport,
|
|
30
|
+
signed_transfer_url,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: Read granularity. The same 64 KiB ``acquisition.result_download`` streams a crawl result at.
|
|
34
|
+
DOWNLOAD_CHUNK_BYTES = 65_536
|
|
35
|
+
|
|
36
|
+
#: A ceiling on any single artifact this lane will write, so a wrong ``expected_size_bytes`` cannot
|
|
37
|
+
#: fill the volume before the digest check gets a chance to refuse. Sixteen gibibytes is far above
|
|
38
|
+
#: any table this product builds and far below a disk.
|
|
39
|
+
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
_PREFIXED_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DownloadedArtifact:
|
|
46
|
+
"""What one verified download produced."""
|
|
47
|
+
|
|
48
|
+
path: Path
|
|
49
|
+
artifact_id: str
|
|
50
|
+
media_type: str
|
|
51
|
+
size_bytes: int
|
|
52
|
+
content_digest: str
|
|
53
|
+
|
|
54
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"path": str(self.path),
|
|
57
|
+
"artifact_id": self.artifact_id,
|
|
58
|
+
"media_type": self.media_type,
|
|
59
|
+
"size_bytes": self.size_bytes,
|
|
60
|
+
"content_digest": self.content_digest,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _required_headers(session: Mapping[str, Any]) -> dict[str, str]:
|
|
65
|
+
"""The headers the signature covers. Sending fewer, or more, invalidates it."""
|
|
66
|
+
|
|
67
|
+
declared = session.get("required_headers")
|
|
68
|
+
if declared is None:
|
|
69
|
+
return {}
|
|
70
|
+
if not isinstance(declared, list):
|
|
71
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "required_headers is not a list")
|
|
72
|
+
headers: dict[str, str] = {}
|
|
73
|
+
for item in declared:
|
|
74
|
+
if not isinstance(item, Mapping):
|
|
75
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "a required header is not an object")
|
|
76
|
+
name = item.get("name")
|
|
77
|
+
value = item.get("value")
|
|
78
|
+
if not isinstance(name, str) or not isinstance(value, str) or not name:
|
|
79
|
+
raise ThinLaneError(
|
|
80
|
+
"THIN_RESPONSE_INVALID", "a required header is not a name/value pair"
|
|
81
|
+
)
|
|
82
|
+
headers[name] = value
|
|
83
|
+
return headers
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def download_signed_artifact(
|
|
87
|
+
session: Mapping[str, Any],
|
|
88
|
+
destination: Path,
|
|
89
|
+
*,
|
|
90
|
+
transport: Transport | None = None,
|
|
91
|
+
) -> DownloadedArtifact:
|
|
92
|
+
"""Fetch the bytes one signed session points at, verify them, and write them once."""
|
|
93
|
+
|
|
94
|
+
url = signed_transfer_url(session.get("signed_url", ""), "the signed download URL")
|
|
95
|
+
expected_digest = session.get("expected_content_digest")
|
|
96
|
+
expected_size = session.get("expected_size_bytes")
|
|
97
|
+
if not isinstance(expected_digest, str) or _PREFIXED_DIGEST.fullmatch(expected_digest) is None:
|
|
98
|
+
raise ThinLaneError(
|
|
99
|
+
"THIN_RESPONSE_INVALID", "the signed session carried no sha256 content digest"
|
|
100
|
+
)
|
|
101
|
+
if not isinstance(expected_size, int) or isinstance(expected_size, bool) or expected_size < 0:
|
|
102
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", "the signed session carried no byte size")
|
|
103
|
+
if expected_size > MAX_ARTIFACT_BYTES:
|
|
104
|
+
raise ThinLaneError(
|
|
105
|
+
"THIN_ARTIFACT_TOO_LARGE",
|
|
106
|
+
f"the artifact declares {expected_size} bytes, above this lane's ceiling",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
selected = transport or UrlLibTransport()
|
|
110
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
112
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
113
|
+
flags |= os.O_NOFOLLOW
|
|
114
|
+
# Windows only. The artifact this writes is Parquet, and its digest is verified against the
|
|
115
|
+
# one the signed session declared -- so a CRLF translation on the way to disk would not be a
|
|
116
|
+
# cosmetic difference, it would be a download that always fails its own digest check.
|
|
117
|
+
flags |= getattr(os, "O_BINARY", 0)
|
|
118
|
+
try:
|
|
119
|
+
descriptor = os.open(destination, flags, 0o600)
|
|
120
|
+
except FileExistsError as error:
|
|
121
|
+
raise ThinLaneError(
|
|
122
|
+
"THIN_DESTINATION_EXISTS",
|
|
123
|
+
f"{destination} already exists; a hosted download never overwrites a local file",
|
|
124
|
+
) from error
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
handle = os.fdopen(descriptor, "wb", closefd=True)
|
|
128
|
+
except BaseException:
|
|
129
|
+
# `fdopen` owns the descriptor only once it succeeds, so this is the one window where
|
|
130
|
+
# closing it is still ours to do.
|
|
131
|
+
os.close(descriptor)
|
|
132
|
+
destination.unlink(missing_ok=True)
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
digest = hashlib.sha256()
|
|
136
|
+
written = 0
|
|
137
|
+
try:
|
|
138
|
+
# The file handle enters FIRST so that a stream that fails to open still closes it.
|
|
139
|
+
with (
|
|
140
|
+
handle,
|
|
141
|
+
selected.stream(url, _required_headers(session), REQUEST_TIMEOUT_SECONDS) as response,
|
|
142
|
+
):
|
|
143
|
+
status = int(getattr(response, "status", 200))
|
|
144
|
+
if status != 200:
|
|
145
|
+
raise ThinLaneError(
|
|
146
|
+
"THIN_DOWNLOAD_REFUSED",
|
|
147
|
+
f"the signed download answered HTTP {status}; the session may have expired",
|
|
148
|
+
)
|
|
149
|
+
while True:
|
|
150
|
+
chunk = response.read(DOWNLOAD_CHUNK_BYTES)
|
|
151
|
+
if not chunk:
|
|
152
|
+
break
|
|
153
|
+
written += len(chunk)
|
|
154
|
+
if written > expected_size:
|
|
155
|
+
raise ThinLaneError(
|
|
156
|
+
"THIN_DOWNLOAD_OVERRUN",
|
|
157
|
+
"the signed download returned more bytes than it declared",
|
|
158
|
+
)
|
|
159
|
+
digest.update(chunk)
|
|
160
|
+
handle.write(chunk)
|
|
161
|
+
if written != expected_size:
|
|
162
|
+
raise ThinLaneError(
|
|
163
|
+
"THIN_DOWNLOAD_TRUNCATED",
|
|
164
|
+
f"the signed download returned {written} of {expected_size} declared bytes",
|
|
165
|
+
)
|
|
166
|
+
observed = "sha256:" + digest.hexdigest()
|
|
167
|
+
if observed != expected_digest:
|
|
168
|
+
raise ThinLaneError(
|
|
169
|
+
"THIN_DOWNLOAD_DIGEST_MISMATCH",
|
|
170
|
+
"the downloaded bytes do not hash to the digest Studio declared",
|
|
171
|
+
)
|
|
172
|
+
except BaseException:
|
|
173
|
+
# Nothing partial and nothing unverified is left behind for a later command to mistake
|
|
174
|
+
# for a dataset.
|
|
175
|
+
destination.unlink(missing_ok=True)
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
media_type = session.get("media_type")
|
|
179
|
+
artifact_id = session.get("artifact_id")
|
|
180
|
+
return DownloadedArtifact(
|
|
181
|
+
path=destination,
|
|
182
|
+
artifact_id=artifact_id if isinstance(artifact_id, str) else "",
|
|
183
|
+
media_type=media_type if isinstance(media_type, str) else "application/octet-stream",
|
|
184
|
+
size_bytes=written,
|
|
185
|
+
content_digest=expected_digest,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"DOWNLOAD_CHUNK_BYTES",
|
|
191
|
+
"MAX_ARTIFACT_BYTES",
|
|
192
|
+
"DownloadedArtifact",
|
|
193
|
+
"download_signed_artifact",
|
|
194
|
+
]
|