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,1500 @@
1
+ """Bring one released hosted Build down and derive its table notebook here.
2
+
3
+ The hosted half of a deployment ends with Studio releasing an independently checked Build. This
4
+ module is the local half: it reads that release through the pinned generated client, redeems one
5
+ Studio-issued signed download session for the exact sealed evidence, verifies the transferred bytes
6
+ against the digests the release already sealed, rebuilds the Build tree on this machine, verifies
7
+ *that* by ordinary local replay, and only then asks the existing deterministic generator for
8
+ ``table.ipynb``.
9
+
10
+ Three properties are the whole point, and each of them is a refusal rather than a comment.
11
+
12
+ **A session is a route, not an authority.** The signed session names a size and a digest, and so
13
+ does the sealed artifact record, and so does the released version's own artifact reference. Bytes
14
+ are accepted only where all three agree, so a session that names a digest of its own decides
15
+ nothing. This is the notebook-sidecar rule of ``docs/ARCHITECTURE.md`` applied one layer out: a
16
+ transport cannot authorize what it carries.
17
+
18
+ **Nothing is exposed before it is verified.** The transfer is bounded and streamed through
19
+ :class:`ResultBytesVerifier`, so a download that will be refused never materializes; the envelope
20
+ is decoded against the released Build fingerprint before a byte reaches the filesystem; the
21
+ rebuilt tree is verified by :func:`pipeline.verify_candidate` before the notebook is derived from
22
+ it; and the notebook is installed by the existing atomic writer, so a half-written one is never
23
+ visible. Rerunning after an interruption converges on the same verified result.
24
+
25
+ **The notebook is derived here, never transported.** Studio has no notebook to offer and would not
26
+ be believed if it had one -- the viewer re-derives the notebook from its own verified snapshot and
27
+ compares bytes before it will show a Table tab (``viewer.py`` ``_dataset_is_admissible``). This
28
+ module therefore lands exactly what the local generator needs to reproduce it, which is the sealed
29
+ Build tree, and nothing that only a remote actor could vouch for.
30
+
31
+ Refusals are :class:`HostedDeployError` because this is the deployment lane's last step, not a
32
+ second lane: a person who ran ``mr-data deploy`` and then ``mr-data deploy-dataset`` is inside one
33
+ journey and should not have to learn two error vocabularies to read its failures.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import errno
39
+ import importlib
40
+ import os
41
+ import re
42
+ import secrets
43
+ import ssl
44
+ import stat
45
+ import tempfile
46
+ from collections.abc import Callable, Iterator, Mapping, Sequence
47
+ from contextlib import contextmanager, suppress
48
+ from dataclasses import dataclass
49
+ from datetime import UTC, datetime, timedelta
50
+ from pathlib import Path
51
+ from typing import Any, Protocol
52
+ from urllib.error import HTTPError
53
+ from urllib.parse import parse_qsl, urlsplit
54
+ from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
55
+ from uuid import UUID
56
+
57
+ from mostlyright.data_harness import canonical, pipeline
58
+ from mostlyright.data_harness.acquisition.result_download import (
59
+ RESULT_CHUNK_BYTES,
60
+ ResultBytesVerifier,
61
+ ResultDownloadError,
62
+ ValidatedResultDownload,
63
+ )
64
+ from mostlyright.data_harness.hosted_deploy import (
65
+ REQUEST_TIMEOUT_SECONDS,
66
+ STUDIO_SCHEMA_VERSION,
67
+ HostedDeployError,
68
+ StudioToken,
69
+ build_editor_client,
70
+ )
71
+ from mostlyright.data_harness.hosted_handoff import (
72
+ HANDOFF_RECEIPT_SCHEMA,
73
+ HANDOFF_RELATIVE_PATH,
74
+ read_handoff_receipt,
75
+ )
76
+ from mostlyright.data_harness.hosted_worker import (
77
+ CANDIDATE_CONTRACT_VERSION,
78
+ HOSTED_ARTIFACT_BYTE_LIMITS,
79
+ MAX_CANDIDATE_ENVELOPE_BYTES,
80
+ HostedWorkerError,
81
+ _decode_candidate_envelope,
82
+ _restore_candidate_members,
83
+ )
84
+ from mostlyright.data_harness.studio_boundary import StudioBoundaryError, studio_wire_dict
85
+ from mostlyright.data_harness.visual_run.contracts import VisualRunError
86
+ from mostlyright.data_harness.visual_run.reducer import reduce_events
87
+ from mostlyright.data_harness.visual_run.sdk import VisualWork
88
+ from mostlyright.data_harness.visual_run.store import VisualRunStore
89
+
90
+ # The one artifact kind this handoff redeems. The Parquet alone cannot reconstitute a Build the
91
+ # viewer will admit -- admission verifies the whole sealed tree against `candidate/manifest.json`
92
+ # -- and the sealed evidence envelope already carries every member of that tree, including the
93
+ # Parquet, under one digest the release itself names. So the minimum set is one artifact, and
94
+ # taking more would mean trusting more.
95
+ REQUIRED_ARTIFACT_KIND = "candidate_evidence"
96
+ REQUIRED_ARTIFACT_MEDIA_TYPE = "application/json"
97
+ REQUIRED_ARTIFACT_PURPOSE = "public_sealed_artifact_download"
98
+
99
+ # The committed object prefix of the Studio artifact store. It is named `quarantine/` for
100
+ # historical reasons that mostlyrightmd/mostlyright-studio#44 is fixing; nothing under it is
101
+ # quarantined, and the short-lived uncommitted uploads live under `pending/` instead. A client
102
+ # allowlist has to admit the prefix the sealed bytes are actually served from, so this admits the
103
+ # committed one and only the committed one.
104
+ GCS_COMMITTED_PREFIX = ("quarantine", "v2")
105
+ GCS_TRANSFER_HOST = "storage.googleapis.com"
106
+
107
+ # A transferred artifact smaller than this never touches the disk; a larger one spills to a
108
+ # temporary file unlinked at creation, so a refused transfer leaves nothing behind on any exit
109
+ # path. The same threshold `ux/hosted_acquisition` stages hosted results at.
110
+ DOWNLOAD_SPOOL_BYTES = 8 * 1024 * 1024
111
+ # A released version lists the artifacts its run sealed. Eight kinds exist; the bound is generous
112
+ # enough to survive a contract that adds some and small enough that a malformed list cannot turn
113
+ # one handoff into an unbounded sequence of reads.
114
+ MAX_ARTIFACT_REFERENCES = 32
115
+ # How many released versions of one Dataset this will read through to find its own. The listing is
116
+ # unpaginated and returns the whole release history, and a Dataset that refreshes daily reaches
117
+ # four thousand after eleven years -- so this is a bound against a malformed answer, not a limit on
118
+ # how often a Dataset may be refreshed. Selecting from the list costs one linear scan; only the
119
+ # selected record is read in full, so the cost here is the response, not the count.
120
+ MAX_RELEASED_VERSIONS = 4096
121
+ MAX_SIGNED_URL_CHARS = 4096
122
+ # A signed transfer carries a handful of required headers at most. Everything else here is
123
+ # bounded explicitly, so the count is too.
124
+ MAX_SESSION_HEADERS = 16
125
+ # The sealed record of an installed tree, read only to recognise a half-finished install.
126
+ MAX_MANIFEST_BYTES = 8 * 1024 * 1024
127
+ # The generated notebook, read back once to narrate it into the open workbench.
128
+ MAX_NOTEBOOK_BYTES = 64 * 1024 * 1024
129
+ _NO_FOLLOW = getattr(os, "O_NOFOLLOW", 0)
130
+ MAX_SESSION_LIFETIME = timedelta(hours=1)
131
+
132
+ _SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
133
+ _BUCKET = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
134
+ # The object name is an opaque store-side identifier, not a content digest, so its exact alphabet
135
+ # is Studio's business. What this pattern is for is path confinement: one flat segment with no
136
+ # separator, no traversal, and no escape.
137
+ _OBJECT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$")
138
+ _HEADER_NAME = re.compile(r"^[A-Za-z0-9-]{1,128}$")
139
+ _FORBIDDEN_HEADERS = frozenset({"authorization", "cookie", "host", "proxy-authorization"})
140
+
141
+ # One sentence for the three transfer bounds it covers, so the status, media-type and length
142
+ # refusals stay indistinguishable to a caller no matter which of them fired.
143
+ _DOWNLOAD_INVALID_DETAIL = "the released Build transfer violated its media type, status, or size"
144
+
145
+
146
+ def _refuse(code: str, detail: str) -> HostedDeployError:
147
+ """Build one typed refusal, for a caller that writes ``raise _refuse(...)``.
148
+
149
+ It returns the error rather than raising it, and every call site raises what it returns. That
150
+ is not a style preference: the completeness gates that require every typed code to carry a
151
+ plain sentence and a remediation read the name written at each ``raise``, so a helper that
152
+ raised for its callers would hide every refusal in this module from them.
153
+ """
154
+
155
+ return HostedDeployError(code, detail)
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class SealedArtifact:
160
+ """One sealed artifact record, already agreed with the release that names it."""
161
+
162
+ artifact_id: UUID
163
+ workspace_id: UUID
164
+ run_id: UUID
165
+ kind: str
166
+ media_type: str
167
+ content_digest: str
168
+ size_bytes: int
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class ReleasedBuild:
173
+ """The released version coordinate, reduced to what the local handoff is allowed to use."""
174
+
175
+ dataset_id: UUID
176
+ table_id: UUID
177
+ table_version_id: UUID
178
+ workspace_id: UUID
179
+ run_id: UUID
180
+ version_number: int
181
+ candidate_digest: str
182
+ artifact_references: tuple[tuple[UUID, str], ...]
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class HostedDatasetHandoff:
187
+ """What one completed handoff installed, and which released Build it came from."""
188
+
189
+ run_dir: Path
190
+ dataset_id: UUID
191
+ table_id: UUID
192
+ table_version_id: UUID
193
+ version_number: int
194
+ hosted_run_id: UUID
195
+ candidate_digest: str
196
+ artifact_id: UUID
197
+ artifact_content_digest: str
198
+ artifact_size_bytes: int
199
+ notebook_path: Path
200
+ transferred: bool
201
+
202
+ def to_dict(self) -> dict[str, Any]:
203
+ return {
204
+ "schema_version": HANDOFF_RECEIPT_SCHEMA,
205
+ "run_dir": str(self.run_dir),
206
+ "dataset_id": str(self.dataset_id),
207
+ "table_id": str(self.table_id),
208
+ "table_version_id": str(self.table_version_id),
209
+ "version_number": self.version_number,
210
+ "hosted_run_id": str(self.hosted_run_id),
211
+ "candidate_digest": "sha256:" + self.candidate_digest,
212
+ "artifact_id": str(self.artifact_id),
213
+ "artifact_content_digest": self.artifact_content_digest,
214
+ "artifact_size_bytes": self.artifact_size_bytes,
215
+ "notebook": str(self.notebook_path),
216
+ "transferred": self.transferred,
217
+ }
218
+
219
+
220
+ class StudioDatasetReader(Protocol):
221
+ """The three Studio reads this handoff makes, and nothing that can mutate anything."""
222
+
223
+ def released_versions(self, table_id: UUID) -> list[Mapping[str, Any]]: ...
224
+
225
+ def artifact(self, artifact_id: UUID) -> Mapping[str, Any]: ...
226
+
227
+ def download_session(self, artifact_id: UUID) -> Mapping[str, Any]: ...
228
+
229
+ def close(self) -> None: ...
230
+
231
+
232
+ class GeneratedStudioDatasetReader:
233
+ """Read-only released-Build access through the pinned generated editor facade."""
234
+
235
+ def __init__(self, token: StudioToken) -> None:
236
+ self._raw_client, self._client = build_editor_client(token)
237
+ self._operations = {
238
+ "list_table_versions": importlib.import_module(
239
+ "mostlyright_studio.api.tables.list_table_versions"
240
+ ),
241
+ "get_artifact": importlib.import_module(
242
+ "mostlyright_studio.api.artifacts.get_artifact"
243
+ ),
244
+ "create_artifact_download_session": importlib.import_module(
245
+ "mostlyright_studio.api.artifacts.create_artifact_download_session"
246
+ ),
247
+ }
248
+
249
+ def _read(self, code: str, operation: Any, **arguments: Any) -> Any:
250
+ """Make one read and give every way it can fail the same typed answer.
251
+
252
+ A Studio that cannot be reached is the most ordinary failure this command has -- the
253
+ docstring above advertises a laptop that slept as the normal case -- and the generated
254
+ client raises its transport errors as its own exception type, which is not an ``OSError``
255
+ and is in nothing the command boundary catches. Without this the commonest failure of the
256
+ command reached a person as a traceback with no code, no fix, and, under ``--json``, no
257
+ object at all. The token exchange before this and the transfer after it both wrap; only
258
+ the reads did not.
259
+ """
260
+
261
+ try:
262
+ response = operation(**arguments)
263
+ except HostedDeployError:
264
+ raise
265
+ except (TypeError, AttributeError):
266
+ # Not a transport failure: the pinned client was regenerated with a different shape,
267
+ # and calling it "the cloud could not be reached" would send somebody looking at their
268
+ # network for a contract change. Let it surface as what it is.
269
+ raise
270
+ except Exception as error:
271
+ raise HostedDeployError(code, "the Mostly Right cloud could not be reached") from error
272
+ return _read_parsed(response, code)
273
+
274
+ def released_versions(self, table_id: UUID) -> list[Mapping[str, Any]]:
275
+ operation = self._operations["list_table_versions"]
276
+ parsed = self._read(
277
+ "DEPLOY_DATASET_VERSION_READ_FAILED",
278
+ operation.sync_detailed,
279
+ client=self._raw_client,
280
+ table_id=table_id,
281
+ )
282
+ if not isinstance(parsed, list):
283
+ raise _refuse(
284
+ "DEPLOY_DATASET_VERSION_READ_FAILED",
285
+ "the released Build list did not come back as a list",
286
+ )
287
+ # Bounded here as well as in the validator every reader passes through: normalizing a
288
+ # malformed answer of a million entries is work this never has to do.
289
+ if len(parsed) > MAX_RELEASED_VERSIONS:
290
+ raise _refuse(
291
+ "DEPLOY_DATASET_VERSION_INVALID",
292
+ "the released Build list is longer than this handoff will read",
293
+ )
294
+ return [_wire(item, "DEPLOY_DATASET_VERSION_READ_FAILED") for item in parsed]
295
+
296
+ def artifact(self, artifact_id: UUID) -> Mapping[str, Any]:
297
+ operation = self._operations["get_artifact"]
298
+ parsed = self._read(
299
+ "DEPLOY_DATASET_ARTIFACT_READ_FAILED",
300
+ operation.sync_detailed,
301
+ client=self._raw_client,
302
+ artifact_id=artifact_id,
303
+ )
304
+ return _wire(parsed, "DEPLOY_DATASET_ARTIFACT_READ_FAILED")
305
+
306
+ def download_session(self, artifact_id: UUID) -> Mapping[str, Any]:
307
+ operation = self._operations["create_artifact_download_session"]
308
+ parsed = self._read(
309
+ "DEPLOY_DATASET_SESSION_READ_FAILED",
310
+ operation.sync_detailed,
311
+ client=self._raw_client,
312
+ artifact_id=artifact_id,
313
+ idempotency_key=f"download:{artifact_id}:{secrets.token_hex(16)}",
314
+ )
315
+ return _wire(parsed, "DEPLOY_DATASET_SESSION_READ_FAILED")
316
+
317
+ def close(self) -> None:
318
+ client = getattr(self._raw_client, "_client", None)
319
+ if client is not None:
320
+ client.close()
321
+
322
+
323
+ def _read_parsed(response: Any, code: str) -> Any:
324
+ status = int(getattr(response, "status_code", 0))
325
+ parsed = getattr(response, "parsed", None)
326
+ if status not in {200, 201} or parsed is None:
327
+ raise _refuse(code, f"the Mostly Right cloud answered with HTTP {status}")
328
+ return parsed
329
+
330
+
331
+ def _wire(model: Any, code: str) -> Mapping[str, Any]:
332
+ try:
333
+ return studio_wire_dict(model)
334
+ except StudioBoundaryError as error:
335
+ raise HostedDeployError(code, "the answer is not a valid Studio object") from error
336
+
337
+
338
+ # ------------------------------------------------------------------------------------------------
339
+ # Pure validation: the release, the sealed record, and the signed session
340
+ # ------------------------------------------------------------------------------------------------
341
+
342
+
343
+ def _uuid(value: Any, code: str, label: str) -> UUID:
344
+ if not isinstance(value, str):
345
+ raise _refuse(code, f"{label} is missing")
346
+ try:
347
+ return UUID(value)
348
+ except ValueError as error:
349
+ raise _refuse(code, f"{label} is invalid") from error
350
+
351
+
352
+ def _timestamp(value: Any, code: str, label: str) -> datetime:
353
+ if not isinstance(value, str) or not value.endswith("Z"):
354
+ raise _refuse(code, f"{label} is invalid")
355
+ try:
356
+ parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
357
+ except ValueError as error:
358
+ raise _refuse(code, f"{label} is invalid") from error
359
+ return parsed.astimezone(UTC)
360
+
361
+
362
+ def select_released_build(
363
+ versions: Sequence[Mapping[str, Any]],
364
+ *,
365
+ dataset_id: UUID,
366
+ table_id: UUID,
367
+ workspace_id: UUID,
368
+ hosted_run_id: UUID,
369
+ ) -> ReleasedBuild:
370
+ """Return the one released version this run produced, or refuse.
371
+
372
+ Selection is by run, not by recency. "The newest version of this Dataset" is a different
373
+ question from "what the run I deployed released", and answering the first when asked the second
374
+ is how a handoff quietly lands somebody else's refresh. A run that has not released anything
375
+ yet has no version here at all, which is why finishing the hosted work is not by itself enough
376
+ to unlock any bytes.
377
+ """
378
+
379
+ if len(versions) > MAX_RELEASED_VERSIONS:
380
+ # The bound belongs here rather than at the transport, because every reader answers this
381
+ # question and only one of them is the generated client.
382
+ raise _refuse(
383
+ "DEPLOY_DATASET_VERSION_INVALID",
384
+ "the released Build list is longer than this handoff will read",
385
+ )
386
+ matches = [
387
+ version
388
+ for version in versions
389
+ if isinstance(version, Mapping) and version.get("run_id") == str(hosted_run_id)
390
+ ]
391
+ if not matches:
392
+ raise _refuse(
393
+ "DEPLOY_DATASET_NOT_RELEASED",
394
+ "this run has not released a Build yet, so there is nothing to bring back",
395
+ )
396
+ if len(matches) > 1:
397
+ raise _refuse(
398
+ "DEPLOY_DATASET_VERSION_INVALID",
399
+ "this run reports more than one released Build",
400
+ )
401
+ return _validate_released_version(
402
+ matches[0],
403
+ dataset_id=dataset_id,
404
+ table_id=table_id,
405
+ workspace_id=workspace_id,
406
+ hosted_run_id=hosted_run_id,
407
+ )
408
+
409
+
410
+ def _validate_released_version(
411
+ version: Mapping[str, Any],
412
+ *,
413
+ dataset_id: UUID,
414
+ table_id: UUID,
415
+ workspace_id: UUID,
416
+ hosted_run_id: UUID,
417
+ ) -> ReleasedBuild:
418
+ if not isinstance(version, Mapping):
419
+ raise _refuse(
420
+ "DEPLOY_DATASET_VERSION_INVALID", "the released Build record is not an object"
421
+ )
422
+ gate = version.get("release_gate")
423
+ references = version.get("artifact_references")
424
+ version_number = version.get("version_number")
425
+ digest = version.get("candidate_digest")
426
+ if (
427
+ version.get("schema_version") != STUDIO_SCHEMA_VERSION
428
+ or version.get("workspace_id") != str(workspace_id)
429
+ or version.get("dataset_id") != str(dataset_id)
430
+ or version.get("table_id") != str(table_id)
431
+ or version.get("run_id") != str(hosted_run_id)
432
+ or version.get("release_authority") != "studio_transactional_release_controller"
433
+ or version.get("candidate_contract_version") != CANDIDATE_CONTRACT_VERSION
434
+ or not isinstance(digest, str)
435
+ or _SHA256.fullmatch(digest) is None
436
+ or type(version_number) is not int
437
+ or version_number < 1
438
+ or not isinstance(references, list)
439
+ or not references
440
+ or len(references) > MAX_ARTIFACT_REFERENCES
441
+ ):
442
+ raise _refuse(
443
+ "DEPLOY_DATASET_VERSION_INVALID",
444
+ "the released Build record is not the exact shape this handoff accepts",
445
+ )
446
+ if not isinstance(gate, Mapping) or gate.get("verification_decision") != "passed":
447
+ raise _refuse(
448
+ "DEPLOY_DATASET_NOT_RELEASED",
449
+ "this Build has not passed its independent check, so its rows stay unavailable",
450
+ )
451
+ if (
452
+ gate.get("open_blocker_count") != 0
453
+ or gate.get("open_critical_count") != 0
454
+ or gate.get("open_high_count") != 0
455
+ or gate.get("deterministic_checks_passed") is not True
456
+ or gate.get("committed_source_states_verified") is not True
457
+ ):
458
+ raise _refuse(
459
+ "DEPLOY_DATASET_NOT_RELEASED",
460
+ "this Build still has open findings, so its rows stay unavailable",
461
+ )
462
+ seen: set[UUID] = set()
463
+ collected: list[tuple[UUID, str]] = []
464
+ for reference in references:
465
+ if not isinstance(reference, Mapping):
466
+ raise _refuse(
467
+ "DEPLOY_DATASET_VERSION_INVALID", "an artifact reference is not an object"
468
+ )
469
+ artifact_id = _uuid(
470
+ reference.get("artifact_id"), "DEPLOY_DATASET_VERSION_INVALID", "an artifact reference"
471
+ )
472
+ content_digest = reference.get("content_digest")
473
+ if (
474
+ reference.get("workspace_id") != str(workspace_id)
475
+ or reference.get("contract_version") != STUDIO_SCHEMA_VERSION
476
+ or not isinstance(content_digest, str)
477
+ or _SHA256.fullmatch(content_digest) is None
478
+ or artifact_id in seen
479
+ ):
480
+ raise _refuse(
481
+ "DEPLOY_DATASET_VERSION_INVALID",
482
+ "an artifact reference is not the exact shape this handoff accepts",
483
+ )
484
+ seen.add(artifact_id)
485
+ collected.append((artifact_id, content_digest))
486
+ return ReleasedBuild(
487
+ dataset_id=dataset_id,
488
+ table_id=table_id,
489
+ table_version_id=_uuid(
490
+ version.get("table_version_id"),
491
+ "DEPLOY_DATASET_VERSION_INVALID",
492
+ "the released Build id",
493
+ ),
494
+ workspace_id=workspace_id,
495
+ run_id=hosted_run_id,
496
+ version_number=version_number,
497
+ candidate_digest=digest,
498
+ artifact_references=tuple(collected),
499
+ )
500
+
501
+
502
+ def validate_sealed_artifact(
503
+ metadata: Mapping[str, Any],
504
+ *,
505
+ released: ReleasedBuild,
506
+ artifact_id: UUID,
507
+ reference_digest: str,
508
+ ) -> SealedArtifact:
509
+ """Accept one sealed artifact record only where it agrees with the release that named it."""
510
+
511
+ if not isinstance(metadata, Mapping):
512
+ raise _refuse(
513
+ "DEPLOY_DATASET_ARTIFACT_INVALID", "the sealed evidence record is not an object"
514
+ )
515
+ size = metadata.get("size_bytes")
516
+ digest = metadata.get("content_digest")
517
+ kind = metadata.get("kind")
518
+ if (
519
+ metadata.get("schema_version") != STUDIO_SCHEMA_VERSION
520
+ or metadata.get("contract_version") != STUDIO_SCHEMA_VERSION
521
+ or metadata.get("artifact_id") != str(artifact_id)
522
+ or metadata.get("workspace_id") != str(released.workspace_id)
523
+ or metadata.get("run_id") != str(released.run_id)
524
+ or not isinstance(kind, str)
525
+ or not isinstance(digest, str)
526
+ or _SHA256.fullmatch(digest) is None
527
+ or type(size) is not int
528
+ ):
529
+ raise _refuse(
530
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
531
+ "the sealed evidence record is not the exact shape this handoff accepts",
532
+ )
533
+ # The reference the release itself carries is the authority; a record that disagrees with it
534
+ # describes some other bytes, whatever it says about itself.
535
+ if digest != reference_digest:
536
+ raise _refuse(
537
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
538
+ "the sealed evidence fingerprint differs from the one the released Build names",
539
+ )
540
+ if metadata.get("storage_state") not in {"sealed", "verified"}:
541
+ raise _refuse(
542
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
543
+ "the sealed evidence is not in a settled, readable state",
544
+ )
545
+ limit = HOSTED_ARTIFACT_BYTE_LIMITS.get(kind)
546
+ if limit is None:
547
+ raise _refuse(
548
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
549
+ "the released Build names evidence of a kind this handoff cannot read",
550
+ )
551
+ if not 1 <= size <= limit:
552
+ raise _refuse(
553
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
554
+ "the sealed evidence size is outside its budget for this kind",
555
+ )
556
+ return SealedArtifact(
557
+ artifact_id=artifact_id,
558
+ workspace_id=released.workspace_id,
559
+ run_id=released.run_id,
560
+ kind=kind,
561
+ media_type=str(metadata.get("media_type", "")),
562
+ content_digest=digest,
563
+ size_bytes=size,
564
+ )
565
+
566
+
567
+ def validate_download_session(
568
+ session: Mapping[str, Any],
569
+ *,
570
+ artifact: SealedArtifact,
571
+ studio_base_url: str,
572
+ now: datetime,
573
+ ) -> tuple[ValidatedResultDownload, dict[str, str]]:
574
+ """Accept one signed download session as a route to already-known bytes, and nothing more.
575
+
576
+ Every fact the transfer will later be judged against -- the size, the fingerprint, the media
577
+ type -- is taken from the sealed record and merely required to match here. A session is
578
+ therefore never the reason a byte is believed; it is only the reason a byte is reachable.
579
+ """
580
+
581
+ if not isinstance(session, Mapping):
582
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer is not an object")
583
+ issued = _timestamp(
584
+ session.get("issued_at"), "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer start time"
585
+ )
586
+ expires = _timestamp(
587
+ session.get("expires_at"), "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer expiry"
588
+ )
589
+ moment = now.astimezone(UTC)
590
+ if (
591
+ session.get("schema_version") != STUDIO_SCHEMA_VERSION
592
+ or session.get("contract_version") != STUDIO_SCHEMA_VERSION
593
+ or session.get("artifact_id") != str(artifact.artifact_id)
594
+ or session.get("workspace_id") != str(artifact.workspace_id)
595
+ or session.get("run_id") != str(artifact.run_id)
596
+ or session.get("kind") != artifact.kind
597
+ or session.get("purpose") != REQUIRED_ARTIFACT_PURPOSE
598
+ or session.get("media_type") != artifact.media_type
599
+ or session.get("direction") != "download"
600
+ or session.get("method") != "GET"
601
+ or session.get("route_authority") != "public_editor"
602
+ or session.get("expected_content_digest") != artifact.content_digest
603
+ or session.get("expected_size_bytes") != artifact.size_bytes
604
+ ):
605
+ raise _refuse(
606
+ "DEPLOY_DATASET_SESSION_INVALID",
607
+ "the signed transfer does not describe the evidence it was asked for",
608
+ )
609
+ if issued >= expires or moment >= expires or expires - issued > MAX_SESSION_LIFETIME:
610
+ raise _refuse(
611
+ "DEPLOY_DATASET_SESSION_INVALID",
612
+ "the signed transfer has expired or lasts longer than is allowed",
613
+ )
614
+ if issued > moment + timedelta(minutes=1):
615
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer starts in the future")
616
+ headers = _session_headers(session.get("required_headers"), media_type=artifact.media_type)
617
+ url = _validate_transfer_url(session.get("signed_url"), studio_base_url=studio_base_url)
618
+ return (
619
+ ValidatedResultDownload(
620
+ url=url,
621
+ size=artifact.size_bytes,
622
+ media_type=artifact.media_type,
623
+ integrity={"kind": "local_sha256", "value": artifact.content_digest},
624
+ ),
625
+ headers,
626
+ )
627
+
628
+
629
+ def _session_headers(required: Any, *, media_type: str) -> dict[str, str]:
630
+ if not isinstance(required, list) or len(required) > MAX_SESSION_HEADERS:
631
+ raise _refuse(
632
+ "DEPLOY_DATASET_SESSION_INVALID",
633
+ "the signed transfer headers are absent or unreasonably many",
634
+ )
635
+ headers: dict[str, str] = {}
636
+ for item in required:
637
+ if not isinstance(item, Mapping):
638
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "a signed transfer header is invalid")
639
+ name = item.get("name")
640
+ value = item.get("value")
641
+ if (
642
+ not isinstance(name, str)
643
+ or not isinstance(value, str)
644
+ or _HEADER_NAME.fullmatch(name) is None
645
+ or name.lower() in _FORBIDDEN_HEADERS
646
+ or name.lower() in headers
647
+ or len(value) > 4096
648
+ or "\r" in value
649
+ or "\n" in value
650
+ ):
651
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "a signed transfer header is unsafe")
652
+ headers[name.lower()] = value
653
+ if headers.get("accept", media_type) != media_type:
654
+ raise _refuse(
655
+ "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer asks for a different media type"
656
+ )
657
+ headers["accept"] = media_type
658
+ return headers
659
+
660
+
661
+ def _validate_transfer_url(signed_url: Any, *, studio_base_url: str) -> str:
662
+ """Admit the two places sealed bytes are actually served from, and no third one.
663
+
664
+ This is deliberately not the allowlist ``acquisition/result_download`` uses. That one pins the
665
+ public-crawl result prefix, which sealed Build evidence is never stored under; reusing it would
666
+ have meant either refusing every real artifact or widening a rule that guards a different
667
+ boundary. So the authority here is its own: the Studio origin the credential was issued for, or
668
+ the committed object prefix of the Studio artifact store.
669
+ """
670
+
671
+ if not isinstance(signed_url, str) or not 1 <= len(signed_url) <= MAX_SIGNED_URL_CHARS:
672
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid")
673
+ parsed = urlsplit(signed_url)
674
+ base = urlsplit(studio_base_url)
675
+ try:
676
+ port = parsed.port
677
+ except ValueError as error:
678
+ raise _refuse(
679
+ "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid"
680
+ ) from error
681
+ if (
682
+ parsed.username is not None
683
+ or parsed.password is not None
684
+ or parsed.fragment
685
+ or "%" in parsed.path
686
+ or "\\" in parsed.path
687
+ ):
688
+ raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid")
689
+ if parsed.hostname == GCS_TRANSFER_HOST:
690
+ _validate_object_store_url(parsed)
691
+ return signed_url
692
+ if (
693
+ parsed.scheme != base.scheme
694
+ or parsed.hostname != base.hostname
695
+ or port != base.port
696
+ or not parsed.hostname
697
+ ):
698
+ raise _refuse(
699
+ "DEPLOY_DATASET_SESSION_INVALID",
700
+ "the signed transfer address is somewhere this command will not go",
701
+ )
702
+ segments = parsed.path.split("/")
703
+ if (
704
+ not parsed.path.startswith("/")
705
+ or len(segments) > 8
706
+ or any(segment in {"", ".", ".."} for segment in segments[1:])
707
+ ):
708
+ raise _refuse(
709
+ "DEPLOY_DATASET_SESSION_INVALID",
710
+ "the signed transfer address is somewhere this command will not go",
711
+ )
712
+ return signed_url
713
+
714
+
715
+ def _validate_object_store_url(parsed: Any) -> None:
716
+ segments = parsed.path.split("/")
717
+ if (
718
+ parsed.scheme != "https"
719
+ or parsed.port is not None
720
+ or len(segments) != 5
721
+ or _BUCKET.fullmatch(segments[1]) is None
722
+ or tuple(segments[2:4]) != GCS_COMMITTED_PREFIX
723
+ or _OBJECT_NAME.fullmatch(segments[4]) is None
724
+ ):
725
+ raise _refuse(
726
+ "DEPLOY_DATASET_SESSION_INVALID",
727
+ "the signed transfer address is outside the sealed store",
728
+ )
729
+ try:
730
+ query = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
731
+ except ValueError as error:
732
+ raise _refuse(
733
+ "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid"
734
+ ) from error
735
+ names = {key for key, _value in query}
736
+ required = {
737
+ "X-Goog-Algorithm",
738
+ "X-Goog-Credential",
739
+ "X-Goog-Date",
740
+ "X-Goog-Expires",
741
+ "X-Goog-SignedHeaders",
742
+ "X-Goog-Signature",
743
+ }
744
+ values = dict(query)
745
+ if (
746
+ len(query) != len(names)
747
+ or not required <= names
748
+ or not names <= required | {"generation"}
749
+ or any(not value for value in values.values())
750
+ or values["X-Goog-Algorithm"] != "GOOG4-RSA-SHA256"
751
+ or not values["X-Goog-Expires"].isdigit()
752
+ or not 1 <= int(values["X-Goog-Expires"]) <= 3600
753
+ or re.fullmatch(r"[0-9]{8}T[0-9]{6}Z", values["X-Goog-Date"]) is None
754
+ or re.fullmatch(r"(?:[0-9A-Fa-f]{2}){64,512}", values["X-Goog-Signature"]) is None
755
+ or re.fullmatch(
756
+ r"[^/\x00-\x20]{1,320}/[0-9]{8}/auto/storage/goog4_request",
757
+ values["X-Goog-Credential"],
758
+ )
759
+ is None
760
+ or values["X-Goog-Credential"].split("/", 2)[1] != values["X-Goog-Date"][:8]
761
+ ):
762
+ raise _refuse(
763
+ "DEPLOY_DATASET_SESSION_INVALID",
764
+ "the signed transfer address is not signed the way the store signs",
765
+ )
766
+
767
+
768
+ # ------------------------------------------------------------------------------------------------
769
+ # The transfer
770
+ # ------------------------------------------------------------------------------------------------
771
+
772
+
773
+ class _NoRedirect(HTTPRedirectHandler):
774
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
775
+ return None
776
+
777
+
778
+ def download_sealed_artifact(
779
+ download: ValidatedResultDownload,
780
+ headers: Mapping[str, str],
781
+ ) -> bytes:
782
+ """Transfer one sealed artifact in bounded units, verifying as it goes.
783
+
784
+ The transfer is chunked into a spool while :class:`ResultBytesVerifier` folds each unit into
785
+ the length and the sealed fingerprint, so the process never has to be able to hold a transfer
786
+ it is going to refuse, and an accepted one materializes once, at the end, from bytes already
787
+ verified. Status and media type are read from the head and refused before the body moves, so a
788
+ wrong answer costs no body bytes at all.
789
+ """
790
+
791
+ opener = build_opener(
792
+ ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
793
+ )
794
+ request = Request(download.url, method="GET", headers=dict(headers))
795
+ try:
796
+ response = opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS)
797
+ except HTTPError as error:
798
+ response = error
799
+ except Exception as error:
800
+ raise HostedDeployError(
801
+ "DEPLOY_DATASET_DOWNLOAD_FAILED", "the released Build could not be transferred"
802
+ ) from error
803
+ # The response is the outermost context deliberately: nothing constructed after the open --
804
+ # not the verifier, not the spool -- can strand the socket by raising before a `with` that
805
+ # would have closed it.
806
+ with response:
807
+ media_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
808
+ status = int(response.status)
809
+ if status != 200 or media_type != download.media_type:
810
+ raise _refuse("DEPLOY_DATASET_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
811
+ verifier = ResultBytesVerifier(download)
812
+ with tempfile.SpooledTemporaryFile(max_size=DOWNLOAD_SPOOL_BYTES, mode="w+b") as spool:
813
+ remaining = download.size + 1
814
+ while remaining > 0:
815
+ try:
816
+ chunk = response.read(min(RESULT_CHUNK_BYTES, remaining))
817
+ except Exception as error:
818
+ raise HostedDeployError(
819
+ "DEPLOY_DATASET_DOWNLOAD_FAILED",
820
+ "the released Build could not be transferred",
821
+ ) from error
822
+ if not chunk:
823
+ break
824
+ try:
825
+ spool.write(chunk)
826
+ except OSError as error:
827
+ raise HostedDeployError(
828
+ "DEPLOY_DATASET_DOWNLOAD_FAILED",
829
+ "the released Build could not be staged for checking",
830
+ ) from error
831
+ remaining -= len(chunk)
832
+ verifier.update(chunk)
833
+ if verifier.length != download.size:
834
+ raise _refuse("DEPLOY_DATASET_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
835
+ try:
836
+ verifier.verify()
837
+ except ResultDownloadError as error:
838
+ raise HostedDeployError(
839
+ "DEPLOY_DATASET_DOWNLOAD_INTEGRITY",
840
+ "the transferred bytes do not match the fingerprint the release sealed",
841
+ ) from error
842
+ spool.seek(0)
843
+ return spool.read()
844
+
845
+
846
+ # ------------------------------------------------------------------------------------------------
847
+ # Local installation
848
+ # ------------------------------------------------------------------------------------------------
849
+
850
+
851
+ def _require_run_directory(run_dir: Path) -> Path:
852
+ """Settle the destination folder without making it.
853
+
854
+ The folder a released Build lands in usually does not exist yet, because the Build that
855
+ produced it was not made here and nothing has had a reason to make the folder. So an absent
856
+ name whose parent is a real folder is accepted -- and it stays absent until there is a verified
857
+ Build to put in it, which is the whole reason this does not make it.
858
+
859
+ Making it here instead was a real defect: the ordinary refusal on this path is "the run has not
860
+ released anything yet", the remediation for it says to run the command again later, and an
861
+ empty folder left behind by that refusal is one ``mr-data view --run-dir`` will not open. The
862
+ command would have broken the automatic reveal it exists to provide, for anyone who checked
863
+ once before opening a window.
864
+ """
865
+
866
+ lexical = Path(os.path.abspath(os.fspath(run_dir)))
867
+ try:
868
+ info = os.stat(lexical, follow_symlinks=False)
869
+ except FileNotFoundError:
870
+ _require_real_directory(
871
+ lexical.parent, f"the folder to write into is not inside a real folder: {lexical}"
872
+ )
873
+ return lexical
874
+ except OSError as error:
875
+ raise HostedDeployError(
876
+ "DEPLOY_DATASET_TARGET_INVALID",
877
+ f"the folder to write into could not be read: {lexical}",
878
+ ) from error
879
+ if not stat.S_ISDIR(info.st_mode):
880
+ raise _refuse(
881
+ "DEPLOY_DATASET_TARGET_INVALID",
882
+ f"the destination is already something other than a folder: {lexical}",
883
+ )
884
+ _require_real_directory(lexical, f"the folder to write into must be one real folder: {lexical}")
885
+ return lexical
886
+
887
+
888
+ def _require_real_directory(path: Path, detail: str) -> None:
889
+ try:
890
+ resolved = path.resolve(strict=True)
891
+ info = os.stat(path, follow_symlinks=False)
892
+ except OSError as error:
893
+ raise HostedDeployError("DEPLOY_DATASET_TARGET_INVALID", detail) from error
894
+ if resolved != path or not stat.S_ISDIR(info.st_mode):
895
+ raise _refuse("DEPLOY_DATASET_TARGET_INVALID", detail)
896
+
897
+
898
+ @contextmanager
899
+ def _staging_directory(run_dir: Path) -> Iterator[Path]:
900
+ """Build the tree beside its destination, so the destination only ever appears finished.
901
+
902
+ Beside rather than inside, because the destination may not exist yet and must not be made
903
+ before there is something verified to put in it. The parent is where the destination itself
904
+ will be created, so it is the same filesystem and the move stays one rename.
905
+ """
906
+
907
+ staging = run_dir.parent / f".hosted-build.{secrets.token_hex(8)}"
908
+ try:
909
+ staging.mkdir(mode=0o700)
910
+ except OSError as error:
911
+ raise HostedDeployError(
912
+ "DEPLOY_DATASET_INSTALL_FAILED",
913
+ f"a folder to build the released Build in could not be made beside {run_dir}",
914
+ ) from error
915
+ try:
916
+ yield staging
917
+ finally:
918
+ _remove_staging(staging)
919
+
920
+
921
+ def _remove_staging(staging: Path) -> None:
922
+ """Discard an abandoned staging tree, including the read-only one a sealed Build is.
923
+
924
+ The tree is made writable from the top down before anything is removed from the bottom up.
925
+ Both directions are load-bearing: a sealed Build's directories are read-only by the time it is
926
+ assembled, and removing a file needs write permission on the directory holding it, not on the
927
+ file. Deleting deepest-first alone therefore stopped at the first sealed directory and left the
928
+ whole staging tree behind for every later run to find.
929
+
930
+ Failure here is not raised. This runs on the way out of a handoff that has already decided its
931
+ answer, and a leftover folder is not a reason to replace that answer with a different one.
932
+ """
933
+
934
+ if not staging.exists():
935
+ return
936
+ entries = sorted(staging.rglob("*"), key=lambda entry: len(entry.parts))
937
+ for item in entries:
938
+ if item.is_dir() and not item.is_symlink():
939
+ try:
940
+ item.chmod(0o700)
941
+ except OSError:
942
+ return
943
+ for item in reversed(entries):
944
+ try:
945
+ if item.is_dir() and not item.is_symlink():
946
+ item.rmdir()
947
+ else:
948
+ item.unlink()
949
+ except OSError:
950
+ return
951
+ try:
952
+ staging.rmdir()
953
+ except OSError:
954
+ return
955
+
956
+
957
+ def install_released_build(
958
+ run_dir: Path, members: Mapping[str, bytes], *, candidate_digest: str
959
+ ) -> None:
960
+ """Install the whole sealed tree, or none of it.
961
+
962
+ Every member is written into a staging folder first, verified there, and only then moved into
963
+ place in one rename, so an interrupted handoff leaves a discarded staging folder rather than a
964
+ half-populated Build for the next run -- or the viewer -- to find and reason about. The
965
+ destination folder itself is made in the same breath as the move, so a refusal on the way here
966
+ leaves no empty folder behind either.
967
+ """
968
+
969
+ with _staging_directory(run_dir) as staging:
970
+ try:
971
+ _restore_candidate_members(members, staging)
972
+ except OSError as error:
973
+ raise HostedDeployError(
974
+ "DEPLOY_DATASET_INSTALL_FAILED", "the released Build could not be written here"
975
+ ) from error
976
+ # Verified where it is discarded rather than where it is kept. A tree that cannot pass
977
+ # the checks a Build made on this machine passes never moves into place at all, so the
978
+ # destination never holds an unverifiable Build for a later run to refuse as somebody
979
+ # else's -- and the refusal below can say, truthfully, that nothing was written.
980
+ _verify_installed_build(staging, candidate_digest)
981
+ made = _make_destination(run_dir)
982
+ source = staging / "candidate"
983
+ try:
984
+ # Moving a folder needs write permission on the folder being moved, because its own
985
+ # link to its parent changes. The sealed tree is read-only by the time it is built, so
986
+ # that permission is restored for the move and taken away again immediately. Both
987
+ # states are unreachable to anything watching this run: before the move the tree is
988
+ # inside a private staging folder, and after it there is still no notebook beside it,
989
+ # which is what any reader has to see first. A process that dies inside that window
990
+ # leaves the right Build under a mode verification will not accept, which is what
991
+ # `_reseal_interrupted_install` exists to converge on.
992
+ os.chmod(source, 0o700)
993
+ os.rename(source, run_dir / "candidate")
994
+ except OSError as error:
995
+ # A folder this call made and could not fill is one the viewer will not open, so it
996
+ # goes back down. And a full disk is not somebody else's Build being in the way: the
997
+ # two are told apart by what the system said rather than by which line raised.
998
+ if made:
999
+ with suppress(OSError):
1000
+ run_dir.rmdir()
1001
+ if error.errno in {errno.EEXIST, errno.ENOTEMPTY}:
1002
+ raise HostedDeployError(
1003
+ "DEPLOY_DATASET_TARGET_OCCUPIED",
1004
+ f"something is already in the way at {run_dir / 'candidate'}",
1005
+ ) from error
1006
+ raise HostedDeployError(
1007
+ "DEPLOY_DATASET_INSTALL_FAILED",
1008
+ f"the released Build could not be put in place at {run_dir}",
1009
+ ) from error
1010
+ try:
1011
+ _seal_installed_build(run_dir)
1012
+ _ensure_table_sidecar_lock(run_dir)
1013
+ except OSError as error:
1014
+ raise HostedDeployError(
1015
+ "DEPLOY_DATASET_INSTALL_FAILED", "the released Build could not be settled here"
1016
+ ) from error
1017
+
1018
+
1019
+ def _make_destination(run_dir: Path) -> bool:
1020
+ """Make the destination folder, now that there is a verified Build to put in it.
1021
+
1022
+ Returns whether this call made it, so a move that then fails can take it back down again.
1023
+
1024
+ A folder that appeared between the check at the start of the handoff and this moment is
1025
+ re-checked rather than assumed: the validation up front pins nothing, so a writer in the parent
1026
+ could have planted a link where the folder was going, and the rename that follows would resolve
1027
+ through it.
1028
+ """
1029
+
1030
+ try:
1031
+ run_dir.mkdir(mode=0o700)
1032
+ except FileExistsError:
1033
+ _require_real_directory(
1034
+ run_dir, f"the folder to write into is not one real folder: {run_dir}"
1035
+ )
1036
+ return False
1037
+ except OSError as error:
1038
+ raise HostedDeployError(
1039
+ "DEPLOY_DATASET_TARGET_INVALID",
1040
+ f"the folder to write into could not be made: {run_dir}",
1041
+ ) from error
1042
+ return True
1043
+
1044
+
1045
+ def _seal_installed_build(run_dir: Path) -> None:
1046
+ """Give the installed tree its sealed mode and make the move survive a power loss."""
1047
+
1048
+ os.chmod(run_dir / "candidate", 0o555)
1049
+ _fsync_directory(run_dir)
1050
+
1051
+
1052
+ def _fsync_directory(path: Path) -> None:
1053
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
1054
+ try:
1055
+ os.fsync(descriptor)
1056
+ finally:
1057
+ os.close(descriptor)
1058
+
1059
+
1060
+ def _reseal_interrupted_install(run_dir: Path, candidate_digest: str) -> None:
1061
+ """Finish the sealing step of an install this or an earlier run left half done.
1062
+
1063
+ An install that died between the move and the sealing chmod leaves the exact released Build in
1064
+ place under an owner-writable mode, and verification requires the sealed one -- so the rerun
1065
+ that is supposed to converge would instead read an unverifiable tree and refuse it as somebody
1066
+ else's Build.
1067
+
1068
+ Only that one state is repaired, and it is identified by what the tree says it is rather than
1069
+ by its mode alone: an owner-writable ``candidate`` whose own record names this exact released
1070
+ Build. Nothing is followed on the way -- the folder and its record are opened without following
1071
+ links and the mode is set through the open folder, so a name swapped underneath this cannot
1072
+ redirect it. And repairing it decides nothing: the mode is tightened rather than loosened, and
1073
+ ordinary verification still has to recognise the tree afterwards or the handoff refuses exactly
1074
+ as it did before.
1075
+ """
1076
+
1077
+ directory = -1
1078
+ try:
1079
+ directory = os.open(run_dir / "candidate", os.O_RDONLY | os.O_DIRECTORY | _NO_FOLLOW)
1080
+ info = os.fstat(directory)
1081
+ if stat.S_IMODE(info.st_mode) != 0o700:
1082
+ return
1083
+ if _recorded_candidate_digest(directory) != candidate_digest:
1084
+ return
1085
+ os.fchmod(directory, 0o555)
1086
+ except OSError:
1087
+ return
1088
+ finally:
1089
+ if directory >= 0:
1090
+ os.close(directory)
1091
+ _fsync_directory(run_dir)
1092
+
1093
+
1094
+ def _recorded_candidate_digest(directory: int) -> str | None:
1095
+ """Read the fingerprint one unsealed tree records for itself, or nothing."""
1096
+
1097
+ descriptor = os.open("manifest.json", os.O_RDONLY | _NO_FOLLOW, dir_fd=directory)
1098
+ try:
1099
+ info = os.fstat(descriptor)
1100
+ if (
1101
+ not stat.S_ISREG(info.st_mode)
1102
+ or info.st_nlink != 1
1103
+ or info.st_size > MAX_MANIFEST_BYTES
1104
+ ):
1105
+ return None
1106
+ raw = os.read(descriptor, MAX_MANIFEST_BYTES)
1107
+ finally:
1108
+ os.close(descriptor)
1109
+ try:
1110
+ value = canonical.parse_json(raw)
1111
+ except canonical.CanonicalJSONError:
1112
+ return None
1113
+ recorded = value.get("candidate_digest") if isinstance(value, dict) else None
1114
+ return f"sha256:{recorded}" if isinstance(recorded, str) else None
1115
+
1116
+
1117
+ def _installed_build_digest(run_dir: Path) -> str | None:
1118
+ """Return the fingerprint of an already-installed Build here, or ``None`` when there is none.
1119
+
1120
+ An unreadable installed tree answers with the empty string rather than ``None``: something is
1121
+ in the way, it is not the released Build, and saying "nothing is here" about it would let the
1122
+ install path try to move a Build on top of it.
1123
+ """
1124
+
1125
+ from mostlyright.data_harness import pipeline
1126
+
1127
+ if not (run_dir / "candidate").exists():
1128
+ return None
1129
+ try:
1130
+ with pipeline.open_verified_snapshot(run_dir) as snapshot:
1131
+ return "sha256:" + snapshot.verified.candidate_digest
1132
+ except Exception:
1133
+ return ""
1134
+
1135
+
1136
+ # ------------------------------------------------------------------------------------------------
1137
+ # The handoff
1138
+ # ------------------------------------------------------------------------------------------------
1139
+
1140
+
1141
+ def materialize_hosted_dataset(
1142
+ run_dir: Path,
1143
+ *,
1144
+ dataset_id: UUID,
1145
+ table_id: UUID,
1146
+ hosted_run_id: UUID,
1147
+ token: StudioToken,
1148
+ reader_factory: Callable[[StudioToken], StudioDatasetReader] | None = None,
1149
+ downloader: Callable[[ValidatedResultDownload, Mapping[str, str]], bytes] | None = None,
1150
+ visual_store: VisualRunStore | None = None,
1151
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
1152
+ ) -> HostedDatasetHandoff:
1153
+ """Bring one released hosted Build here and derive its table notebook from it.
1154
+
1155
+ The order is the contract. Nothing on this machine is written before the transferred bytes have
1156
+ matched the fingerprint the release sealed and decoded into the exact Build the release names;
1157
+ nothing is derived from the installed tree before ordinary local verification has re-derived
1158
+ that same fingerprint from it; and the notebook is written by the existing atomic installer, so
1159
+ the viewer never observes a partial one. A rerun after any interruption converges: an already
1160
+ installed Build with the released fingerprint is kept and its notebook simply re-derived.
1161
+
1162
+ ``visual_store`` is the open workbench's own run log. Narrating into it is what turns a written
1163
+ file into a revealed Table tab, and it is deliberately the last thing that happens: the
1164
+ viewer will not show the tab on the narration alone, because it re-derives the notebook from
1165
+ its own verified snapshot and compares bytes first, so narrating earlier would only mean
1166
+ announcing something that is not there yet.
1167
+ """
1168
+
1169
+ target = _require_run_directory(run_dir)
1170
+ if token.workspace_id is None:
1171
+ raise _refuse("DEPLOY_TOKEN_EXCHANGE_FAILED", "the sign-in has no workspace")
1172
+ # The two boundaries are resolved here rather than bound as default arguments, so substituting
1173
+ # either one is a matter of naming it on this module, exactly as it is for every other seam in
1174
+ # this tree. A default bound at definition time is one nothing can reach afterwards.
1175
+ reader = (reader_factory or GeneratedStudioDatasetReader)(token)
1176
+ transfer = downloader or download_sealed_artifact
1177
+ try:
1178
+ released = select_released_build(
1179
+ reader.released_versions(table_id),
1180
+ dataset_id=dataset_id,
1181
+ table_id=table_id,
1182
+ workspace_id=token.workspace_id,
1183
+ hosted_run_id=hosted_run_id,
1184
+ )
1185
+ _require_narratable(visual_store, released.candidate_digest)
1186
+ _reseal_interrupted_install(target, released.candidate_digest)
1187
+ installed = _installed_build_digest(target)
1188
+ transferred = installed != released.candidate_digest
1189
+ artifact = _select_sealed_evidence(reader, released)
1190
+ if transferred:
1191
+ if installed is not None:
1192
+ raise _refuse(
1193
+ "DEPLOY_DATASET_TARGET_OCCUPIED",
1194
+ f"{target} already holds a different Build; name an unused folder with --into",
1195
+ )
1196
+ session, headers = validate_download_session(
1197
+ reader.download_session(artifact.artifact_id),
1198
+ artifact=artifact,
1199
+ studio_base_url=token.studio_base_url,
1200
+ now=now(),
1201
+ )
1202
+ raw = transfer(session, headers)
1203
+ members = _decode_released_build(raw, released.candidate_digest)
1204
+ install_released_build(target, members, candidate_digest=released.candidate_digest)
1205
+ finally:
1206
+ # Closing a socket is not a verdict. Letting it raise here would replace whatever this
1207
+ # handoff had already decided -- including a refusal a person is waiting to read.
1208
+ try:
1209
+ reader.close()
1210
+ except Exception:
1211
+ pass
1212
+ candidate_digest = _verify_installed_build(target, released.candidate_digest)
1213
+ _ensure_table_sidecar_lock(target)
1214
+ notebook_path = _render_notebook(target, replace_existing=transferred)
1215
+ handoff = HostedDatasetHandoff(
1216
+ run_dir=target,
1217
+ dataset_id=released.dataset_id,
1218
+ table_id=released.table_id,
1219
+ table_version_id=released.table_version_id,
1220
+ version_number=released.version_number,
1221
+ hosted_run_id=released.run_id,
1222
+ candidate_digest=candidate_digest,
1223
+ artifact_id=artifact.artifact_id,
1224
+ artifact_content_digest=artifact.content_digest,
1225
+ artifact_size_bytes=artifact.size_bytes,
1226
+ notebook_path=notebook_path,
1227
+ transferred=transferred,
1228
+ )
1229
+ _write_handoff_receipt(target, handoff)
1230
+ _narrate_hosted_build(visual_store, handoff)
1231
+ return handoff
1232
+
1233
+
1234
+ def _require_narratable(visual_store: VisualRunStore | None, candidate_digest: str) -> None:
1235
+ """Refuse before anything is written when this workbench log belongs to another Build."""
1236
+
1237
+ if visual_store is None:
1238
+ return
1239
+ try:
1240
+ state = reduce_events(visual_store.events())
1241
+ except VisualRunError as error:
1242
+ raise HostedDeployError(
1243
+ "DEPLOY_DATASET_NARRATION_UNAVAILABLE",
1244
+ "the open workbench record could not be read",
1245
+ ) from error
1246
+ if (
1247
+ state.candidate_digest is not None
1248
+ and "sha256:" + state.candidate_digest != candidate_digest
1249
+ ):
1250
+ raise _refuse(
1251
+ "DEPLOY_DATASET_NARRATION_CONFLICT",
1252
+ "this workbench is already showing a different Build; start a new one for this",
1253
+ )
1254
+ if state.terminal and state.lifecycle != "completed":
1255
+ raise _refuse(
1256
+ "DEPLOY_DATASET_NARRATION_CONFLICT",
1257
+ "this workbench already ended without a dataset; start a new one for this",
1258
+ )
1259
+
1260
+
1261
+ def _narrate_hosted_build(
1262
+ visual_store: VisualRunStore | None, handoff: HostedDatasetHandoff
1263
+ ) -> None:
1264
+ """Tell the open workbench what landed, using the same records a local Build writes."""
1265
+
1266
+ if visual_store is None:
1267
+ return
1268
+ digest = handoff.candidate_digest
1269
+ try:
1270
+ content = _read_installed_notebook(handoff.notebook_path)
1271
+ work = VisualWork(visual_store, actor="worker")
1272
+ if not visual_store.events():
1273
+ work.start_run(key=f"hosted-build:{digest[:16]}:started")
1274
+ work.build_sealed(digest, key=f"hosted-build:{digest[:16]}:sealed")
1275
+ work.build_verified(digest, key=f"hosted-build:{digest[:16]}:verified")
1276
+ work.notebook_materialized("table", content, key=f"hosted-build:{digest[:16]}:notebook")
1277
+ if reduce_events(visual_store.events()).lifecycle != "completed":
1278
+ work.complete_run(key=f"hosted-build:{digest[:16]}:completed")
1279
+ except (OSError, ValueError) as error:
1280
+ raise HostedDeployError(
1281
+ "DEPLOY_DATASET_NARRATION_UNAVAILABLE",
1282
+ "the dataset is here, but the open workbench could not be told about it",
1283
+ ) from error
1284
+
1285
+
1286
+ def _select_sealed_evidence(reader: StudioDatasetReader, released: ReleasedBuild) -> SealedArtifact:
1287
+ """Read every artifact the release names and keep the one kind this handoff redeems."""
1288
+
1289
+ selected: SealedArtifact | None = None
1290
+ for artifact_id, reference_digest in released.artifact_references:
1291
+ sealed = validate_sealed_artifact(
1292
+ reader.artifact(artifact_id),
1293
+ released=released,
1294
+ artifact_id=artifact_id,
1295
+ reference_digest=reference_digest,
1296
+ )
1297
+ if sealed.kind != REQUIRED_ARTIFACT_KIND:
1298
+ continue
1299
+ if sealed.media_type != REQUIRED_ARTIFACT_MEDIA_TYPE:
1300
+ # The contract for a kind is part of what the kind means. Evidence that arrives under
1301
+ # the wrong one is refused here rather than repaired later, so a Studio slot whose
1302
+ # contract has drifted cannot be rendered as though it had not.
1303
+ raise _refuse(
1304
+ "DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
1305
+ "the sealed evidence does not meet the contract for its kind",
1306
+ )
1307
+ if selected is not None:
1308
+ raise _refuse(
1309
+ "DEPLOY_DATASET_ARTIFACT_INVALID",
1310
+ "the released Build names its sealed evidence more than once",
1311
+ )
1312
+ selected = sealed
1313
+ if selected is None:
1314
+ raise _refuse(
1315
+ "DEPLOY_DATASET_ARTIFACT_ABSENT",
1316
+ "the released Build does not name the sealed evidence this needs",
1317
+ )
1318
+ return selected
1319
+
1320
+
1321
+ def _decode_released_build(raw: bytes, candidate_digest: str) -> Mapping[str, bytes]:
1322
+ if len(raw) > MAX_CANDIDATE_ENVELOPE_BYTES:
1323
+ raise _refuse(
1324
+ "DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
1325
+ "the sealed evidence is larger than the contract for its kind allows",
1326
+ )
1327
+ try:
1328
+ return _decode_candidate_envelope(raw, candidate_digest)
1329
+ except HostedWorkerError as error:
1330
+ raise HostedDeployError(
1331
+ "DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
1332
+ "the sealed evidence does not meet the contract for its kind",
1333
+ ) from error
1334
+
1335
+
1336
+ def _verify_installed_build(run_dir: Path, candidate_digest: str) -> str:
1337
+ """Re-derive the fingerprint here, from the installed tree, by ordinary local verification."""
1338
+
1339
+ from mostlyright.data_harness import pipeline
1340
+
1341
+ try:
1342
+ verified = pipeline.verify_candidate(run_dir)
1343
+ except Exception as error:
1344
+ raise HostedDeployError(
1345
+ "DEPLOY_DATASET_LOCAL_CHECK_FAILED",
1346
+ "the Build written here did not pass the same checks a local Build passes",
1347
+ ) from error
1348
+ if "sha256:" + verified.candidate_digest != candidate_digest:
1349
+ raise _refuse(
1350
+ "DEPLOY_DATASET_LOCAL_CHECK_FAILED",
1351
+ "the Build written here is not the Build the release named",
1352
+ )
1353
+ return verified.candidate_digest
1354
+
1355
+
1356
+ def _ensure_table_sidecar_lock(run_dir: Path) -> None:
1357
+ """Install the structural sidecar lock omitted from the sealed candidate envelope."""
1358
+
1359
+ directory = os.open(
1360
+ run_dir,
1361
+ os.O_RDONLY | os.O_DIRECTORY | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
1362
+ )
1363
+ lock = -1
1364
+ try:
1365
+ try:
1366
+ lock = os.open(
1367
+ pipeline.RUN_BUILD_ACTIVITY_LOCK,
1368
+ os.O_RDWR | os.O_CREAT | os.O_EXCL | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
1369
+ 0o600,
1370
+ dir_fd=directory,
1371
+ )
1372
+ os.fsync(lock)
1373
+ os.fsync(directory)
1374
+ except FileExistsError:
1375
+ lock = os.open(
1376
+ pipeline.RUN_BUILD_ACTIVITY_LOCK,
1377
+ os.O_RDONLY | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
1378
+ dir_fd=directory,
1379
+ )
1380
+ metadata = os.fstat(lock)
1381
+ named = os.stat(
1382
+ pipeline.RUN_BUILD_ACTIVITY_LOCK,
1383
+ dir_fd=directory,
1384
+ follow_symlinks=False,
1385
+ )
1386
+ if (
1387
+ not stat.S_ISREG(metadata.st_mode)
1388
+ or metadata.st_nlink != 1
1389
+ or metadata.st_size != 0
1390
+ or stat.S_IMODE(metadata.st_mode) != 0o600
1391
+ or pipeline._entry_identity(metadata) != pipeline._entry_identity(named)
1392
+ ):
1393
+ raise OSError(errno.EPERM, "the Table sidecar lock is not one stable empty file")
1394
+ except OSError as error:
1395
+ raise HostedDeployError(
1396
+ "DEPLOY_DATASET_NOTEBOOK_FAILED",
1397
+ "the Table notebook lock could not be established here",
1398
+ ) from error
1399
+ finally:
1400
+ if lock >= 0:
1401
+ os.close(lock)
1402
+ os.close(directory)
1403
+
1404
+
1405
+ def _render_notebook(run_dir: Path, *, replace_existing: bool) -> Path:
1406
+ """Ask the one generator for the notebook, replacing only what this run just installed.
1407
+
1408
+ A Build that has just landed has no notebook worth keeping, so its own is written. A rerun over
1409
+ an already installed Build fills an absence and no more: the shipped skill tells an agent to
1410
+ append executed chart cells to ``table.ipynb``, and this may be called unattended and
1411
+ repeatedly by whatever is watching the hosted run, which is exactly the shape that would
1412
+ silently discard them. It is the same line ``notebook.py`` draws between the command a person
1413
+ ran and the viewer's own recovery.
1414
+ """
1415
+
1416
+ from mostlyright.data_harness.notebook import (
1417
+ TABLE_NOTEBOOK_NAME,
1418
+ render_table_notebook,
1419
+ )
1420
+
1421
+ try:
1422
+ return render_table_notebook(run_dir, replace_existing=replace_existing)
1423
+ except FileExistsError:
1424
+ return run_dir / TABLE_NOTEBOOK_NAME
1425
+ except Exception as error:
1426
+ raise HostedDeployError(
1427
+ "DEPLOY_DATASET_NOTEBOOK_FAILED", "the table notebook could not be written here"
1428
+ ) from error
1429
+
1430
+
1431
+ def _read_installed_notebook(path: Path) -> bytes:
1432
+ """Read the notebook about to be narrated without following a link or taking an unbounded file.
1433
+
1434
+ On a converging rerun this is a file this command did not write, so it is read the way every
1435
+ other file here is read rather than the way a path is usually opened.
1436
+ """
1437
+
1438
+ descriptor = os.open(path, os.O_RDONLY | _NO_FOLLOW)
1439
+ try:
1440
+ info = os.fstat(descriptor)
1441
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
1442
+ raise OSError(errno.EPERM, "the table notebook is not one ordinary file")
1443
+ if info.st_size > MAX_NOTEBOOK_BYTES:
1444
+ raise OSError(errno.EFBIG, "the table notebook is larger than this will read")
1445
+ return os.read(descriptor, MAX_NOTEBOOK_BYTES)
1446
+ finally:
1447
+ os.close(descriptor)
1448
+
1449
+
1450
+ def _write_handoff_receipt(run_dir: Path, handoff: HostedDatasetHandoff) -> None:
1451
+ """Record which released Build these local bytes came from, atomically and non-secretly.
1452
+
1453
+ This is the coordinate the viewer otherwise has no way to know: it observes content
1454
+ fingerprints and local files, and nothing in it names a hosted run. The receipt states nothing
1455
+ the viewer is allowed to admit anything on -- admission stays exactly what it was -- it only
1456
+ lets what is already admitted be attributed to the run that produced it.
1457
+ """
1458
+
1459
+ destination = run_dir / HANDOFF_RELATIVE_PATH
1460
+ raw = canonical.canonical_json_bytes(handoff.to_dict())
1461
+ temporary = destination.parent / f".handoff.{secrets.token_hex(8)}"
1462
+ try:
1463
+ destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
1464
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NO_FOLLOW, 0o600)
1465
+ try:
1466
+ view = memoryview(raw)
1467
+ while view:
1468
+ view = view[os.write(descriptor, view) :]
1469
+ os.fsync(descriptor)
1470
+ finally:
1471
+ os.close(descriptor)
1472
+ os.replace(temporary, destination)
1473
+ except OSError as error:
1474
+ try:
1475
+ temporary.unlink()
1476
+ except OSError:
1477
+ pass
1478
+ raise HostedDeployError(
1479
+ "DEPLOY_DATASET_RECEIPT_FAILED", "the record of this handoff could not be written"
1480
+ ) from error
1481
+
1482
+
1483
+ __all__ = [
1484
+ "GCS_COMMITTED_PREFIX",
1485
+ "HANDOFF_RECEIPT_SCHEMA",
1486
+ "HANDOFF_RELATIVE_PATH",
1487
+ "REQUIRED_ARTIFACT_KIND",
1488
+ "GeneratedStudioDatasetReader",
1489
+ "HostedDatasetHandoff",
1490
+ "ReleasedBuild",
1491
+ "SealedArtifact",
1492
+ "StudioDatasetReader",
1493
+ "download_sealed_artifact",
1494
+ "install_released_build",
1495
+ "materialize_hosted_dataset",
1496
+ "read_handoff_receipt",
1497
+ "select_released_build",
1498
+ "validate_download_session",
1499
+ "validate_sealed_artifact",
1500
+ ]