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,579 @@
|
|
|
1
|
+
"""Cancel one still-active hosted public acquisition, once and durably.
|
|
2
|
+
|
|
3
|
+
The acquisition receipt names the workspace and crawler-session coordinate. Cancellation is a
|
|
4
|
+
separate, authority-bearing act: this module owns its one V3 mutation rather than teaching the
|
|
5
|
+
read-only run client a cancel route. A small local journal is written *before* the request so a
|
|
6
|
+
lost response is retried with the same idempotency key. It deliberately stores only public
|
|
7
|
+
coordinates, the idempotency key, and Studio's cancellation receipt -- never a device key, bearer
|
|
8
|
+
token, Cloud URL, or environment variable name/value.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import stat
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
from mostlyright.data_harness.canonical import CanonicalJSONError, canonical_sha256, parse_json
|
|
26
|
+
from mostlyright.data_harness.thin.runs import StudioApiClient
|
|
27
|
+
from mostlyright.data_harness.thin.session import StudioSession, open_studio_session
|
|
28
|
+
from mostlyright.data_harness.thin.transport import ThinLaneError
|
|
29
|
+
from mostlyright.data_harness.ux.credentials import (
|
|
30
|
+
default_credentials_dir,
|
|
31
|
+
resolve_cloud_credentials,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
CANCEL_PUBLIC_ACQUISITION_PATH = (
|
|
35
|
+
"/v3/workspaces/{workspace_id}/public-acquisitions/{crawler_session_id}:cancel"
|
|
36
|
+
)
|
|
37
|
+
CANCELLATION_RECEIPT_VERSION = "mostlyright-public-acquisition-cancellation-receipt.v1"
|
|
38
|
+
CANCELLATION_JOURNAL_VERSION = "mostlyright-thin-client-public-acquisition-cancellation-journal.v1"
|
|
39
|
+
MAX_JOURNAL_BYTES = 64 * 1024
|
|
40
|
+
MAX_RETRY_AFTER_SECONDS = 900
|
|
41
|
+
_JOURNAL_STATE_DIRECTORY = "mr-data/public-acquisition-cancellations"
|
|
42
|
+
|
|
43
|
+
RECEIPT_FIELDS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"schema_version",
|
|
46
|
+
"crawler_session_id",
|
|
47
|
+
"workspace_id",
|
|
48
|
+
"requester_principal_id",
|
|
49
|
+
"status",
|
|
50
|
+
"cancelled_at",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
JOURNAL_FIELDS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"schema_version",
|
|
56
|
+
"workspace_id",
|
|
57
|
+
"crawler_session_id",
|
|
58
|
+
"idempotency_key",
|
|
59
|
+
"state",
|
|
60
|
+
"receipt",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
|
65
|
+
_IDEMPOTENCY = re.compile(r"^mr-data\.acquire-cancel\.v1:[0-9a-f]{64}$")
|
|
66
|
+
_SECONDS_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CancellationRefusal(ThinLaneError):
|
|
70
|
+
"""A cancellation refusal whose retry facts the caller must be able to retain."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
code: str,
|
|
75
|
+
detail: str,
|
|
76
|
+
*,
|
|
77
|
+
workspace_id: str | None = None,
|
|
78
|
+
crawler_session_id: str | None = None,
|
|
79
|
+
retry_after_seconds: int | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(code, detail)
|
|
82
|
+
self.workspace_id = workspace_id
|
|
83
|
+
self.crawler_session_id = crawler_session_id
|
|
84
|
+
self.retry_after_seconds = retry_after_seconds
|
|
85
|
+
|
|
86
|
+
def to_payload(self) -> dict[str, Any]:
|
|
87
|
+
payload: dict[str, Any] = {
|
|
88
|
+
"schema_version": "mr-data-error.v1",
|
|
89
|
+
"code": self.code,
|
|
90
|
+
"error": self.detail,
|
|
91
|
+
"headline": self.detail,
|
|
92
|
+
}
|
|
93
|
+
if self.retry_after_seconds is not None:
|
|
94
|
+
payload["retry_after_seconds"] = self.retry_after_seconds
|
|
95
|
+
if self.workspace_id is not None and self.crawler_session_id is not None:
|
|
96
|
+
payload["active_public_acquisition"] = {
|
|
97
|
+
"workspace_id": self.workspace_id,
|
|
98
|
+
"crawler_session_id": self.crawler_session_id,
|
|
99
|
+
}
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class StudioAcquisitionCancellationClient(StudioApiClient):
|
|
104
|
+
"""The one public-acquisition cancellation route and no hidden follow-up mutation."""
|
|
105
|
+
|
|
106
|
+
def cancel(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
workspace_id: str,
|
|
110
|
+
crawler_session_id: str,
|
|
111
|
+
idempotency_key: str,
|
|
112
|
+
) -> tuple[dict[str, Any], bool]:
|
|
113
|
+
headers: dict[str, str] = {}
|
|
114
|
+
try:
|
|
115
|
+
receipt = self._call(
|
|
116
|
+
"POST",
|
|
117
|
+
CANCEL_PUBLIC_ACQUISITION_PATH.format(
|
|
118
|
+
workspace_id=workspace_id,
|
|
119
|
+
crawler_session_id=crawler_session_id,
|
|
120
|
+
),
|
|
121
|
+
body={
|
|
122
|
+
"schema_version": "3.0.0",
|
|
123
|
+
"workspace_id": workspace_id,
|
|
124
|
+
"crawler_session_id": crawler_session_id,
|
|
125
|
+
},
|
|
126
|
+
extra_headers={"Idempotency-Key": idempotency_key},
|
|
127
|
+
expected=(200,),
|
|
128
|
+
response_headers=headers,
|
|
129
|
+
)
|
|
130
|
+
except ThinLaneError as refusal:
|
|
131
|
+
status = getattr(refusal, "http_status", None)
|
|
132
|
+
if status == 429:
|
|
133
|
+
window = _retry_after(headers)
|
|
134
|
+
if window is None:
|
|
135
|
+
raise ThinLaneError(
|
|
136
|
+
"THIN_CANCELLATION_RETRY_AFTER_INVALID",
|
|
137
|
+
"Studio rate-limited cancellation without a retry window from 1 to 900 "
|
|
138
|
+
"seconds",
|
|
139
|
+
) from refusal
|
|
140
|
+
raise CancellationRefusal(
|
|
141
|
+
refusal.code,
|
|
142
|
+
f"{refusal.detail}; nothing was cancelled and Studio asked for a retry after "
|
|
143
|
+
f"{window} seconds",
|
|
144
|
+
workspace_id=workspace_id,
|
|
145
|
+
crawler_session_id=crawler_session_id,
|
|
146
|
+
retry_after_seconds=window,
|
|
147
|
+
) from refusal
|
|
148
|
+
if status == 409:
|
|
149
|
+
raise CancellationRefusal(
|
|
150
|
+
"THIN_ACQUISITION_CANCEL_TERMINAL",
|
|
151
|
+
"Studio cannot cancel this public acquisition because it already completed "
|
|
152
|
+
"or failed",
|
|
153
|
+
) from refusal
|
|
154
|
+
raise
|
|
155
|
+
replay = headers.get("idempotent-replay")
|
|
156
|
+
if replay not in {"true", "false"}:
|
|
157
|
+
raise ThinLaneError(
|
|
158
|
+
"THIN_CANCELLATION_REPLAY_INVALID",
|
|
159
|
+
"Studio answered cancellation without Idempotent-Replay true or false",
|
|
160
|
+
)
|
|
161
|
+
return receipt, replay == "true"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class CancellationJournal:
|
|
165
|
+
"""A narrowly shaped, atomically written receipt journal for one coordinate."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, path: Path) -> None:
|
|
168
|
+
self.path = path
|
|
169
|
+
self.state: dict[str, Any] = {}
|
|
170
|
+
|
|
171
|
+
def load_or_start(self, *, workspace_id: str, crawler_session_id: str) -> None:
|
|
172
|
+
_journal_location(self.path)
|
|
173
|
+
if self.path.exists():
|
|
174
|
+
self.state = _read_journal(self.path)
|
|
175
|
+
if (
|
|
176
|
+
self.state["workspace_id"] != workspace_id
|
|
177
|
+
or self.state["crawler_session_id"] != crawler_session_id
|
|
178
|
+
):
|
|
179
|
+
raise ThinLaneError(
|
|
180
|
+
"THIN_CANCELLATION_JOURNAL_SCOPE",
|
|
181
|
+
"the cancellation journal belongs to a different public acquisition coordinate",
|
|
182
|
+
)
|
|
183
|
+
expected = _idempotency_key(workspace_id, crawler_session_id)
|
|
184
|
+
if self.state["idempotency_key"] != expected:
|
|
185
|
+
raise ThinLaneError(
|
|
186
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
187
|
+
"the cancellation journal has an idempotency key this client would not use",
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
self.state = {
|
|
191
|
+
"schema_version": CANCELLATION_JOURNAL_VERSION,
|
|
192
|
+
"workspace_id": workspace_id,
|
|
193
|
+
"crawler_session_id": crawler_session_id,
|
|
194
|
+
"idempotency_key": _idempotency_key(workspace_id, crawler_session_id),
|
|
195
|
+
"state": "prepared",
|
|
196
|
+
"receipt": None,
|
|
197
|
+
}
|
|
198
|
+
self.save()
|
|
199
|
+
|
|
200
|
+
def save(self) -> None:
|
|
201
|
+
rendered = json.dumps(self.state, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
202
|
+
if len(rendered) > MAX_JOURNAL_BYTES:
|
|
203
|
+
raise ThinLaneError(
|
|
204
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
205
|
+
"the cancellation journal exceeds its byte limit",
|
|
206
|
+
)
|
|
207
|
+
parent = _open_private_parent(self.path.parent)
|
|
208
|
+
staged = f".{self.path.name}.{secrets.token_hex(16)}.partial"
|
|
209
|
+
descriptor: int | None = None
|
|
210
|
+
try:
|
|
211
|
+
try:
|
|
212
|
+
flags = (
|
|
213
|
+
os.O_WRONLY
|
|
214
|
+
| os.O_CREAT
|
|
215
|
+
| os.O_EXCL
|
|
216
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
217
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
218
|
+
| getattr(os, "O_BINARY", 0)
|
|
219
|
+
)
|
|
220
|
+
descriptor = os.open(staged, flags, 0o600, dir_fd=parent)
|
|
221
|
+
_validate_private_file(os.fstat(descriptor))
|
|
222
|
+
offset = 0
|
|
223
|
+
while offset < len(rendered):
|
|
224
|
+
written = os.write(descriptor, rendered[offset:])
|
|
225
|
+
if written <= 0:
|
|
226
|
+
raise OSError("could not finish writing cancellation journal")
|
|
227
|
+
offset += written
|
|
228
|
+
os.fsync(descriptor)
|
|
229
|
+
finally:
|
|
230
|
+
if descriptor is not None:
|
|
231
|
+
os.close(descriptor)
|
|
232
|
+
os.replace(staged, self.path.name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
233
|
+
os.fsync(parent)
|
|
234
|
+
except BaseException:
|
|
235
|
+
_remove_stage(parent, staged)
|
|
236
|
+
raise
|
|
237
|
+
finally:
|
|
238
|
+
os.close(parent)
|
|
239
|
+
|
|
240
|
+
def cancelled(self, receipt: Mapping[str, Any]) -> None:
|
|
241
|
+
self.state["state"] = "cancelled"
|
|
242
|
+
self.state["receipt"] = dict(receipt)
|
|
243
|
+
self.save()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def declare_arguments(parser: argparse.ArgumentParser) -> None:
|
|
247
|
+
parser.add_argument(
|
|
248
|
+
"--workspace-id",
|
|
249
|
+
required=True,
|
|
250
|
+
help="the workspace_id printed by mr-data acquire for this public acquisition",
|
|
251
|
+
)
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--crawler-session-id",
|
|
254
|
+
required=True,
|
|
255
|
+
help="the crawler_session_id printed by mr-data acquire for this public acquisition",
|
|
256
|
+
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--journal",
|
|
259
|
+
metavar="FILE",
|
|
260
|
+
help=(
|
|
261
|
+
"override the protected Receipt journal for this address; retries reuse its "
|
|
262
|
+
"idempotency key and it stores no credential"
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def acquire_cancel(
|
|
268
|
+
args: argparse.Namespace,
|
|
269
|
+
*,
|
|
270
|
+
client: StudioAcquisitionCancellationClient | None = None,
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
"""Cancel an active acquisition, or return its locally confirmed terminal receipt."""
|
|
273
|
+
|
|
274
|
+
workspace_id = _coordinate(getattr(args, "workspace_id", None), "--workspace-id")
|
|
275
|
+
crawler_session_id = _coordinate(
|
|
276
|
+
getattr(args, "crawler_session_id", None), "--crawler-session-id"
|
|
277
|
+
)
|
|
278
|
+
supplied_journal = getattr(args, "journal", None)
|
|
279
|
+
journal = CancellationJournal(
|
|
280
|
+
Path(str(supplied_journal)).expanduser()
|
|
281
|
+
if supplied_journal
|
|
282
|
+
else default_cancellation_journal_path(workspace_id, crawler_session_id)
|
|
283
|
+
)
|
|
284
|
+
# A bad coordinate or journal path is refused before Cloud is contacted; creating the durable
|
|
285
|
+
# record itself waits until the effective hosted credential exists, so an unauthenticated typo
|
|
286
|
+
# does not leave a file behind.
|
|
287
|
+
_journal_location(journal.path, create_parent=supplied_journal is None)
|
|
288
|
+
selected = client or StudioAcquisitionCancellationClient(_session(args))
|
|
289
|
+
journal.load_or_start(workspace_id=workspace_id, crawler_session_id=crawler_session_id)
|
|
290
|
+
receipt = journal.state.get("receipt")
|
|
291
|
+
if journal.state.get("state") == "cancelled":
|
|
292
|
+
if not isinstance(receipt, Mapping):
|
|
293
|
+
raise ThinLaneError(
|
|
294
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
295
|
+
"the cancellation journal says cancelled but holds no receipt",
|
|
296
|
+
)
|
|
297
|
+
_validate_receipt(receipt, workspace_id=workspace_id, crawler_session_id=crawler_session_id)
|
|
298
|
+
return dict(receipt)
|
|
299
|
+
returned, _replayed = selected.cancel(
|
|
300
|
+
workspace_id=workspace_id,
|
|
301
|
+
crawler_session_id=crawler_session_id,
|
|
302
|
+
idempotency_key=str(journal.state["idempotency_key"]),
|
|
303
|
+
)
|
|
304
|
+
_validate_receipt(returned, workspace_id=workspace_id, crawler_session_id=crawler_session_id)
|
|
305
|
+
journal.cancelled(returned)
|
|
306
|
+
return dict(returned)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _session(args: argparse.Namespace) -> StudioSession:
|
|
310
|
+
return open_studio_session(resolve_cloud_credentials())
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _coordinate(value: Any, flag: str) -> str:
|
|
314
|
+
if not isinstance(value, str) or _UUID.fullmatch(value) is None:
|
|
315
|
+
raise ThinLaneError(
|
|
316
|
+
"THIN_CANCELLATION_COORDINATE_INVALID",
|
|
317
|
+
f"{flag} must be one lowercase RFC 4122 UUID",
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
return str(UUID(value))
|
|
321
|
+
except ValueError as error: # pragma: no cover - the expression excludes malformed UUIDs
|
|
322
|
+
raise ThinLaneError(
|
|
323
|
+
"THIN_CANCELLATION_COORDINATE_INVALID", f"{flag} is not a UUID"
|
|
324
|
+
) from error
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _idempotency_key(workspace_id: str, crawler_session_id: str) -> str:
|
|
328
|
+
return "mr-data.acquire-cancel.v1:" + canonical_sha256(
|
|
329
|
+
{
|
|
330
|
+
"schema_version": "hosted-public-acquisition-cancellation-idempotency.v1",
|
|
331
|
+
"workspace_id": workspace_id,
|
|
332
|
+
"crawler_session_id": crawler_session_id,
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _retry_after(headers: Mapping[str, str]) -> int | None:
|
|
338
|
+
value = headers.get("retry-after", "")
|
|
339
|
+
if not value.isdecimal():
|
|
340
|
+
return None
|
|
341
|
+
seconds = int(value)
|
|
342
|
+
return seconds if 1 <= seconds <= MAX_RETRY_AFTER_SECONDS else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_receipt(
|
|
346
|
+
receipt: Mapping[str, Any], *, workspace_id: str, crawler_session_id: str
|
|
347
|
+
) -> None:
|
|
348
|
+
if set(receipt) != RECEIPT_FIELDS:
|
|
349
|
+
raise ThinLaneError(
|
|
350
|
+
"THIN_CANCELLATION_RECEIPT_INVALID",
|
|
351
|
+
"Studio's cancellation receipt has missing, malformed, or extra fields",
|
|
352
|
+
)
|
|
353
|
+
cancelled_at = receipt.get("cancelled_at")
|
|
354
|
+
if (
|
|
355
|
+
receipt.get("schema_version") != CANCELLATION_RECEIPT_VERSION
|
|
356
|
+
or receipt.get("workspace_id") != workspace_id
|
|
357
|
+
or receipt.get("crawler_session_id") != crawler_session_id
|
|
358
|
+
or receipt.get("status") != "cancelled"
|
|
359
|
+
or not isinstance(receipt.get("requester_principal_id"), str)
|
|
360
|
+
or _UUID.fullmatch(str(receipt.get("requester_principal_id"))) is None
|
|
361
|
+
or not isinstance(cancelled_at, str)
|
|
362
|
+
or _SECONDS_Z.fullmatch(cancelled_at) is None
|
|
363
|
+
):
|
|
364
|
+
raise ThinLaneError(
|
|
365
|
+
"THIN_CANCELLATION_RECEIPT_INVALID",
|
|
366
|
+
"Studio's cancellation receipt is not for this coordinate or is not terminally "
|
|
367
|
+
"cancelled",
|
|
368
|
+
)
|
|
369
|
+
try:
|
|
370
|
+
datetime.strptime(cancelled_at, "%Y-%m-%dT%H:%M:%SZ")
|
|
371
|
+
except ValueError as error:
|
|
372
|
+
raise ThinLaneError(
|
|
373
|
+
"THIN_CANCELLATION_RECEIPT_INVALID",
|
|
374
|
+
"Studio's cancellation receipt has no RFC3339 seconds timestamp",
|
|
375
|
+
) from error
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def default_cancellation_journal_path(workspace_id: str, crawler_session_id: str) -> Path:
|
|
379
|
+
"""The private, deterministic receipt location for one acquisition coordinate."""
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
default_credentials_dir()
|
|
383
|
+
/ _JOURNAL_STATE_DIRECTORY
|
|
384
|
+
/ (f"{workspace_id}-{crawler_session_id}.json")
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _journal_location(path: Path, *, create_parent: bool = False) -> None:
|
|
389
|
+
_require_secure_journal_primitives()
|
|
390
|
+
if path.name in {"", ".", ".."}:
|
|
391
|
+
raise ThinLaneError(
|
|
392
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
393
|
+
"--journal must name one ordinary file",
|
|
394
|
+
)
|
|
395
|
+
parent = path.parent
|
|
396
|
+
if create_parent:
|
|
397
|
+
_create_private_state_parent(parent)
|
|
398
|
+
try:
|
|
399
|
+
descriptor = _open_private_parent(parent)
|
|
400
|
+
except OSError as error:
|
|
401
|
+
raise ThinLaneError(
|
|
402
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
403
|
+
"--journal's parent must be one private directory owned by this user",
|
|
404
|
+
) from error
|
|
405
|
+
else:
|
|
406
|
+
os.close(descriptor)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _create_private_state_parent(parent: Path) -> None:
|
|
410
|
+
"""Create only our two state directories, then verify them through no-follow opens."""
|
|
411
|
+
|
|
412
|
+
root = default_credentials_dir()
|
|
413
|
+
try:
|
|
414
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
415
|
+
_assert_private_directory(root)
|
|
416
|
+
current = root
|
|
417
|
+
for part in Path(_JOURNAL_STATE_DIRECTORY).parts:
|
|
418
|
+
current = current / part
|
|
419
|
+
current.mkdir(mode=0o700, exist_ok=True)
|
|
420
|
+
_assert_private_directory(current)
|
|
421
|
+
except OSError as error:
|
|
422
|
+
raise ThinLaneError(
|
|
423
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
424
|
+
"the default cancellation journal directory could not be created securely",
|
|
425
|
+
) from error
|
|
426
|
+
if parent != current:
|
|
427
|
+
raise ThinLaneError(
|
|
428
|
+
"THIN_CANCELLATION_JOURNAL_INVALID", "the default cancellation journal path is invalid"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _assert_private_directory(path: Path) -> None:
|
|
433
|
+
descriptor = _open_private_parent(path)
|
|
434
|
+
os.close(descriptor)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _open_private_parent(path: Path) -> int:
|
|
438
|
+
_require_secure_journal_primitives()
|
|
439
|
+
flags = (
|
|
440
|
+
os.O_RDONLY
|
|
441
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
442
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
443
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
444
|
+
)
|
|
445
|
+
descriptor = os.open(path, flags)
|
|
446
|
+
try:
|
|
447
|
+
_validate_private_directory(os.fstat(descriptor))
|
|
448
|
+
except BaseException:
|
|
449
|
+
os.close(descriptor)
|
|
450
|
+
raise
|
|
451
|
+
return descriptor
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _require_secure_journal_primitives() -> None:
|
|
455
|
+
"""Refuse platforms without the descriptor operations that make journal writes safe.
|
|
456
|
+
|
|
457
|
+
Windows does not expose the POSIX ``dir_fd``/``O_NOFOLLOW`` contract needed to protect this
|
|
458
|
+
state from reparse-point replacement. A path-based fallback would make a cancellation retry
|
|
459
|
+
less safe than the first cancellation, so portability here means an explicit, pre-network
|
|
460
|
+
refusal until an equally strong native primitive is available.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
supported = getattr(os, "supports_dir_fd", frozenset())
|
|
464
|
+
if (
|
|
465
|
+
os.name != "posix"
|
|
466
|
+
or not all(operation in supported for operation in (os.open, os.rename, os.unlink))
|
|
467
|
+
or not hasattr(os, "O_NOFOLLOW")
|
|
468
|
+
or not hasattr(os, "O_DIRECTORY")
|
|
469
|
+
):
|
|
470
|
+
raise ThinLaneError(
|
|
471
|
+
"THIN_CANCELLATION_JOURNAL_UNSUPPORTED",
|
|
472
|
+
"this platform lacks the secure filesystem primitives required for cancellation "
|
|
473
|
+
"receipt journaling",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _validate_private_directory(info: os.stat_result) -> None:
|
|
478
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
479
|
+
raise OSError("journal parent is not a directory")
|
|
480
|
+
if os.name == "posix" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022):
|
|
481
|
+
raise OSError("journal parent is not private to this user")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _validate_private_file(info: os.stat_result) -> None:
|
|
485
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
486
|
+
raise OSError("journal is not a single-link regular file")
|
|
487
|
+
if os.name == "posix" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600):
|
|
488
|
+
raise OSError("journal is not a private file")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _remove_stage(parent: int, staged: str) -> None:
|
|
492
|
+
"""Best-effort cleanup of our unguessable staging name; unlink never follows links."""
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
os.unlink(staged, dir_fd=parent)
|
|
496
|
+
except FileNotFoundError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _read_journal(path: Path) -> dict[str, Any]:
|
|
501
|
+
parent: int | None = None
|
|
502
|
+
descriptor: int | None = None
|
|
503
|
+
try:
|
|
504
|
+
parent = _open_private_parent(path.parent)
|
|
505
|
+
flags = (
|
|
506
|
+
os.O_RDONLY
|
|
507
|
+
| os.O_NONBLOCK
|
|
508
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
509
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
510
|
+
| getattr(os, "O_BINARY", 0)
|
|
511
|
+
)
|
|
512
|
+
descriptor = os.open(path.name, flags, dir_fd=parent)
|
|
513
|
+
info = os.fstat(descriptor)
|
|
514
|
+
_validate_private_file(info)
|
|
515
|
+
if info.st_size > MAX_JOURNAL_BYTES:
|
|
516
|
+
raise OSError("journal exceeds its byte limit")
|
|
517
|
+
pieces: list[bytes] = []
|
|
518
|
+
remaining = MAX_JOURNAL_BYTES + 1
|
|
519
|
+
while remaining:
|
|
520
|
+
piece = os.read(descriptor, min(remaining, 1 << 16))
|
|
521
|
+
if not piece:
|
|
522
|
+
break
|
|
523
|
+
pieces.append(piece)
|
|
524
|
+
remaining -= len(piece)
|
|
525
|
+
raw = b"".join(pieces)
|
|
526
|
+
if len(raw) > MAX_JOURNAL_BYTES:
|
|
527
|
+
raise OSError("journal exceeds its byte limit")
|
|
528
|
+
value = parse_json(raw)
|
|
529
|
+
except (OSError, CanonicalJSONError) as error:
|
|
530
|
+
raise ThinLaneError(
|
|
531
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
532
|
+
"the cancellation journal could not be read as one canonical journal this client wrote",
|
|
533
|
+
) from error
|
|
534
|
+
finally:
|
|
535
|
+
if descriptor is not None:
|
|
536
|
+
os.close(descriptor)
|
|
537
|
+
if parent is not None:
|
|
538
|
+
os.close(parent)
|
|
539
|
+
if not isinstance(value, dict) or set(value) != JOURNAL_FIELDS:
|
|
540
|
+
raise ThinLaneError(
|
|
541
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
542
|
+
"the cancellation journal has missing, malformed, or extra fields",
|
|
543
|
+
)
|
|
544
|
+
if (
|
|
545
|
+
value.get("schema_version") != CANCELLATION_JOURNAL_VERSION
|
|
546
|
+
or value.get("state") not in {"prepared", "cancelled"}
|
|
547
|
+
or not isinstance(value.get("workspace_id"), str)
|
|
548
|
+
or not isinstance(value.get("crawler_session_id"), str)
|
|
549
|
+
or not isinstance(value.get("idempotency_key"), str)
|
|
550
|
+
or _IDEMPOTENCY.fullmatch(str(value.get("idempotency_key"))) is None
|
|
551
|
+
):
|
|
552
|
+
raise ThinLaneError(
|
|
553
|
+
"THIN_CANCELLATION_JOURNAL_INVALID", "the cancellation journal is invalid"
|
|
554
|
+
)
|
|
555
|
+
if value["state"] == "prepared" and value.get("receipt") is not None:
|
|
556
|
+
raise ThinLaneError(
|
|
557
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
558
|
+
"the prepared cancellation journal already holds a receipt",
|
|
559
|
+
)
|
|
560
|
+
if value["state"] == "cancelled" and not isinstance(value.get("receipt"), dict):
|
|
561
|
+
raise ThinLaneError(
|
|
562
|
+
"THIN_CANCELLATION_JOURNAL_INVALID",
|
|
563
|
+
"the cancelled cancellation journal holds no receipt",
|
|
564
|
+
)
|
|
565
|
+
return value
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
__all__ = [
|
|
569
|
+
"CANCELLATION_JOURNAL_VERSION",
|
|
570
|
+
"CANCELLATION_RECEIPT_VERSION",
|
|
571
|
+
"CANCEL_PUBLIC_ACQUISITION_PATH",
|
|
572
|
+
"JOURNAL_FIELDS",
|
|
573
|
+
"CancellationJournal",
|
|
574
|
+
"CancellationRefusal",
|
|
575
|
+
"StudioAcquisitionCancellationClient",
|
|
576
|
+
"acquire_cancel",
|
|
577
|
+
"declare_arguments",
|
|
578
|
+
"default_cancellation_journal_path",
|
|
579
|
+
]
|