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,447 @@
|
|
|
1
|
+
"""Bounded, direct Cloud v1 device-key management client.
|
|
2
|
+
|
|
3
|
+
The Cloud service is authoritative for device-key liveness and identity. This module only
|
|
4
|
+
projects a fixed public allowlist from its responses; it never treats local metadata as authority
|
|
5
|
+
and never includes a credential, raw response, header, or exception representation in an error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import ssl
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Final
|
|
18
|
+
|
|
19
|
+
REQUEST_TIMEOUT_SECONDS: Final = 10.0
|
|
20
|
+
MAX_RESPONSE_BYTES: Final = 64 * 1024
|
|
21
|
+
MAX_KEY_ID_CHARS: Final = 256
|
|
22
|
+
_ROLE_VALUES: Final = frozenset({"owner", "admin", "editor", "viewer"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CloudAuthError(RuntimeError):
|
|
26
|
+
"""A stable, credential-free client outcome."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code: str, detail: str, *, retry_after_seconds: int | None = None) -> None:
|
|
29
|
+
super().__init__(f"{code}: {detail}")
|
|
30
|
+
self.code = code
|
|
31
|
+
self.detail = detail
|
|
32
|
+
self.retry_after_seconds = retry_after_seconds
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
"CloudAuthError("
|
|
37
|
+
f"code={self.code!r}, detail={self.detail!r}, "
|
|
38
|
+
f"retry_after_seconds={self.retry_after_seconds!r})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CloudAuthResponse:
|
|
44
|
+
"""A bounded response seam used by both urllib and isolated tests."""
|
|
45
|
+
|
|
46
|
+
status: int
|
|
47
|
+
headers: Mapping[str, str]
|
|
48
|
+
body: bytes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DeviceMetadata:
|
|
53
|
+
"""Display-safe device metadata projected from the Cloud allowlist."""
|
|
54
|
+
|
|
55
|
+
key_id: str
|
|
56
|
+
name: str
|
|
57
|
+
prefix: str
|
|
58
|
+
last_four: str
|
|
59
|
+
created_at: str
|
|
60
|
+
last_used_at: str | None
|
|
61
|
+
current: bool | None = None
|
|
62
|
+
|
|
63
|
+
def to_receipt(self) -> dict[str, str | bool | None]:
|
|
64
|
+
receipt: dict[str, str | bool | None] = {
|
|
65
|
+
"id": self.key_id,
|
|
66
|
+
"name": self.name,
|
|
67
|
+
"prefix": self.prefix,
|
|
68
|
+
"last_four": self.last_four,
|
|
69
|
+
"created_at": self.created_at,
|
|
70
|
+
"last_used_at": self.last_used_at,
|
|
71
|
+
}
|
|
72
|
+
if self.current is not None:
|
|
73
|
+
receipt["current"] = self.current
|
|
74
|
+
return receipt
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class CloudIdentity:
|
|
79
|
+
organization_id: str
|
|
80
|
+
user_id: str
|
|
81
|
+
role: str
|
|
82
|
+
|
|
83
|
+
def to_receipt(self) -> dict[str, str]:
|
|
84
|
+
return {
|
|
85
|
+
"organization_id": self.organization_id,
|
|
86
|
+
"user_id": self.user_id,
|
|
87
|
+
"role": self.role,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class StudioIdentity:
|
|
93
|
+
workspace_id: str
|
|
94
|
+
principal_id: str
|
|
95
|
+
|
|
96
|
+
def to_receipt(self) -> dict[str, str]:
|
|
97
|
+
return {"workspace_id": self.workspace_id, "principal_id": self.principal_id}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class CurrentIdentity:
|
|
102
|
+
cloud: CloudIdentity
|
|
103
|
+
studio: StudioIdentity | None
|
|
104
|
+
|
|
105
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"cloud": self.cloud.to_receipt(),
|
|
108
|
+
"studio": None if self.studio is None else self.studio.to_receipt(),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class CurrentDevice:
|
|
114
|
+
device: DeviceMetadata
|
|
115
|
+
identity: CurrentIdentity
|
|
116
|
+
|
|
117
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
118
|
+
return {"device": self.device.to_receipt(), "identity": self.identity.to_receipt()}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class RevokeProof:
|
|
123
|
+
"""One exact remote proof; callers still own every local state transition."""
|
|
124
|
+
|
|
125
|
+
status: str
|
|
126
|
+
|
|
127
|
+
def to_receipt(self) -> dict[str, str]:
|
|
128
|
+
return {"status": self.status}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
Transport = Callable[[urllib.request.Request, float], CloudAuthResponse]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
135
|
+
"""Do not let an auth header cross an unreviewed redirect boundary."""
|
|
136
|
+
|
|
137
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
138
|
+
del args, kwargs
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _ObjectPairs(list[tuple[str, Any]]):
|
|
143
|
+
"""Marks JSON objects so a top-level array cannot imitate an object pair list."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CloudAuthClient:
|
|
147
|
+
"""The exact v1 current/list/delete contract, over direct HTTPS only."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
cloud_url: str,
|
|
152
|
+
raw_key: str,
|
|
153
|
+
*,
|
|
154
|
+
transport: Transport | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self._base_url = _validated_base_url(cloud_url)
|
|
157
|
+
if not isinstance(raw_key, str) or not raw_key:
|
|
158
|
+
raise CloudAuthError(
|
|
159
|
+
"CLOUD_AUTH_CREDENTIAL_INVALID", "the device credential is invalid"
|
|
160
|
+
)
|
|
161
|
+
self._raw_key = raw_key
|
|
162
|
+
self._transport = transport or _urllib_transport
|
|
163
|
+
|
|
164
|
+
def __repr__(self) -> str:
|
|
165
|
+
return f"CloudAuthClient(cloud_url={self._base_url!r}, raw_key='<redacted>')"
|
|
166
|
+
|
|
167
|
+
def current(self) -> CurrentDevice:
|
|
168
|
+
"""Return the allowlisted current device and remotely authoritative identity."""
|
|
169
|
+
|
|
170
|
+
response = self._request("GET", "/api/cli/v1/device-keys/current")
|
|
171
|
+
value = _json_object(response.body)
|
|
172
|
+
_require_api_version(value)
|
|
173
|
+
device = _device_metadata(value.get("deviceKey"))
|
|
174
|
+
identity_value = value.get("identity")
|
|
175
|
+
if not isinstance(identity_value, dict):
|
|
176
|
+
raise _contract_error()
|
|
177
|
+
cloud_value = identity_value.get("cloud")
|
|
178
|
+
if not isinstance(cloud_value, dict):
|
|
179
|
+
raise _contract_error()
|
|
180
|
+
organization_id = _required_text(cloud_value, "organizationId")
|
|
181
|
+
user_id = _required_text(cloud_value, "userId")
|
|
182
|
+
role = _required_text(cloud_value, "role")
|
|
183
|
+
if role not in _ROLE_VALUES:
|
|
184
|
+
raise _contract_error()
|
|
185
|
+
studio_value = identity_value.get("studio")
|
|
186
|
+
if studio_value is None:
|
|
187
|
+
studio = None
|
|
188
|
+
elif isinstance(studio_value, dict):
|
|
189
|
+
# Both fields must be present together. Any additional remote data stays remote.
|
|
190
|
+
studio = StudioIdentity(
|
|
191
|
+
workspace_id=_required_text(studio_value, "workspaceId"),
|
|
192
|
+
principal_id=_required_text(studio_value, "principalId"),
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
raise _contract_error()
|
|
196
|
+
return CurrentDevice(
|
|
197
|
+
device=device,
|
|
198
|
+
identity=CurrentIdentity(
|
|
199
|
+
cloud=CloudIdentity(
|
|
200
|
+
organization_id=organization_id,
|
|
201
|
+
user_id=user_id,
|
|
202
|
+
role=role,
|
|
203
|
+
),
|
|
204
|
+
studio=studio,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def list_devices(self) -> tuple[DeviceMetadata, ...]:
|
|
209
|
+
"""Return Cloud-ordered metadata only; no local capability is inferred."""
|
|
210
|
+
|
|
211
|
+
response = self._request("GET", "/api/cli/v1/device-keys")
|
|
212
|
+
value = _json_object(response.body)
|
|
213
|
+
_require_api_version(value)
|
|
214
|
+
device_keys = value.get("deviceKeys")
|
|
215
|
+
if not isinstance(device_keys, list):
|
|
216
|
+
raise _contract_error()
|
|
217
|
+
return tuple(_device_metadata(item, require_current=True) for item in device_keys)
|
|
218
|
+
|
|
219
|
+
def revoke(
|
|
220
|
+
self,
|
|
221
|
+
key_id: str,
|
|
222
|
+
*,
|
|
223
|
+
exact_secret_same_key_replay: bool = False,
|
|
224
|
+
) -> RevokeProof:
|
|
225
|
+
"""Prove only Cloud's exact DELETE acknowledgement.
|
|
226
|
+
|
|
227
|
+
``already_revoked`` is meaningful solely when the caller retained the raw secret for the
|
|
228
|
+
very same key and explicitly marks that recovery condition. The client does not delete
|
|
229
|
+
anything locally.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
path_segment = _path_segment(key_id)
|
|
233
|
+
response = self._request("DELETE", f"/api/cli/v1/device-keys/{path_segment}")
|
|
234
|
+
proof = _exact_revoke_proof(response.body)
|
|
235
|
+
if proof.status == "already_revoked" and not exact_secret_same_key_replay:
|
|
236
|
+
raise _contract_error()
|
|
237
|
+
return proof
|
|
238
|
+
|
|
239
|
+
def _request(self, method: str, path: str) -> CloudAuthResponse:
|
|
240
|
+
request = urllib.request.Request(
|
|
241
|
+
urllib.parse.urljoin(self._base_url + "/", path.lstrip("/")),
|
|
242
|
+
headers={"x-api-key": self._raw_key},
|
|
243
|
+
method=method,
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
response = self._transport(request, REQUEST_TIMEOUT_SECONDS)
|
|
247
|
+
except urllib.error.HTTPError as error:
|
|
248
|
+
response = CloudAuthResponse(
|
|
249
|
+
status=error.code,
|
|
250
|
+
headers={str(key): str(value) for key, value in error.headers.items()},
|
|
251
|
+
body=b"",
|
|
252
|
+
)
|
|
253
|
+
except (urllib.error.URLError, TimeoutError, ssl.SSLError, OSError, ValueError):
|
|
254
|
+
raise CloudAuthError(
|
|
255
|
+
"CLOUD_AUTH_TRANSPORT_FAILED", "Cloud could not be reached securely"
|
|
256
|
+
) from None
|
|
257
|
+
if not isinstance(response, CloudAuthResponse):
|
|
258
|
+
raise CloudAuthError("CLOUD_AUTH_TRANSPORT_FAILED", "Cloud returned no usable response")
|
|
259
|
+
_bounded_response(response)
|
|
260
|
+
mapped = _remote_error(response)
|
|
261
|
+
if mapped is not None:
|
|
262
|
+
raise mapped
|
|
263
|
+
if response.status != 200:
|
|
264
|
+
raise CloudAuthError(
|
|
265
|
+
"CLOUD_AUTH_UNEXPECTED_STATUS", "Cloud returned an unexpected status"
|
|
266
|
+
)
|
|
267
|
+
if not _is_json_media_type(response.headers):
|
|
268
|
+
raise _contract_error()
|
|
269
|
+
return response
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validated_base_url(value: str) -> str:
|
|
273
|
+
if not isinstance(value, str) or len(value) > 2048:
|
|
274
|
+
raise CloudAuthError("CLOUD_AUTH_URL_INVALID", "Cloud URL is invalid")
|
|
275
|
+
parsed = urllib.parse.urlsplit(value)
|
|
276
|
+
if (
|
|
277
|
+
parsed.scheme != "https"
|
|
278
|
+
or not parsed.hostname
|
|
279
|
+
or parsed.username is not None
|
|
280
|
+
or parsed.password is not None
|
|
281
|
+
or parsed.query
|
|
282
|
+
or parsed.fragment
|
|
283
|
+
):
|
|
284
|
+
raise CloudAuthError("CLOUD_AUTH_URL_INVALID", "Cloud URL is invalid")
|
|
285
|
+
try:
|
|
286
|
+
_ = parsed.port
|
|
287
|
+
except ValueError:
|
|
288
|
+
raise CloudAuthError("CLOUD_AUTH_URL_INVALID", "Cloud URL is invalid") from None
|
|
289
|
+
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _urllib_transport(request: urllib.request.Request, timeout: float) -> CloudAuthResponse:
|
|
293
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
|
|
294
|
+
with opener.open(request, timeout=timeout) as response:
|
|
295
|
+
headers = {str(key): str(value) for key, value in response.headers.items()}
|
|
296
|
+
content_length = headers.get("Content-Length")
|
|
297
|
+
if content_length is not None:
|
|
298
|
+
try:
|
|
299
|
+
if int(content_length) > MAX_RESPONSE_BYTES:
|
|
300
|
+
raise _contract_error()
|
|
301
|
+
except ValueError:
|
|
302
|
+
raise _contract_error() from None
|
|
303
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
304
|
+
return CloudAuthResponse(status=response.status, headers=headers, body=body)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _bounded_response(response: CloudAuthResponse) -> None:
|
|
308
|
+
if not isinstance(response.status, int) or isinstance(response.status, bool):
|
|
309
|
+
raise CloudAuthError("CLOUD_AUTH_TRANSPORT_FAILED", "Cloud returned no usable response")
|
|
310
|
+
if not isinstance(response.body, bytes) or len(response.body) > MAX_RESPONSE_BYTES:
|
|
311
|
+
raise _contract_error()
|
|
312
|
+
if not isinstance(response.headers, Mapping):
|
|
313
|
+
raise CloudAuthError("CLOUD_AUTH_TRANSPORT_FAILED", "Cloud returned no usable response")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _remote_error(response: CloudAuthResponse) -> CloudAuthError | None:
|
|
317
|
+
code = {
|
|
318
|
+
401: "CLOUD_AUTH_UNAUTHORIZED",
|
|
319
|
+
403: "CLOUD_AUTH_FORBIDDEN",
|
|
320
|
+
404: "CLOUD_AUTH_NOT_FOUND",
|
|
321
|
+
429: "CLOUD_AUTH_RATE_LIMITED",
|
|
322
|
+
503: "CLOUD_AUTH_TEMPORARILY_UNAVAILABLE",
|
|
323
|
+
}.get(response.status)
|
|
324
|
+
if code is None:
|
|
325
|
+
return None
|
|
326
|
+
retry_after = _retry_after(response.headers) if response.status in {429, 503} else None
|
|
327
|
+
details = {
|
|
328
|
+
"CLOUD_AUTH_UNAUTHORIZED": "Cloud did not accept this device credential",
|
|
329
|
+
"CLOUD_AUTH_FORBIDDEN": "Cloud did not authorize this device operation",
|
|
330
|
+
"CLOUD_AUTH_NOT_FOUND": "Cloud did not find that device operation",
|
|
331
|
+
"CLOUD_AUTH_RATE_LIMITED": "Cloud temporarily rate limited this device operation",
|
|
332
|
+
"CLOUD_AUTH_TEMPORARILY_UNAVAILABLE": "Cloud is temporarily unavailable",
|
|
333
|
+
}
|
|
334
|
+
return CloudAuthError(code, details[code], retry_after_seconds=retry_after)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _retry_after(headers: Mapping[str, str]) -> int | None:
|
|
338
|
+
value = next((item for key, item in headers.items() if key.lower() == "retry-after"), None)
|
|
339
|
+
if not isinstance(value, str) or not value.isascii() or not value.isdecimal():
|
|
340
|
+
return None
|
|
341
|
+
parsed = int(value)
|
|
342
|
+
return parsed if 0 <= parsed <= 86_400 else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_json_media_type(headers: Mapping[str, str]) -> bool:
|
|
346
|
+
value = next((item for key, item in headers.items() if key.lower() == "content-type"), None)
|
|
347
|
+
return isinstance(value, str) and value.split(";", 1)[0].strip().lower() == "application/json"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _json_object(body: bytes) -> dict[str, Any]:
|
|
351
|
+
try:
|
|
352
|
+
value = json.loads(body.decode("utf-8"))
|
|
353
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
354
|
+
raise _contract_error() from None
|
|
355
|
+
if not isinstance(value, dict):
|
|
356
|
+
raise _contract_error()
|
|
357
|
+
return value
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _require_api_version(value: Mapping[str, Any]) -> None:
|
|
361
|
+
if value.get("apiVersion") != "1":
|
|
362
|
+
raise _contract_error()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _required_text(value: Mapping[str, Any], field: str) -> str:
|
|
366
|
+
item = value.get(field)
|
|
367
|
+
if not _safe_text(item, required=True):
|
|
368
|
+
raise _contract_error()
|
|
369
|
+
return item
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _safe_text(value: object, *, required: bool) -> bool:
|
|
373
|
+
return (
|
|
374
|
+
isinstance(value, str)
|
|
375
|
+
and (bool(value) if required else True)
|
|
376
|
+
and len(value) <= 4096
|
|
377
|
+
and not any(ord(character) < 0x20 or ord(character) == 0x7F for character in value)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _device_metadata(value: Any, *, require_current: bool = False) -> DeviceMetadata:
|
|
382
|
+
if not isinstance(value, dict):
|
|
383
|
+
raise _contract_error()
|
|
384
|
+
if "lastUsedAt" not in value:
|
|
385
|
+
raise _contract_error()
|
|
386
|
+
last_used_at = value["lastUsedAt"]
|
|
387
|
+
if last_used_at is not None and not _safe_text(last_used_at, required=False):
|
|
388
|
+
raise _contract_error()
|
|
389
|
+
current = value.get("current")
|
|
390
|
+
if require_current and not isinstance(current, bool):
|
|
391
|
+
raise _contract_error()
|
|
392
|
+
return DeviceMetadata(
|
|
393
|
+
key_id=_required_text(value, "id"),
|
|
394
|
+
name=_required_text(value, "name"),
|
|
395
|
+
prefix=_required_text(value, "prefix"),
|
|
396
|
+
last_four=_required_text(value, "lastFour"),
|
|
397
|
+
created_at=_required_text(value, "createdAt"),
|
|
398
|
+
last_used_at=last_used_at,
|
|
399
|
+
current=current if require_current else None,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _path_segment(key_id: str) -> str:
|
|
404
|
+
if (
|
|
405
|
+
not isinstance(key_id, str)
|
|
406
|
+
or not key_id
|
|
407
|
+
or len(key_id) > MAX_KEY_ID_CHARS
|
|
408
|
+
or any(ord(character) < 0x20 or ord(character) == 0x7F for character in key_id)
|
|
409
|
+
):
|
|
410
|
+
raise CloudAuthError("CLOUD_AUTH_KEY_ID_INVALID", "device key id is invalid")
|
|
411
|
+
return urllib.parse.quote(key_id, safe="")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _exact_revoke_proof(body: bytes) -> RevokeProof:
|
|
415
|
+
try:
|
|
416
|
+
value = json.loads(body.decode("utf-8"), object_pairs_hook=_ObjectPairs)
|
|
417
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
418
|
+
raise _contract_error() from None
|
|
419
|
+
if not isinstance(value, _ObjectPairs) or len(value) != 2:
|
|
420
|
+
raise _contract_error()
|
|
421
|
+
keys = [key for key, _item in value]
|
|
422
|
+
if set(keys) != {"apiVersion", "status"} or len(set(keys)) != 2:
|
|
423
|
+
raise _contract_error()
|
|
424
|
+
fields = dict(value)
|
|
425
|
+
if fields.get("apiVersion") != "1" or fields.get("status") not in {
|
|
426
|
+
"revoked",
|
|
427
|
+
"already_revoked",
|
|
428
|
+
}:
|
|
429
|
+
raise _contract_error()
|
|
430
|
+
return RevokeProof(status=fields["status"])
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _contract_error() -> CloudAuthError:
|
|
434
|
+
return CloudAuthError("CLOUD_AUTH_CONTRACT_INVALID", "Cloud returned an invalid auth contract")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
__all__ = [
|
|
438
|
+
"CloudAuthClient",
|
|
439
|
+
"CloudAuthError",
|
|
440
|
+
"CloudAuthResponse",
|
|
441
|
+
"CloudIdentity",
|
|
442
|
+
"CurrentDevice",
|
|
443
|
+
"CurrentIdentity",
|
|
444
|
+
"DeviceMetadata",
|
|
445
|
+
"RevokeProof",
|
|
446
|
+
"StudioIdentity",
|
|
447
|
+
]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Closed, lazy descriptors for the ``mr-data`` subcommands.
|
|
2
|
+
|
|
3
|
+
Inventory and top-level help read the literal descriptors below without scanning packages,
|
|
4
|
+
loading entry points, or importing command implementations. A selected command resolves exactly
|
|
5
|
+
one fixed module and verifies that module's ``SPEC`` still matches its descriptor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib
|
|
12
|
+
import pkgutil
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CommandSpec:
|
|
20
|
+
"""One subcommand: its name, its help line, its arguments, and its payload builder.
|
|
21
|
+
|
|
22
|
+
``run`` returns the payload mapping and nothing else. It never prints and never decides the
|
|
23
|
+
output mode -- the caller renders the payload as plain lines or as JSON.
|
|
24
|
+
|
|
25
|
+
``exit_code`` is read from that same payload, for the commands whose answer is a gate a script
|
|
26
|
+
acts on rather than a report a person reads. It is derived from the payload rather than
|
|
27
|
+
returned alongside it so that the exit code cannot say anything the printed answer does not;
|
|
28
|
+
a command without one always succeeds when it produced a payload at all.
|
|
29
|
+
|
|
30
|
+
``validate`` refuses an argument set ``run`` would refuse, without running it. Argparse can
|
|
31
|
+
only express the rules one flag states about itself; a command whose flags are required in one
|
|
32
|
+
mode and forbidden in another has rules argparse cannot carry, and without this hook the only
|
|
33
|
+
way to discover them is to execute the command. A command that declares one promises that
|
|
34
|
+
``validate`` accepts exactly the namespaces ``run`` reaches its first side effect with.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
help: str
|
|
39
|
+
add_arguments: Callable[[argparse.ArgumentParser], None]
|
|
40
|
+
run: Callable[[argparse.Namespace], dict[str, Any]]
|
|
41
|
+
exit_code: Callable[[Mapping[str, Any]], int] | None = None
|
|
42
|
+
implementation_module: str | None = None
|
|
43
|
+
validate: Callable[[argparse.Namespace], None] | None = None
|
|
44
|
+
|
|
45
|
+
def resolve(self) -> CommandSpec:
|
|
46
|
+
"""Load and verify this descriptor's fixed implementation, if it is lazy."""
|
|
47
|
+
|
|
48
|
+
if self.implementation_module is None:
|
|
49
|
+
return self
|
|
50
|
+
return _load(self.implementation_module, self.name, self.help)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_LOADED: dict[str, CommandSpec] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load(module_name: str, expected_name: str, expected_help: str) -> CommandSpec:
|
|
57
|
+
loaded = _LOADED.get(module_name)
|
|
58
|
+
if loaded is None:
|
|
59
|
+
module = importlib.import_module(f"{__name__}.{module_name}")
|
|
60
|
+
loaded = getattr(module, "SPEC", None)
|
|
61
|
+
if not isinstance(loaded, CommandSpec):
|
|
62
|
+
raise RuntimeError(f"command module {module_name!r} does not declare a CommandSpec")
|
|
63
|
+
if loaded.name != expected_name or loaded.help != expected_help:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
f"command descriptor for {expected_name!r} disagrees with module {module_name!r}"
|
|
66
|
+
)
|
|
67
|
+
_LOADED[module_name] = loaded
|
|
68
|
+
return loaded
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _lazy(module_name: str, name: str, help_text: str) -> CommandSpec:
|
|
72
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
73
|
+
_load(module_name, name, help_text).add_arguments(parser)
|
|
74
|
+
|
|
75
|
+
def run(args: argparse.Namespace) -> dict[str, Any]:
|
|
76
|
+
return _load(module_name, name, help_text).run(args)
|
|
77
|
+
|
|
78
|
+
def exit_code(payload: Mapping[str, Any]) -> int:
|
|
79
|
+
implementation = _load(module_name, name, help_text).exit_code
|
|
80
|
+
return 0 if implementation is None else implementation(payload)
|
|
81
|
+
|
|
82
|
+
def validate(args: argparse.Namespace) -> None:
|
|
83
|
+
implementation = _load(module_name, name, help_text).validate
|
|
84
|
+
if implementation is not None:
|
|
85
|
+
implementation(args)
|
|
86
|
+
|
|
87
|
+
return CommandSpec(
|
|
88
|
+
name=name,
|
|
89
|
+
help=help_text,
|
|
90
|
+
add_arguments=add_arguments,
|
|
91
|
+
run=run,
|
|
92
|
+
exit_code=exit_code,
|
|
93
|
+
implementation_module=module_name,
|
|
94
|
+
validate=validate,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_DISCOVERED: tuple[CommandSpec, ...] = (
|
|
99
|
+
_lazy(
|
|
100
|
+
"approve",
|
|
101
|
+
"approve",
|
|
102
|
+
"freeze one recipe by fingerprint, after showing you exactly what you are freezing",
|
|
103
|
+
),
|
|
104
|
+
_lazy(
|
|
105
|
+
"auth",
|
|
106
|
+
"auth",
|
|
107
|
+
"inspect remotely validated device authority and token boundaries",
|
|
108
|
+
),
|
|
109
|
+
_lazy(
|
|
110
|
+
"author",
|
|
111
|
+
"author",
|
|
112
|
+
"turn a YAML recipe description into a recipe you can approve",
|
|
113
|
+
),
|
|
114
|
+
# The provider-scale workflow is three verbs -- harvest, then author, then publish -- and each
|
|
115
|
+
# one is a separate mutation with a receipt of its own: `catalog-harvest` is the only one that
|
|
116
|
+
# reaches a network or reads a key, and it is also the only one that can be asked for the key
|
|
117
|
+
# check on its own. The descriptor tuple stays sorted by name, so they are not listed in the
|
|
118
|
+
# order they are run.
|
|
119
|
+
_lazy(
|
|
120
|
+
"catalog_author",
|
|
121
|
+
"catalog-author",
|
|
122
|
+
"author one converged public catalog generation into bounded authoring shards",
|
|
123
|
+
),
|
|
124
|
+
_lazy(
|
|
125
|
+
"catalog_fill",
|
|
126
|
+
"catalog-fill",
|
|
127
|
+
"stage, reconcile, and publish one bounded public catalog successor",
|
|
128
|
+
),
|
|
129
|
+
_lazy(
|
|
130
|
+
"catalog_harvest",
|
|
131
|
+
"catalog-harvest",
|
|
132
|
+
"stage one keyed public catalog sweep, or just check the key file boundary",
|
|
133
|
+
),
|
|
134
|
+
_lazy(
|
|
135
|
+
"catalog_publish",
|
|
136
|
+
"catalog-publish",
|
|
137
|
+
"classify one authored generation, publish it, and write its one composite receipt",
|
|
138
|
+
),
|
|
139
|
+
_lazy(
|
|
140
|
+
"catalog_search",
|
|
141
|
+
"catalog-search",
|
|
142
|
+
"search one exact verified sealed source catalog",
|
|
143
|
+
),
|
|
144
|
+
_lazy(
|
|
145
|
+
"catalog_update",
|
|
146
|
+
"catalog-update",
|
|
147
|
+
"install one published catalog release after checking its exact fingerprint",
|
|
148
|
+
),
|
|
149
|
+
_lazy(
|
|
150
|
+
"deploy",
|
|
151
|
+
"deploy",
|
|
152
|
+
"stage one verified Build and activate it as the authenticated Editor",
|
|
153
|
+
),
|
|
154
|
+
_lazy(
|
|
155
|
+
"deploy_dataset",
|
|
156
|
+
"deploy-dataset",
|
|
157
|
+
"bring the Build a deployment released back here and write its dataset notebook",
|
|
158
|
+
),
|
|
159
|
+
_lazy(
|
|
160
|
+
"deploy_plan",
|
|
161
|
+
"deploy-plan",
|
|
162
|
+
"plan a live dataset request without deploying it",
|
|
163
|
+
),
|
|
164
|
+
_lazy(
|
|
165
|
+
"deploy_status",
|
|
166
|
+
"deploy-status",
|
|
167
|
+
"report what Studio says about the run one deployment queued",
|
|
168
|
+
),
|
|
169
|
+
_lazy("diff", "diff", "compare two builds and say what changed"),
|
|
170
|
+
_lazy(
|
|
171
|
+
"index",
|
|
172
|
+
"index",
|
|
173
|
+
"build one bounded private index from exact-verified local Builds",
|
|
174
|
+
),
|
|
175
|
+
_lazy(
|
|
176
|
+
"inventory",
|
|
177
|
+
"inventory",
|
|
178
|
+
"list what this build can do: operations, types, joins, engines, and checks",
|
|
179
|
+
),
|
|
180
|
+
_lazy(
|
|
181
|
+
"list_builds",
|
|
182
|
+
"list",
|
|
183
|
+
"list the builds under a folder, and say whether they were checked",
|
|
184
|
+
),
|
|
185
|
+
_lazy(
|
|
186
|
+
"login",
|
|
187
|
+
"login",
|
|
188
|
+
"sign in to the Mostly Right cloud and store a device credential",
|
|
189
|
+
),
|
|
190
|
+
_lazy(
|
|
191
|
+
"peek",
|
|
192
|
+
"peek",
|
|
193
|
+
"open any file or public https address and show its columns, types, and first rows",
|
|
194
|
+
),
|
|
195
|
+
_lazy(
|
|
196
|
+
"plan_check",
|
|
197
|
+
"plan-check",
|
|
198
|
+
"build this plan into a throwaway folder to see whether it works, then throw it away",
|
|
199
|
+
),
|
|
200
|
+
_lazy("preflight", "preflight", "check what would stop a build before you start it"),
|
|
201
|
+
_lazy(
|
|
202
|
+
"record",
|
|
203
|
+
"record",
|
|
204
|
+
"seal already-recorded live messages into batches a build can read",
|
|
205
|
+
),
|
|
206
|
+
_lazy(
|
|
207
|
+
"review_setup",
|
|
208
|
+
"review-setup",
|
|
209
|
+
"create protected review material; this does not approve a Build",
|
|
210
|
+
),
|
|
211
|
+
_lazy(
|
|
212
|
+
"search",
|
|
213
|
+
"search",
|
|
214
|
+
"search exact published public and private local stores without changing them",
|
|
215
|
+
),
|
|
216
|
+
_lazy(
|
|
217
|
+
"show",
|
|
218
|
+
"show",
|
|
219
|
+
"show one build: what it answers, what a row is, its columns, checks, and fingerprints",
|
|
220
|
+
),
|
|
221
|
+
_lazy(
|
|
222
|
+
"whoami",
|
|
223
|
+
"whoami",
|
|
224
|
+
"compatibility alias for auth status",
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def discovered_commands() -> tuple[CommandSpec, ...]:
|
|
230
|
+
"""Return the closed descriptor tuple without importing implementation modules."""
|
|
231
|
+
|
|
232
|
+
return _DISCOVERED
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _module_names(search_path: Sequence[str] | None = None) -> tuple[str, ...]:
|
|
236
|
+
"""Module names in this package, sorted, excluding private ones."""
|
|
237
|
+
|
|
238
|
+
path = list(search_path) if search_path is not None else list(__path__)
|
|
239
|
+
return tuple(
|
|
240
|
+
sorted(name for _finder, name, _ispkg in pkgutil.iter_modules(path) if name[:1] != "_")
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _collect(modules: Iterable[tuple[str, Any]]) -> tuple[CommandSpec, ...]:
|
|
245
|
+
"""Read one ``SPEC`` per module, preserving order and refusing duplicate command names."""
|
|
246
|
+
|
|
247
|
+
specs: list[CommandSpec] = []
|
|
248
|
+
seen: dict[str, str] = {}
|
|
249
|
+
for module_name, module in modules:
|
|
250
|
+
spec = getattr(module, "SPEC", None)
|
|
251
|
+
if spec is None:
|
|
252
|
+
continue
|
|
253
|
+
if spec.name in seen:
|
|
254
|
+
raise RuntimeError(
|
|
255
|
+
f"two command modules declare the command name {spec.name!r}: "
|
|
256
|
+
f"{seen[spec.name]!r} and {module_name!r}"
|
|
257
|
+
)
|
|
258
|
+
seen[spec.name] = module_name
|
|
259
|
+
specs.append(spec)
|
|
260
|
+
return tuple(specs)
|