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,676 @@
|
|
|
1
|
+
"""Exact public-crawler request/result protocol shared with hosted workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from mostlyright.data_harness.canonical import (
|
|
12
|
+
canonical_json_bytes,
|
|
13
|
+
canonical_sha256,
|
|
14
|
+
sha256_bytes,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CRAWLER_REQUEST_SCHEMA_V1 = "mostlyright-hosted-crawler-request.v1"
|
|
18
|
+
CRAWLER_REQUEST_SCHEMA_V3 = "mostlyright-hosted-crawler-request.v3"
|
|
19
|
+
CRAWLER_REQUEST_SCHEMA = CRAWLER_REQUEST_SCHEMA_V1
|
|
20
|
+
CRAWLER_RESULT_SCHEMA_V1 = "mostlyright-hosted-crawler-result.v1"
|
|
21
|
+
CRAWLER_RESULT_SCHEMA_V3 = "mostlyright-hosted-crawler-result.v3"
|
|
22
|
+
# Compatibility export for consumers that only understand the original OpenLigaDB/V1 result.
|
|
23
|
+
CRAWLER_RESULT_SCHEMA = CRAWLER_RESULT_SCHEMA_V1
|
|
24
|
+
CRAWLER_FAILURE_SCHEMA = "mostlyright-hosted-crawler-failure.v1"
|
|
25
|
+
CRAWLER_RESULT_MEDIA_TYPE = "application/vnd.mostlyright.hosted-crawler-result+json"
|
|
26
|
+
MAX_CRAWLER_REQUEST_BYTES = 64 * 1024
|
|
27
|
+
MAX_CRAWLER_RESULT_BYTES = 6_442_450_944
|
|
28
|
+
MAX_NORMALIZED_SOURCE_BYTES = 4_294_967_296
|
|
29
|
+
MAX_CRAWLER_BASE64URL_CHUNK_CHARS = 768 * 1024
|
|
30
|
+
OPENLIGADB_EGRESS_POLICY_ATTESTATION = canonical_sha256(
|
|
31
|
+
{
|
|
32
|
+
"schema_version": "mostlyright-hosted-crawler-egress-policy.v1",
|
|
33
|
+
"adapter_id": "external.openligadb",
|
|
34
|
+
"adapter_version": "1.0.0",
|
|
35
|
+
"credential_free": True,
|
|
36
|
+
"allowed_hostnames": ["api.openligadb.de"],
|
|
37
|
+
"allowed_ports": [443],
|
|
38
|
+
"allowed_path_template": "/getmatchdata/{league_shortcut}/{league_season}",
|
|
39
|
+
"redirects": 0,
|
|
40
|
+
"ambient_proxy": False,
|
|
41
|
+
"source_retriever_private_metadata_internal_networks": "denied",
|
|
42
|
+
"wrapper_metadata_identity": "fixed-link-local-google-oidc-exact-session-bootstrap",
|
|
43
|
+
"wrapper_studio_authority": "bootstrap-only-no-attempt-token",
|
|
44
|
+
"wrapper_dumpability": "disabled-before-workload-identity",
|
|
45
|
+
"parser_network_syscalls": "linux-amd64-no-new-privs-seccomp-deny",
|
|
46
|
+
"parser_x32_syscalls": "denied-before-native-allowlist",
|
|
47
|
+
"parser_process_creation_and_parent_inspection": "denied",
|
|
48
|
+
"parser_network_fds": "closed-before-exec",
|
|
49
|
+
"isolation": "separate-crawler-job-and-networkless-parser-child",
|
|
50
|
+
"max_response_bytes": 8 * 1024 * 1024,
|
|
51
|
+
"max_requests": 1,
|
|
52
|
+
"connect_timeout_milliseconds": 5_000,
|
|
53
|
+
"read_timeout_milliseconds": 15_000,
|
|
54
|
+
"total_timeout_milliseconds": 20_000,
|
|
55
|
+
"max_concurrency": 2,
|
|
56
|
+
"min_interval_milliseconds": 1_000,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
# The exact attested public-HTTPS egress policy. Named, rather than inlined into the
|
|
60
|
+
# digest call below, so a test can hold each attested figure against the constant that
|
|
61
|
+
# enforces it in hosted_crawler_fetch without restating the whole policy: the attestation
|
|
62
|
+
# must never claim an envelope the fetch path does not run under.
|
|
63
|
+
PUBLIC_HTTPS_EGRESS_POLICY: dict[str, Any] = {
|
|
64
|
+
"schema_version": "mostlyright-hosted-crawler-egress-policy.v1",
|
|
65
|
+
"adapter_id": "public.https",
|
|
66
|
+
"adapter_version": "1.0.0",
|
|
67
|
+
"credential_free": True,
|
|
68
|
+
"allowed_hostnames": "exact canonical hostname from the Studio-owned request",
|
|
69
|
+
"allowed_ports": [443],
|
|
70
|
+
"redirects": "at most 3; every hop stays on the exact hostname",
|
|
71
|
+
"ambient_proxy": False,
|
|
72
|
+
"dns_policy": "public addresses only; rebinding refused on every connection",
|
|
73
|
+
"source_retriever_private_metadata_internal_networks": "denied",
|
|
74
|
+
"wrapper_metadata_identity": "fixed-link-local-google-oidc-exact-session-bootstrap",
|
|
75
|
+
"wrapper_studio_authority": "bootstrap-only-no-user-or-attempt-token",
|
|
76
|
+
"wrapper_dumpability": "disabled-before-workload-identity",
|
|
77
|
+
"parser_network_syscalls": "linux-amd64-no-new-privs-seccomp-deny",
|
|
78
|
+
"parser_x32_syscalls": "denied-before-native-allowlist",
|
|
79
|
+
"parser_process_creation_and_parent_inspection": "denied",
|
|
80
|
+
"parser_network_fds": "closed-before-exec",
|
|
81
|
+
"isolation": "separate-crawler-job-and-networkless-parser-child",
|
|
82
|
+
# The enforced public-HTTPS fetch ceiling (hosted_crawler.MAX_RAW_RESPONSE_BYTES),
|
|
83
|
+
# equal to the hosted contract's max_source_bytes maximum. Decode output is bounded
|
|
84
|
+
# separately by MAX_NORMALIZED_SOURCE_BYTES.
|
|
85
|
+
"max_response_bytes": 268_435_456,
|
|
86
|
+
"max_requests": 4,
|
|
87
|
+
"connect_timeout_milliseconds": 5_000,
|
|
88
|
+
# Large public archives (EPA AirData) stall tens of seconds before the first
|
|
89
|
+
# byte for cloud egress addresses; the total budget, not the per-read stall,
|
|
90
|
+
# is the meaningful ceiling for a bulk fetch.
|
|
91
|
+
"read_timeout_milliseconds": 120_000,
|
|
92
|
+
"total_timeout_milliseconds": 600_000,
|
|
93
|
+
"max_concurrency": 2,
|
|
94
|
+
"min_interval_milliseconds": 1_000,
|
|
95
|
+
# A bulk object whose 0-0 probe names a total above threshold_bytes is fetched as
|
|
96
|
+
# consecutive range_bytes slices, at most `parallelism` in flight, each slice under
|
|
97
|
+
# its own tight stall timeouts with up to range_attempts fresh attempts. The
|
|
98
|
+
# total_timeout_milliseconds budget above bounds the ranged fetch at attempt
|
|
99
|
+
# admission: no new range attempt starts after it, and one already-admitted attempt
|
|
100
|
+
# may still run to range_total_timeout_milliseconds, so the wall-clock worst case is
|
|
101
|
+
# the sum of the two. range_validator_pinning means the ranged path runs only when
|
|
102
|
+
# the probe names an ETag or Last-Modified validator, and every range must answer
|
|
103
|
+
# with exactly the probed validators -- an origin that updates the object mid-fetch
|
|
104
|
+
# is refused rather than assembled across editions, and a validator-less origin keeps
|
|
105
|
+
# the single-shot path. Origins that ignore or refuse the range probe keep the
|
|
106
|
+
# single-shot behavior the rest of this policy describes, with the probe's own
|
|
107
|
+
# transfer sealed into the result's transport evidence.
|
|
108
|
+
"ranged_fetch": {
|
|
109
|
+
"threshold_bytes": 33_554_432,
|
|
110
|
+
"range_bytes": 16_777_216,
|
|
111
|
+
"parallelism": 2,
|
|
112
|
+
"range_read_timeout_milliseconds": 30_000,
|
|
113
|
+
"range_total_timeout_milliseconds": 120_000,
|
|
114
|
+
"range_attempts": 4,
|
|
115
|
+
"range_validator_pinning": True,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION = canonical_sha256(PUBLIC_HTTPS_EGRESS_POLICY)
|
|
119
|
+
|
|
120
|
+
# The OpenLigaDB query is validated twice against the same reviewed shape: once producer-side
|
|
121
|
+
# in ``hosted_worker`` before the intent leaves, and once crawler-side in ``hosted_crawler``
|
|
122
|
+
# before any egress. The two refusals keep their own codes (SOURCE_ADAPTER_QUERY_INVALID and
|
|
123
|
+
# CRAWLER_QUERY_INVALID) because they name different boundaries, but the detail text is shared
|
|
124
|
+
# so the two sides cannot drift into describing the same rejected query differently.
|
|
125
|
+
OPENLIGADB_QUERY_FIELDS_DETAIL = (
|
|
126
|
+
"OpenLigaDB query must contain only league_shortcut and league_season"
|
|
127
|
+
)
|
|
128
|
+
OPENLIGADB_QUERY_ALLOWLIST_DETAIL = "OpenLigaDB league or season is outside the reviewed allowlist"
|
|
129
|
+
|
|
130
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
131
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
132
|
+
_PREFIXED_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
133
|
+
_BASE64URL_CHUNK = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
134
|
+
_TIMESTAMP = re.compile(
|
|
135
|
+
r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])"
|
|
136
|
+
r"T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,6})?Z$"
|
|
137
|
+
)
|
|
138
|
+
HOSTED_CRAWLER_FAILURE_CODES = frozenset(
|
|
139
|
+
{
|
|
140
|
+
"HTTP_NOT_FOUND",
|
|
141
|
+
"HTTP_STATUS",
|
|
142
|
+
"MEDIA_TYPE",
|
|
143
|
+
"BYTE_LIMIT",
|
|
144
|
+
"TOTAL_TIMEOUT",
|
|
145
|
+
"CONNECT_TIMEOUT",
|
|
146
|
+
"READ_TIMEOUT",
|
|
147
|
+
"CONNECT_FAILED",
|
|
148
|
+
"TLS_FAILED",
|
|
149
|
+
"DNS_FAILED",
|
|
150
|
+
"REDIRECT_LIMIT",
|
|
151
|
+
"REDIRECT_POLICY",
|
|
152
|
+
"CRAWLER_ENDPOINT_MISMATCH",
|
|
153
|
+
"CRAWLER_MEDIA_TYPE_MISMATCH",
|
|
154
|
+
"CRAWLER_RESPONSE_INVALID",
|
|
155
|
+
"CRAWLER_NORMALIZATION_INPUT_INVALID",
|
|
156
|
+
"CRAWLER_MEMORY_BOUNDARY",
|
|
157
|
+
"READER_FAMILY_UNKNOWN",
|
|
158
|
+
"READER_DEPENDENCY_UNAVAILABLE",
|
|
159
|
+
"READER_CONTRACT",
|
|
160
|
+
"READER_OPTIONS",
|
|
161
|
+
"READER_ADMISSION",
|
|
162
|
+
"READER_BUDGET",
|
|
163
|
+
"READER_MEMBER_MISSING",
|
|
164
|
+
"READER_MEMBER_AMBIGUOUS",
|
|
165
|
+
"READER_MEMBER_TYPE",
|
|
166
|
+
"READER_NESTED_CONTAINER",
|
|
167
|
+
"READER_SHEET_MISSING",
|
|
168
|
+
"READER_RANGE",
|
|
169
|
+
"READER_GRID_UNKNOWN",
|
|
170
|
+
"READER_PACKING_UNKNOWN",
|
|
171
|
+
"READER_DECODE",
|
|
172
|
+
"READER_OUTPUT",
|
|
173
|
+
"READER_WARMUP",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class HostedCrawlerProtocolError(ValueError):
|
|
179
|
+
"""One closed crawler request/result invariant failed."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
182
|
+
self.code = code
|
|
183
|
+
self.detail = detail
|
|
184
|
+
super().__init__(f"{detail} [{code}]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _exact(value: Any, fields: set[str], label: str) -> dict[str, Any]:
|
|
188
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
189
|
+
raise HostedCrawlerProtocolError(
|
|
190
|
+
"CRAWLER_PROTOCOL_FIELDS",
|
|
191
|
+
f"{label} fields are not exact",
|
|
192
|
+
)
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _identifier(value: Any, label: str) -> str:
|
|
197
|
+
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
|
198
|
+
raise HostedCrawlerProtocolError(
|
|
199
|
+
"CRAWLER_PROTOCOL_IDENTIFIER",
|
|
200
|
+
f"{label} is not a canonical identifier",
|
|
201
|
+
)
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _timestamp(value: Any, label: str) -> str:
|
|
206
|
+
if not isinstance(value, str) or _TIMESTAMP.fullmatch(value) is None:
|
|
207
|
+
raise HostedCrawlerProtocolError(
|
|
208
|
+
"CRAWLER_PROTOCOL_TIMESTAMP",
|
|
209
|
+
f"{label} is not a canonical UTC timestamp",
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
213
|
+
except ValueError as error:
|
|
214
|
+
raise HostedCrawlerProtocolError(
|
|
215
|
+
"CRAWLER_PROTOCOL_TIMESTAMP",
|
|
216
|
+
f"{label} is not a real UTC timestamp",
|
|
217
|
+
) from error
|
|
218
|
+
if parsed.tzinfo is None or parsed.astimezone(UTC).utcoffset().total_seconds() != 0:
|
|
219
|
+
raise HostedCrawlerProtocolError(
|
|
220
|
+
"CRAWLER_PROTOCOL_TIMESTAMP",
|
|
221
|
+
f"{label} is not a canonical UTC timestamp",
|
|
222
|
+
)
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _digest(value: Any, label: str, *, prefixed: bool = False) -> str:
|
|
227
|
+
pattern = _PREFIXED_DIGEST if prefixed else _DIGEST
|
|
228
|
+
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
|
229
|
+
raise HostedCrawlerProtocolError(
|
|
230
|
+
"CRAWLER_PROTOCOL_DIGEST",
|
|
231
|
+
f"{label} is not a canonical SHA-256 digest",
|
|
232
|
+
)
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _json_object(value: Any, label: str) -> dict[str, Any]:
|
|
237
|
+
if not isinstance(value, dict):
|
|
238
|
+
raise HostedCrawlerProtocolError(
|
|
239
|
+
"CRAWLER_PROTOCOL_JSON",
|
|
240
|
+
f"{label} must be an object",
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
encoded = canonical_json_bytes(value)
|
|
244
|
+
except (TypeError, ValueError) as error:
|
|
245
|
+
raise HostedCrawlerProtocolError(
|
|
246
|
+
"CRAWLER_PROTOCOL_JSON",
|
|
247
|
+
f"{label} contains unsupported JSON values",
|
|
248
|
+
) from error
|
|
249
|
+
if len(encoded) > MAX_CRAWLER_REQUEST_BYTES:
|
|
250
|
+
raise HostedCrawlerProtocolError(
|
|
251
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
252
|
+
f"{label} exceeds its byte bound",
|
|
253
|
+
)
|
|
254
|
+
return dict(value)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True)
|
|
258
|
+
class HostedCrawlerRequest:
|
|
259
|
+
request_id: str
|
|
260
|
+
source_id: str
|
|
261
|
+
adapter_id: str
|
|
262
|
+
adapter_version: str
|
|
263
|
+
requested_at: str
|
|
264
|
+
query: dict[str, Any]
|
|
265
|
+
egress_policy_attestation: str
|
|
266
|
+
schema_version: str | None = None
|
|
267
|
+
|
|
268
|
+
def __post_init__(self) -> None:
|
|
269
|
+
_identifier(self.request_id, "request_id")
|
|
270
|
+
_identifier(self.source_id, "source_id")
|
|
271
|
+
_identifier(self.adapter_id, "adapter_id")
|
|
272
|
+
if self.adapter_version != "1.0.0" or self.adapter_id not in {
|
|
273
|
+
"external.openligadb",
|
|
274
|
+
"public.https",
|
|
275
|
+
}:
|
|
276
|
+
raise HostedCrawlerProtocolError(
|
|
277
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
278
|
+
"adapter coordinate is not a reviewed hosted-crawler version",
|
|
279
|
+
)
|
|
280
|
+
_timestamp(self.requested_at, "requested_at")
|
|
281
|
+
object.__setattr__(self, "query", _json_object(self.query, "query"))
|
|
282
|
+
_digest(self.egress_policy_attestation, "egress_policy_attestation")
|
|
283
|
+
schema_version = self.schema_version or CRAWLER_REQUEST_SCHEMA_V1
|
|
284
|
+
if schema_version not in {CRAWLER_REQUEST_SCHEMA_V1, CRAWLER_REQUEST_SCHEMA_V3} or (
|
|
285
|
+
schema_version == CRAWLER_REQUEST_SCHEMA_V3 and self.adapter_id != "public.https"
|
|
286
|
+
):
|
|
287
|
+
raise HostedCrawlerProtocolError(
|
|
288
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
289
|
+
"crawler request schema is unsupported for its adapter",
|
|
290
|
+
)
|
|
291
|
+
object.__setattr__(self, "schema_version", schema_version)
|
|
292
|
+
if len(canonical_json_bytes(self.to_dict())) > MAX_CRAWLER_REQUEST_BYTES:
|
|
293
|
+
raise HostedCrawlerProtocolError(
|
|
294
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
295
|
+
"crawler request exceeds its byte bound",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def digest(self) -> str:
|
|
300
|
+
return sha256_bytes(canonical_json_bytes(self.to_dict()))
|
|
301
|
+
|
|
302
|
+
def to_dict(self) -> dict[str, Any]:
|
|
303
|
+
return {
|
|
304
|
+
"schema_version": self.schema_version,
|
|
305
|
+
"request_id": self.request_id,
|
|
306
|
+
"source_id": self.source_id,
|
|
307
|
+
"adapter_id": self.adapter_id,
|
|
308
|
+
"adapter_version": self.adapter_version,
|
|
309
|
+
"requested_at": self.requested_at,
|
|
310
|
+
"query": dict(self.query),
|
|
311
|
+
"egress_policy_attestation": self.egress_policy_attestation,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass(frozen=True)
|
|
316
|
+
class HostedCrawlerFailure:
|
|
317
|
+
request_digest: str
|
|
318
|
+
source_id: str
|
|
319
|
+
adapter_id: str
|
|
320
|
+
adapter_version: str
|
|
321
|
+
failure_code: str
|
|
322
|
+
retryable: bool = False
|
|
323
|
+
|
|
324
|
+
def __post_init__(self) -> None:
|
|
325
|
+
_digest(self.request_digest, "request_digest")
|
|
326
|
+
_identifier(self.source_id, "source_id")
|
|
327
|
+
if (self.adapter_id, self.adapter_version) not in {
|
|
328
|
+
("external.openligadb", "1.0.0"),
|
|
329
|
+
("public.https", "1.0.0"),
|
|
330
|
+
}:
|
|
331
|
+
raise HostedCrawlerProtocolError(
|
|
332
|
+
"CRAWLER_PROTOCOL_VERSION", "failure adapter coordinate is not reviewed"
|
|
333
|
+
)
|
|
334
|
+
if self.failure_code not in HOSTED_CRAWLER_FAILURE_CODES or self.retryable is not False:
|
|
335
|
+
raise HostedCrawlerProtocolError(
|
|
336
|
+
"CRAWLER_PROTOCOL_FAILURE", "crawler failure code is not safely transportable"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def to_dict(self) -> dict[str, Any]:
|
|
340
|
+
return {
|
|
341
|
+
"schema_version": CRAWLER_FAILURE_SCHEMA,
|
|
342
|
+
"request_digest": self.request_digest,
|
|
343
|
+
"source_id": self.source_id,
|
|
344
|
+
"adapter_id": self.adapter_id,
|
|
345
|
+
"adapter_version": self.adapter_version,
|
|
346
|
+
"failure_code": self.failure_code,
|
|
347
|
+
"retryable": self.retryable,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def parse_crawler_failure(value: Any) -> HostedCrawlerFailure:
|
|
352
|
+
document = _exact(
|
|
353
|
+
value,
|
|
354
|
+
{
|
|
355
|
+
"schema_version",
|
|
356
|
+
"request_digest",
|
|
357
|
+
"source_id",
|
|
358
|
+
"adapter_id",
|
|
359
|
+
"adapter_version",
|
|
360
|
+
"failure_code",
|
|
361
|
+
"retryable",
|
|
362
|
+
},
|
|
363
|
+
"crawler failure",
|
|
364
|
+
)
|
|
365
|
+
if document["schema_version"] != CRAWLER_FAILURE_SCHEMA:
|
|
366
|
+
raise HostedCrawlerProtocolError(
|
|
367
|
+
"CRAWLER_PROTOCOL_SCHEMA", "crawler failure schema version is unsupported"
|
|
368
|
+
)
|
|
369
|
+
return HostedCrawlerFailure(
|
|
370
|
+
request_digest=document["request_digest"],
|
|
371
|
+
source_id=document["source_id"],
|
|
372
|
+
adapter_id=document["adapter_id"],
|
|
373
|
+
adapter_version=document["adapter_version"],
|
|
374
|
+
failure_code=document["failure_code"],
|
|
375
|
+
retryable=document["retryable"],
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_crawler_request(value: Any) -> HostedCrawlerRequest:
|
|
380
|
+
document = _exact(
|
|
381
|
+
value,
|
|
382
|
+
{
|
|
383
|
+
"schema_version",
|
|
384
|
+
"request_id",
|
|
385
|
+
"source_id",
|
|
386
|
+
"adapter_id",
|
|
387
|
+
"adapter_version",
|
|
388
|
+
"requested_at",
|
|
389
|
+
"query",
|
|
390
|
+
"egress_policy_attestation",
|
|
391
|
+
},
|
|
392
|
+
"crawler request",
|
|
393
|
+
)
|
|
394
|
+
if document["schema_version"] not in {
|
|
395
|
+
CRAWLER_REQUEST_SCHEMA_V1,
|
|
396
|
+
CRAWLER_REQUEST_SCHEMA_V3,
|
|
397
|
+
} or (
|
|
398
|
+
document["schema_version"] == CRAWLER_REQUEST_SCHEMA_V3
|
|
399
|
+
and document["adapter_id"] != "public.https"
|
|
400
|
+
):
|
|
401
|
+
raise HostedCrawlerProtocolError(
|
|
402
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
403
|
+
"crawler request schema is unsupported",
|
|
404
|
+
)
|
|
405
|
+
return HostedCrawlerRequest(
|
|
406
|
+
request_id=document["request_id"],
|
|
407
|
+
source_id=document["source_id"],
|
|
408
|
+
adapter_id=document["adapter_id"],
|
|
409
|
+
adapter_version=document["adapter_version"],
|
|
410
|
+
requested_at=document["requested_at"],
|
|
411
|
+
query=document["query"],
|
|
412
|
+
egress_policy_attestation=document["egress_policy_attestation"],
|
|
413
|
+
schema_version=document["schema_version"],
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class HostedCrawlerIntent:
|
|
419
|
+
"""Producer request fields that Studio expands into server-owned crawler authority."""
|
|
420
|
+
|
|
421
|
+
source_id: str
|
|
422
|
+
adapter_id: str
|
|
423
|
+
adapter_version: str
|
|
424
|
+
query: dict[str, Any]
|
|
425
|
+
egress_policy_attestation: str
|
|
426
|
+
|
|
427
|
+
def __post_init__(self) -> None:
|
|
428
|
+
_identifier(self.source_id, "source_id")
|
|
429
|
+
_identifier(self.adapter_id, "adapter_id")
|
|
430
|
+
if self.adapter_version != "1.0.0" or self.adapter_id not in {
|
|
431
|
+
"external.openligadb",
|
|
432
|
+
"public.https",
|
|
433
|
+
}:
|
|
434
|
+
raise HostedCrawlerProtocolError(
|
|
435
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
436
|
+
"intent adapter coordinate is unsupported",
|
|
437
|
+
)
|
|
438
|
+
object.__setattr__(self, "query", _json_object(self.query, "query"))
|
|
439
|
+
_digest(self.egress_policy_attestation, "egress_policy_attestation")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass(frozen=True)
|
|
443
|
+
class HostedCrawlerResult:
|
|
444
|
+
request_digest: str
|
|
445
|
+
source_id: str
|
|
446
|
+
adapter_id: str
|
|
447
|
+
adapter_version: str
|
|
448
|
+
normalized_content: bytes
|
|
449
|
+
acquisition_receipt: dict[str, Any]
|
|
450
|
+
observation: dict[str, Any]
|
|
451
|
+
next_watermark: str | None
|
|
452
|
+
schema_version: str | None = None
|
|
453
|
+
|
|
454
|
+
def __post_init__(self) -> None:
|
|
455
|
+
_digest(self.request_digest, "request_digest")
|
|
456
|
+
_identifier(self.source_id, "source_id")
|
|
457
|
+
_identifier(self.adapter_id, "adapter_id")
|
|
458
|
+
if self.adapter_version != "1.0.0" or self.adapter_id not in {
|
|
459
|
+
"external.openligadb",
|
|
460
|
+
"public.https",
|
|
461
|
+
}:
|
|
462
|
+
raise HostedCrawlerProtocolError(
|
|
463
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
464
|
+
"result adapter coordinate is unsupported",
|
|
465
|
+
)
|
|
466
|
+
if (
|
|
467
|
+
not isinstance(self.normalized_content, bytes)
|
|
468
|
+
or not 1 <= len(self.normalized_content) <= MAX_NORMALIZED_SOURCE_BYTES
|
|
469
|
+
):
|
|
470
|
+
raise HostedCrawlerProtocolError(
|
|
471
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
472
|
+
"normalized crawler content is empty or exceeds its byte bound",
|
|
473
|
+
)
|
|
474
|
+
object.__setattr__(
|
|
475
|
+
self,
|
|
476
|
+
"acquisition_receipt",
|
|
477
|
+
_json_object(self.acquisition_receipt, "acquisition_receipt"),
|
|
478
|
+
)
|
|
479
|
+
object.__setattr__(
|
|
480
|
+
self,
|
|
481
|
+
"observation",
|
|
482
|
+
_json_object(self.observation, "observation"),
|
|
483
|
+
)
|
|
484
|
+
if self.next_watermark is not None:
|
|
485
|
+
_timestamp(self.next_watermark, "next_watermark")
|
|
486
|
+
schema_version = self.schema_version or CRAWLER_RESULT_SCHEMA_V1
|
|
487
|
+
if schema_version not in {CRAWLER_RESULT_SCHEMA_V1, CRAWLER_RESULT_SCHEMA_V3} or (
|
|
488
|
+
schema_version == CRAWLER_RESULT_SCHEMA_V3 and self.adapter_id != "public.https"
|
|
489
|
+
):
|
|
490
|
+
raise HostedCrawlerProtocolError(
|
|
491
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
492
|
+
"crawler result schema is unsupported for its adapter",
|
|
493
|
+
)
|
|
494
|
+
object.__setattr__(self, "schema_version", schema_version)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def normalized_content_digest(self) -> str:
|
|
498
|
+
return "sha256:" + sha256_bytes(self.normalized_content)
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def result_digest(self) -> str:
|
|
502
|
+
return sha256_bytes(canonical_json_bytes(self._preimage()))
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def normalized_content_base64url_chunks(self) -> list[str]:
|
|
506
|
+
encoded = base64.urlsafe_b64encode(self.normalized_content).decode("ascii").rstrip("=")
|
|
507
|
+
return [
|
|
508
|
+
encoded[index : index + MAX_CRAWLER_BASE64URL_CHUNK_CHARS]
|
|
509
|
+
for index in range(0, len(encoded), MAX_CRAWLER_BASE64URL_CHUNK_CHARS)
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
def _preimage(self) -> dict[str, Any]:
|
|
513
|
+
return {
|
|
514
|
+
"schema_version": self.schema_version,
|
|
515
|
+
"request_digest": self.request_digest,
|
|
516
|
+
"source_id": self.source_id,
|
|
517
|
+
"adapter_id": self.adapter_id,
|
|
518
|
+
"adapter_version": self.adapter_version,
|
|
519
|
+
"normalized_content_base64url_chunks": (self.normalized_content_base64url_chunks),
|
|
520
|
+
"normalized_content_digest": self.normalized_content_digest,
|
|
521
|
+
"normalized_size_bytes": len(self.normalized_content),
|
|
522
|
+
"acquisition_receipt": dict(self.acquisition_receipt),
|
|
523
|
+
"observation": dict(self.observation),
|
|
524
|
+
"next_watermark": self.next_watermark,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
def to_dict(self) -> dict[str, Any]:
|
|
528
|
+
return {**self._preimage(), "result_digest": self.result_digest}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def parse_crawler_result(value: Any) -> HostedCrawlerResult:
|
|
532
|
+
document = _exact(
|
|
533
|
+
value,
|
|
534
|
+
{
|
|
535
|
+
"schema_version",
|
|
536
|
+
"request_digest",
|
|
537
|
+
"source_id",
|
|
538
|
+
"adapter_id",
|
|
539
|
+
"adapter_version",
|
|
540
|
+
"normalized_content_base64url_chunks",
|
|
541
|
+
"normalized_content_digest",
|
|
542
|
+
"normalized_size_bytes",
|
|
543
|
+
"acquisition_receipt",
|
|
544
|
+
"observation",
|
|
545
|
+
"next_watermark",
|
|
546
|
+
"result_digest",
|
|
547
|
+
},
|
|
548
|
+
"crawler result",
|
|
549
|
+
)
|
|
550
|
+
if document["schema_version"] not in {
|
|
551
|
+
CRAWLER_RESULT_SCHEMA_V1,
|
|
552
|
+
CRAWLER_RESULT_SCHEMA_V3,
|
|
553
|
+
} or (
|
|
554
|
+
document["schema_version"] == CRAWLER_RESULT_SCHEMA_V3
|
|
555
|
+
and document["adapter_id"] != "public.https"
|
|
556
|
+
):
|
|
557
|
+
raise HostedCrawlerProtocolError(
|
|
558
|
+
"CRAWLER_PROTOCOL_VERSION",
|
|
559
|
+
"crawler result schema is unsupported",
|
|
560
|
+
)
|
|
561
|
+
chunks = document["normalized_content_base64url_chunks"]
|
|
562
|
+
complete_groups, remainder = divmod(MAX_NORMALIZED_SOURCE_BYTES, 3)
|
|
563
|
+
maximum_encoded_chars = 4 * complete_groups + (0 if remainder == 0 else remainder + 1)
|
|
564
|
+
maximum_chunks = (
|
|
565
|
+
maximum_encoded_chars + MAX_CRAWLER_BASE64URL_CHUNK_CHARS - 1
|
|
566
|
+
) // MAX_CRAWLER_BASE64URL_CHUNK_CHARS
|
|
567
|
+
if (
|
|
568
|
+
not isinstance(chunks, list)
|
|
569
|
+
or not 1 <= len(chunks) <= maximum_chunks
|
|
570
|
+
or any(
|
|
571
|
+
not isinstance(chunk, str)
|
|
572
|
+
or not 1 <= len(chunk) <= MAX_CRAWLER_BASE64URL_CHUNK_CHARS
|
|
573
|
+
or _BASE64URL_CHUNK.fullmatch(chunk) is None
|
|
574
|
+
for chunk in chunks
|
|
575
|
+
)
|
|
576
|
+
or any(len(chunk) != MAX_CRAWLER_BASE64URL_CHUNK_CHARS for chunk in chunks[:-1])
|
|
577
|
+
):
|
|
578
|
+
raise HostedCrawlerProtocolError(
|
|
579
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
580
|
+
"normalized content chunk inventory is invalid or exceeds its bound",
|
|
581
|
+
)
|
|
582
|
+
encoded = "".join(chunks)
|
|
583
|
+
if len(encoded) > maximum_encoded_chars:
|
|
584
|
+
raise HostedCrawlerProtocolError(
|
|
585
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
586
|
+
"normalized content encoding exceeds its byte bound",
|
|
587
|
+
)
|
|
588
|
+
try:
|
|
589
|
+
content = base64.b64decode(
|
|
590
|
+
encoded + "=" * (-len(encoded) % 4),
|
|
591
|
+
altchars=b"-_",
|
|
592
|
+
validate=True,
|
|
593
|
+
)
|
|
594
|
+
except (ValueError, TypeError) as error:
|
|
595
|
+
raise HostedCrawlerProtocolError(
|
|
596
|
+
"CRAWLER_PROTOCOL_BASE64",
|
|
597
|
+
"normalized content is not canonical base64url",
|
|
598
|
+
) from error
|
|
599
|
+
if base64.urlsafe_b64encode(content).decode("ascii").rstrip("=") != encoded:
|
|
600
|
+
raise HostedCrawlerProtocolError(
|
|
601
|
+
"CRAWLER_PROTOCOL_BASE64",
|
|
602
|
+
"normalized content is not canonical base64url",
|
|
603
|
+
)
|
|
604
|
+
result = HostedCrawlerResult(
|
|
605
|
+
request_digest=document["request_digest"],
|
|
606
|
+
source_id=document["source_id"],
|
|
607
|
+
adapter_id=document["adapter_id"],
|
|
608
|
+
adapter_version=document["adapter_version"],
|
|
609
|
+
normalized_content=content,
|
|
610
|
+
acquisition_receipt=document["acquisition_receipt"],
|
|
611
|
+
observation=document["observation"],
|
|
612
|
+
next_watermark=document["next_watermark"],
|
|
613
|
+
schema_version=document["schema_version"],
|
|
614
|
+
)
|
|
615
|
+
if (
|
|
616
|
+
type(document["normalized_size_bytes"]) is not int
|
|
617
|
+
or document["normalized_content_digest"] != result.normalized_content_digest
|
|
618
|
+
or document["normalized_size_bytes"] != len(content)
|
|
619
|
+
or document["result_digest"] != result.result_digest
|
|
620
|
+
):
|
|
621
|
+
raise HostedCrawlerProtocolError(
|
|
622
|
+
"CRAWLER_PROTOCOL_BINDING",
|
|
623
|
+
"crawler result digest or size binding mismatches",
|
|
624
|
+
)
|
|
625
|
+
if len(canonical_json_bytes(document)) > MAX_CRAWLER_RESULT_BYTES:
|
|
626
|
+
raise HostedCrawlerProtocolError(
|
|
627
|
+
"CRAWLER_PROTOCOL_LIMIT",
|
|
628
|
+
"crawler result exceeds its byte bound",
|
|
629
|
+
)
|
|
630
|
+
return result
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@dataclass(frozen=True)
|
|
634
|
+
class HostedCrawlerExchange:
|
|
635
|
+
"""Studio-returned server request plus its one-shot isolated crawler result."""
|
|
636
|
+
|
|
637
|
+
request: HostedCrawlerRequest
|
|
638
|
+
result: HostedCrawlerResult
|
|
639
|
+
|
|
640
|
+
def __post_init__(self) -> None:
|
|
641
|
+
expected_result_schema = (
|
|
642
|
+
CRAWLER_RESULT_SCHEMA_V3
|
|
643
|
+
if self.request.schema_version == CRAWLER_REQUEST_SCHEMA_V3
|
|
644
|
+
else CRAWLER_RESULT_SCHEMA_V1
|
|
645
|
+
)
|
|
646
|
+
if (
|
|
647
|
+
self.result.request_digest != self.request.digest
|
|
648
|
+
or self.result.source_id != self.request.source_id
|
|
649
|
+
or self.result.adapter_id != self.request.adapter_id
|
|
650
|
+
or self.result.adapter_version != self.request.adapter_version
|
|
651
|
+
or self.result.schema_version != expected_result_schema
|
|
652
|
+
):
|
|
653
|
+
raise HostedCrawlerProtocolError(
|
|
654
|
+
"CRAWLER_PROTOCOL_BINDING",
|
|
655
|
+
"crawler result differs from its server-owned request authority",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
def validate_intent(self, intent: HostedCrawlerIntent) -> None:
|
|
659
|
+
if (
|
|
660
|
+
self.request.source_id != intent.source_id
|
|
661
|
+
or self.request.adapter_id != intent.adapter_id
|
|
662
|
+
or self.request.adapter_version != intent.adapter_version
|
|
663
|
+
or self.request.query != intent.query
|
|
664
|
+
or self.request.egress_policy_attestation != intent.egress_policy_attestation
|
|
665
|
+
):
|
|
666
|
+
raise HostedCrawlerProtocolError(
|
|
667
|
+
"CRAWLER_PROTOCOL_BINDING",
|
|
668
|
+
"Studio crawler request differs from the producer's frozen intent",
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class HostedCrawlerClient(Protocol):
|
|
673
|
+
"""Producer-side create/poll/download abstraction for one isolated crawler result."""
|
|
674
|
+
|
|
675
|
+
def acquire(self, intent: HostedCrawlerIntent) -> HostedCrawlerExchange:
|
|
676
|
+
"""Create/poll/download one server-owned request and its exact claimed result."""
|