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,501 @@
|
|
|
1
|
+
"""The run-event stream a hosted ``mr-data watch`` consumes, and how it survives a cut.
|
|
2
|
+
|
|
3
|
+
Studio publishes one run's append-only log as ``text/event-stream`` (ADR 0021). Three
|
|
4
|
+
properties of that endpoint decide the shape of everything below:
|
|
5
|
+
|
|
6
|
+
* **The SSE ``id`` is the log cursor.** Resume is exclusive and happens in Studio's own cursor
|
|
7
|
+
space, so a reconnect sends ``Last-Event-ID`` and never a sequence number it invented.
|
|
8
|
+
* **The stream closes itself.** Studio sends ``event: end`` before the Cloud Run request timeout
|
|
9
|
+
rather than letting the platform truncate the response, and names why: ``run_terminal`` means
|
|
10
|
+
the run will never append again and reconnecting would be a lie; anything else is a deadline and
|
|
11
|
+
the client reconnects from its last cursor. A dropped connection with no ``end`` is the same
|
|
12
|
+
case as a deadline.
|
|
13
|
+
* **Facts live only in the unsealed adjunct.** A durable run event is a sealed record -- type,
|
|
14
|
+
time, sequence, digest -- and carries no facts by design. What the worker measured rides beside
|
|
15
|
+
it in ``unsealed.payload``. A renderer that reads only the sealed half draws a correctly ordered,
|
|
16
|
+
correctly framed, entirely factless timeline, which is the failure this module exists to avoid.
|
|
17
|
+
|
|
18
|
+
The frame NAME is upstream's two-way partition and is preserved rather than collapsed:
|
|
19
|
+
``run_progress`` is an incremental tick, ``run_event`` is a bulk-append proof or a lifecycle
|
|
20
|
+
transition. Five of the six producer command types collapse onto one durable event type, so the
|
|
21
|
+
partition is not recoverable from the sealed record and a consumer that discards the name has
|
|
22
|
+
destroyed information nothing downstream can rebuild.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from mostlyright.data_harness.progress_events import PROGRESS_EVENT_FACTS, PROGRESS_SCHEMA_VERSION
|
|
34
|
+
from mostlyright.data_harness.thin.runs import RUN_EVENTS_STREAM_PATH
|
|
35
|
+
from mostlyright.data_harness.thin.session import StudioSession
|
|
36
|
+
from mostlyright.data_harness.thin.transport import ThinLaneError, Transport, UrlLibTransport
|
|
37
|
+
|
|
38
|
+
#: Upstream's frame names. The first two are the tick/proof partition; ``end`` is its
|
|
39
|
+
#: end-of-stream notice.
|
|
40
|
+
RUN_EVENT_FRAME = "run_event"
|
|
41
|
+
RUN_PROGRESS_FRAME = "run_progress"
|
|
42
|
+
RUN_END_FRAME = "end"
|
|
43
|
+
|
|
44
|
+
#: The one ``end`` reason that means "do not reconnect".
|
|
45
|
+
RUN_END_REASON_TERMINAL = "run_terminal"
|
|
46
|
+
|
|
47
|
+
#: The four producer command types Studio frames as ``run_progress``. Used only as a fallback when
|
|
48
|
+
#: a frame arrived with no name this build knows -- when upstream named the frame, that name wins.
|
|
49
|
+
PROGRESS_PRODUCER_EVENT_TYPES = frozenset(
|
|
50
|
+
{"attempt_progress", "run_stage_reported", "source_probe_completed", "diagnostic_emitted"}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Statuses after which a run appends nothing further. Mirrors Studio's own terminal set.
|
|
54
|
+
TERMINAL_RUN_STATUSES = frozenset({"released", "failed", "cancelled", "abandoned"})
|
|
55
|
+
|
|
56
|
+
#: One SSE line may not exceed this. A stream is untrusted input like any other network body, and
|
|
57
|
+
#: ``readline`` with no bound is how a hostile or broken producer turns a watch into an OOM.
|
|
58
|
+
MAX_STREAM_LINE_BYTES = 1024 * 1024
|
|
59
|
+
|
|
60
|
+
#: Studio closes at 3300 s and expects the client to come back; the socket read timeout has to
|
|
61
|
+
#: exceed the 15 s heartbeat by enough that a slow hop is not mistaken for a cut.
|
|
62
|
+
STREAM_READ_TIMEOUT_SECONDS = 90.0
|
|
63
|
+
|
|
64
|
+
#: How long a reconnect waits, and the ceiling the doubling stops at.
|
|
65
|
+
FIRST_RECONNECT_DELAY_SECONDS = 1.0
|
|
66
|
+
MAX_RECONNECT_DELAY_SECONDS = 30.0
|
|
67
|
+
|
|
68
|
+
#: Consecutive failures to re-establish the stream before the watch gives up and says so. A
|
|
69
|
+
#: successful reconnect resets the count, so a long run that is cut hourly never exhausts it.
|
|
70
|
+
MAX_CONSECUTIVE_RECONNECTS = 8
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class SseFrame:
|
|
75
|
+
"""One dispatched Server-Sent-Events frame: its name, its data, and its id."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
data: str
|
|
79
|
+
event_id: str | None = None
|
|
80
|
+
|
|
81
|
+
def json(self) -> dict[str, Any]:
|
|
82
|
+
try:
|
|
83
|
+
parsed = json.loads(self.data)
|
|
84
|
+
except ValueError as error:
|
|
85
|
+
raise ThinLaneError(
|
|
86
|
+
"THIN_STREAM_FRAME_INVALID", "a stream frame carried a body that is not JSON"
|
|
87
|
+
) from error
|
|
88
|
+
if not isinstance(parsed, dict):
|
|
89
|
+
raise ThinLaneError(
|
|
90
|
+
"THIN_STREAM_FRAME_INVALID", "a stream frame carried a body that is not an object"
|
|
91
|
+
)
|
|
92
|
+
return parsed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_sse(lines: Iterable[bytes]) -> Iterator[SseFrame]:
|
|
96
|
+
"""Turn a byte-line stream into dispatched frames, dropping comments and heartbeats.
|
|
97
|
+
|
|
98
|
+
Implements only the part of the SSE grammar this endpoint uses: ``id``, ``event``, ``data``
|
|
99
|
+
(possibly repeated, joined with newlines) and comment lines, dispatched on a blank line. A
|
|
100
|
+
``retry:`` field is read and discarded -- the reconnect policy here is the client's, and a
|
|
101
|
+
server-supplied delay is advice this lane does not need. A frame with no ``event:`` defaults to
|
|
102
|
+
``message`` per the specification; Studio always names its frames, so such a frame is a signal
|
|
103
|
+
that something between us rewrote the stream, and it is passed through under that name rather
|
|
104
|
+
than being guessed at.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
name: str | None = None
|
|
108
|
+
event_id: str | None = None
|
|
109
|
+
data: list[str] = []
|
|
110
|
+
for raw in lines:
|
|
111
|
+
if len(raw) > MAX_STREAM_LINE_BYTES:
|
|
112
|
+
raise ThinLaneError(
|
|
113
|
+
"THIN_STREAM_LINE_TOO_LONG", "a stream line exceeded its byte ceiling"
|
|
114
|
+
)
|
|
115
|
+
line = raw.rstrip(b"\n").rstrip(b"\r").decode("utf-8", "replace")
|
|
116
|
+
if not line:
|
|
117
|
+
if name is not None or data:
|
|
118
|
+
yield SseFrame(name=name or "message", data="\n".join(data), event_id=event_id)
|
|
119
|
+
name, data = None, []
|
|
120
|
+
continue
|
|
121
|
+
if line.startswith(":"):
|
|
122
|
+
continue
|
|
123
|
+
field_name, _, value = line.partition(":")
|
|
124
|
+
if value.startswith(" "):
|
|
125
|
+
value = value[1:]
|
|
126
|
+
if field_name == "event":
|
|
127
|
+
name = value
|
|
128
|
+
elif field_name == "data":
|
|
129
|
+
data.append(value)
|
|
130
|
+
elif field_name == "id" and "\x00" not in value:
|
|
131
|
+
event_id = value
|
|
132
|
+
if name is not None or data:
|
|
133
|
+
yield SseFrame(name=name or "message", data="\n".join(data), event_id=event_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def frame_name_for(frame: Mapping[str, Any], upstream_name: str) -> str:
|
|
137
|
+
"""The tick/proof partition for one event body.
|
|
138
|
+
|
|
139
|
+
Precedence: upstream's own name when it is one of the two the partition defines, then the
|
|
140
|
+
producer command type in the adjunct applying Studio's rule, then ``run_event`` -- the honest
|
|
141
|
+
answer for a record with neither, because a lifecycle transition is not a tick.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
if upstream_name in {RUN_EVENT_FRAME, RUN_PROGRESS_FRAME}:
|
|
145
|
+
return upstream_name
|
|
146
|
+
unsealed = frame.get("unsealed")
|
|
147
|
+
if isinstance(unsealed, Mapping):
|
|
148
|
+
producer_event_type = unsealed.get("producer_event_type")
|
|
149
|
+
if producer_event_type in PROGRESS_PRODUCER_EVENT_TYPES:
|
|
150
|
+
return RUN_PROGRESS_FRAME
|
|
151
|
+
return RUN_EVENT_FRAME
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class RunProgress:
|
|
156
|
+
"""What one watch has learned so far: the last cursor, the stage rail, and the last facts."""
|
|
157
|
+
|
|
158
|
+
last_event_id: str | None = None
|
|
159
|
+
last_sequence: int = 0
|
|
160
|
+
events_seen: int = 0
|
|
161
|
+
progress_seen: int = 0
|
|
162
|
+
stage: str | None = None
|
|
163
|
+
stage_index: int | None = None
|
|
164
|
+
stage_total: int | None = None
|
|
165
|
+
status: str | None = None
|
|
166
|
+
end_reason: str | None = None
|
|
167
|
+
reconnects: int = 0
|
|
168
|
+
#: The follow stopped because the caller said so -- its observer saw the frame it was waiting
|
|
169
|
+
#: for, or its ``stop`` check answered yes between frames. Distinct from ``end_reason``, which
|
|
170
|
+
#: is what STUDIO said: a follow halted here left the stream open on Studio's side, and the
|
|
171
|
+
#: run is still appending.
|
|
172
|
+
halted: bool = False
|
|
173
|
+
#: The follow stopped because the caller's deadline passed first. The run is unaffected and the
|
|
174
|
+
#: frame being waited for may still arrive; a caller reads the resource itself to find out.
|
|
175
|
+
expired: bool = False
|
|
176
|
+
lines: list[str] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
179
|
+
return {
|
|
180
|
+
"last_event_id": self.last_event_id,
|
|
181
|
+
"last_sequence": self.last_sequence,
|
|
182
|
+
"events_seen": self.events_seen,
|
|
183
|
+
"progress_seen": self.progress_seen,
|
|
184
|
+
"stage": self.stage,
|
|
185
|
+
"stage_index": self.stage_index,
|
|
186
|
+
"stage_total": self.stage_total,
|
|
187
|
+
"status": self.status,
|
|
188
|
+
"end_reason": self.end_reason,
|
|
189
|
+
"reconnects": self.reconnects,
|
|
190
|
+
"halted": self.halted,
|
|
191
|
+
"expired": self.expired,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def progress_facts(frame: Mapping[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
|
196
|
+
"""The progress event name and its facts, from the unsealed adjunct, or ``None``.
|
|
197
|
+
|
|
198
|
+
⚠ NEVER EVIDENCE. ``payload_digest`` on a record whose adjunct carries a payload is the content
|
|
199
|
+
address of THAT payload: it says the facts were not rewritten in transit and says nothing about
|
|
200
|
+
the Build. Nothing here is verified against a candidate, and nothing here reaches a receipt
|
|
201
|
+
that claims to be attestation.
|
|
202
|
+
|
|
203
|
+
An unknown event name is rendered rather than dropped -- the vocabulary is the harness's and it
|
|
204
|
+
grows, and a client that has not been taught a name still knows the facts came with it.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
unsealed = frame.get("unsealed")
|
|
208
|
+
if not isinstance(unsealed, Mapping):
|
|
209
|
+
return None
|
|
210
|
+
payload = unsealed.get("payload")
|
|
211
|
+
if not isinstance(payload, Mapping):
|
|
212
|
+
return None
|
|
213
|
+
if payload.get("schema_version") != PROGRESS_SCHEMA_VERSION:
|
|
214
|
+
return None
|
|
215
|
+
name = payload.get("event")
|
|
216
|
+
facts = payload.get("facts")
|
|
217
|
+
if not isinstance(name, str) or not isinstance(facts, Mapping):
|
|
218
|
+
return None
|
|
219
|
+
known = PROGRESS_EVENT_FACTS.get(name)
|
|
220
|
+
ordered = known if known is not None else tuple(sorted(str(key) for key in facts))
|
|
221
|
+
return name, {key: facts[key] for key in ordered if key in facts}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_frame(frame: Mapping[str, Any], upstream_name: str) -> str:
|
|
225
|
+
"""One timeline line for one stream frame.
|
|
226
|
+
|
|
227
|
+
A tick renders its facts; a proof renders its durable event type. Both carry the sequence
|
|
228
|
+
number, which is the only ordering authority the log has and the thing a person reads back to
|
|
229
|
+
us when something looks wrong.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
sequence = frame.get("sequence")
|
|
233
|
+
marker = f"{sequence:>5}" if isinstance(sequence, int) else " ?"
|
|
234
|
+
named = progress_facts(frame)
|
|
235
|
+
if frame_name_for(frame, upstream_name) == RUN_PROGRESS_FRAME and named is not None:
|
|
236
|
+
name, facts = named
|
|
237
|
+
rendered = " ".join(f"{key}={_scalar(value)}" for key, value in facts.items())
|
|
238
|
+
return f"{marker} {name}{' ' + rendered if rendered else ''}"
|
|
239
|
+
event_type = frame.get("event_type")
|
|
240
|
+
return f"{marker} {event_type if isinstance(event_type, str) else 'run_event'}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _scalar(value: Any) -> str:
|
|
244
|
+
if isinstance(value, bool):
|
|
245
|
+
return "true" if value else "false"
|
|
246
|
+
if isinstance(value, int | float | str):
|
|
247
|
+
return str(value)
|
|
248
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class RunEventStream:
|
|
252
|
+
"""Replay-then-tail one run's event log, reconnecting across Cloud Run stream cuts."""
|
|
253
|
+
|
|
254
|
+
def __init__(
|
|
255
|
+
self,
|
|
256
|
+
session: StudioSession,
|
|
257
|
+
run_id: str,
|
|
258
|
+
*,
|
|
259
|
+
transport: Transport | None = None,
|
|
260
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
261
|
+
max_reconnects: int = MAX_CONSECUTIVE_RECONNECTS,
|
|
262
|
+
) -> None:
|
|
263
|
+
self._session = session
|
|
264
|
+
self._run_id = run_id
|
|
265
|
+
self._transport = transport or UrlLibTransport()
|
|
266
|
+
self._sleep = sleep
|
|
267
|
+
self._max_reconnects = max_reconnects
|
|
268
|
+
|
|
269
|
+
def follow(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
from_seq: int = 0,
|
|
273
|
+
last_event_id: str | None = None,
|
|
274
|
+
on_line: Callable[[str], None] | None = None,
|
|
275
|
+
observe: Callable[[Mapping[str, Any], str], bool] | None = None,
|
|
276
|
+
stop: Callable[[], bool] | None = None,
|
|
277
|
+
deadline: float | None = None,
|
|
278
|
+
clock: Callable[[], float] = time.monotonic,
|
|
279
|
+
) -> RunProgress:
|
|
280
|
+
"""Consume the stream to its terminal end, or until reconnects are exhausted.
|
|
281
|
+
|
|
282
|
+
Returns the accumulated :class:`RunProgress` in every case that is not a refusal, so a
|
|
283
|
+
caller can report how far it got even when the run outlived the watch.
|
|
284
|
+
|
|
285
|
+
Three optional ways to stop before the run does, all written for a caller that is waiting
|
|
286
|
+
for ONE record on a run that will keep appending -- a probe settling on a research
|
|
287
|
+
session's run, whose stream only ends when the session closes. ``observe`` sees every new
|
|
288
|
+
frame body with its frame name, after it is folded into ``progress``, and answers whether
|
|
289
|
+
the follow is over. ``stop`` is asked between frames -- after every frame boundary and
|
|
290
|
+
every heartbeat on a quiet stream, and before every reconnect -- so a caller whose answer
|
|
291
|
+
may arrive somewhere OTHER than this stream can go and look there; it is never asked in
|
|
292
|
+
the middle of a frame. ``deadline`` is a ``clock()`` reading past which the follow gives
|
|
293
|
+
up. The receipt says what happened under ``halted`` (the first two) and ``expired`` (the
|
|
294
|
+
third), and none of them is an ``end_reason``: Studio did not end anything, the caller
|
|
295
|
+
stopped listening.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
progress = RunProgress(last_event_id=last_event_id)
|
|
299
|
+
delay = FIRST_RECONNECT_DELAY_SECONDS
|
|
300
|
+
consecutive = 0
|
|
301
|
+
while True:
|
|
302
|
+
delivered = self._one_connection(
|
|
303
|
+
progress,
|
|
304
|
+
from_seq=from_seq,
|
|
305
|
+
on_line=on_line,
|
|
306
|
+
observe=observe,
|
|
307
|
+
stop=stop,
|
|
308
|
+
deadline=deadline,
|
|
309
|
+
clock=clock,
|
|
310
|
+
)
|
|
311
|
+
if progress.end_reason == RUN_END_REASON_TERMINAL:
|
|
312
|
+
return progress
|
|
313
|
+
if progress.status in TERMINAL_RUN_STATUSES:
|
|
314
|
+
return progress
|
|
315
|
+
if progress.halted:
|
|
316
|
+
return progress
|
|
317
|
+
if deadline is not None and clock() >= deadline:
|
|
318
|
+
progress.expired = True
|
|
319
|
+
return progress
|
|
320
|
+
if stop is not None and stop():
|
|
321
|
+
# Asked before the reconnect wait rather than after it: a connection that just
|
|
322
|
+
# closed is exactly the gap in which an answer can have landed elsewhere.
|
|
323
|
+
progress.halted = True
|
|
324
|
+
return progress
|
|
325
|
+
if delivered:
|
|
326
|
+
consecutive = 0
|
|
327
|
+
delay = FIRST_RECONNECT_DELAY_SECONDS
|
|
328
|
+
else:
|
|
329
|
+
consecutive += 1
|
|
330
|
+
if consecutive > self._max_reconnects:
|
|
331
|
+
raise ThinLaneError(
|
|
332
|
+
"THIN_STREAM_UNAVAILABLE",
|
|
333
|
+
f"the run event stream could not be re-established after {consecutive} "
|
|
334
|
+
"attempts; the run is unaffected and watch can be resumed",
|
|
335
|
+
)
|
|
336
|
+
progress.reconnects += 1
|
|
337
|
+
self._sleep(delay)
|
|
338
|
+
delay = min(delay * 2, MAX_RECONNECT_DELAY_SECONDS)
|
|
339
|
+
|
|
340
|
+
def _one_connection(
|
|
341
|
+
self,
|
|
342
|
+
progress: RunProgress,
|
|
343
|
+
*,
|
|
344
|
+
from_seq: int,
|
|
345
|
+
on_line: Callable[[str], None] | None,
|
|
346
|
+
observe: Callable[[Mapping[str, Any], str], bool] | None = None,
|
|
347
|
+
stop: Callable[[], bool] | None = None,
|
|
348
|
+
deadline: float | None = None,
|
|
349
|
+
clock: Callable[[], float] = time.monotonic,
|
|
350
|
+
) -> bool:
|
|
351
|
+
"""Hold one connection until it ends. Returns whether it delivered any frame."""
|
|
352
|
+
|
|
353
|
+
def _between_frames() -> bool:
|
|
354
|
+
# The deadline first, because it costs nothing; the caller's check second, because it
|
|
355
|
+
# may cost a round trip and a deadline that has passed makes the answer moot.
|
|
356
|
+
if deadline is not None and clock() >= deadline:
|
|
357
|
+
return True
|
|
358
|
+
if stop is not None and stop():
|
|
359
|
+
progress.halted = True
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
headers = {"Accept": "text/event-stream", **self._session.authorization()}
|
|
364
|
+
# Resume in Studio's own cursor space when we have one. `from_seq` is only ever the
|
|
365
|
+
# starting position of a watch that has not yet seen a frame.
|
|
366
|
+
if progress.last_event_id:
|
|
367
|
+
headers["Last-Event-ID"] = progress.last_event_id
|
|
368
|
+
query = ""
|
|
369
|
+
else:
|
|
370
|
+
query = f"?from_seq={int(from_seq)}" if from_seq else ""
|
|
371
|
+
url = (
|
|
372
|
+
f"{self._session.studio_base_url}"
|
|
373
|
+
f"{RUN_EVENTS_STREAM_PATH.format(run_id=self._run_id)}{query}"
|
|
374
|
+
)
|
|
375
|
+
delivered = False
|
|
376
|
+
try:
|
|
377
|
+
with self._transport.stream(url, headers, STREAM_READ_TIMEOUT_SECONDS) as response:
|
|
378
|
+
status = int(getattr(response, "status", 200))
|
|
379
|
+
if status in {401, 403}:
|
|
380
|
+
raise ThinLaneError(
|
|
381
|
+
"THIN_AUTHORIZATION_DENIED",
|
|
382
|
+
"the Studio token does not carry authority to read this run's events",
|
|
383
|
+
)
|
|
384
|
+
if status == 404:
|
|
385
|
+
raise ThinLaneError("THIN_NOT_FOUND", "Studio has no run with that identifier")
|
|
386
|
+
if status != 200:
|
|
387
|
+
raise ThinLaneError(
|
|
388
|
+
"THIN_STREAM_REFUSED",
|
|
389
|
+
f"Studio answered the event stream with HTTP {status}",
|
|
390
|
+
)
|
|
391
|
+
for frame in parse_sse(_lines_until(response, _between_frames)):
|
|
392
|
+
delivered = True
|
|
393
|
+
if self._consume(frame, progress, on_line, observe):
|
|
394
|
+
return delivered
|
|
395
|
+
except ThinLaneError:
|
|
396
|
+
raise
|
|
397
|
+
except Exception:
|
|
398
|
+
# A cut connection is the ordinary case this endpoint is designed around, not an
|
|
399
|
+
# error: Studio closes at its own deadline and every hop between us may close sooner.
|
|
400
|
+
# The caller reconnects from the last cursor.
|
|
401
|
+
return delivered
|
|
402
|
+
return delivered
|
|
403
|
+
|
|
404
|
+
def _consume(
|
|
405
|
+
self,
|
|
406
|
+
frame: SseFrame,
|
|
407
|
+
progress: RunProgress,
|
|
408
|
+
on_line: Callable[[str], None] | None,
|
|
409
|
+
observe: Callable[[Mapping[str, Any], str], bool] | None = None,
|
|
410
|
+
) -> bool:
|
|
411
|
+
"""Fold one frame into ``progress``. Returns whether this connection is over."""
|
|
412
|
+
|
|
413
|
+
if frame.name == RUN_END_FRAME:
|
|
414
|
+
body = frame.json()
|
|
415
|
+
reason = body.get("reason")
|
|
416
|
+
progress.end_reason = reason if isinstance(reason, str) else None
|
|
417
|
+
status = body.get("status")
|
|
418
|
+
if isinstance(status, str):
|
|
419
|
+
progress.status = status
|
|
420
|
+
trailing = body.get("last_event_id")
|
|
421
|
+
if isinstance(trailing, str) and trailing:
|
|
422
|
+
progress.last_event_id = trailing
|
|
423
|
+
return True
|
|
424
|
+
body = frame.json()
|
|
425
|
+
if frame.event_id:
|
|
426
|
+
progress.last_event_id = frame.event_id
|
|
427
|
+
sequence = body.get("sequence")
|
|
428
|
+
if isinstance(sequence, int):
|
|
429
|
+
if sequence <= progress.last_sequence:
|
|
430
|
+
# Replay is exclusive by contract, so a repeat means a retried delivery rather
|
|
431
|
+
# than a new record. Counting it twice would make the receipt lie.
|
|
432
|
+
return False
|
|
433
|
+
progress.last_sequence = sequence
|
|
434
|
+
progress.events_seen += 1
|
|
435
|
+
named = progress_facts(body)
|
|
436
|
+
if named is not None:
|
|
437
|
+
progress.progress_seen += 1
|
|
438
|
+
name, facts = named
|
|
439
|
+
if name in {"stage_started", "stage_completed"}:
|
|
440
|
+
progress.stage = facts.get("stage") if isinstance(facts.get("stage"), str) else None
|
|
441
|
+
progress.stage_index = _as_int(facts.get("index"))
|
|
442
|
+
progress.stage_total = _as_int(facts.get("total"))
|
|
443
|
+
line = render_frame(body, frame.name)
|
|
444
|
+
progress.lines.append(line)
|
|
445
|
+
if on_line is not None:
|
|
446
|
+
on_line(line)
|
|
447
|
+
if observe is not None and observe(body, frame.name):
|
|
448
|
+
progress.halted = True
|
|
449
|
+
return True
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _lines_until(response: Iterable[bytes], should_stop: Callable[[], bool]) -> Iterator[bytes]:
|
|
454
|
+
"""The response's lines, stopping where the caller says rather than at the stream's end.
|
|
455
|
+
|
|
456
|
+
Asked per LINE rather than per frame on purpose. Studio writes a heartbeat comment at most
|
|
457
|
+
fifteen seconds apart on a quiet stream, and a comment is a line the frame parser drops without
|
|
458
|
+
dispatching anything -- so a check made only when a frame arrived would, on a quiet session,
|
|
459
|
+
be made at Studio's own stream deadline and not before. A line is the finest grain the socket
|
|
460
|
+
offers, and the heartbeat is what makes it arrive.
|
|
461
|
+
|
|
462
|
+
⚠ NEVER BETWEEN A FRAME'S OWN LINES. A frame is ``id``, ``event`` and ``data`` lines closed by
|
|
463
|
+
a blank one, and stopping after the ``event`` line would hand the parser a frame with no body
|
|
464
|
+
-- which it refuses as a malformed frame, so a deadline would have reported itself as a broken
|
|
465
|
+
stream. The check is made only at a blank line, which closes a frame, or at a comment that
|
|
466
|
+
arrived while no frame was open.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
mid_frame = False
|
|
470
|
+
for raw in response:
|
|
471
|
+
yield raw
|
|
472
|
+
line = raw.rstrip(b"\r\n")
|
|
473
|
+
if not line:
|
|
474
|
+
mid_frame = False
|
|
475
|
+
elif not line.startswith(b":"):
|
|
476
|
+
mid_frame = True
|
|
477
|
+
if not mid_frame and should_stop():
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _as_int(value: Any) -> int | None:
|
|
482
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
__all__ = [
|
|
486
|
+
"MAX_CONSECUTIVE_RECONNECTS",
|
|
487
|
+
"MAX_STREAM_LINE_BYTES",
|
|
488
|
+
"PROGRESS_PRODUCER_EVENT_TYPES",
|
|
489
|
+
"RUN_END_FRAME",
|
|
490
|
+
"RUN_END_REASON_TERMINAL",
|
|
491
|
+
"RUN_EVENT_FRAME",
|
|
492
|
+
"RUN_PROGRESS_FRAME",
|
|
493
|
+
"TERMINAL_RUN_STATUSES",
|
|
494
|
+
"RunEventStream",
|
|
495
|
+
"RunProgress",
|
|
496
|
+
"SseFrame",
|
|
497
|
+
"frame_name_for",
|
|
498
|
+
"parse_sse",
|
|
499
|
+
"progress_facts",
|
|
500
|
+
"render_frame",
|
|
501
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""One bounded HTTPS transport for the thin lane, and the origin check every URL passes first.
|
|
2
|
+
|
|
3
|
+
``urllib`` rather than a client library, for the reason the rest of this repository already uses
|
|
4
|
+
it: the thin profile must install with no third-party runtime dependency at all, so the transport
|
|
5
|
+
that reaches Cloud and Studio has to come out of the standard library. The hardening is the same
|
|
6
|
+
hardening ``hosted_deploy.UrlLibTokenExchangeTransport`` applies -- ambient proxies off, redirects
|
|
7
|
+
off, a byte ceiling on every response, a request timeout -- because a thin client is not a reason
|
|
8
|
+
to relax any of it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ipaddress
|
|
14
|
+
import ssl
|
|
15
|
+
from collections.abc import Iterator, Mapping
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
from urllib.error import HTTPError
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
21
|
+
|
|
22
|
+
#: The same thirty seconds ``hosted_deploy.REQUEST_TIMEOUT_SECONDS`` gives an ordinary request. A
|
|
23
|
+
#: held-open event stream is not an ordinary request and passes its own, much larger, timeout.
|
|
24
|
+
REQUEST_TIMEOUT_SECONDS = 30.0
|
|
25
|
+
|
|
26
|
+
#: A JSON answer from Cloud or Studio that exceeds this is refused unread. Every response this
|
|
27
|
+
#: lane parses is a small object; a signed download session is the largest of them.
|
|
28
|
+
MAX_JSON_RESPONSE_BYTES = 1024 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ThinLaneError(RuntimeError):
|
|
32
|
+
"""A typed, credential-safe refusal from the hosted lane.
|
|
33
|
+
|
|
34
|
+
``code`` is a stable token a script may branch on and ``detail`` is one sentence a person
|
|
35
|
+
reads. Neither ever carries a credential: the raw ``mr_cli_`` key and the short-lived Studio
|
|
36
|
+
token are the two secrets this lane holds, and no refusal below is built from either.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
40
|
+
self.code = code
|
|
41
|
+
self.detail = detail
|
|
42
|
+
super().__init__(f"{code}: {detail}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Transport(Protocol):
|
|
46
|
+
"""The one seam every test substitutes. Returns status, body bytes, and lowercased headers."""
|
|
47
|
+
|
|
48
|
+
def request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
url: str,
|
|
52
|
+
headers: Mapping[str, str],
|
|
53
|
+
body: bytes | None,
|
|
54
|
+
maximum: int,
|
|
55
|
+
) -> tuple[int, bytes, Mapping[str, str]]: ...
|
|
56
|
+
|
|
57
|
+
def stream(
|
|
58
|
+
self,
|
|
59
|
+
url: str,
|
|
60
|
+
headers: Mapping[str, str],
|
|
61
|
+
timeout: float,
|
|
62
|
+
) -> Any: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
66
|
+
"""Refuse every redirect. A bearer token must not be replayed at an origin we did not choose."""
|
|
67
|
+
|
|
68
|
+
def redirect_request(
|
|
69
|
+
self,
|
|
70
|
+
request: Request,
|
|
71
|
+
file_pointer: Any,
|
|
72
|
+
code: int,
|
|
73
|
+
message: str,
|
|
74
|
+
headers: Any,
|
|
75
|
+
new_url: str,
|
|
76
|
+
) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UrlLibTransport:
|
|
81
|
+
"""Bounded HTTPS with ambient proxies and redirects disabled."""
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
self._opener = build_opener(
|
|
85
|
+
ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
url: str,
|
|
92
|
+
headers: Mapping[str, str],
|
|
93
|
+
body: bytes | None,
|
|
94
|
+
maximum: int,
|
|
95
|
+
) -> tuple[int, bytes, Mapping[str, str]]:
|
|
96
|
+
request = Request(url, data=body, headers=dict(headers), method=method)
|
|
97
|
+
try:
|
|
98
|
+
response = self._opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
99
|
+
except HTTPError as error:
|
|
100
|
+
response = error
|
|
101
|
+
with response:
|
|
102
|
+
raw = response.read(maximum + 1)
|
|
103
|
+
status = int(response.status)
|
|
104
|
+
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
|
105
|
+
if len(raw) > maximum:
|
|
106
|
+
raise ThinLaneError(
|
|
107
|
+
"THIN_RESPONSE_TOO_LARGE", "the response exceeded the byte ceiling for its route"
|
|
108
|
+
)
|
|
109
|
+
return status, raw, response_headers
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def stream(self, url: str, headers: Mapping[str, str], timeout: float) -> Iterator[Any]:
|
|
113
|
+
"""Open a long-lived response whose body the caller reads incrementally.
|
|
114
|
+
|
|
115
|
+
Separate from :meth:`request` because everything about it differs: no byte ceiling (the
|
|
116
|
+
caller bounds each line instead), a timeout measured in the length of a run rather than of
|
|
117
|
+
a request, and a body that must never be read whole.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
request = Request(url, headers=dict(headers), method="GET")
|
|
121
|
+
try:
|
|
122
|
+
response = self._opener.open(request, timeout=timeout)
|
|
123
|
+
except HTTPError as error:
|
|
124
|
+
response = error
|
|
125
|
+
try:
|
|
126
|
+
yield response
|
|
127
|
+
finally:
|
|
128
|
+
response.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def service_origin(value: str, label: str, *, allow_loopback_http: bool) -> str:
|
|
132
|
+
"""Return ``value`` when it is a bare HTTPS origin, and refuse it otherwise.
|
|
133
|
+
|
|
134
|
+
Mirrors ``hosted_deploy._service_url``: no user information, no path, no query, no fragment,
|
|
135
|
+
and plain HTTP only for a loopback host, which is what a developer running Studio locally
|
|
136
|
+
needs and what nothing in production is allowed to be.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 2048 or value.endswith("/"):
|
|
140
|
+
raise ThinLaneError("THIN_CONFIG_INVALID", f"{label} is not a usable origin")
|
|
141
|
+
parsed = urlsplit(value)
|
|
142
|
+
loopback = False
|
|
143
|
+
if parsed.hostname:
|
|
144
|
+
try:
|
|
145
|
+
loopback = ipaddress.ip_address(parsed.hostname).is_loopback
|
|
146
|
+
except ValueError:
|
|
147
|
+
loopback = parsed.hostname == "localhost"
|
|
148
|
+
if (
|
|
149
|
+
parsed.scheme not in ({"https", "http"} if allow_loopback_http else {"https"})
|
|
150
|
+
or (parsed.scheme == "http" and not loopback)
|
|
151
|
+
or not parsed.hostname
|
|
152
|
+
or parsed.username is not None
|
|
153
|
+
or parsed.password is not None
|
|
154
|
+
or parsed.fragment
|
|
155
|
+
or parsed.path not in {"", "/"}
|
|
156
|
+
or parsed.query
|
|
157
|
+
):
|
|
158
|
+
raise ThinLaneError(
|
|
159
|
+
"THIN_CONFIG_INVALID",
|
|
160
|
+
f"{label} must be an HTTPS origin without user information, path, query, or fragment",
|
|
161
|
+
)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def signed_transfer_url(value: str, label: str) -> str:
|
|
166
|
+
"""Return ``value`` when it is an HTTPS URL a signed transfer may be performed against.
|
|
167
|
+
|
|
168
|
+
A signed download URL is the one URL in this lane that legitimately carries a path and a
|
|
169
|
+
query, so it cannot pass :func:`service_origin`. Everything else stays refused: no plain HTTP,
|
|
170
|
+
no embedded credentials, no fragment.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 8192:
|
|
174
|
+
raise ThinLaneError("THIN_RESPONSE_INVALID", f"{label} is not a usable URL")
|
|
175
|
+
parsed = urlsplit(value)
|
|
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.fragment
|
|
182
|
+
):
|
|
183
|
+
raise ThinLaneError(
|
|
184
|
+
"THIN_RESPONSE_INVALID",
|
|
185
|
+
f"{label} must be an HTTPS URL without user information or a fragment",
|
|
186
|
+
)
|
|
187
|
+
return value
|