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,617 @@
|
|
|
1
|
+
"""``mr-data approve`` and ``mr-data recipe-approve``, answered from Studio -- without deciding.
|
|
2
|
+
|
|
3
|
+
WHAT CHANGED, AND WHAT DID NOT. Studio PR #95 did not give a hosted command line the authority to
|
|
4
|
+
approve anything, and this module does not take any. A decision still needs a human: a run
|
|
5
|
+
candidate is decided only against a freshly verified, operation-bound step-up, and a Table recipe
|
|
6
|
+
only from an ordinary signed-in editor session. A Cloud-minted Studio token resolves to the
|
|
7
|
+
organization's *service* principal, which both of those gates refuse by design, and lending that
|
|
8
|
+
signing authority to a stored credential would dismantle the property the gates exist for.
|
|
9
|
+
|
|
10
|
+
What was missing was never authority. It was everything between "open a request" and "wait
|
|
11
|
+
forever": no way to say which decision was wanted or why, no way to tell the person who must settle
|
|
12
|
+
it where to go, and no way to read what they decided. Those three are what this module adds.
|
|
13
|
+
|
|
14
|
+
THE THREE CALLS, IN ORDER.
|
|
15
|
+
|
|
16
|
+
1. ``GET /v3/approvals/{id}`` -- read the request, its ``version``, its ``subject_digest`` and its
|
|
17
|
+
``ETag``. The ask is pinned to exactly that reading, so it can never be mistaken for an ask
|
|
18
|
+
about a later state of the same approval.
|
|
19
|
+
2. ``POST /v3/approvals/{id}:approve`` with ``If-Match`` and ``Idempotency-Key`` -- record the ask.
|
|
20
|
+
Studio answers with ``human_action``: the one path this subject actually takes, and the address
|
|
21
|
+
a person opens to take it. Studio is the only party that knows which of the two it is, and a
|
|
22
|
+
client that guessed would send half its users to a page that cannot do what they were told.
|
|
23
|
+
3. ``GET /v3/approvals/{id}`` on ``poll_after_seconds`` until the status is no longer pending, then
|
|
24
|
+
``GET /v3/approvals/{id}/decision`` -- the receipt: who decided, when, under which step-up, and
|
|
25
|
+
against which audit digest.
|
|
26
|
+
|
|
27
|
+
⚠ THE NEVER-DECIDES PROPERTY IS STRUCTURAL HERE, NOT A PROMISE IN A COMMENT. The only mutation
|
|
28
|
+
this module can make is :data:`APPROVE_PATH`, and :data:`DECIDE_PATH` is written down precisely so
|
|
29
|
+
a reader can see it is never called: ``tests/test_thin_parity.py`` asserts no request this lane
|
|
30
|
+
makes reaches it. Every other call below is a GET.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import re
|
|
37
|
+
import time
|
|
38
|
+
from collections.abc import Callable, Mapping
|
|
39
|
+
from typing import Any
|
|
40
|
+
from uuid import UUID
|
|
41
|
+
|
|
42
|
+
from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
|
|
43
|
+
|
|
44
|
+
# `_no_effect` is the accepted-but-inert flag reporter, imported rather than written a second
|
|
45
|
+
# time: `thin.parity` already owns the one rule about telling a default apart from an argument
|
|
46
|
+
# somebody typed, and two copies of that rule is how `--wait-seconds 0` starts being read as
|
|
47
|
+
# absent.
|
|
48
|
+
from mostlyright.data_harness.thin.parity import _no_effect
|
|
49
|
+
from mostlyright.data_harness.thin.runs import StudioApiClient, new_idempotency_key
|
|
50
|
+
from mostlyright.data_harness.thin.session import (
|
|
51
|
+
StudioSession,
|
|
52
|
+
open_studio_session,
|
|
53
|
+
run_dashboard_url,
|
|
54
|
+
)
|
|
55
|
+
from mostlyright.data_harness.thin.transport import ThinLaneError
|
|
56
|
+
from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
|
|
57
|
+
|
|
58
|
+
APPROVE_SCHEMA = f"{THIN_SCHEMA_PREFIX}-approval.v1"
|
|
59
|
+
|
|
60
|
+
#: The routes. ``APPROVE_PATH`` is the only one this lane may POST to.
|
|
61
|
+
GET_APPROVAL_PATH = "/v3/approvals/{approval_request_id}"
|
|
62
|
+
APPROVE_PATH = "/v3/approvals/{approval_request_id}:approve"
|
|
63
|
+
APPROVAL_DECISION_PATH = "/v3/approvals/{approval_request_id}/decision"
|
|
64
|
+
|
|
65
|
+
#: The route that records a decision, written down so that "this lane never calls it" is an
|
|
66
|
+
#: assertion a test can make rather than a sentence somebody has to believe. Nothing in this
|
|
67
|
+
#: module formats it.
|
|
68
|
+
DECIDE_PATH = "/v3/approvals/{approval_request_id}:decide"
|
|
69
|
+
|
|
70
|
+
#: ``approval.schema.json#/$defs/grant_request_command/properties/decision``.
|
|
71
|
+
DECISIONS = ("approved", "rejected")
|
|
72
|
+
|
|
73
|
+
#: ``approval.schema.json#/$defs/request/properties/status``. ``pending`` is the one that means
|
|
74
|
+
#: "keep waiting"; the other four all settle it, and three of them settle it as a no.
|
|
75
|
+
APPROVAL_STATUSES = ("pending", "approved", "rejected", "expired", "cancelled")
|
|
76
|
+
|
|
77
|
+
#: The two human paths, and the sentence each one gets. Studio names the kind; this names what a
|
|
78
|
+
#: person does about it, because ``dashboard_confirmation`` is not an instruction.
|
|
79
|
+
HUMAN_ACTION_SENTENCE = {
|
|
80
|
+
"interactive_step_up": (
|
|
81
|
+
"open this address and complete the step-up there; a run-candidate approval is recorded "
|
|
82
|
+
"only against a freshly verified, operation-bound step-up"
|
|
83
|
+
),
|
|
84
|
+
"dashboard_confirmation": (
|
|
85
|
+
"open this address and confirm it in the dashboard; a Table recipe is confirmed from an "
|
|
86
|
+
"ordinary signed-in editor session, which deliberately refuses a step-up credential"
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#: The one sentence this lane says about itself in every answer it prints, whatever the outcome.
|
|
91
|
+
#: It is a payload key rather than a docstring because the property it states -- that nothing here
|
|
92
|
+
#: decided anything -- is the property somebody reading the output needs to be able to check.
|
|
93
|
+
NEVER_DECIDES = (
|
|
94
|
+
"this command asked; it did not decide. Studio records an approval decision only from a "
|
|
95
|
+
"human, through the operation named under human_action"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
#: How long a `--wait-seconds` may ask for. Above this a person is not waiting at a terminal, and
|
|
99
|
+
#: a command line holding a socket open for a day is a command line somebody kills.
|
|
100
|
+
MAX_WAIT_SECONDS = 86_400
|
|
101
|
+
|
|
102
|
+
#: What to wait when Studio's own ``poll_after_seconds`` is missing or out of contract. The
|
|
103
|
+
#: contract bounds it at 1..3600; a value outside that is Studio saying something this lane does
|
|
104
|
+
#: not understand, and guessing small would turn one unreadable field into a poll storm.
|
|
105
|
+
DEFAULT_POLL_SECONDS = 15
|
|
106
|
+
MAX_POLL_SECONDS = 3_600
|
|
107
|
+
|
|
108
|
+
#: How long ``approve`` waits for a person by default. Fifteen minutes is long enough for somebody
|
|
109
|
+
#: who is at their desk and short enough that a CI job pointed at an approval nobody is watching
|
|
110
|
+
#: fails on its own rather than holding a runner until the job timeout kills it. ``0`` records the
|
|
111
|
+
#: ask, prints where it is settled, and returns.
|
|
112
|
+
DEFAULT_WAIT_SECONDS = 900
|
|
113
|
+
|
|
114
|
+
_ETAG = re.compile(r'^(?:W/)?"[\x21\x23-\x7e]{1,128}"$')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class StudioApprovalClient(StudioApiClient):
|
|
118
|
+
"""The three approval calls, and no fourth one.
|
|
119
|
+
|
|
120
|
+
``approve`` is a POST and is the only mutation in this class. It writes an ask and cannot write
|
|
121
|
+
a decision: Studio's handler creates one immutable grant-request row, never sets ``status``,
|
|
122
|
+
and never reaches the decision path -- which the upstream suite asserts directly, and which
|
|
123
|
+
this lane relies on rather than restates.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def get_approval(self, approval_request_id: str) -> tuple[dict[str, Any], str | None]:
|
|
127
|
+
"""One approval request, with the ETag a later ``If-Match`` has to carry.
|
|
128
|
+
|
|
129
|
+
The ETag is returned beside the body rather than folded into it, because it is a transport
|
|
130
|
+
fact about this reading and not a field of the resource: putting it in the payload would
|
|
131
|
+
make it look like something Studio said about the approval.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
headers: dict[str, str] = {}
|
|
135
|
+
record = self._call(
|
|
136
|
+
"GET",
|
|
137
|
+
GET_APPROVAL_PATH.format(approval_request_id=approval_request_id),
|
|
138
|
+
expected=(200,),
|
|
139
|
+
response_headers=headers,
|
|
140
|
+
)
|
|
141
|
+
etag = headers.get("etag")
|
|
142
|
+
return record, etag if isinstance(etag, str) and _ETAG.fullmatch(etag) else None
|
|
143
|
+
|
|
144
|
+
def request_grant(
|
|
145
|
+
self,
|
|
146
|
+
approval_request_id: str,
|
|
147
|
+
*,
|
|
148
|
+
body: Mapping[str, Any],
|
|
149
|
+
if_match: str,
|
|
150
|
+
idempotency_key: str | None = None,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Record what the caller wants, pinned to the exact request version it read."""
|
|
153
|
+
|
|
154
|
+
return self._call(
|
|
155
|
+
"POST",
|
|
156
|
+
APPROVE_PATH.format(approval_request_id=approval_request_id),
|
|
157
|
+
body=body,
|
|
158
|
+
extra_headers={
|
|
159
|
+
"If-Match": if_match,
|
|
160
|
+
"Idempotency-Key": idempotency_key or new_idempotency_key("mr-data-approve"),
|
|
161
|
+
},
|
|
162
|
+
expected=(201,),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def get_decision(self, approval_request_id: str) -> dict[str, Any]:
|
|
166
|
+
"""The immutable decision that settled one approval. A read, and it grants nothing."""
|
|
167
|
+
|
|
168
|
+
return self._call(
|
|
169
|
+
"GET",
|
|
170
|
+
APPROVAL_DECISION_PATH.format(approval_request_id=approval_request_id),
|
|
171
|
+
expected=(200,),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def grant_request_body(
|
|
176
|
+
*,
|
|
177
|
+
workspace_id: UUID,
|
|
178
|
+
decision: str,
|
|
179
|
+
expected_request_version: int,
|
|
180
|
+
subject_digest: str,
|
|
181
|
+
reason: str | None = None,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""The ``ApprovalGrantRequestCommand`` this lane sends, with the contract applied here.
|
|
184
|
+
|
|
185
|
+
Every field is refused locally before it can become a 422 from Studio, and ``reason`` is the
|
|
186
|
+
one field a person types freely: it is bounded and its control characters are refused by the
|
|
187
|
+
same pattern the schema declares, because a sentence that lands in somebody else's approval
|
|
188
|
+
queue is attacker-controlled text wherever it came from.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
if decision not in DECISIONS:
|
|
192
|
+
raise ThinLaneError(
|
|
193
|
+
"THIN_REQUEST_INVALID", "the decision to ask for must be approved or rejected"
|
|
194
|
+
)
|
|
195
|
+
if type(expected_request_version) is not int or expected_request_version < 1:
|
|
196
|
+
raise ThinLaneError(
|
|
197
|
+
"THIN_RESPONSE_INVALID", "Studio's approval carries no version to pin the ask to"
|
|
198
|
+
)
|
|
199
|
+
if not isinstance(subject_digest, str) or not subject_digest:
|
|
200
|
+
raise ThinLaneError(
|
|
201
|
+
"THIN_RESPONSE_INVALID", "Studio's approval carries no subject digest to pin the ask to"
|
|
202
|
+
)
|
|
203
|
+
body: dict[str, Any] = {
|
|
204
|
+
"schema_version": "3.0.0",
|
|
205
|
+
"workspace_id": str(workspace_id),
|
|
206
|
+
"decision": decision,
|
|
207
|
+
"expected_request_version": expected_request_version,
|
|
208
|
+
"subject_digest": subject_digest,
|
|
209
|
+
}
|
|
210
|
+
if reason is not None:
|
|
211
|
+
if not 1 <= len(reason) <= 2000 or re.search(r"[\x00-\x1f\x7f]", reason):
|
|
212
|
+
raise ThinLaneError(
|
|
213
|
+
"THIN_REQUEST_INVALID",
|
|
214
|
+
"--reason is one line of at most 2000 characters, with no control characters",
|
|
215
|
+
)
|
|
216
|
+
body["reason"] = reason
|
|
217
|
+
return body
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _session(args: argparse.Namespace) -> StudioSession:
|
|
221
|
+
return open_studio_session(resolve_cloud_credentials())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _client(args: argparse.Namespace) -> StudioApprovalClient:
|
|
225
|
+
return StudioApprovalClient(_session(args))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _approval_id(value: Any) -> str:
|
|
229
|
+
"""Refuse anything that is not an approval identifier before it reaches a URL path.
|
|
230
|
+
|
|
231
|
+
Checked before a credential is resolved and a token minted, and the sentence names the
|
|
232
|
+
substitution: a path to a Recipe file is exactly what somebody moving over from the local lane
|
|
233
|
+
types here first, because that is what ``--recipe`` means there.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
return str(UUID(str(value)))
|
|
238
|
+
except (ValueError, AttributeError) as error:
|
|
239
|
+
raise ThinLaneError(
|
|
240
|
+
"THIN_REQUEST_INVALID",
|
|
241
|
+
f"{value!r} is not an approval identifier; the hosted lane names the approval Studio "
|
|
242
|
+
f"holds, not a Recipe file on this computer",
|
|
243
|
+
) from error
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _human_action(grant: Mapping[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Where a person settles this, said in their words as well as Studio's.
|
|
248
|
+
|
|
249
|
+
⚠ Mandatory. An ask whose answer carries no human action is an ask nobody can act on, and
|
|
250
|
+
reporting one as a success would be the "wait forever" this whole lane exists to end.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
action = grant.get("human_action")
|
|
254
|
+
if not isinstance(action, Mapping):
|
|
255
|
+
raise ThinLaneError(
|
|
256
|
+
"THIN_RESPONSE_INVALID",
|
|
257
|
+
"Studio recorded the ask without saying where a human settles it",
|
|
258
|
+
)
|
|
259
|
+
kind = action.get("kind")
|
|
260
|
+
url = action.get("url")
|
|
261
|
+
if not isinstance(kind, str) or not isinstance(url, str) or not url.startswith("https://"):
|
|
262
|
+
raise ThinLaneError(
|
|
263
|
+
"THIN_RESPONSE_INVALID", "Studio named a human action with no usable address"
|
|
264
|
+
)
|
|
265
|
+
roles = action.get("roles")
|
|
266
|
+
return {
|
|
267
|
+
"kind": kind,
|
|
268
|
+
"url": url,
|
|
269
|
+
"operation_id": action.get("operation_id"),
|
|
270
|
+
"roles": list(roles) if isinstance(roles, list) else [],
|
|
271
|
+
"what_to_do": HUMAN_ACTION_SENTENCE.get(
|
|
272
|
+
kind, "open this address; Studio named a human path this client has no sentence for"
|
|
273
|
+
),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _poll_seconds(grant: Mapping[str, Any]) -> int:
|
|
278
|
+
declared = grant.get("poll_after_seconds")
|
|
279
|
+
if type(declared) is int and 1 <= declared <= MAX_POLL_SECONDS:
|
|
280
|
+
return declared
|
|
281
|
+
return DEFAULT_POLL_SECONDS
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _decision_receipt(decision: Mapping[str, Any]) -> dict[str, Any]:
|
|
285
|
+
"""The decision coordinates a person reads, exactly as Studio reported them."""
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
key: decision[key]
|
|
289
|
+
for key in (
|
|
290
|
+
"approval_decision_id",
|
|
291
|
+
"approval_request_id",
|
|
292
|
+
"run_id",
|
|
293
|
+
"decision",
|
|
294
|
+
"actor_principal_id",
|
|
295
|
+
"actor_kind",
|
|
296
|
+
"actor_role",
|
|
297
|
+
"interactive",
|
|
298
|
+
"step_up",
|
|
299
|
+
"expected_request_version",
|
|
300
|
+
"subject_digest",
|
|
301
|
+
"decided_at",
|
|
302
|
+
"audit_digest",
|
|
303
|
+
)
|
|
304
|
+
if key in decision
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _request_receipt(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
309
|
+
return {
|
|
310
|
+
key: record[key]
|
|
311
|
+
for key in (
|
|
312
|
+
"approval_request_id",
|
|
313
|
+
"subject_kind",
|
|
314
|
+
"subject_id",
|
|
315
|
+
"recipe_proposal_id",
|
|
316
|
+
"run_id",
|
|
317
|
+
"approval_type",
|
|
318
|
+
"status",
|
|
319
|
+
"subject_digest",
|
|
320
|
+
"version",
|
|
321
|
+
"created_at",
|
|
322
|
+
"expires_at",
|
|
323
|
+
"approval_decision_id",
|
|
324
|
+
"decided_at",
|
|
325
|
+
)
|
|
326
|
+
if key in record
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
#: Every local flag whose meaning does not survive the move, and the sentence it gets. Accepted and
|
|
331
|
+
#: reported, never refused, for the reason the rest of this lane already gives: a flag that errors
|
|
332
|
+
#: is a script that breaks on a machine that installed a different extra.
|
|
333
|
+
#:
|
|
334
|
+
#: The first four are the same argument in four spellings -- who decided, when, under what -- and
|
|
335
|
+
#: every one of them is a fact Studio records about the human who actually decided. A hosted lane
|
|
336
|
+
#: that accepted a caller's word for any of them would be recording an approval's provenance from
|
|
337
|
+
#: the party that is not allowed to grant it.
|
|
338
|
+
_APPROVAL_NO_EFFECT = (
|
|
339
|
+
(
|
|
340
|
+
"approved_by",
|
|
341
|
+
"--approved-by",
|
|
342
|
+
"Studio records who decided, from the human session that decided; a caller cannot name "
|
|
343
|
+
"the approver of a decision it is not allowed to make",
|
|
344
|
+
),
|
|
345
|
+
(
|
|
346
|
+
"approved_at",
|
|
347
|
+
"--approved-at",
|
|
348
|
+
"Studio records when the decision was made, from its own clock at the moment it was made",
|
|
349
|
+
),
|
|
350
|
+
(
|
|
351
|
+
"approval_id",
|
|
352
|
+
"--approval-id",
|
|
353
|
+
"the approval already has an identifier here: it is the one this command names",
|
|
354
|
+
),
|
|
355
|
+
(
|
|
356
|
+
"approval_policy",
|
|
357
|
+
"--approval-policy",
|
|
358
|
+
"the policy a hosted approval is decided under is the workspace's, and Studio applies it",
|
|
359
|
+
),
|
|
360
|
+
(
|
|
361
|
+
"approval_policy_digest",
|
|
362
|
+
"--approval-policy-digest",
|
|
363
|
+
"the policy a hosted approval is decided under is the workspace's, and Studio applies it",
|
|
364
|
+
),
|
|
365
|
+
(
|
|
366
|
+
"output",
|
|
367
|
+
"--output",
|
|
368
|
+
"the decision is Studio's immutable record and is read back from it; writing a second "
|
|
369
|
+
"copy here would be a local file that says approved and proves nothing",
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def approve(
|
|
375
|
+
args: argparse.Namespace,
|
|
376
|
+
*,
|
|
377
|
+
client: StudioApprovalClient | None = None,
|
|
378
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
379
|
+
clock: Callable[[], float] = time.monotonic,
|
|
380
|
+
) -> dict[str, Any]:
|
|
381
|
+
"""``mr-data approve``: ask for the decision, say where a person makes it, read what they did.
|
|
382
|
+
|
|
383
|
+
The local command freezes a Recipe on this computer by writing an approval file beside it.
|
|
384
|
+
There is no file here and no Recipe here: a hosted approval is a record Studio holds and a
|
|
385
|
+
decision a person makes in a browser, so this names the approval, records what was asked for,
|
|
386
|
+
prints where it is settled, and then reads the receipt back.
|
|
387
|
+
|
|
388
|
+
⚠ WHAT ``approved`` MEANS IN THIS PAYLOAD. It means a human recorded that decision and Studio
|
|
389
|
+
returned its receipt. It never means this command approved anything, and the payload says so
|
|
390
|
+
under ``never_decides`` whatever the outcome.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
approval_id = _approval_id(getattr(args, "recipe", None))
|
|
394
|
+
selected = client or _client(args)
|
|
395
|
+
record, etag = selected.get_approval(approval_id)
|
|
396
|
+
if etag is None:
|
|
397
|
+
raise ThinLaneError(
|
|
398
|
+
"THIN_RESPONSE_INVALID",
|
|
399
|
+
"Studio answered the approval without an ETag, and the ask must be pinned to the "
|
|
400
|
+
"exact version it was made against",
|
|
401
|
+
)
|
|
402
|
+
grant = selected.request_grant(
|
|
403
|
+
approval_id,
|
|
404
|
+
body=grant_request_body(
|
|
405
|
+
workspace_id=selected.session.workspace_id,
|
|
406
|
+
decision=getattr(args, "decision", None) or "approved",
|
|
407
|
+
expected_request_version=record.get("version"),
|
|
408
|
+
subject_digest=record.get("subject_digest"),
|
|
409
|
+
reason=getattr(args, "reason", None),
|
|
410
|
+
),
|
|
411
|
+
if_match=etag,
|
|
412
|
+
)
|
|
413
|
+
action = _human_action(grant)
|
|
414
|
+
payload: dict[str, Any] = {
|
|
415
|
+
"schema_version": APPROVE_SCHEMA,
|
|
416
|
+
"status": "approval_requested",
|
|
417
|
+
"lane": "hosted",
|
|
418
|
+
"approval_request_id": approval_id,
|
|
419
|
+
"workspace_id": str(selected.session.workspace_id),
|
|
420
|
+
"asked_for": grant.get("decision"),
|
|
421
|
+
"requested_by_principal_id": grant.get("requested_by_principal_id"),
|
|
422
|
+
"requested_by_kind": grant.get("requested_by_kind"),
|
|
423
|
+
"approval_grant_request_id": grant.get("approval_grant_request_id"),
|
|
424
|
+
"human_action": action,
|
|
425
|
+
"never_decides": NEVER_DECIDES,
|
|
426
|
+
"request": _request_receipt(record),
|
|
427
|
+
"flags_without_effect": _no_effect(args, _APPROVAL_NO_EFFECT),
|
|
428
|
+
}
|
|
429
|
+
if getattr(args, "json", False) is False:
|
|
430
|
+
# Printed before the wait rather than after it. The whole point of `human_action` is that
|
|
431
|
+
# somebody has to go and do something, and a line that arrives when the waiting is over is
|
|
432
|
+
# a line that arrived too late to be the reason it ended.
|
|
433
|
+
print(f"{action['what_to_do']}:", flush=True)
|
|
434
|
+
print(action["url"], flush=True)
|
|
435
|
+
settled = _wait_for_decision(
|
|
436
|
+
selected,
|
|
437
|
+
approval_id,
|
|
438
|
+
poll_seconds=_poll_seconds(grant),
|
|
439
|
+
wait_seconds=_wait_seconds(args),
|
|
440
|
+
sleep=sleep,
|
|
441
|
+
clock=clock,
|
|
442
|
+
)
|
|
443
|
+
if settled is None:
|
|
444
|
+
payload["settled"] = False
|
|
445
|
+
payload["note"] = (
|
|
446
|
+
"Still waiting on a person. Nothing was decided, and running this command again "
|
|
447
|
+
"records the same ask rather than a second one."
|
|
448
|
+
)
|
|
449
|
+
return payload
|
|
450
|
+
payload["status"] = "approval_settled"
|
|
451
|
+
payload["settled"] = True
|
|
452
|
+
payload["request"] = _request_receipt(settled)
|
|
453
|
+
payload["approval_status"] = settled.get("status")
|
|
454
|
+
payload["decision"] = _decision_receipt(selected.get_decision(approval_id))
|
|
455
|
+
return payload
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def recipe_approve(
|
|
459
|
+
args: argparse.Namespace,
|
|
460
|
+
*,
|
|
461
|
+
client: StudioApprovalClient | None = None,
|
|
462
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
463
|
+
clock: Callable[[], float] = time.monotonic,
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
"""``mr-data recipe-approve``: the same act, about one Table recipe.
|
|
466
|
+
|
|
467
|
+
The two names stay distinct here for the reason they are distinct locally -- one binds an
|
|
468
|
+
approval to an exact recipe fingerprint, the other is the general approval -- and the hosted
|
|
469
|
+
route is the same one, because Studio's approval request already carries which kind of subject
|
|
470
|
+
it is about. What differs is the human path Studio names in the answer, and this command says
|
|
471
|
+
so when the approval it was pointed at is not about a recipe at all.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
payload = approve(args, client=client, sleep=sleep, clock=clock)
|
|
475
|
+
subject = payload.get("request", {}).get("subject_kind")
|
|
476
|
+
if subject is None or subject == "table_recipe":
|
|
477
|
+
if subject == "table_recipe" and approve_exit_code(payload) == 0:
|
|
478
|
+
payload["build"] = _what_follows_an_approved_recipe(client or _client(args), payload)
|
|
479
|
+
return payload
|
|
480
|
+
# ⚠ APPENDED, NEVER ASSIGNED. `approve` writes its own note when nobody has settled the
|
|
481
|
+
# approval yet, and that note is the one thing a caller acts on; overwriting it to say
|
|
482
|
+
# something about the subject kind would answer a question nobody asked with the answer to
|
|
483
|
+
# the one they did.
|
|
484
|
+
said = (
|
|
485
|
+
f"This approval is about a {subject}, not a Table recipe. The ask was recorded and the "
|
|
486
|
+
"human path above is the right one for it; mr-data approve is its name."
|
|
487
|
+
)
|
|
488
|
+
existing = payload.get("note")
|
|
489
|
+
payload["note"] = f"{existing} {said}" if isinstance(existing, str) and existing else said
|
|
490
|
+
return payload
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _what_follows_an_approved_recipe(
|
|
494
|
+
client: StudioApprovalClient, payload: Mapping[str, Any]
|
|
495
|
+
) -> dict[str, Any]:
|
|
496
|
+
"""Whether the approval already started the Build, or the build command that will.
|
|
497
|
+
|
|
498
|
+
Since Studio #124 a person confirming a Table recipe in the dashboard can start its first
|
|
499
|
+
hosted Build in the same transaction. Once a decision is approved, the question a caller acts
|
|
500
|
+
on is therefore "is there a run already, or do I queue one" -- and ``POST /v3/runs`` refuses
|
|
501
|
+
the first case rather than replaying it, so the answer has to be read from the approval's own
|
|
502
|
+
start projection: a run means nothing is left to queue; its absence means ``mr-data build``
|
|
503
|
+
with the coordinates the approval carries.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
request = payload.get("request", {})
|
|
507
|
+
approval_id = str(payload.get("approval_request_id"))
|
|
508
|
+
started = client.approval_start(approval_id)
|
|
509
|
+
if started is not None:
|
|
510
|
+
run = started.get("run") if isinstance(started.get("run"), Mapping) else {}
|
|
511
|
+
run_id = run.get("run_id")
|
|
512
|
+
if not isinstance(run_id, str):
|
|
513
|
+
raise ThinLaneError(
|
|
514
|
+
"THIN_RESPONSE_INVALID", "Studio named a started Build without a run"
|
|
515
|
+
)
|
|
516
|
+
return {
|
|
517
|
+
"state": "run_already_queued",
|
|
518
|
+
"run_id": run_id,
|
|
519
|
+
"dashboard_url": run_dashboard_url(client.session.cloud_url, run_id),
|
|
520
|
+
"watch_command": f"mr-data watch {run_id} --hosted",
|
|
521
|
+
"note": (
|
|
522
|
+
"The confirmation started the first hosted Build in the same act; there is "
|
|
523
|
+
"nothing to queue. Follow this run."
|
|
524
|
+
),
|
|
525
|
+
}
|
|
526
|
+
proposal_id = request.get("recipe_proposal_id")
|
|
527
|
+
digest = request.get("subject_digest")
|
|
528
|
+
return {
|
|
529
|
+
"state": "build_not_started",
|
|
530
|
+
"build_command": (
|
|
531
|
+
f"mr-data build --recipe-proposal {proposal_id} --recipe-digest {digest} --hosted"
|
|
532
|
+
),
|
|
533
|
+
"note": (
|
|
534
|
+
"The recipe is approved and no Build has been started for it; queue the first hosted "
|
|
535
|
+
"Build with the command above. It replays to the same run if one is started meanwhile."
|
|
536
|
+
),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _wait_seconds(args: argparse.Namespace) -> int:
|
|
541
|
+
declared = getattr(args, "wait_seconds", None)
|
|
542
|
+
if declared is None:
|
|
543
|
+
return DEFAULT_WAIT_SECONDS
|
|
544
|
+
if type(declared) is not int or not 0 <= declared <= MAX_WAIT_SECONDS:
|
|
545
|
+
raise ThinLaneError(
|
|
546
|
+
"THIN_REQUEST_INVALID",
|
|
547
|
+
f"--wait-seconds is from 0 to {MAX_WAIT_SECONDS}; 0 records the ask and returns",
|
|
548
|
+
)
|
|
549
|
+
return declared
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _wait_for_decision(
|
|
553
|
+
client: StudioApprovalClient,
|
|
554
|
+
approval_id: str,
|
|
555
|
+
*,
|
|
556
|
+
poll_seconds: int,
|
|
557
|
+
wait_seconds: int,
|
|
558
|
+
sleep: Callable[[float], None],
|
|
559
|
+
clock: Callable[[], float],
|
|
560
|
+
) -> dict[str, Any] | None:
|
|
561
|
+
"""Poll until the approval is no longer pending, or until the caller's patience runs out.
|
|
562
|
+
|
|
563
|
+
``None`` when it is still pending at the deadline, which is an answer and not a failure: the
|
|
564
|
+
ask was recorded, and a person has simply not got to it yet. The cadence is Studio's own
|
|
565
|
+
``poll_after_seconds`` rather than a number chosen here, so a backend under load can slow every
|
|
566
|
+
client down by saying so once.
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
deadline = clock() + wait_seconds
|
|
570
|
+
while True:
|
|
571
|
+
record, _etag = client.get_approval(approval_id)
|
|
572
|
+
status = record.get("status")
|
|
573
|
+
if status != "pending":
|
|
574
|
+
return record
|
|
575
|
+
remaining = deadline - clock()
|
|
576
|
+
if remaining <= 0:
|
|
577
|
+
return None
|
|
578
|
+
sleep(min(float(poll_seconds), remaining))
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def approve_exit_code(payload: Mapping[str, Any]) -> int:
|
|
582
|
+
"""``2`` unless a human recorded the decision that was asked for.
|
|
583
|
+
|
|
584
|
+
Three different outcomes exit 2 here -- rejected, expired or cancelled, and still pending --
|
|
585
|
+
and that is deliberate: a script gating on an approval must not read any of them as a yes, and
|
|
586
|
+
the payload is where the difference between them is stated.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
if payload.get("settled") is not True:
|
|
590
|
+
return 2
|
|
591
|
+
decision = payload.get("decision")
|
|
592
|
+
asked = payload.get("asked_for")
|
|
593
|
+
if not isinstance(decision, Mapping) or decision.get("decision") != asked:
|
|
594
|
+
return 2
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
__all__ = [
|
|
599
|
+
"APPROVAL_DECISION_PATH",
|
|
600
|
+
"APPROVAL_STATUSES",
|
|
601
|
+
"APPROVE_PATH",
|
|
602
|
+
"APPROVE_SCHEMA",
|
|
603
|
+
"DECIDE_PATH",
|
|
604
|
+
"DECISIONS",
|
|
605
|
+
"DEFAULT_POLL_SECONDS",
|
|
606
|
+
"DEFAULT_WAIT_SECONDS",
|
|
607
|
+
"GET_APPROVAL_PATH",
|
|
608
|
+
"HUMAN_ACTION_SENTENCE",
|
|
609
|
+
"MAX_POLL_SECONDS",
|
|
610
|
+
"MAX_WAIT_SECONDS",
|
|
611
|
+
"NEVER_DECIDES",
|
|
612
|
+
"StudioApprovalClient",
|
|
613
|
+
"approve",
|
|
614
|
+
"approve_exit_code",
|
|
615
|
+
"grant_request_body",
|
|
616
|
+
"recipe_approve",
|
|
617
|
+
]
|