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,974 @@
|
|
|
1
|
+
"""Requester-bound Studio acquisition for hosts without the reviewed local clean room."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
from urllib.error import HTTPError
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from mostlyright.data_harness.acquisition.result_download import (
|
|
20
|
+
RESULT_CHUNK_BYTES,
|
|
21
|
+
ResultBytesVerifier,
|
|
22
|
+
ResultDownloadError,
|
|
23
|
+
ValidatedResultDownload,
|
|
24
|
+
validate_result_download,
|
|
25
|
+
)
|
|
26
|
+
from mostlyright.data_harness.canonical import (
|
|
27
|
+
CanonicalJSONError,
|
|
28
|
+
canonical_json_bytes,
|
|
29
|
+
canonical_sha256,
|
|
30
|
+
parse_foreign_json,
|
|
31
|
+
sha256_bytes,
|
|
32
|
+
)
|
|
33
|
+
from mostlyright.data_harness.formats import FORMAT_MEDIA_TYPES, FORMAT_SUFFIXES
|
|
34
|
+
from mostlyright.data_harness.hosted_crawler_protocol import (
|
|
35
|
+
CRAWLER_FAILURE_SCHEMA,
|
|
36
|
+
PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
|
|
37
|
+
HostedCrawlerProtocolError,
|
|
38
|
+
HostedCrawlerRequest,
|
|
39
|
+
parse_crawler_failure,
|
|
40
|
+
parse_crawler_result,
|
|
41
|
+
)
|
|
42
|
+
from mostlyright.data_harness.local_contracts import ContractError, validate_source_locator
|
|
43
|
+
from mostlyright.data_harness.readers.contracts import DECODE_FLAGS, ReaderPin
|
|
44
|
+
from mostlyright.data_harness.readers.registry import TOOLBOX
|
|
45
|
+
from mostlyright.data_harness.studio_boundary import (
|
|
46
|
+
StudioBoundaryError,
|
|
47
|
+
StudioClientIncompatibleError,
|
|
48
|
+
ensure_studio_client,
|
|
49
|
+
studio_wire_dict,
|
|
50
|
+
)
|
|
51
|
+
from mostlyright.data_harness.ux.credentials import ResolvedCloudCredentials, StoredCredentials
|
|
52
|
+
from mostlyright.data_harness.ux.login import DeviceFlowTransport, UrlLibDeviceFlowTransport
|
|
53
|
+
|
|
54
|
+
TOKEN_EXCHANGE_PATH = "/api/cli/public-acquisition-token"
|
|
55
|
+
TOKEN_EXCHANGE_MAX_BYTES = 16 * 1024
|
|
56
|
+
POLL_TIMEOUT_SECONDS = 1800.0
|
|
57
|
+
# A transferred result smaller than this never touches the disk; a larger one spills to a temporary
|
|
58
|
+
# file that is unlinked before a byte is written to it. Eight mebibytes is the threshold the
|
|
59
|
+
# viewer's spool already uses, and a hosted result is either a small refusal bundle or a very large
|
|
60
|
+
# one, so the value only decides which side of that gap the common cases land on.
|
|
61
|
+
DOWNLOAD_SPOOL_BYTES = 8 * 1024 * 1024
|
|
62
|
+
# One sentence for the three transfer bounds it covers, so the status, media-type and length
|
|
63
|
+
# refusals stay indistinguishable to a caller no matter which of them fired.
|
|
64
|
+
_DOWNLOAD_INVALID_DETAIL = (
|
|
65
|
+
"the hosted acquisition result violated its media type, status, or byte bound"
|
|
66
|
+
)
|
|
67
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
68
|
+
_FAILURE_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,127}$")
|
|
69
|
+
_BARE_FILENAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
|
|
70
|
+
_RECEIPT_FIELDS = {
|
|
71
|
+
"schema_version",
|
|
72
|
+
"source_id",
|
|
73
|
+
"adapter_id",
|
|
74
|
+
"adapter_version",
|
|
75
|
+
"request_digest",
|
|
76
|
+
"source_url",
|
|
77
|
+
"final_url",
|
|
78
|
+
"fetched_media_type",
|
|
79
|
+
"normalized_media_type",
|
|
80
|
+
"normalized_data_format",
|
|
81
|
+
"normalized_filename",
|
|
82
|
+
"acquired_at",
|
|
83
|
+
"raw_content_sha256",
|
|
84
|
+
"raw_size_bytes",
|
|
85
|
+
"normalized_content_sha256",
|
|
86
|
+
"normalized_size_bytes",
|
|
87
|
+
"row_count",
|
|
88
|
+
"column_names",
|
|
89
|
+
"parsed_schema_digest",
|
|
90
|
+
"family_id",
|
|
91
|
+
"family_version",
|
|
92
|
+
"decode_options_digest",
|
|
93
|
+
"decode_flags",
|
|
94
|
+
"resource_caps",
|
|
95
|
+
"reader_budgets",
|
|
96
|
+
"transport_evidence_digest",
|
|
97
|
+
"egress_policy_attestation",
|
|
98
|
+
"clean_room",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HostedAcquisitionError(RuntimeError):
|
|
103
|
+
"""One hosted acquisition boundary failed with a safe, stable code."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, code: str, detail: str, *, retry_after_seconds: int | None = None) -> None:
|
|
106
|
+
self.code = code
|
|
107
|
+
self.detail = detail
|
|
108
|
+
self.retry_after_seconds = retry_after_seconds
|
|
109
|
+
super().__init__(f"{detail} [{code}]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _failure_code(value: Any, fallback: str) -> str:
|
|
113
|
+
"""Admit Studio's bounded worker-result code, never an arbitrary failure body."""
|
|
114
|
+
|
|
115
|
+
return value if isinstance(value, str) and _FAILURE_CODE.fullmatch(value) else fallback
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class StudioToken:
|
|
120
|
+
base_url: str
|
|
121
|
+
workspace_id: UUID
|
|
122
|
+
token: str
|
|
123
|
+
expires_at: datetime
|
|
124
|
+
crawler_results_bucket: str | None = None
|
|
125
|
+
principal_id: UUID | None = None
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return (
|
|
129
|
+
f"StudioToken(base_url={self.base_url!r}, workspace_id={self.workspace_id!r}, "
|
|
130
|
+
f"expires_at={self.expires_at!r}, token='<redacted>')"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class HostedAcquisition:
|
|
136
|
+
content: bytes
|
|
137
|
+
source_url: str
|
|
138
|
+
final_url: str
|
|
139
|
+
media_type: str
|
|
140
|
+
data_format: str
|
|
141
|
+
raw_content_sha256: str
|
|
142
|
+
transport_evidence_digest: str
|
|
143
|
+
parsed_schema_digest: str
|
|
144
|
+
family_id: str | None
|
|
145
|
+
family_version: str | None
|
|
146
|
+
decode_options_digest: str | None
|
|
147
|
+
decode_flags: tuple[str, ...]
|
|
148
|
+
egress_policy_attestation: str
|
|
149
|
+
request_digest: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class StudioExchange(Protocol):
|
|
153
|
+
def acquire(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
workspace_id: UUID,
|
|
157
|
+
source_id: str,
|
|
158
|
+
source_authority_digest: str,
|
|
159
|
+
acquisition_attempt_id: str,
|
|
160
|
+
query: Mapping[str, Any],
|
|
161
|
+
) -> tuple[Mapping[str, Any], bytes]: ...
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _https_origin(value: Any, label: str) -> str:
|
|
165
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 2_048 or value.endswith("/"):
|
|
166
|
+
raise HostedAcquisitionError(
|
|
167
|
+
"HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
parsed = urlsplit(value)
|
|
171
|
+
port = parsed.port
|
|
172
|
+
except ValueError as error:
|
|
173
|
+
raise HostedAcquisitionError(
|
|
174
|
+
"HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
|
|
175
|
+
) from error
|
|
176
|
+
if (
|
|
177
|
+
parsed.scheme != "https"
|
|
178
|
+
or not parsed.hostname
|
|
179
|
+
or parsed.username is not None
|
|
180
|
+
or parsed.password is not None
|
|
181
|
+
or parsed.path not in {"", "/"}
|
|
182
|
+
or parsed.query
|
|
183
|
+
or parsed.fragment
|
|
184
|
+
or port not in {None, 443}
|
|
185
|
+
):
|
|
186
|
+
raise HostedAcquisitionError(
|
|
187
|
+
"HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
|
|
188
|
+
)
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def exchange_studio_token(
|
|
193
|
+
credentials: StoredCredentials | ResolvedCloudCredentials,
|
|
194
|
+
*,
|
|
195
|
+
transport: DeviceFlowTransport | None = None,
|
|
196
|
+
) -> StudioToken:
|
|
197
|
+
"""Exchange the selected cloud key without ever sending it to Studio."""
|
|
198
|
+
|
|
199
|
+
cloud = _https_origin(credentials.cloud_url, "configured cloud URL")
|
|
200
|
+
try:
|
|
201
|
+
status, raw = (transport or UrlLibDeviceFlowTransport()).request(
|
|
202
|
+
"POST",
|
|
203
|
+
cloud + TOKEN_EXCHANGE_PATH,
|
|
204
|
+
{
|
|
205
|
+
"Authorization": f"Bearer {credentials.raw_key}",
|
|
206
|
+
"Accept": "application/json",
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
},
|
|
209
|
+
b"{}",
|
|
210
|
+
TOKEN_EXCHANGE_MAX_BYTES,
|
|
211
|
+
)
|
|
212
|
+
except Exception as error:
|
|
213
|
+
raise HostedAcquisitionError(
|
|
214
|
+
"HOSTED_TOKEN_EXCHANGE_UNAVAILABLE",
|
|
215
|
+
"the Mostly Right cloud could not prepare a short-lived Studio credential",
|
|
216
|
+
) from error
|
|
217
|
+
try:
|
|
218
|
+
value = parse_foreign_json(raw)
|
|
219
|
+
except CanonicalJSONError as error:
|
|
220
|
+
raise HostedAcquisitionError(
|
|
221
|
+
"HOSTED_TOKEN_EXCHANGE_INVALID",
|
|
222
|
+
"the Mostly Right cloud returned an invalid Studio credential response",
|
|
223
|
+
) from error
|
|
224
|
+
if (
|
|
225
|
+
status != 200
|
|
226
|
+
or not isinstance(value, dict)
|
|
227
|
+
or set(value)
|
|
228
|
+
!= {
|
|
229
|
+
"studio_base_url",
|
|
230
|
+
"workspace_id",
|
|
231
|
+
"token",
|
|
232
|
+
"expires_at",
|
|
233
|
+
"crawler_results_bucket",
|
|
234
|
+
"studio_principal_id",
|
|
235
|
+
}
|
|
236
|
+
):
|
|
237
|
+
if status == 401 and value == {"error": "missing or invalid access key"}:
|
|
238
|
+
code = "HOSTED_AUTHENTICATION_REQUIRED"
|
|
239
|
+
elif status == 402 and value == {"error": "subscription required"}:
|
|
240
|
+
code = "HOSTED_ENTITLEMENT_REQUIRED"
|
|
241
|
+
else:
|
|
242
|
+
code = "HOSTED_TOKEN_EXCHANGE_UNAVAILABLE"
|
|
243
|
+
retry_after_seconds = None
|
|
244
|
+
if (
|
|
245
|
+
status == 503
|
|
246
|
+
and isinstance(value, dict)
|
|
247
|
+
and set(value) == {"error", "retry_after_seconds"}
|
|
248
|
+
and value.get("error") == "temporarily unavailable"
|
|
249
|
+
and isinstance(value.get("retry_after_seconds"), int)
|
|
250
|
+
and not isinstance(value.get("retry_after_seconds"), bool)
|
|
251
|
+
and 1 <= value["retry_after_seconds"] <= 3_600
|
|
252
|
+
):
|
|
253
|
+
retry_after_seconds = value["retry_after_seconds"]
|
|
254
|
+
retry_detail = (
|
|
255
|
+
f"; retry after {retry_after_seconds} seconds"
|
|
256
|
+
if retry_after_seconds is not None
|
|
257
|
+
else ""
|
|
258
|
+
)
|
|
259
|
+
raise HostedAcquisitionError(
|
|
260
|
+
code,
|
|
261
|
+
"the Mostly Right cloud did not issue a usable Studio credential" + retry_detail,
|
|
262
|
+
retry_after_seconds=retry_after_seconds,
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
workspace_id = UUID(value["workspace_id"])
|
|
266
|
+
principal_id = UUID(value["studio_principal_id"])
|
|
267
|
+
expires_at = datetime.fromisoformat(value["expires_at"].replace("Z", "+00:00"))
|
|
268
|
+
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
|
269
|
+
raise HostedAcquisitionError(
|
|
270
|
+
"HOSTED_TOKEN_EXCHANGE_INVALID",
|
|
271
|
+
"the Mostly Right cloud returned an invalid Studio credential response",
|
|
272
|
+
) from error
|
|
273
|
+
token = value["token"]
|
|
274
|
+
results_bucket = value["crawler_results_bucket"]
|
|
275
|
+
if (
|
|
276
|
+
not isinstance(token, str)
|
|
277
|
+
or not 32 <= len(token) <= 16_384
|
|
278
|
+
or expires_at.tzinfo is None
|
|
279
|
+
or expires_at.astimezone(UTC) <= datetime.now(UTC)
|
|
280
|
+
or (
|
|
281
|
+
results_bucket is not None
|
|
282
|
+
and (
|
|
283
|
+
not isinstance(results_bucket, str)
|
|
284
|
+
or re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]", results_bucket) is None
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
):
|
|
288
|
+
raise HostedAcquisitionError(
|
|
289
|
+
"HOSTED_TOKEN_EXCHANGE_INVALID",
|
|
290
|
+
"the Mostly Right cloud returned an invalid Studio credential response",
|
|
291
|
+
)
|
|
292
|
+
return StudioToken(
|
|
293
|
+
base_url=_https_origin(value["studio_base_url"], "Studio base URL"),
|
|
294
|
+
workspace_id=workspace_id,
|
|
295
|
+
token=token,
|
|
296
|
+
expires_at=expires_at.astimezone(UTC),
|
|
297
|
+
crawler_results_bucket=results_bucket,
|
|
298
|
+
principal_id=principal_id,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
303
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _download_bundle(download: ValidatedResultDownload) -> bytes:
|
|
308
|
+
"""Transfer, bound, and verify one hosted result without holding a second copy of it.
|
|
309
|
+
|
|
310
|
+
The transfer used to be a single ``response.read(download.size + 1)``. That asks the runtime
|
|
311
|
+
for one contiguous allocation sized by a number Studio supplied -- up to
|
|
312
|
+
``MAX_CRAWLER_RESULT_BYTES`` -- before any check has run, and on a chunked response
|
|
313
|
+
``http.client`` satisfies it by accumulating every chunk in a list and joining it, so the
|
|
314
|
+
2.6 GB result that opened issue #204 peaked at roughly twice itself in the interpreter heap
|
|
315
|
+
with nothing verified yet. Every check that ran on that buffer is accumulative, so the result
|
|
316
|
+
is now transferred in ``RESULT_CHUNK_BYTES`` units into a spool while
|
|
317
|
+
:class:`ResultBytesVerifier` folds each unit into the length and the pinned integrity value.
|
|
318
|
+
|
|
319
|
+
The verdict is unchanged in what it accepts. The same ``size + 1`` transfer bound still
|
|
320
|
+
detects a longer body, the status and media type still gate, the length is still decided
|
|
321
|
+
before the integrity value is, and acceptance still requires the exact generation-pinned
|
|
322
|
+
digest -- through the same code ``verify_result_bytes`` runs. What changed is when the
|
|
323
|
+
process has to be able to hold the result: a refused download now never materialises it at
|
|
324
|
+
all, and an accepted one materialises it once, at the end, from bytes already verified.
|
|
325
|
+
|
|
326
|
+
Status and media type are read from the head and refused before the body is transferred
|
|
327
|
+
rather than after it. The refusal is the same ``HOSTED_DOWNLOAD_INVALID`` it was; only the
|
|
328
|
+
quantity of untrusted bytes pulled across before it fires changes, from the whole body to
|
|
329
|
+
none.
|
|
330
|
+
|
|
331
|
+
The spool is a :class:`tempfile.SpooledTemporaryFile`, so a result under
|
|
332
|
+
``DOWNLOAD_SPOOL_BYTES`` stays in memory -- the residency claims above are about results
|
|
333
|
+
larger than that, which is every result the claims matter for -- and a larger one rolls over
|
|
334
|
+
to a ``tempfile.TemporaryFile``, which is unlinked at creation. A partial or refused
|
|
335
|
+
transfer therefore leaves nothing behind on any exit path, including a crash: there is no
|
|
336
|
+
name for it to be left under.
|
|
337
|
+
|
|
338
|
+
What the spill buys is bounded by where it lands. The staging area is the temporary
|
|
339
|
+
directory, so that filesystem must have room for one result -- up to
|
|
340
|
+
``MAX_CRAWLER_RESULT_BYTES`` -- and a ``TMPDIR`` on a memory-backed filesystem stages in RAM,
|
|
341
|
+
which keeps the transfer bounded and the refusal cheap but gives back most of the accepted
|
|
342
|
+
case's saving. A staging write that cannot complete is refused with its own detail rather
|
|
343
|
+
than the transport one, because a full disk and a dead connection are different things to be
|
|
344
|
+
told.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
opener = build_opener(
|
|
348
|
+
ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
|
|
349
|
+
)
|
|
350
|
+
request = Request(download.url, method="GET", headers={"Accept": download.media_type})
|
|
351
|
+
try:
|
|
352
|
+
response = opener.open(request, timeout=30.0)
|
|
353
|
+
except HTTPError as error:
|
|
354
|
+
response = error
|
|
355
|
+
except Exception as error:
|
|
356
|
+
raise HostedAcquisitionError(
|
|
357
|
+
"HOSTED_DOWNLOAD_FAILED", "the hosted acquisition result could not be downloaded"
|
|
358
|
+
) from error
|
|
359
|
+
# The response is the outermost context deliberately: nothing constructed after the open --
|
|
360
|
+
# not the verifier, not the spool -- can strand the socket by raising before a `with` that
|
|
361
|
+
# would have closed it.
|
|
362
|
+
with response:
|
|
363
|
+
media_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
364
|
+
status = int(response.status)
|
|
365
|
+
if status != 200 or media_type != download.media_type:
|
|
366
|
+
raise HostedAcquisitionError("HOSTED_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
|
|
367
|
+
verifier = ResultBytesVerifier(download)
|
|
368
|
+
with tempfile.SpooledTemporaryFile(max_size=DOWNLOAD_SPOOL_BYTES, mode="w+b") as spool:
|
|
369
|
+
remaining = download.size + 1
|
|
370
|
+
while remaining > 0:
|
|
371
|
+
# Each guard covers one call and no more. A wider one would rewrite the code of
|
|
372
|
+
# any refusal that later moves inside this loop into the transport code below.
|
|
373
|
+
try:
|
|
374
|
+
chunk = response.read(min(RESULT_CHUNK_BYTES, remaining))
|
|
375
|
+
except Exception as error:
|
|
376
|
+
raise HostedAcquisitionError(
|
|
377
|
+
"HOSTED_DOWNLOAD_FAILED",
|
|
378
|
+
"the hosted acquisition result could not be downloaded",
|
|
379
|
+
) from error
|
|
380
|
+
if not chunk:
|
|
381
|
+
break
|
|
382
|
+
try:
|
|
383
|
+
spool.write(chunk)
|
|
384
|
+
except OSError as error:
|
|
385
|
+
raise HostedAcquisitionError(
|
|
386
|
+
"HOSTED_DOWNLOAD_FAILED",
|
|
387
|
+
"the hosted acquisition result could not be staged for verification",
|
|
388
|
+
) from error
|
|
389
|
+
remaining -= len(chunk)
|
|
390
|
+
verifier.update(chunk)
|
|
391
|
+
if verifier.length != download.size:
|
|
392
|
+
raise HostedAcquisitionError("HOSTED_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
|
|
393
|
+
try:
|
|
394
|
+
verifier.verify()
|
|
395
|
+
except ResultDownloadError as error:
|
|
396
|
+
raise HostedAcquisitionError(
|
|
397
|
+
"HOSTED_DOWNLOAD_INTEGRITY",
|
|
398
|
+
"the hosted acquisition result violated its generation-pinned integrity",
|
|
399
|
+
) from error
|
|
400
|
+
spool.seek(0)
|
|
401
|
+
return spool.read()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class GeneratedStudioExchange:
|
|
405
|
+
"""The generated-client-only Studio HTTP boundary plus signed-result transfer."""
|
|
406
|
+
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
token: StudioToken,
|
|
410
|
+
*,
|
|
411
|
+
downloader: Callable[[ValidatedResultDownload], bytes] = _download_bundle,
|
|
412
|
+
clock: Callable[[], float] = time.monotonic,
|
|
413
|
+
utc_now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
414
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
415
|
+
) -> None:
|
|
416
|
+
self.token = token
|
|
417
|
+
self.downloader = downloader
|
|
418
|
+
self.clock = clock
|
|
419
|
+
self.utc_now = utc_now
|
|
420
|
+
self.sleep = sleep
|
|
421
|
+
try:
|
|
422
|
+
ensure_studio_client()
|
|
423
|
+
client_module = importlib.import_module("mostlyright_studio.client")
|
|
424
|
+
self.create_endpoint = importlib.import_module(
|
|
425
|
+
"mostlyright_studio.api.sources.create_public_acquisition"
|
|
426
|
+
)
|
|
427
|
+
self.get_endpoint = importlib.import_module(
|
|
428
|
+
"mostlyright_studio.api.sources.get_public_acquisition"
|
|
429
|
+
)
|
|
430
|
+
self.models = importlib.import_module("mostlyright_studio.models")
|
|
431
|
+
self.client = client_module.AuthenticatedClient(
|
|
432
|
+
base_url=token.base_url,
|
|
433
|
+
token=token.token,
|
|
434
|
+
timeout=30.0,
|
|
435
|
+
follow_redirects=False,
|
|
436
|
+
)
|
|
437
|
+
except StudioBoundaryError as error:
|
|
438
|
+
raise HostedAcquisitionError(
|
|
439
|
+
"HOSTED_CLIENT_UNAVAILABLE",
|
|
440
|
+
"the installed Studio client cannot perform hosted public acquisition",
|
|
441
|
+
) from error
|
|
442
|
+
except (ModuleNotFoundError, AttributeError, TypeError):
|
|
443
|
+
incompatible = StudioClientIncompatibleError(
|
|
444
|
+
"generated Studio client lacks hosted public acquisition support"
|
|
445
|
+
)
|
|
446
|
+
raise HostedAcquisitionError(
|
|
447
|
+
"HOSTED_CLIENT_UNAVAILABLE",
|
|
448
|
+
"the installed Studio client cannot perform hosted public acquisition",
|
|
449
|
+
) from incompatible
|
|
450
|
+
|
|
451
|
+
def acquire(
|
|
452
|
+
self,
|
|
453
|
+
*,
|
|
454
|
+
workspace_id: UUID,
|
|
455
|
+
source_id: str,
|
|
456
|
+
source_authority_digest: str,
|
|
457
|
+
acquisition_attempt_id: str,
|
|
458
|
+
query: Mapping[str, Any],
|
|
459
|
+
) -> tuple[Mapping[str, Any], bytes]:
|
|
460
|
+
command_wire = {
|
|
461
|
+
"schema_version": "3.0.0",
|
|
462
|
+
"workspace_id": str(workspace_id),
|
|
463
|
+
"source_id": source_id,
|
|
464
|
+
"source_authority_digest": source_authority_digest,
|
|
465
|
+
"query": dict(query),
|
|
466
|
+
}
|
|
467
|
+
try:
|
|
468
|
+
command = self.models.CreateHostedPublicHTTPSAcquisitionCommand.from_dict(command_wire)
|
|
469
|
+
response = self.create_endpoint.sync_detailed(
|
|
470
|
+
workspace_id,
|
|
471
|
+
client=self.client,
|
|
472
|
+
body=command,
|
|
473
|
+
idempotency_key=_acquisition_idempotency_key(
|
|
474
|
+
workspace_id=workspace_id,
|
|
475
|
+
source_authority_digest=source_authority_digest,
|
|
476
|
+
acquisition_attempt_id=acquisition_attempt_id,
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
except Exception as error:
|
|
480
|
+
raise HostedAcquisitionError(
|
|
481
|
+
"HOSTED_REQUEST_INVALID", "the hosted acquisition request was not admitted"
|
|
482
|
+
) from error
|
|
483
|
+
if int(response.status_code) != 202 or type(response.parsed).__name__ != (
|
|
484
|
+
"HostedPublicHTTPSAcquisitionSession"
|
|
485
|
+
):
|
|
486
|
+
if type(response.parsed).__name__ in {"StableAPIError", "StudioV3Error"}:
|
|
487
|
+
error_wire = studio_wire_dict(response.parsed)
|
|
488
|
+
error_code = error_wire.get("code")
|
|
489
|
+
retry_after = error_wire.get("retry_after_seconds")
|
|
490
|
+
if retry_after is None:
|
|
491
|
+
header = response.headers.get("Retry-After")
|
|
492
|
+
if header is not None and header.isascii() and header.isdecimal():
|
|
493
|
+
retry_after = int(header)
|
|
494
|
+
if (
|
|
495
|
+
isinstance(error_code, str)
|
|
496
|
+
and error_code
|
|
497
|
+
and (
|
|
498
|
+
retry_after is None
|
|
499
|
+
or (
|
|
500
|
+
isinstance(retry_after, int)
|
|
501
|
+
and not isinstance(retry_after, bool)
|
|
502
|
+
and 1 <= retry_after <= 3_600
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
):
|
|
506
|
+
retry_detail = (
|
|
507
|
+
f"; retry after {retry_after} seconds" if retry_after is not None else ""
|
|
508
|
+
)
|
|
509
|
+
raise HostedAcquisitionError(
|
|
510
|
+
error_code,
|
|
511
|
+
"Studio refused the hosted public acquisition request" + retry_detail,
|
|
512
|
+
retry_after_seconds=retry_after,
|
|
513
|
+
)
|
|
514
|
+
raise HostedAcquisitionError(
|
|
515
|
+
"HOSTED_REQUEST_REFUSED", "Studio refused the hosted public acquisition request"
|
|
516
|
+
)
|
|
517
|
+
session = studio_wire_dict(response.parsed)
|
|
518
|
+
_validate_initial_session(
|
|
519
|
+
session,
|
|
520
|
+
workspace_id=workspace_id,
|
|
521
|
+
source_id=source_id,
|
|
522
|
+
source_authority_digest=source_authority_digest,
|
|
523
|
+
principal_id=self.token.principal_id,
|
|
524
|
+
query=query,
|
|
525
|
+
now=self.utc_now(),
|
|
526
|
+
)
|
|
527
|
+
deadline = self.clock() + POLL_TIMEOUT_SECONDS
|
|
528
|
+
delay = 0.5
|
|
529
|
+
while session["status"] not in {"result_ready", "failed"}:
|
|
530
|
+
if self.clock() >= deadline:
|
|
531
|
+
raise HostedAcquisitionError(
|
|
532
|
+
"HOSTED_ACQUISITION_TIMEOUT", "the hosted public acquisition did not finish"
|
|
533
|
+
)
|
|
534
|
+
self.sleep(delay)
|
|
535
|
+
delay = min(delay * 2, 5.0)
|
|
536
|
+
try:
|
|
537
|
+
observed = self.get_endpoint.sync_detailed(
|
|
538
|
+
workspace_id,
|
|
539
|
+
UUID(session["crawler_session_id"]),
|
|
540
|
+
client=self.client,
|
|
541
|
+
)
|
|
542
|
+
except Exception as error:
|
|
543
|
+
raise HostedAcquisitionError(
|
|
544
|
+
"HOSTED_POLL_UNAVAILABLE",
|
|
545
|
+
"the hosted acquisition status could not be checked",
|
|
546
|
+
) from error
|
|
547
|
+
if int(observed.status_code) != 200 or type(observed.parsed).__name__ != (
|
|
548
|
+
"HostedPublicHTTPSAcquisitionSession"
|
|
549
|
+
):
|
|
550
|
+
raise HostedAcquisitionError(
|
|
551
|
+
"HOSTED_POLL_REFUSED", "Studio refused the hosted acquisition status check"
|
|
552
|
+
)
|
|
553
|
+
next_session = studio_wire_dict(observed.parsed)
|
|
554
|
+
for field in (
|
|
555
|
+
"schema_version",
|
|
556
|
+
"crawler_session_id",
|
|
557
|
+
"workspace_id",
|
|
558
|
+
"requester_principal_id",
|
|
559
|
+
"source_id",
|
|
560
|
+
"source_authority_digest",
|
|
561
|
+
"adapter_id",
|
|
562
|
+
"adapter_version",
|
|
563
|
+
"request_id",
|
|
564
|
+
"request_digest",
|
|
565
|
+
"egress_policy_attestation",
|
|
566
|
+
"created_at",
|
|
567
|
+
"expires_at",
|
|
568
|
+
):
|
|
569
|
+
if next_session[field] != session[field]:
|
|
570
|
+
raise HostedAcquisitionError(
|
|
571
|
+
"HOSTED_SESSION_BINDING",
|
|
572
|
+
"Studio changed an immutable hosted acquisition coordinate",
|
|
573
|
+
)
|
|
574
|
+
session = next_session
|
|
575
|
+
if session["status"] == "failed":
|
|
576
|
+
raise HostedAcquisitionError(
|
|
577
|
+
_failure_code(session.get("failure_code"), "HOSTED_ACQUISITION_FAILED"),
|
|
578
|
+
"the hosted public acquisition failed inside the isolated Courier",
|
|
579
|
+
)
|
|
580
|
+
try:
|
|
581
|
+
validated_download = validate_result_download(
|
|
582
|
+
session.get("result_download"),
|
|
583
|
+
base_url=self.token.base_url,
|
|
584
|
+
crawler_session_id=session["crawler_session_id"],
|
|
585
|
+
request_digest=session["request_digest"],
|
|
586
|
+
source_id=source_id,
|
|
587
|
+
source_authority_digest=source_authority_digest,
|
|
588
|
+
now=self.utc_now(),
|
|
589
|
+
expected_gcs_bucket=self.token.crawler_results_bucket,
|
|
590
|
+
)
|
|
591
|
+
except (KeyError, ResultDownloadError) as error:
|
|
592
|
+
raise HostedAcquisitionError(
|
|
593
|
+
"HOSTED_DOWNLOAD_BINDING", "Studio returned an unbound result download"
|
|
594
|
+
) from error
|
|
595
|
+
return session, self.downloader(validated_download)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _validate_initial_session(
|
|
599
|
+
session: Mapping[str, Any],
|
|
600
|
+
*,
|
|
601
|
+
workspace_id: UUID,
|
|
602
|
+
source_id: str,
|
|
603
|
+
source_authority_digest: str,
|
|
604
|
+
principal_id: UUID | None,
|
|
605
|
+
query: Mapping[str, Any],
|
|
606
|
+
now: datetime,
|
|
607
|
+
) -> None:
|
|
608
|
+
"""Refuse an unbound Studio session before polling or touching its download URL."""
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
crawler_session_id = UUID(session["crawler_session_id"])
|
|
612
|
+
requester_principal_id = UUID(session["requester_principal_id"])
|
|
613
|
+
request_id = UUID(session["request_id"])
|
|
614
|
+
created = datetime.fromisoformat(session["created_at"].replace("Z", "+00:00"))
|
|
615
|
+
expires = datetime.fromisoformat(session["expires_at"].replace("Z", "+00:00"))
|
|
616
|
+
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
|
617
|
+
raise HostedAcquisitionError(
|
|
618
|
+
"HOSTED_SESSION_BINDING", "Studio returned an invalid hosted acquisition session"
|
|
619
|
+
) from error
|
|
620
|
+
if (
|
|
621
|
+
session.get("schema_version") != "mostlyright-public-acquisition-session.v1"
|
|
622
|
+
or session.get("workspace_id") != str(workspace_id)
|
|
623
|
+
or session.get("source_id") != source_id
|
|
624
|
+
or session.get("source_authority_digest") != source_authority_digest
|
|
625
|
+
or session.get("adapter_id") != "public.https"
|
|
626
|
+
or session.get("adapter_version") != "1.0.0"
|
|
627
|
+
or session.get("egress_policy_attestation") != PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION
|
|
628
|
+
or str(crawler_session_id) != session.get("crawler_session_id")
|
|
629
|
+
or str(requester_principal_id) != session.get("requester_principal_id")
|
|
630
|
+
or principal_id is None
|
|
631
|
+
or requester_principal_id != principal_id
|
|
632
|
+
or str(request_id) != session.get("request_id")
|
|
633
|
+
or not isinstance(session.get("request_digest"), str)
|
|
634
|
+
or _DIGEST.fullmatch(session["request_digest"]) is None
|
|
635
|
+
or session.get("status") not in {"enqueue_pending", "enqueued", "result_ready", "failed"}
|
|
636
|
+
or created.tzinfo is None
|
|
637
|
+
or expires.tzinfo is None
|
|
638
|
+
or created.astimezone(UTC) >= expires.astimezone(UTC)
|
|
639
|
+
or now.astimezone(UTC) >= expires.astimezone(UTC)
|
|
640
|
+
):
|
|
641
|
+
raise HostedAcquisitionError(
|
|
642
|
+
"HOSTED_SESSION_BINDING", "Studio returned an unbound hosted acquisition session"
|
|
643
|
+
)
|
|
644
|
+
try:
|
|
645
|
+
expected_request = HostedCrawlerRequest(
|
|
646
|
+
request_id=str(request_id),
|
|
647
|
+
source_id=source_id,
|
|
648
|
+
adapter_id="public.https",
|
|
649
|
+
adapter_version="1.0.0",
|
|
650
|
+
requested_at=session["created_at"],
|
|
651
|
+
query=dict(query),
|
|
652
|
+
egress_policy_attestation=PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
|
|
653
|
+
)
|
|
654
|
+
except (HostedCrawlerProtocolError, TypeError, ValueError) as error:
|
|
655
|
+
raise HostedAcquisitionError(
|
|
656
|
+
"HOSTED_SESSION_BINDING", "the exact hosted Courier request is invalid"
|
|
657
|
+
) from error
|
|
658
|
+
if expected_request.digest != session["request_digest"]:
|
|
659
|
+
raise HostedAcquisitionError(
|
|
660
|
+
"HOSTED_SESSION_BINDING", "Studio changed the exact hosted Courier request"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def acquire_public_https(
|
|
665
|
+
*,
|
|
666
|
+
exchange: StudioExchange,
|
|
667
|
+
workspace_id: UUID,
|
|
668
|
+
source_id: str,
|
|
669
|
+
source_authority_digest: str,
|
|
670
|
+
acquisition_attempt_id: str,
|
|
671
|
+
query: Mapping[str, Any],
|
|
672
|
+
) -> HostedAcquisition:
|
|
673
|
+
"""Acquire and independently bind the exact Studio session, result, and Reader evidence."""
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
expected_url = validate_source_locator(
|
|
677
|
+
query.get("url"), "https_url", "hosted_acquisition.query.url"
|
|
678
|
+
)
|
|
679
|
+
except ContractError as error:
|
|
680
|
+
raise HostedAcquisitionError(
|
|
681
|
+
"HOSTED_RESULT_BINDING",
|
|
682
|
+
"the hosted public acquisition locator is not credential-free and canonical",
|
|
683
|
+
) from error
|
|
684
|
+
if urlsplit(expected_url).port is not None:
|
|
685
|
+
raise HostedAcquisitionError(
|
|
686
|
+
"HOSTED_RESULT_BINDING",
|
|
687
|
+
"the hosted public acquisition locator must use implicit HTTPS port 443",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
session, bundle = exchange.acquire(
|
|
691
|
+
workspace_id=workspace_id,
|
|
692
|
+
source_id=source_id,
|
|
693
|
+
source_authority_digest=source_authority_digest,
|
|
694
|
+
acquisition_attempt_id=acquisition_attempt_id,
|
|
695
|
+
query=query,
|
|
696
|
+
)
|
|
697
|
+
if (
|
|
698
|
+
session.get("workspace_id") != str(workspace_id)
|
|
699
|
+
or session.get("source_id") != source_id
|
|
700
|
+
or session.get("source_authority_digest") != source_authority_digest
|
|
701
|
+
or session.get("adapter_id") != "public.https"
|
|
702
|
+
or session.get("adapter_version") != "1.0.0"
|
|
703
|
+
or session.get("egress_policy_attestation") != PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION
|
|
704
|
+
):
|
|
705
|
+
raise HostedAcquisitionError(
|
|
706
|
+
"HOSTED_SESSION_BINDING", "hosted acquisition session differs from the request"
|
|
707
|
+
)
|
|
708
|
+
try:
|
|
709
|
+
document = parse_foreign_json(bundle)
|
|
710
|
+
if isinstance(document, dict) and document.get("schema_version") == CRAWLER_FAILURE_SCHEMA:
|
|
711
|
+
failure = parse_crawler_failure(document)
|
|
712
|
+
if (
|
|
713
|
+
failure.request_digest != session.get("request_digest")
|
|
714
|
+
or failure.source_id != source_id
|
|
715
|
+
or failure.adapter_id != "public.https"
|
|
716
|
+
or failure.adapter_version != "1.0.0"
|
|
717
|
+
):
|
|
718
|
+
raise HostedAcquisitionError(
|
|
719
|
+
"HOSTED_RESULT_BINDING", "hosted Courier failure differs from its session"
|
|
720
|
+
)
|
|
721
|
+
raise HostedAcquisitionError(
|
|
722
|
+
failure.failure_code,
|
|
723
|
+
"the hosted public acquisition was safely refused inside the isolated Courier",
|
|
724
|
+
)
|
|
725
|
+
result = parse_crawler_result(document)
|
|
726
|
+
except HostedAcquisitionError:
|
|
727
|
+
raise
|
|
728
|
+
except (CanonicalJSONError, HostedCrawlerProtocolError) as error:
|
|
729
|
+
raise HostedAcquisitionError(
|
|
730
|
+
"HOSTED_RESULT_INVALID", "hosted Courier result is not a valid bound bundle"
|
|
731
|
+
) from error
|
|
732
|
+
if (
|
|
733
|
+
result.request_digest != session.get("request_digest")
|
|
734
|
+
or result.source_id != source_id
|
|
735
|
+
or result.adapter_id != "public.https"
|
|
736
|
+
or result.adapter_version != "1.0.0"
|
|
737
|
+
):
|
|
738
|
+
raise HostedAcquisitionError(
|
|
739
|
+
"HOSTED_RESULT_BINDING", "hosted Courier result differs from its Studio session"
|
|
740
|
+
)
|
|
741
|
+
receipt = result.acquisition_receipt
|
|
742
|
+
reader_value = query.get("reader_pin")
|
|
743
|
+
pin = None
|
|
744
|
+
family = None
|
|
745
|
+
if reader_value is not None and not isinstance(reader_value, Mapping):
|
|
746
|
+
raise HostedAcquisitionError(
|
|
747
|
+
"HOSTED_READER_BINDING", "hosted acquisition Reader pin is invalid"
|
|
748
|
+
)
|
|
749
|
+
if isinstance(reader_value, Mapping):
|
|
750
|
+
try:
|
|
751
|
+
pin = ReaderPin(
|
|
752
|
+
reader_value["family_id"],
|
|
753
|
+
reader_value["family_version"],
|
|
754
|
+
reader_value["decode_options"],
|
|
755
|
+
)
|
|
756
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
757
|
+
raise HostedAcquisitionError(
|
|
758
|
+
"HOSTED_READER_BINDING", "hosted acquisition Reader pin is invalid"
|
|
759
|
+
) from error
|
|
760
|
+
expected_decode = (
|
|
761
|
+
(None, None, None)
|
|
762
|
+
if pin is None
|
|
763
|
+
else (pin.family_id, pin.family_version, pin.options_digest)
|
|
764
|
+
)
|
|
765
|
+
returned_decode = (
|
|
766
|
+
receipt.get("family_id"),
|
|
767
|
+
receipt.get("family_version"),
|
|
768
|
+
receipt.get("decode_options_digest"),
|
|
769
|
+
)
|
|
770
|
+
resource_caps = query.get("resource_caps")
|
|
771
|
+
if pin is None:
|
|
772
|
+
if resource_caps is not None:
|
|
773
|
+
raise HostedAcquisitionError(
|
|
774
|
+
"HOSTED_READER_BINDING", "resource caps require a hosted Reader pin"
|
|
775
|
+
)
|
|
776
|
+
expected_resource_caps = None
|
|
777
|
+
expected_reader_budgets = None
|
|
778
|
+
else:
|
|
779
|
+
if resource_caps is not None and not isinstance(resource_caps, Mapping):
|
|
780
|
+
raise HostedAcquisitionError(
|
|
781
|
+
"HOSTED_READER_BINDING", "hosted Reader resource caps are invalid"
|
|
782
|
+
)
|
|
783
|
+
try:
|
|
784
|
+
family = TOOLBOX.resolve(pin.family_id, pin.family_version)
|
|
785
|
+
expected_reader_budgets = asdict(
|
|
786
|
+
family.default_budgets.narrowed_by(
|
|
787
|
+
{
|
|
788
|
+
"max_input_bytes": query["limits"]["max_source_bytes"],
|
|
789
|
+
"max_output_bytes": query["limits"]["max_normalized_bytes"],
|
|
790
|
+
"max_rows": query["limits"]["max_rows"],
|
|
791
|
+
"max_columns": query["limits"]["max_columns"],
|
|
792
|
+
**({} if resource_caps is None else dict(resource_caps)),
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
797
|
+
raise HostedAcquisitionError(
|
|
798
|
+
"HOSTED_READER_BINDING", "hosted Reader budgets are invalid"
|
|
799
|
+
) from error
|
|
800
|
+
expected_resource_caps = None if resource_caps is None else dict(resource_caps)
|
|
801
|
+
try:
|
|
802
|
+
final_url = validate_source_locator(
|
|
803
|
+
receipt.get("final_url"), "https_url", "hosted_acquisition.receipt.final_url"
|
|
804
|
+
)
|
|
805
|
+
original_parsed = urlsplit(expected_url)
|
|
806
|
+
final_parsed = urlsplit(final_url)
|
|
807
|
+
original_host = original_parsed.hostname
|
|
808
|
+
final_host = final_parsed.hostname
|
|
809
|
+
except (ContractError, ValueError) as error:
|
|
810
|
+
raise HostedAcquisitionError(
|
|
811
|
+
"HOSTED_RESULT_BINDING", "hosted Courier returned an invalid final locator"
|
|
812
|
+
) from error
|
|
813
|
+
limits = query.get("limits")
|
|
814
|
+
if not isinstance(limits, Mapping):
|
|
815
|
+
raise HostedAcquisitionError("HOSTED_RESULT_BINDING", "hosted limits are unavailable")
|
|
816
|
+
data_format = query.get("data_format")
|
|
817
|
+
filename = query.get("filename")
|
|
818
|
+
if data_format not in FORMAT_MEDIA_TYPES or not isinstance(filename, str):
|
|
819
|
+
raise HostedAcquisitionError(
|
|
820
|
+
"HOSTED_RESULT_BINDING", "hosted output format coordinates are unavailable"
|
|
821
|
+
)
|
|
822
|
+
expected_fetched_media_types = (
|
|
823
|
+
FORMAT_MEDIA_TYPES[data_format]
|
|
824
|
+
if family is None
|
|
825
|
+
else frozenset(family.accepted_media_types)
|
|
826
|
+
)
|
|
827
|
+
expected_output_format = data_format if family is None else family.output_format
|
|
828
|
+
normalized_filename = receipt.get("normalized_filename")
|
|
829
|
+
decode_flags = receipt.get("decode_flags")
|
|
830
|
+
try:
|
|
831
|
+
acquired_at = datetime.fromisoformat(receipt.get("acquired_at", "").replace("Z", "+00:00"))
|
|
832
|
+
session_created_at = datetime.fromisoformat(session["created_at"].replace("Z", "+00:00"))
|
|
833
|
+
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
|
834
|
+
raise HostedAcquisitionError(
|
|
835
|
+
"HOSTED_RESULT_BINDING", "hosted Courier returned an invalid acquisition time"
|
|
836
|
+
) from error
|
|
837
|
+
required_receipt = {
|
|
838
|
+
"schema_version": "hosted-public-https-acquisition-receipt.v1",
|
|
839
|
+
"source_id": source_id,
|
|
840
|
+
"adapter_id": "public.https",
|
|
841
|
+
"adapter_version": "1.0.0",
|
|
842
|
+
"request_digest": result.request_digest,
|
|
843
|
+
"source_url": expected_url,
|
|
844
|
+
"normalized_content_sha256": sha256_bytes(result.normalized_content),
|
|
845
|
+
"normalized_size_bytes": len(result.normalized_content),
|
|
846
|
+
"normalized_data_format": expected_output_format,
|
|
847
|
+
"egress_policy_attestation": PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
|
|
848
|
+
}
|
|
849
|
+
if (
|
|
850
|
+
set(receipt) != _RECEIPT_FIELDS
|
|
851
|
+
or any(receipt.get(key) != value for key, value in required_receipt.items())
|
|
852
|
+
or original_host is None
|
|
853
|
+
or final_host != original_host
|
|
854
|
+
or original_parsed.port is not None
|
|
855
|
+
or final_parsed.port is not None
|
|
856
|
+
or acquired_at.tzinfo is None
|
|
857
|
+
or session_created_at.tzinfo is None
|
|
858
|
+
or acquired_at.astimezone(UTC) != session_created_at.astimezone(UTC)
|
|
859
|
+
or receipt.get("fetched_media_type") not in expected_fetched_media_types
|
|
860
|
+
or receipt.get("normalized_media_type") not in FORMAT_MEDIA_TYPES[expected_output_format]
|
|
861
|
+
or not isinstance(normalized_filename, str)
|
|
862
|
+
or _BARE_FILENAME.fullmatch(normalized_filename) is None
|
|
863
|
+
or not any(
|
|
864
|
+
normalized_filename.endswith(suffix)
|
|
865
|
+
for suffix in FORMAT_SUFFIXES[expected_output_format]
|
|
866
|
+
)
|
|
867
|
+
or (family is None and normalized_filename != filename)
|
|
868
|
+
or returned_decode != expected_decode
|
|
869
|
+
or receipt.get("resource_caps") != expected_resource_caps
|
|
870
|
+
or receipt.get("reader_budgets") != expected_reader_budgets
|
|
871
|
+
or type(receipt.get("normalized_size_bytes")) is not int
|
|
872
|
+
or type(receipt.get("raw_size_bytes")) is not int
|
|
873
|
+
or not 1 <= receipt["raw_size_bytes"] <= limits.get("max_source_bytes", 0)
|
|
874
|
+
# The normalized decode output is bounded by its own budget, not the fetched-byte
|
|
875
|
+
# budget, with the same fallback the query composition uses.
|
|
876
|
+
or not 1
|
|
877
|
+
<= len(result.normalized_content)
|
|
878
|
+
<= limits.get("max_normalized_bytes", limits.get("max_source_bytes", 0))
|
|
879
|
+
or not isinstance(receipt.get("raw_content_sha256"), str)
|
|
880
|
+
or _DIGEST.fullmatch(receipt["raw_content_sha256"]) is None
|
|
881
|
+
or not isinstance(receipt.get("transport_evidence_digest"), str)
|
|
882
|
+
or _DIGEST.fullmatch(receipt["transport_evidence_digest"]) is None
|
|
883
|
+
or not isinstance(receipt.get("parsed_schema_digest"), str)
|
|
884
|
+
or _DIGEST.fullmatch(receipt["parsed_schema_digest"]) is None
|
|
885
|
+
or type(receipt.get("row_count")) is not int
|
|
886
|
+
or not 1 <= receipt["row_count"] <= limits.get("max_rows", 0)
|
|
887
|
+
or not isinstance(receipt.get("column_names"), list)
|
|
888
|
+
or not 1 <= len(receipt["column_names"]) <= limits.get("max_columns", 0)
|
|
889
|
+
or not all(
|
|
890
|
+
isinstance(column, str) and 1 <= len(column) <= 255
|
|
891
|
+
for column in receipt["column_names"]
|
|
892
|
+
)
|
|
893
|
+
or receipt.get("clean_room")
|
|
894
|
+
!= {
|
|
895
|
+
"host_platform": "linux-amd64",
|
|
896
|
+
"parser_network_syscalls": "denied",
|
|
897
|
+
"parser_process_creation": "denied",
|
|
898
|
+
"no_new_privs": True,
|
|
899
|
+
}
|
|
900
|
+
or not isinstance(decode_flags, list)
|
|
901
|
+
or not all(isinstance(flag, str) and flag in DECODE_FLAGS for flag in decode_flags)
|
|
902
|
+
or decode_flags != sorted(set(decode_flags))
|
|
903
|
+
or (family is None and decode_flags != [])
|
|
904
|
+
or (
|
|
905
|
+
pin is None
|
|
906
|
+
and (
|
|
907
|
+
receipt["raw_content_sha256"] != receipt["normalized_content_sha256"]
|
|
908
|
+
or receipt["raw_size_bytes"] != receipt["normalized_size_bytes"]
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
):
|
|
912
|
+
raise HostedAcquisitionError(
|
|
913
|
+
"HOSTED_RESULT_BINDING", "hosted Courier evidence differs from the exact request"
|
|
914
|
+
)
|
|
915
|
+
return HostedAcquisition(
|
|
916
|
+
content=result.normalized_content,
|
|
917
|
+
source_url=receipt["source_url"],
|
|
918
|
+
final_url=receipt["final_url"],
|
|
919
|
+
media_type=receipt["normalized_media_type"],
|
|
920
|
+
data_format=receipt["normalized_data_format"],
|
|
921
|
+
raw_content_sha256=receipt["raw_content_sha256"],
|
|
922
|
+
transport_evidence_digest=receipt["transport_evidence_digest"],
|
|
923
|
+
parsed_schema_digest=receipt["parsed_schema_digest"],
|
|
924
|
+
family_id=receipt["family_id"],
|
|
925
|
+
family_version=receipt["family_version"],
|
|
926
|
+
decode_options_digest=receipt["decode_options_digest"],
|
|
927
|
+
decode_flags=tuple(receipt["decode_flags"]),
|
|
928
|
+
egress_policy_attestation=receipt["egress_policy_attestation"],
|
|
929
|
+
request_digest=result.request_digest,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def source_authority_digest(source_id: str, query: Mapping[str, Any]) -> str:
|
|
934
|
+
return sha256_bytes(
|
|
935
|
+
canonical_json_bytes(
|
|
936
|
+
{
|
|
937
|
+
"schema_version": "mr-data-hosted-public-source-authority.v1",
|
|
938
|
+
"source_id": source_id,
|
|
939
|
+
"adapter_id": "public.https",
|
|
940
|
+
"adapter_version": "1.0.0",
|
|
941
|
+
"query": dict(query),
|
|
942
|
+
}
|
|
943
|
+
)
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _acquisition_idempotency_key(
|
|
948
|
+
*,
|
|
949
|
+
workspace_id: UUID,
|
|
950
|
+
source_authority_digest: str,
|
|
951
|
+
acquisition_attempt_id: str,
|
|
952
|
+
) -> str:
|
|
953
|
+
"""Retry one attempt consistently without replaying an expired prior acquisition."""
|
|
954
|
+
|
|
955
|
+
return "mr-data-acquire-" + canonical_sha256(
|
|
956
|
+
{
|
|
957
|
+
"schema_version": "hosted-public-acquisition-idempotency.v1",
|
|
958
|
+
"workspace_id": str(workspace_id),
|
|
959
|
+
"source_authority_digest": source_authority_digest,
|
|
960
|
+
"acquisition_attempt_id": acquisition_attempt_id,
|
|
961
|
+
}
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
__all__ = [
|
|
966
|
+
"GeneratedStudioExchange",
|
|
967
|
+
"HostedAcquisition",
|
|
968
|
+
"HostedAcquisitionError",
|
|
969
|
+
"StudioExchange",
|
|
970
|
+
"StudioToken",
|
|
971
|
+
"acquire_public_https",
|
|
972
|
+
"exchange_studio_token",
|
|
973
|
+
"source_authority_digest",
|
|
974
|
+
]
|