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.
Files changed (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,617 @@
1
+ """``mr-data approve`` and ``mr-data recipe-approve``, answered from Studio -- without deciding.
2
+
3
+ WHAT CHANGED, AND WHAT DID NOT. Studio PR #95 did not give a hosted command line the authority to
4
+ approve anything, and this module does not take any. A decision still needs a human: a run
5
+ candidate is decided only against a freshly verified, operation-bound step-up, and a Table recipe
6
+ only from an ordinary signed-in editor session. A Cloud-minted Studio token resolves to the
7
+ organization's *service* principal, which both of those gates refuse by design, and lending that
8
+ signing authority to a stored credential would dismantle the property the gates exist for.
9
+
10
+ What was missing was never authority. It was everything between "open a request" and "wait
11
+ forever": no way to say which decision was wanted or why, no way to tell the person who must settle
12
+ it where to go, and no way to read what they decided. Those three are what this module adds.
13
+
14
+ THE THREE CALLS, IN ORDER.
15
+
16
+ 1. ``GET /v3/approvals/{id}`` -- read the request, its ``version``, its ``subject_digest`` and its
17
+ ``ETag``. The ask is pinned to exactly that reading, so it can never be mistaken for an ask
18
+ about a later state of the same approval.
19
+ 2. ``POST /v3/approvals/{id}:approve`` with ``If-Match`` and ``Idempotency-Key`` -- record the ask.
20
+ Studio answers with ``human_action``: the one path this subject actually takes, and the address
21
+ a person opens to take it. Studio is the only party that knows which of the two it is, and a
22
+ client that guessed would send half its users to a page that cannot do what they were told.
23
+ 3. ``GET /v3/approvals/{id}`` on ``poll_after_seconds`` until the status is no longer pending, then
24
+ ``GET /v3/approvals/{id}/decision`` -- the receipt: who decided, when, under which step-up, and
25
+ against which audit digest.
26
+
27
+ ⚠ THE NEVER-DECIDES PROPERTY IS STRUCTURAL HERE, NOT A PROMISE IN A COMMENT. The only mutation
28
+ this module can make is :data:`APPROVE_PATH`, and :data:`DECIDE_PATH` is written down precisely so
29
+ a reader can see it is never called: ``tests/test_thin_parity.py`` asserts no request this lane
30
+ makes reaches it. Every other call below is a GET.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import re
37
+ import time
38
+ from collections.abc import Callable, Mapping
39
+ from typing import Any
40
+ from uuid import UUID
41
+
42
+ from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
43
+
44
+ # `_no_effect` is the accepted-but-inert flag reporter, imported rather than written a second
45
+ # time: `thin.parity` already owns the one rule about telling a default apart from an argument
46
+ # somebody typed, and two copies of that rule is how `--wait-seconds 0` starts being read as
47
+ # absent.
48
+ from mostlyright.data_harness.thin.parity import _no_effect
49
+ from mostlyright.data_harness.thin.runs import StudioApiClient, new_idempotency_key
50
+ from mostlyright.data_harness.thin.session import (
51
+ StudioSession,
52
+ open_studio_session,
53
+ run_dashboard_url,
54
+ )
55
+ from mostlyright.data_harness.thin.transport import ThinLaneError
56
+ from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
57
+
58
+ APPROVE_SCHEMA = f"{THIN_SCHEMA_PREFIX}-approval.v1"
59
+
60
+ #: The routes. ``APPROVE_PATH`` is the only one this lane may POST to.
61
+ GET_APPROVAL_PATH = "/v3/approvals/{approval_request_id}"
62
+ APPROVE_PATH = "/v3/approvals/{approval_request_id}:approve"
63
+ APPROVAL_DECISION_PATH = "/v3/approvals/{approval_request_id}/decision"
64
+
65
+ #: The route that records a decision, written down so that "this lane never calls it" is an
66
+ #: assertion a test can make rather than a sentence somebody has to believe. Nothing in this
67
+ #: module formats it.
68
+ DECIDE_PATH = "/v3/approvals/{approval_request_id}:decide"
69
+
70
+ #: ``approval.schema.json#/$defs/grant_request_command/properties/decision``.
71
+ DECISIONS = ("approved", "rejected")
72
+
73
+ #: ``approval.schema.json#/$defs/request/properties/status``. ``pending`` is the one that means
74
+ #: "keep waiting"; the other four all settle it, and three of them settle it as a no.
75
+ APPROVAL_STATUSES = ("pending", "approved", "rejected", "expired", "cancelled")
76
+
77
+ #: The two human paths, and the sentence each one gets. Studio names the kind; this names what a
78
+ #: person does about it, because ``dashboard_confirmation`` is not an instruction.
79
+ HUMAN_ACTION_SENTENCE = {
80
+ "interactive_step_up": (
81
+ "open this address and complete the step-up there; a run-candidate approval is recorded "
82
+ "only against a freshly verified, operation-bound step-up"
83
+ ),
84
+ "dashboard_confirmation": (
85
+ "open this address and confirm it in the dashboard; a Table recipe is confirmed from an "
86
+ "ordinary signed-in editor session, which deliberately refuses a step-up credential"
87
+ ),
88
+ }
89
+
90
+ #: The one sentence this lane says about itself in every answer it prints, whatever the outcome.
91
+ #: It is a payload key rather than a docstring because the property it states -- that nothing here
92
+ #: decided anything -- is the property somebody reading the output needs to be able to check.
93
+ NEVER_DECIDES = (
94
+ "this command asked; it did not decide. Studio records an approval decision only from a "
95
+ "human, through the operation named under human_action"
96
+ )
97
+
98
+ #: How long a `--wait-seconds` may ask for. Above this a person is not waiting at a terminal, and
99
+ #: a command line holding a socket open for a day is a command line somebody kills.
100
+ MAX_WAIT_SECONDS = 86_400
101
+
102
+ #: What to wait when Studio's own ``poll_after_seconds`` is missing or out of contract. The
103
+ #: contract bounds it at 1..3600; a value outside that is Studio saying something this lane does
104
+ #: not understand, and guessing small would turn one unreadable field into a poll storm.
105
+ DEFAULT_POLL_SECONDS = 15
106
+ MAX_POLL_SECONDS = 3_600
107
+
108
+ #: How long ``approve`` waits for a person by default. Fifteen minutes is long enough for somebody
109
+ #: who is at their desk and short enough that a CI job pointed at an approval nobody is watching
110
+ #: fails on its own rather than holding a runner until the job timeout kills it. ``0`` records the
111
+ #: ask, prints where it is settled, and returns.
112
+ DEFAULT_WAIT_SECONDS = 900
113
+
114
+ _ETAG = re.compile(r'^(?:W/)?"[\x21\x23-\x7e]{1,128}"$')
115
+
116
+
117
+ class StudioApprovalClient(StudioApiClient):
118
+ """The three approval calls, and no fourth one.
119
+
120
+ ``approve`` is a POST and is the only mutation in this class. It writes an ask and cannot write
121
+ a decision: Studio's handler creates one immutable grant-request row, never sets ``status``,
122
+ and never reaches the decision path -- which the upstream suite asserts directly, and which
123
+ this lane relies on rather than restates.
124
+ """
125
+
126
+ def get_approval(self, approval_request_id: str) -> tuple[dict[str, Any], str | None]:
127
+ """One approval request, with the ETag a later ``If-Match`` has to carry.
128
+
129
+ The ETag is returned beside the body rather than folded into it, because it is a transport
130
+ fact about this reading and not a field of the resource: putting it in the payload would
131
+ make it look like something Studio said about the approval.
132
+ """
133
+
134
+ headers: dict[str, str] = {}
135
+ record = self._call(
136
+ "GET",
137
+ GET_APPROVAL_PATH.format(approval_request_id=approval_request_id),
138
+ expected=(200,),
139
+ response_headers=headers,
140
+ )
141
+ etag = headers.get("etag")
142
+ return record, etag if isinstance(etag, str) and _ETAG.fullmatch(etag) else None
143
+
144
+ def request_grant(
145
+ self,
146
+ approval_request_id: str,
147
+ *,
148
+ body: Mapping[str, Any],
149
+ if_match: str,
150
+ idempotency_key: str | None = None,
151
+ ) -> dict[str, Any]:
152
+ """Record what the caller wants, pinned to the exact request version it read."""
153
+
154
+ return self._call(
155
+ "POST",
156
+ APPROVE_PATH.format(approval_request_id=approval_request_id),
157
+ body=body,
158
+ extra_headers={
159
+ "If-Match": if_match,
160
+ "Idempotency-Key": idempotency_key or new_idempotency_key("mr-data-approve"),
161
+ },
162
+ expected=(201,),
163
+ )
164
+
165
+ def get_decision(self, approval_request_id: str) -> dict[str, Any]:
166
+ """The immutable decision that settled one approval. A read, and it grants nothing."""
167
+
168
+ return self._call(
169
+ "GET",
170
+ APPROVAL_DECISION_PATH.format(approval_request_id=approval_request_id),
171
+ expected=(200,),
172
+ )
173
+
174
+
175
+ def grant_request_body(
176
+ *,
177
+ workspace_id: UUID,
178
+ decision: str,
179
+ expected_request_version: int,
180
+ subject_digest: str,
181
+ reason: str | None = None,
182
+ ) -> dict[str, Any]:
183
+ """The ``ApprovalGrantRequestCommand`` this lane sends, with the contract applied here.
184
+
185
+ Every field is refused locally before it can become a 422 from Studio, and ``reason`` is the
186
+ one field a person types freely: it is bounded and its control characters are refused by the
187
+ same pattern the schema declares, because a sentence that lands in somebody else's approval
188
+ queue is attacker-controlled text wherever it came from.
189
+ """
190
+
191
+ if decision not in DECISIONS:
192
+ raise ThinLaneError(
193
+ "THIN_REQUEST_INVALID", "the decision to ask for must be approved or rejected"
194
+ )
195
+ if type(expected_request_version) is not int or expected_request_version < 1:
196
+ raise ThinLaneError(
197
+ "THIN_RESPONSE_INVALID", "Studio's approval carries no version to pin the ask to"
198
+ )
199
+ if not isinstance(subject_digest, str) or not subject_digest:
200
+ raise ThinLaneError(
201
+ "THIN_RESPONSE_INVALID", "Studio's approval carries no subject digest to pin the ask to"
202
+ )
203
+ body: dict[str, Any] = {
204
+ "schema_version": "3.0.0",
205
+ "workspace_id": str(workspace_id),
206
+ "decision": decision,
207
+ "expected_request_version": expected_request_version,
208
+ "subject_digest": subject_digest,
209
+ }
210
+ if reason is not None:
211
+ if not 1 <= len(reason) <= 2000 or re.search(r"[\x00-\x1f\x7f]", reason):
212
+ raise ThinLaneError(
213
+ "THIN_REQUEST_INVALID",
214
+ "--reason is one line of at most 2000 characters, with no control characters",
215
+ )
216
+ body["reason"] = reason
217
+ return body
218
+
219
+
220
+ def _session(args: argparse.Namespace) -> StudioSession:
221
+ return open_studio_session(resolve_cloud_credentials())
222
+
223
+
224
+ def _client(args: argparse.Namespace) -> StudioApprovalClient:
225
+ return StudioApprovalClient(_session(args))
226
+
227
+
228
+ def _approval_id(value: Any) -> str:
229
+ """Refuse anything that is not an approval identifier before it reaches a URL path.
230
+
231
+ Checked before a credential is resolved and a token minted, and the sentence names the
232
+ substitution: a path to a Recipe file is exactly what somebody moving over from the local lane
233
+ types here first, because that is what ``--recipe`` means there.
234
+ """
235
+
236
+ try:
237
+ return str(UUID(str(value)))
238
+ except (ValueError, AttributeError) as error:
239
+ raise ThinLaneError(
240
+ "THIN_REQUEST_INVALID",
241
+ f"{value!r} is not an approval identifier; the hosted lane names the approval Studio "
242
+ f"holds, not a Recipe file on this computer",
243
+ ) from error
244
+
245
+
246
+ def _human_action(grant: Mapping[str, Any]) -> dict[str, Any]:
247
+ """Where a person settles this, said in their words as well as Studio's.
248
+
249
+ ⚠ Mandatory. An ask whose answer carries no human action is an ask nobody can act on, and
250
+ reporting one as a success would be the "wait forever" this whole lane exists to end.
251
+ """
252
+
253
+ action = grant.get("human_action")
254
+ if not isinstance(action, Mapping):
255
+ raise ThinLaneError(
256
+ "THIN_RESPONSE_INVALID",
257
+ "Studio recorded the ask without saying where a human settles it",
258
+ )
259
+ kind = action.get("kind")
260
+ url = action.get("url")
261
+ if not isinstance(kind, str) or not isinstance(url, str) or not url.startswith("https://"):
262
+ raise ThinLaneError(
263
+ "THIN_RESPONSE_INVALID", "Studio named a human action with no usable address"
264
+ )
265
+ roles = action.get("roles")
266
+ return {
267
+ "kind": kind,
268
+ "url": url,
269
+ "operation_id": action.get("operation_id"),
270
+ "roles": list(roles) if isinstance(roles, list) else [],
271
+ "what_to_do": HUMAN_ACTION_SENTENCE.get(
272
+ kind, "open this address; Studio named a human path this client has no sentence for"
273
+ ),
274
+ }
275
+
276
+
277
+ def _poll_seconds(grant: Mapping[str, Any]) -> int:
278
+ declared = grant.get("poll_after_seconds")
279
+ if type(declared) is int and 1 <= declared <= MAX_POLL_SECONDS:
280
+ return declared
281
+ return DEFAULT_POLL_SECONDS
282
+
283
+
284
+ def _decision_receipt(decision: Mapping[str, Any]) -> dict[str, Any]:
285
+ """The decision coordinates a person reads, exactly as Studio reported them."""
286
+
287
+ return {
288
+ key: decision[key]
289
+ for key in (
290
+ "approval_decision_id",
291
+ "approval_request_id",
292
+ "run_id",
293
+ "decision",
294
+ "actor_principal_id",
295
+ "actor_kind",
296
+ "actor_role",
297
+ "interactive",
298
+ "step_up",
299
+ "expected_request_version",
300
+ "subject_digest",
301
+ "decided_at",
302
+ "audit_digest",
303
+ )
304
+ if key in decision
305
+ }
306
+
307
+
308
+ def _request_receipt(record: Mapping[str, Any]) -> dict[str, Any]:
309
+ return {
310
+ key: record[key]
311
+ for key in (
312
+ "approval_request_id",
313
+ "subject_kind",
314
+ "subject_id",
315
+ "recipe_proposal_id",
316
+ "run_id",
317
+ "approval_type",
318
+ "status",
319
+ "subject_digest",
320
+ "version",
321
+ "created_at",
322
+ "expires_at",
323
+ "approval_decision_id",
324
+ "decided_at",
325
+ )
326
+ if key in record
327
+ }
328
+
329
+
330
+ #: Every local flag whose meaning does not survive the move, and the sentence it gets. Accepted and
331
+ #: reported, never refused, for the reason the rest of this lane already gives: a flag that errors
332
+ #: is a script that breaks on a machine that installed a different extra.
333
+ #:
334
+ #: The first four are the same argument in four spellings -- who decided, when, under what -- and
335
+ #: every one of them is a fact Studio records about the human who actually decided. A hosted lane
336
+ #: that accepted a caller's word for any of them would be recording an approval's provenance from
337
+ #: the party that is not allowed to grant it.
338
+ _APPROVAL_NO_EFFECT = (
339
+ (
340
+ "approved_by",
341
+ "--approved-by",
342
+ "Studio records who decided, from the human session that decided; a caller cannot name "
343
+ "the approver of a decision it is not allowed to make",
344
+ ),
345
+ (
346
+ "approved_at",
347
+ "--approved-at",
348
+ "Studio records when the decision was made, from its own clock at the moment it was made",
349
+ ),
350
+ (
351
+ "approval_id",
352
+ "--approval-id",
353
+ "the approval already has an identifier here: it is the one this command names",
354
+ ),
355
+ (
356
+ "approval_policy",
357
+ "--approval-policy",
358
+ "the policy a hosted approval is decided under is the workspace's, and Studio applies it",
359
+ ),
360
+ (
361
+ "approval_policy_digest",
362
+ "--approval-policy-digest",
363
+ "the policy a hosted approval is decided under is the workspace's, and Studio applies it",
364
+ ),
365
+ (
366
+ "output",
367
+ "--output",
368
+ "the decision is Studio's immutable record and is read back from it; writing a second "
369
+ "copy here would be a local file that says approved and proves nothing",
370
+ ),
371
+ )
372
+
373
+
374
+ def approve(
375
+ args: argparse.Namespace,
376
+ *,
377
+ client: StudioApprovalClient | None = None,
378
+ sleep: Callable[[float], None] = time.sleep,
379
+ clock: Callable[[], float] = time.monotonic,
380
+ ) -> dict[str, Any]:
381
+ """``mr-data approve``: ask for the decision, say where a person makes it, read what they did.
382
+
383
+ The local command freezes a Recipe on this computer by writing an approval file beside it.
384
+ There is no file here and no Recipe here: a hosted approval is a record Studio holds and a
385
+ decision a person makes in a browser, so this names the approval, records what was asked for,
386
+ prints where it is settled, and then reads the receipt back.
387
+
388
+ ⚠ WHAT ``approved`` MEANS IN THIS PAYLOAD. It means a human recorded that decision and Studio
389
+ returned its receipt. It never means this command approved anything, and the payload says so
390
+ under ``never_decides`` whatever the outcome.
391
+ """
392
+
393
+ approval_id = _approval_id(getattr(args, "recipe", None))
394
+ selected = client or _client(args)
395
+ record, etag = selected.get_approval(approval_id)
396
+ if etag is None:
397
+ raise ThinLaneError(
398
+ "THIN_RESPONSE_INVALID",
399
+ "Studio answered the approval without an ETag, and the ask must be pinned to the "
400
+ "exact version it was made against",
401
+ )
402
+ grant = selected.request_grant(
403
+ approval_id,
404
+ body=grant_request_body(
405
+ workspace_id=selected.session.workspace_id,
406
+ decision=getattr(args, "decision", None) or "approved",
407
+ expected_request_version=record.get("version"),
408
+ subject_digest=record.get("subject_digest"),
409
+ reason=getattr(args, "reason", None),
410
+ ),
411
+ if_match=etag,
412
+ )
413
+ action = _human_action(grant)
414
+ payload: dict[str, Any] = {
415
+ "schema_version": APPROVE_SCHEMA,
416
+ "status": "approval_requested",
417
+ "lane": "hosted",
418
+ "approval_request_id": approval_id,
419
+ "workspace_id": str(selected.session.workspace_id),
420
+ "asked_for": grant.get("decision"),
421
+ "requested_by_principal_id": grant.get("requested_by_principal_id"),
422
+ "requested_by_kind": grant.get("requested_by_kind"),
423
+ "approval_grant_request_id": grant.get("approval_grant_request_id"),
424
+ "human_action": action,
425
+ "never_decides": NEVER_DECIDES,
426
+ "request": _request_receipt(record),
427
+ "flags_without_effect": _no_effect(args, _APPROVAL_NO_EFFECT),
428
+ }
429
+ if getattr(args, "json", False) is False:
430
+ # Printed before the wait rather than after it. The whole point of `human_action` is that
431
+ # somebody has to go and do something, and a line that arrives when the waiting is over is
432
+ # a line that arrived too late to be the reason it ended.
433
+ print(f"{action['what_to_do']}:", flush=True)
434
+ print(action["url"], flush=True)
435
+ settled = _wait_for_decision(
436
+ selected,
437
+ approval_id,
438
+ poll_seconds=_poll_seconds(grant),
439
+ wait_seconds=_wait_seconds(args),
440
+ sleep=sleep,
441
+ clock=clock,
442
+ )
443
+ if settled is None:
444
+ payload["settled"] = False
445
+ payload["note"] = (
446
+ "Still waiting on a person. Nothing was decided, and running this command again "
447
+ "records the same ask rather than a second one."
448
+ )
449
+ return payload
450
+ payload["status"] = "approval_settled"
451
+ payload["settled"] = True
452
+ payload["request"] = _request_receipt(settled)
453
+ payload["approval_status"] = settled.get("status")
454
+ payload["decision"] = _decision_receipt(selected.get_decision(approval_id))
455
+ return payload
456
+
457
+
458
+ def recipe_approve(
459
+ args: argparse.Namespace,
460
+ *,
461
+ client: StudioApprovalClient | None = None,
462
+ sleep: Callable[[float], None] = time.sleep,
463
+ clock: Callable[[], float] = time.monotonic,
464
+ ) -> dict[str, Any]:
465
+ """``mr-data recipe-approve``: the same act, about one Table recipe.
466
+
467
+ The two names stay distinct here for the reason they are distinct locally -- one binds an
468
+ approval to an exact recipe fingerprint, the other is the general approval -- and the hosted
469
+ route is the same one, because Studio's approval request already carries which kind of subject
470
+ it is about. What differs is the human path Studio names in the answer, and this command says
471
+ so when the approval it was pointed at is not about a recipe at all.
472
+ """
473
+
474
+ payload = approve(args, client=client, sleep=sleep, clock=clock)
475
+ subject = payload.get("request", {}).get("subject_kind")
476
+ if subject is None or subject == "table_recipe":
477
+ if subject == "table_recipe" and approve_exit_code(payload) == 0:
478
+ payload["build"] = _what_follows_an_approved_recipe(client or _client(args), payload)
479
+ return payload
480
+ # ⚠ APPENDED, NEVER ASSIGNED. `approve` writes its own note when nobody has settled the
481
+ # approval yet, and that note is the one thing a caller acts on; overwriting it to say
482
+ # something about the subject kind would answer a question nobody asked with the answer to
483
+ # the one they did.
484
+ said = (
485
+ f"This approval is about a {subject}, not a Table recipe. The ask was recorded and the "
486
+ "human path above is the right one for it; mr-data approve is its name."
487
+ )
488
+ existing = payload.get("note")
489
+ payload["note"] = f"{existing} {said}" if isinstance(existing, str) and existing else said
490
+ return payload
491
+
492
+
493
+ def _what_follows_an_approved_recipe(
494
+ client: StudioApprovalClient, payload: Mapping[str, Any]
495
+ ) -> dict[str, Any]:
496
+ """Whether the approval already started the Build, or the build command that will.
497
+
498
+ Since Studio #124 a person confirming a Table recipe in the dashboard can start its first
499
+ hosted Build in the same transaction. Once a decision is approved, the question a caller acts
500
+ on is therefore "is there a run already, or do I queue one" -- and ``POST /v3/runs`` refuses
501
+ the first case rather than replaying it, so the answer has to be read from the approval's own
502
+ start projection: a run means nothing is left to queue; its absence means ``mr-data build``
503
+ with the coordinates the approval carries.
504
+ """
505
+
506
+ request = payload.get("request", {})
507
+ approval_id = str(payload.get("approval_request_id"))
508
+ started = client.approval_start(approval_id)
509
+ if started is not None:
510
+ run = started.get("run") if isinstance(started.get("run"), Mapping) else {}
511
+ run_id = run.get("run_id")
512
+ if not isinstance(run_id, str):
513
+ raise ThinLaneError(
514
+ "THIN_RESPONSE_INVALID", "Studio named a started Build without a run"
515
+ )
516
+ return {
517
+ "state": "run_already_queued",
518
+ "run_id": run_id,
519
+ "dashboard_url": run_dashboard_url(client.session.cloud_url, run_id),
520
+ "watch_command": f"mr-data watch {run_id} --hosted",
521
+ "note": (
522
+ "The confirmation started the first hosted Build in the same act; there is "
523
+ "nothing to queue. Follow this run."
524
+ ),
525
+ }
526
+ proposal_id = request.get("recipe_proposal_id")
527
+ digest = request.get("subject_digest")
528
+ return {
529
+ "state": "build_not_started",
530
+ "build_command": (
531
+ f"mr-data build --recipe-proposal {proposal_id} --recipe-digest {digest} --hosted"
532
+ ),
533
+ "note": (
534
+ "The recipe is approved and no Build has been started for it; queue the first hosted "
535
+ "Build with the command above. It replays to the same run if one is started meanwhile."
536
+ ),
537
+ }
538
+
539
+
540
+ def _wait_seconds(args: argparse.Namespace) -> int:
541
+ declared = getattr(args, "wait_seconds", None)
542
+ if declared is None:
543
+ return DEFAULT_WAIT_SECONDS
544
+ if type(declared) is not int or not 0 <= declared <= MAX_WAIT_SECONDS:
545
+ raise ThinLaneError(
546
+ "THIN_REQUEST_INVALID",
547
+ f"--wait-seconds is from 0 to {MAX_WAIT_SECONDS}; 0 records the ask and returns",
548
+ )
549
+ return declared
550
+
551
+
552
+ def _wait_for_decision(
553
+ client: StudioApprovalClient,
554
+ approval_id: str,
555
+ *,
556
+ poll_seconds: int,
557
+ wait_seconds: int,
558
+ sleep: Callable[[float], None],
559
+ clock: Callable[[], float],
560
+ ) -> dict[str, Any] | None:
561
+ """Poll until the approval is no longer pending, or until the caller's patience runs out.
562
+
563
+ ``None`` when it is still pending at the deadline, which is an answer and not a failure: the
564
+ ask was recorded, and a person has simply not got to it yet. The cadence is Studio's own
565
+ ``poll_after_seconds`` rather than a number chosen here, so a backend under load can slow every
566
+ client down by saying so once.
567
+ """
568
+
569
+ deadline = clock() + wait_seconds
570
+ while True:
571
+ record, _etag = client.get_approval(approval_id)
572
+ status = record.get("status")
573
+ if status != "pending":
574
+ return record
575
+ remaining = deadline - clock()
576
+ if remaining <= 0:
577
+ return None
578
+ sleep(min(float(poll_seconds), remaining))
579
+
580
+
581
+ def approve_exit_code(payload: Mapping[str, Any]) -> int:
582
+ """``2`` unless a human recorded the decision that was asked for.
583
+
584
+ Three different outcomes exit 2 here -- rejected, expired or cancelled, and still pending --
585
+ and that is deliberate: a script gating on an approval must not read any of them as a yes, and
586
+ the payload is where the difference between them is stated.
587
+ """
588
+
589
+ if payload.get("settled") is not True:
590
+ return 2
591
+ decision = payload.get("decision")
592
+ asked = payload.get("asked_for")
593
+ if not isinstance(decision, Mapping) or decision.get("decision") != asked:
594
+ return 2
595
+ return 0
596
+
597
+
598
+ __all__ = [
599
+ "APPROVAL_DECISION_PATH",
600
+ "APPROVAL_STATUSES",
601
+ "APPROVE_PATH",
602
+ "APPROVE_SCHEMA",
603
+ "DECIDE_PATH",
604
+ "DECISIONS",
605
+ "DEFAULT_POLL_SECONDS",
606
+ "DEFAULT_WAIT_SECONDS",
607
+ "GET_APPROVAL_PATH",
608
+ "HUMAN_ACTION_SENTENCE",
609
+ "MAX_POLL_SECONDS",
610
+ "MAX_WAIT_SECONDS",
611
+ "NEVER_DECIDES",
612
+ "StudioApprovalClient",
613
+ "approve",
614
+ "approve_exit_code",
615
+ "grant_request_body",
616
+ "recipe_approve",
617
+ ]