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,691 @@
|
|
|
1
|
+
"""``mr-data login`` -- RFC 8628 device authorization against the Mostly Right cloud.
|
|
2
|
+
|
|
3
|
+
Three calls, in order, against the cloud's better-auth device-authorization plugin and its device
|
|
4
|
+
key mint route:
|
|
5
|
+
|
|
6
|
+
1. ``POST {cloud}/api/auth/device/code`` starts the flow and returns a ``user_code`` to show the
|
|
7
|
+
operator and a ``device_code`` this machine polls with.
|
|
8
|
+
2. ``POST {cloud}/api/auth/device/token`` is polled on the interval the cloud names, until the
|
|
9
|
+
operator approves in their browser (``authorization_pending`` keeps polling, ``slow_down`` widens
|
|
10
|
+
the interval by RFC 8628's required five seconds, ``access_denied``/``expired_token`` stop the
|
|
11
|
+
flow) or it returns a Better Auth session token.
|
|
12
|
+
3. ``POST {cloud}/api/device/keys``, immediately, with that session token as a bearer credential,
|
|
13
|
+
mints the durable ``mr_cli_`` device key this command stores. The cloud revokes the bearer
|
|
14
|
+
session right after this mint, so nothing else may call the cloud between steps 2 and 3.
|
|
15
|
+
|
|
16
|
+
The transport is stdlib ``urllib`` in the shape :mod:`mostlyright.data_harness.key_seam` and
|
|
17
|
+
:mod:`mostlyright.data_harness.hosted_bootstrap` already use -- proxies and redirects disabled, a
|
|
18
|
+
byte cap and a bounded timeout on every response -- rather than a new dependency. ``login`` has to
|
|
19
|
+
work on the same plain install ``mr-data inventory`` does.
|
|
20
|
+
|
|
21
|
+
The raw key this mints is handed to :mod:`mostlyright.data_harness.ux.credentials` the moment it
|
|
22
|
+
exists and is never returned from here, logged, or put in a payload; only :class:`DeviceKey`'s
|
|
23
|
+
``prefix`` and ``last_four`` are.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import socket
|
|
32
|
+
import ssl
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable, Mapping
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Protocol
|
|
40
|
+
from urllib.error import HTTPError
|
|
41
|
+
from urllib.parse import urlsplit
|
|
42
|
+
from urllib.request import (
|
|
43
|
+
HTTPRedirectHandler,
|
|
44
|
+
HTTPSHandler,
|
|
45
|
+
ProxyHandler,
|
|
46
|
+
Request,
|
|
47
|
+
build_opener,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from mostlyright.data_harness.canonical import CanonicalJSONError, parse_foreign_json
|
|
51
|
+
from mostlyright.data_harness.key_seam import LEGACY_API_KEY_ENV
|
|
52
|
+
from mostlyright.data_harness.ux.cloud_auth import CloudAuthClient, CloudAuthError
|
|
53
|
+
from mostlyright.data_harness.ux.credential_store import (
|
|
54
|
+
CredentialEnvelope,
|
|
55
|
+
CredentialStoreError,
|
|
56
|
+
ProtectedCredential,
|
|
57
|
+
credential_lock,
|
|
58
|
+
)
|
|
59
|
+
from mostlyright.data_harness.ux.credentials import (
|
|
60
|
+
CLOUD_URL_ENV,
|
|
61
|
+
DEFAULT_CLOUD_URL,
|
|
62
|
+
LoginError,
|
|
63
|
+
StoredCredentials,
|
|
64
|
+
credential_transaction_store,
|
|
65
|
+
install_credential_manifest,
|
|
66
|
+
refuse_existing_credentials,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
#: The OAuth client identity every device-flow request carries. Fixed, because this command is the
|
|
70
|
+
#: only issuer of it.
|
|
71
|
+
CLIENT_ID = "mostlyright-cli"
|
|
72
|
+
|
|
73
|
+
DEVICE_CODE_PATH = "/api/auth/device/code"
|
|
74
|
+
DEVICE_TOKEN_PATH = "/api/auth/device/token"
|
|
75
|
+
DEVICE_KEYS_PATH = "/api/device/keys"
|
|
76
|
+
|
|
77
|
+
#: RFC 8628's grant type string for the token endpoint, verbatim.
|
|
78
|
+
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
79
|
+
|
|
80
|
+
#: A verdict is a small object; anything larger than this is not one. Mirrors
|
|
81
|
+
#: ``key_seam.MAX_VERIFY_RESPONSE_BYTES`` and ``hosted_bootstrap.MAX_BOOTSTRAP_RESPONSE_BYTES``: a
|
|
82
|
+
#: cap sized for what the endpoint is documented to return, not for what it could be made to send.
|
|
83
|
+
MAX_RESPONSE_BYTES = 16 * 1024
|
|
84
|
+
|
|
85
|
+
#: A bounded wait on each individual request. A poll that hangs is a login that never returns.
|
|
86
|
+
REQUEST_TIMEOUT_SECONDS = 10.0
|
|
87
|
+
|
|
88
|
+
#: A floor under whatever interval the cloud names, so a misconfigured or hostile ``interval`` of
|
|
89
|
+
#: zero cannot turn polling into a busy loop.
|
|
90
|
+
MIN_POLL_INTERVAL_SECONDS = 1.0
|
|
91
|
+
|
|
92
|
+
#: RFC 8628 section 3.5: on ``slow_down`` the client MUST widen the polling interval by at least
|
|
93
|
+
#: five seconds for the rest of the session.
|
|
94
|
+
SLOW_DOWN_INCREMENT_SECONDS = 5.0
|
|
95
|
+
|
|
96
|
+
MIN_DEVICE_NAME_LENGTH = 1
|
|
97
|
+
MAX_DEVICE_NAME_LENGTH = 100
|
|
98
|
+
|
|
99
|
+
LOGIN_SCHEMA_VERSION = "mr-data-login.v1"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _with_environment_authority(payload: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
+
"""State the surviving parent-process authority without ever reading its value."""
|
|
104
|
+
|
|
105
|
+
if LEGACY_API_KEY_ENV in os.environ:
|
|
106
|
+
payload["environment_authority"] = "remains_effective"
|
|
107
|
+
return payload
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class DeviceAuthorization:
|
|
112
|
+
"""The device flow's starting grant: what to show the operator, and what to poll with."""
|
|
113
|
+
|
|
114
|
+
device_code: str
|
|
115
|
+
user_code: str
|
|
116
|
+
verification_uri: str
|
|
117
|
+
verification_uri_complete: str
|
|
118
|
+
expires_in: int
|
|
119
|
+
interval: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class DeviceSession:
|
|
124
|
+
"""A Better Auth session token, good for exactly one thing: minting the device key."""
|
|
125
|
+
|
|
126
|
+
access_token: str
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return "DeviceSession(access_token='<redacted>')"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class DeviceKey:
|
|
134
|
+
"""The durable ``mr_cli_`` credential the mint route hands back."""
|
|
135
|
+
|
|
136
|
+
key_id: str
|
|
137
|
+
raw_key: str
|
|
138
|
+
prefix: str
|
|
139
|
+
last_four: str
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
return (
|
|
143
|
+
f"DeviceKey(key_id={self.key_id!r}, prefix={self.prefix!r}, "
|
|
144
|
+
f"last_four={self.last_four!r}, raw_key='<redacted>')"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class DeviceFlowTransport(Protocol):
|
|
149
|
+
"""Minimal redirect-free, proxy-free transport -- the same shape ``key_seam`` and
|
|
150
|
+
``hosted_bootstrap`` already define, so a stub written for one reads as a stub for any."""
|
|
151
|
+
|
|
152
|
+
def request(
|
|
153
|
+
self,
|
|
154
|
+
method: str,
|
|
155
|
+
url: str,
|
|
156
|
+
headers: Mapping[str, str],
|
|
157
|
+
body: bytes | None,
|
|
158
|
+
maximum: int,
|
|
159
|
+
) -> tuple[int, bytes]: ...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
163
|
+
"""Refuse every redirect; a followed one would re-send the bearer token to another host."""
|
|
164
|
+
|
|
165
|
+
def redirect_request(
|
|
166
|
+
self,
|
|
167
|
+
request: Request,
|
|
168
|
+
file_pointer: Any,
|
|
169
|
+
code: int,
|
|
170
|
+
message: str,
|
|
171
|
+
headers: Any,
|
|
172
|
+
new_url: str,
|
|
173
|
+
) -> None:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UrlLibDeviceFlowTransport:
|
|
178
|
+
"""Small stdlib transport with ambient proxies and redirects disabled."""
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
self._opener = build_opener(
|
|
182
|
+
ProxyHandler({}),
|
|
183
|
+
HTTPSHandler(context=ssl.create_default_context()),
|
|
184
|
+
_NoRedirect(),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def request(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
url: str,
|
|
191
|
+
headers: Mapping[str, str],
|
|
192
|
+
body: bytes | None,
|
|
193
|
+
maximum: int,
|
|
194
|
+
) -> tuple[int, bytes]:
|
|
195
|
+
request = Request(url, data=body, headers=dict(headers), method=method)
|
|
196
|
+
try:
|
|
197
|
+
response = self._opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
198
|
+
except HTTPError as error:
|
|
199
|
+
response = error
|
|
200
|
+
with response:
|
|
201
|
+
status = int(response.status)
|
|
202
|
+
content_length = response.headers.get("Content-Length")
|
|
203
|
+
if content_length is not None:
|
|
204
|
+
try:
|
|
205
|
+
declared = int(content_length)
|
|
206
|
+
except ValueError as error:
|
|
207
|
+
raise LoginError(
|
|
208
|
+
"LOGIN_TRANSPORT_FAILED",
|
|
209
|
+
"the cloud sent an invalid content length",
|
|
210
|
+
) from error
|
|
211
|
+
if declared < 0 or declared > maximum:
|
|
212
|
+
raise LoginError(
|
|
213
|
+
"LOGIN_TRANSPORT_FAILED",
|
|
214
|
+
"the cloud's response exceeds its byte limit",
|
|
215
|
+
)
|
|
216
|
+
raw = response.read(maximum + 1)
|
|
217
|
+
if len(raw) > maximum:
|
|
218
|
+
raise LoginError(
|
|
219
|
+
"LOGIN_TRANSPORT_FAILED", "the cloud's response exceeds its byte limit"
|
|
220
|
+
)
|
|
221
|
+
return status, raw
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _https_url(value: str, label: str) -> str:
|
|
225
|
+
"""Return ``value`` when it is a plain absolute https URL, otherwise refuse it.
|
|
226
|
+
|
|
227
|
+
Copied rather than imported, the way ``key_seam._https_url`` and
|
|
228
|
+
``hosted_bootstrap._https_url`` are each their own copy of the same policy: importing across
|
|
229
|
+
those modules would pull in code this command does not otherwise touch, for a validation that
|
|
230
|
+
has to stay identical without needing to be the same function.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 2048:
|
|
234
|
+
raise LoginError("LOGIN_CONFIG_INVALID", f"{label} is not a usable URL")
|
|
235
|
+
if value.endswith("/"):
|
|
236
|
+
raise LoginError("LOGIN_CONFIG_INVALID", f"{label} must not end with a slash")
|
|
237
|
+
parsed = urlsplit(value)
|
|
238
|
+
if (
|
|
239
|
+
parsed.scheme != "https"
|
|
240
|
+
or not parsed.hostname
|
|
241
|
+
or parsed.username is not None
|
|
242
|
+
or parsed.password is not None
|
|
243
|
+
or parsed.fragment
|
|
244
|
+
):
|
|
245
|
+
raise LoginError(
|
|
246
|
+
"LOGIN_CONFIG_INVALID",
|
|
247
|
+
f"{label} must be an absolute https URL without user information or fragment",
|
|
248
|
+
)
|
|
249
|
+
return value
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _device_name(value: str | None) -> str:
|
|
253
|
+
name = value if value is not None else socket.gethostname()
|
|
254
|
+
if not isinstance(name, str):
|
|
255
|
+
raise LoginError("LOGIN_CONFIG_INVALID", "a device name must be text")
|
|
256
|
+
if not MIN_DEVICE_NAME_LENGTH <= len(name) <= MAX_DEVICE_NAME_LENGTH:
|
|
257
|
+
raise LoginError(
|
|
258
|
+
"LOGIN_CONFIG_INVALID",
|
|
259
|
+
f"a device name must be {MIN_DEVICE_NAME_LENGTH}-{MAX_DEVICE_NAME_LENGTH} characters",
|
|
260
|
+
)
|
|
261
|
+
return name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _post_json(
|
|
265
|
+
transport: DeviceFlowTransport,
|
|
266
|
+
url: str,
|
|
267
|
+
payload: Mapping[str, Any],
|
|
268
|
+
*,
|
|
269
|
+
headers: Mapping[str, str] | None = None,
|
|
270
|
+
) -> tuple[int, Any]:
|
|
271
|
+
"""POST one JSON body and return ``(status, parsed_body_or_None)``.
|
|
272
|
+
|
|
273
|
+
Never raises for a non-2xx status or a body that fails to parse: the three device-flow callers
|
|
274
|
+
each read those differently (a poll's ``authorization_pending`` is not a failure; a mint
|
|
275
|
+
failure means something else again), so only what none of them can do anything useful with --
|
|
276
|
+
the request never reaching the cloud at all, or the byte cap being exceeded -- is collapsed
|
|
277
|
+
into one typed failure here.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
body = json.dumps(dict(payload), sort_keys=True).encode("utf-8")
|
|
281
|
+
request_headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
282
|
+
request_headers.update(headers or {})
|
|
283
|
+
try:
|
|
284
|
+
status, raw = transport.request("POST", url, request_headers, body, MAX_RESPONSE_BYTES)
|
|
285
|
+
except LoginError:
|
|
286
|
+
raise
|
|
287
|
+
except Exception as error:
|
|
288
|
+
raise LoginError(
|
|
289
|
+
"LOGIN_TRANSPORT_FAILED", f"the cloud could not be reached: {error}"
|
|
290
|
+
) from error
|
|
291
|
+
if not raw:
|
|
292
|
+
return status, None
|
|
293
|
+
try:
|
|
294
|
+
# `parse_foreign_json`, not `parse_json`: this body is authored by a separate cloud
|
|
295
|
+
# codebase, not by this harness, and the Harness's own integer-only canonical form is not a
|
|
296
|
+
# contract that document ever signed up to.
|
|
297
|
+
return status, parse_foreign_json(raw)
|
|
298
|
+
except CanonicalJSONError:
|
|
299
|
+
return status, None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _required_str(payload: Mapping[str, Any], field: str) -> str:
|
|
303
|
+
value = payload.get(field)
|
|
304
|
+
if not isinstance(value, str) or not value:
|
|
305
|
+
raise LoginError(
|
|
306
|
+
"LOGIN_TRANSPORT_FAILED", f"the cloud's response is missing a usable {field!r}"
|
|
307
|
+
)
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _required_positive_int(payload: Mapping[str, Any], field: str) -> int:
|
|
312
|
+
value = payload.get(field)
|
|
313
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
314
|
+
raise LoginError(
|
|
315
|
+
"LOGIN_TRANSPORT_FAILED", f"the cloud's response is missing a usable {field!r}"
|
|
316
|
+
)
|
|
317
|
+
return value
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _positive_number(payload: Mapping[str, Any], field: str, *, default: float) -> float:
|
|
321
|
+
value = payload.get(field, default)
|
|
322
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or value <= 0:
|
|
323
|
+
raise LoginError(
|
|
324
|
+
"LOGIN_TRANSPORT_FAILED", f"the cloud's response is missing a usable {field!r}"
|
|
325
|
+
)
|
|
326
|
+
return float(value)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def request_device_code(cloud_url: str, *, transport: DeviceFlowTransport) -> DeviceAuthorization:
|
|
330
|
+
"""Start the device flow: ``POST {cloud}/api/auth/device/code``."""
|
|
331
|
+
|
|
332
|
+
url = f"{cloud_url}{DEVICE_CODE_PATH}"
|
|
333
|
+
status, parsed = _post_json(transport, url, {"client_id": CLIENT_ID})
|
|
334
|
+
if status != 200 or not isinstance(parsed, dict):
|
|
335
|
+
raise LoginError(
|
|
336
|
+
"LOGIN_TRANSPORT_FAILED",
|
|
337
|
+
f"the cloud could not start a device sign-in (HTTP {status})",
|
|
338
|
+
)
|
|
339
|
+
return DeviceAuthorization(
|
|
340
|
+
device_code=_required_str(parsed, "device_code"),
|
|
341
|
+
user_code=_required_str(parsed, "user_code"),
|
|
342
|
+
verification_uri=_required_str(parsed, "verification_uri"),
|
|
343
|
+
verification_uri_complete=_required_str(parsed, "verification_uri_complete"),
|
|
344
|
+
expires_in=_required_positive_int(parsed, "expires_in"),
|
|
345
|
+
interval=_positive_number(parsed, "interval", default=MIN_POLL_INTERVAL_SECONDS * 5),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def poll_for_token(
|
|
350
|
+
cloud_url: str,
|
|
351
|
+
authorization: DeviceAuthorization,
|
|
352
|
+
*,
|
|
353
|
+
transport: DeviceFlowTransport,
|
|
354
|
+
clock: Callable[[], float] = time.monotonic,
|
|
355
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
356
|
+
) -> DeviceSession:
|
|
357
|
+
"""Poll ``POST {cloud}/api/auth/device/token`` until approval, denial, or expiry.
|
|
358
|
+
|
|
359
|
+
Waits ``interval`` seconds before every poll, including the first -- the operator cannot have
|
|
360
|
+
approved before they have seen the code -- and widens ``interval`` by
|
|
361
|
+
:data:`SLOW_DOWN_INCREMENT_SECONDS` on every ``slow_down`` for the rest of this call, per
|
|
362
|
+
RFC 8628 section 3.5.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
deadline = clock() + authorization.expires_in
|
|
366
|
+
interval = max(authorization.interval, MIN_POLL_INTERVAL_SECONDS)
|
|
367
|
+
url = f"{cloud_url}{DEVICE_TOKEN_PATH}"
|
|
368
|
+
payload = {
|
|
369
|
+
"grant_type": DEVICE_CODE_GRANT_TYPE,
|
|
370
|
+
"device_code": authorization.device_code,
|
|
371
|
+
"client_id": CLIENT_ID,
|
|
372
|
+
}
|
|
373
|
+
while True:
|
|
374
|
+
if clock() >= deadline:
|
|
375
|
+
raise LoginError(
|
|
376
|
+
"LOGIN_EXPIRED",
|
|
377
|
+
"the sign-in code expired before it was approved; run mr-data login again",
|
|
378
|
+
)
|
|
379
|
+
sleep(interval)
|
|
380
|
+
status, parsed = _post_json(transport, url, payload)
|
|
381
|
+
if not isinstance(parsed, dict):
|
|
382
|
+
raise LoginError(
|
|
383
|
+
"LOGIN_TRANSPORT_FAILED",
|
|
384
|
+
f"the cloud's sign-in response could not be read (HTTP {status})",
|
|
385
|
+
)
|
|
386
|
+
error = parsed.get("error")
|
|
387
|
+
if error == "authorization_pending":
|
|
388
|
+
continue
|
|
389
|
+
if error == "slow_down":
|
|
390
|
+
interval += SLOW_DOWN_INCREMENT_SECONDS
|
|
391
|
+
continue
|
|
392
|
+
if error == "access_denied":
|
|
393
|
+
raise LoginError(
|
|
394
|
+
"LOGIN_ACCESS_DENIED",
|
|
395
|
+
"sign-in was denied in the browser; run mr-data login again to retry",
|
|
396
|
+
)
|
|
397
|
+
if error == "expired_token":
|
|
398
|
+
raise LoginError(
|
|
399
|
+
"LOGIN_EXPIRED",
|
|
400
|
+
"the sign-in code expired before it was approved; run mr-data login again",
|
|
401
|
+
)
|
|
402
|
+
if isinstance(error, str) and error:
|
|
403
|
+
raise LoginError("LOGIN_TRANSPORT_FAILED", f"the cloud refused the sign-in: {error}")
|
|
404
|
+
if status == 200:
|
|
405
|
+
return DeviceSession(access_token=_required_str(parsed, "access_token"))
|
|
406
|
+
raise LoginError(
|
|
407
|
+
"LOGIN_TRANSPORT_FAILED",
|
|
408
|
+
f"the cloud's sign-in response was not understood (HTTP {status})",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def mint_device_key(
|
|
413
|
+
cloud_url: str,
|
|
414
|
+
session: DeviceSession,
|
|
415
|
+
*,
|
|
416
|
+
device_name: str,
|
|
417
|
+
transport: DeviceFlowTransport,
|
|
418
|
+
) -> DeviceKey:
|
|
419
|
+
"""``POST {cloud}/api/device/keys``, the one call this session's bearer token is good for.
|
|
420
|
+
|
|
421
|
+
Must run immediately after :func:`poll_for_token` returns: the cloud revokes the bearer session
|
|
422
|
+
right after this mint, so no other network call may sit between the two.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
status, parsed = _post_json(
|
|
426
|
+
transport,
|
|
427
|
+
f"{cloud_url}{DEVICE_KEYS_PATH}",
|
|
428
|
+
{"deviceName": device_name},
|
|
429
|
+
headers={"Authorization": f"Bearer {session.access_token}"},
|
|
430
|
+
)
|
|
431
|
+
if status != 201 or not isinstance(parsed, dict):
|
|
432
|
+
raise LoginError(
|
|
433
|
+
"LOGIN_MINT_FAILED",
|
|
434
|
+
f"signed in, but the cloud could not issue a device key (HTTP {status}); "
|
|
435
|
+
"run mr-data login again",
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
return DeviceKey(
|
|
439
|
+
key_id=_required_str(parsed, "id"),
|
|
440
|
+
raw_key=_required_str(parsed, "rawKey"),
|
|
441
|
+
prefix=_required_str(parsed, "prefix"),
|
|
442
|
+
last_four=_required_str(parsed, "lastFour"),
|
|
443
|
+
)
|
|
444
|
+
except LoginError as error:
|
|
445
|
+
raise LoginError(
|
|
446
|
+
"LOGIN_MINT_FAILED",
|
|
447
|
+
"signed in, but the cloud's device key response is missing fields it must have; "
|
|
448
|
+
"run mr-data login again",
|
|
449
|
+
) from error
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _now_iso() -> str:
|
|
453
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def announce_device_code(authorization: DeviceAuthorization) -> None:
|
|
457
|
+
"""Print the code and address prominently, on stderr, so it survives a piped stdout.
|
|
458
|
+
|
|
459
|
+
Plain text only, and only wired up from plain-mode command dispatch: ``--json`` mode calls
|
|
460
|
+
:func:`run_login` without an announcer, because a caller asking for one machine-readable object
|
|
461
|
+
should get exactly one, on stdout, when this finishes -- not an interstitial line ahead of it.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
print(file=sys.stderr)
|
|
465
|
+
print(f" {authorization.user_code}", file=sys.stderr)
|
|
466
|
+
print(file=sys.stderr)
|
|
467
|
+
print(
|
|
468
|
+
f"Open {authorization.verification_uri_complete} to approve this device.",
|
|
469
|
+
file=sys.stderr,
|
|
470
|
+
)
|
|
471
|
+
print("Waiting for approval...", file=sys.stderr, flush=True)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def run_login(
|
|
475
|
+
*,
|
|
476
|
+
cloud_url: str | None = None,
|
|
477
|
+
device_name: str | None = None,
|
|
478
|
+
force: bool = False,
|
|
479
|
+
config_dir: Path | None = None,
|
|
480
|
+
credential_store: str | None = None,
|
|
481
|
+
transport: DeviceFlowTransport | None = None,
|
|
482
|
+
clock: Callable[[], float] = time.monotonic,
|
|
483
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
484
|
+
open_browser: Callable[[str], bool] | None = None,
|
|
485
|
+
announce: Callable[[DeviceAuthorization], None] | None = None,
|
|
486
|
+
cloud_client_factory: Callable[[str, str], Any] | None = None,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""Run the whole device flow and store the resulting credential.
|
|
489
|
+
|
|
490
|
+
``open_browser`` defaults to :func:`webbrowser.open`, resolved lazily so importing this module
|
|
491
|
+
never imports ``webbrowser``'s platform-probing machinery for callers -- ``whoami`` among them
|
|
492
|
+
-- that never open a browser at all.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
if open_browser is None:
|
|
496
|
+
import webbrowser
|
|
497
|
+
|
|
498
|
+
open_browser = webbrowser.open
|
|
499
|
+
|
|
500
|
+
resolved_cloud_url = _https_url(
|
|
501
|
+
cloud_url if cloud_url is not None else _configured_cloud_url(), "the cloud address"
|
|
502
|
+
)
|
|
503
|
+
resolved_device_name = _device_name(device_name)
|
|
504
|
+
if not force:
|
|
505
|
+
# Preserve the compatibility preflight for every occupied control path, including a
|
|
506
|
+
# dangling link. The durable lease below remains the authoritative concurrent gate.
|
|
507
|
+
refuse_existing_credentials(config_dir=config_dir)
|
|
508
|
+
try:
|
|
509
|
+
opened = credential_transaction_store(
|
|
510
|
+
config_dir=config_dir, credential_store=credential_store
|
|
511
|
+
)
|
|
512
|
+
except LoginError:
|
|
513
|
+
raise
|
|
514
|
+
if opened is None: # Defensive: the helper returns a selected empty store for first login.
|
|
515
|
+
raise LoginError(
|
|
516
|
+
"LOGIN_CREDENTIAL_STORE_UNAVAILABLE", "the credential store is unavailable"
|
|
517
|
+
)
|
|
518
|
+
store, manifest_path = opened
|
|
519
|
+
try:
|
|
520
|
+
current = store.load()
|
|
521
|
+
if current is not None and current.state == "active" and not force:
|
|
522
|
+
refuse_existing_credentials(config_dir=config_dir)
|
|
523
|
+
operation = "rotate" if force else "login"
|
|
524
|
+
pending = store.acquire_login_lease(
|
|
525
|
+
operation=operation,
|
|
526
|
+
requested_device_name=resolved_device_name,
|
|
527
|
+
created_at=_now_iso(),
|
|
528
|
+
)
|
|
529
|
+
if not manifest_path.exists():
|
|
530
|
+
install_credential_manifest(manifest_path, store.backend.name)
|
|
531
|
+
except CredentialStoreError as error:
|
|
532
|
+
raise LoginError("LOGIN_CREDENTIAL_RECOVERY_REQUIRED", error.detail) from None
|
|
533
|
+
live_transport = transport or UrlLibDeviceFlowTransport()
|
|
534
|
+
|
|
535
|
+
authorization = request_device_code(resolved_cloud_url, transport=live_transport)
|
|
536
|
+
if announce is not None:
|
|
537
|
+
announce(authorization)
|
|
538
|
+
with contextlib.suppress(Exception):
|
|
539
|
+
# Best-effort: a headless box or a browser that fails to launch must not stop a flow whose
|
|
540
|
+
# answer is printed either way.
|
|
541
|
+
open_browser(authorization.verification_uri_complete)
|
|
542
|
+
|
|
543
|
+
session = poll_for_token(
|
|
544
|
+
resolved_cloud_url, authorization, transport=live_transport, clock=clock, sleep=sleep
|
|
545
|
+
)
|
|
546
|
+
# No network call of any kind may sit between the line above and the one below -- see
|
|
547
|
+
# `mint_device_key`'s docstring: the cloud revokes this bearer session immediately after mint.
|
|
548
|
+
key = mint_device_key(
|
|
549
|
+
resolved_cloud_url, session, device_name=resolved_device_name, transport=live_transport
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
credentials = StoredCredentials(
|
|
553
|
+
cloud_url=resolved_cloud_url,
|
|
554
|
+
key_id=key.key_id,
|
|
555
|
+
prefix=key.prefix,
|
|
556
|
+
last_four=key.last_four,
|
|
557
|
+
device_name=resolved_device_name,
|
|
558
|
+
created_at=_now_iso(),
|
|
559
|
+
raw_key=key.raw_key,
|
|
560
|
+
)
|
|
561
|
+
minted = credentials._protected()
|
|
562
|
+
try:
|
|
563
|
+
installed = store.complete_login_lease(nonce=pending.login_pending.nonce, minted=minted)
|
|
564
|
+
except CredentialStoreError as error:
|
|
565
|
+
cleanup = _self_revoke_after_mint(minted, cloud_client_factory=cloud_client_factory)
|
|
566
|
+
raise LoginError(
|
|
567
|
+
"LOGIN_MINT_CLEANUP_REQUIRED",
|
|
568
|
+
"the minted device key could not be stored; remote cleanup is " + cleanup,
|
|
569
|
+
) from error
|
|
570
|
+
|
|
571
|
+
if installed.state == "active":
|
|
572
|
+
if force:
|
|
573
|
+
return _with_environment_authority(
|
|
574
|
+
{
|
|
575
|
+
"schema_version": "mr-data-auth-rotate.v1",
|
|
576
|
+
"status": "rotated",
|
|
577
|
+
"remote_revocation": "proved",
|
|
578
|
+
"local_state": "active",
|
|
579
|
+
**credentials.to_receipt(),
|
|
580
|
+
}
|
|
581
|
+
)
|
|
582
|
+
return _with_environment_authority(
|
|
583
|
+
{
|
|
584
|
+
"schema_version": LOGIN_SCHEMA_VERSION,
|
|
585
|
+
"status": "device_authenticated",
|
|
586
|
+
**credentials.to_receipt(),
|
|
587
|
+
"credentials_path": str(manifest_path),
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
assert installed.active is not None and installed.prior is not None
|
|
592
|
+
try:
|
|
593
|
+
_revoke_exact(
|
|
594
|
+
installed.prior,
|
|
595
|
+
cloud_client_factory=cloud_client_factory,
|
|
596
|
+
)
|
|
597
|
+
except CloudAuthError as error:
|
|
598
|
+
return _with_environment_authority(
|
|
599
|
+
{
|
|
600
|
+
"schema_version": "mr-data-auth-rotate.v1",
|
|
601
|
+
"status": "recovery_required",
|
|
602
|
+
"remote_revocation": "unproved",
|
|
603
|
+
"local_state": "revoke_pending",
|
|
604
|
+
"error_code": error.code,
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
try:
|
|
608
|
+
with credential_lock(store.config_dir):
|
|
609
|
+
cleanup_pending = CredentialEnvelope.pending_cleanup(
|
|
610
|
+
generation=installed.generation + 1,
|
|
611
|
+
active=installed.active,
|
|
612
|
+
prior=installed.prior,
|
|
613
|
+
)
|
|
614
|
+
store.replace_under_held_lock(
|
|
615
|
+
cleanup_pending,
|
|
616
|
+
expected_generation=installed.generation,
|
|
617
|
+
expected_states=frozenset({"revoke_pending"}),
|
|
618
|
+
)
|
|
619
|
+
active = CredentialEnvelope.active_envelope(
|
|
620
|
+
generation=cleanup_pending.generation + 1,
|
|
621
|
+
active=cleanup_pending.active,
|
|
622
|
+
)
|
|
623
|
+
store.replace_under_held_lock(
|
|
624
|
+
active,
|
|
625
|
+
expected_generation=cleanup_pending.generation,
|
|
626
|
+
expected_states=frozenset({"cleanup_pending"}),
|
|
627
|
+
)
|
|
628
|
+
except CredentialStoreError as error:
|
|
629
|
+
return _with_environment_authority(
|
|
630
|
+
{
|
|
631
|
+
"schema_version": "mr-data-auth-rotate.v1",
|
|
632
|
+
"status": "recovery_required",
|
|
633
|
+
"remote_revocation": "proved",
|
|
634
|
+
"local_state": "cleanup_pending",
|
|
635
|
+
"error_code": error.code,
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
return _with_environment_authority(
|
|
639
|
+
{
|
|
640
|
+
"schema_version": "mr-data-auth-rotate.v1",
|
|
641
|
+
"status": "rotated",
|
|
642
|
+
"remote_revocation": "proved",
|
|
643
|
+
"local_state": "active",
|
|
644
|
+
**credentials.to_receipt(),
|
|
645
|
+
}
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _revoke_exact(
|
|
650
|
+
credential: ProtectedCredential,
|
|
651
|
+
*,
|
|
652
|
+
cloud_client_factory: Callable[[str, str], Any] | None,
|
|
653
|
+
) -> None:
|
|
654
|
+
client = (CloudAuthClient if cloud_client_factory is None else cloud_client_factory)(
|
|
655
|
+
credential.cloud_url, credential.raw_key
|
|
656
|
+
)
|
|
657
|
+
client.revoke(credential.key_id, exact_secret_same_key_replay=True)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _self_revoke_after_mint(
|
|
661
|
+
credential: ProtectedCredential,
|
|
662
|
+
*,
|
|
663
|
+
cloud_client_factory: Callable[[str, str], Any] | None,
|
|
664
|
+
) -> str:
|
|
665
|
+
try:
|
|
666
|
+
_revoke_exact(credential, cloud_client_factory=cloud_client_factory)
|
|
667
|
+
except CloudAuthError:
|
|
668
|
+
return "unproved"
|
|
669
|
+
return "proved"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _configured_cloud_url() -> str:
|
|
673
|
+
return os.environ.get(CLOUD_URL_ENV) or DEFAULT_CLOUD_URL
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
__all__ = [
|
|
677
|
+
"CLIENT_ID",
|
|
678
|
+
"CLOUD_URL_ENV",
|
|
679
|
+
"DEFAULT_CLOUD_URL",
|
|
680
|
+
"LOGIN_SCHEMA_VERSION",
|
|
681
|
+
"DeviceAuthorization",
|
|
682
|
+
"DeviceFlowTransport",
|
|
683
|
+
"DeviceKey",
|
|
684
|
+
"DeviceSession",
|
|
685
|
+
"UrlLibDeviceFlowTransport",
|
|
686
|
+
"announce_device_code",
|
|
687
|
+
"mint_device_key",
|
|
688
|
+
"poll_for_token",
|
|
689
|
+
"request_device_code",
|
|
690
|
+
"run_login",
|
|
691
|
+
]
|