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,406 @@
1
+ """The hosted lane behind the command names people already type.
2
+
3
+ Each function here returns one payload mapping and prints nothing. That is the convention
4
+ ``ux.commands.CommandSpec`` already establishes for every auto-discovered subcommand, and it is
5
+ what lets :mod:`mostlyright.data_harness.thin.router` decide plain lines or ``--json`` in one
6
+ place for the whole lane.
7
+
8
+ WHAT THE PAYLOADS PROMISE. Every fact in them comes out of one Studio response or out of the
9
+ submission this command just made. Nothing is derived from a clock, no percentage is manufactured,
10
+ and no progress record is presented as evidence -- the unsealed stream says what it measured and
11
+ the sealed candidate evidence says what was proved, and the two are reported under separate keys
12
+ so a reader cannot mistake one for the other.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from collections.abc import Mapping
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from uuid import UUID
23
+
24
+ from mostlyright.data_harness.formats import FORMAT_MEDIA_TYPES, FORMAT_SUFFIXES
25
+ from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
26
+ from mostlyright.data_harness.thin.download import download_signed_artifact
27
+ from mostlyright.data_harness.thin.runs import RESOURCE_CLASSES, StudioRunClient
28
+ from mostlyright.data_harness.thin.session import (
29
+ StudioSession,
30
+ open_studio_session,
31
+ run_dashboard_url,
32
+ )
33
+ from mostlyright.data_harness.thin.stream import TERMINAL_RUN_STATUSES, RunEventStream
34
+ from mostlyright.data_harness.thin.transport import ThinLaneError
35
+ from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
36
+
37
+ SUBMIT_SCHEMA = f"{THIN_SCHEMA_PREFIX}-submission.v1"
38
+ WATCH_SCHEMA = f"{THIN_SCHEMA_PREFIX}-watch.v1"
39
+ STATUS_SCHEMA = f"{THIN_SCHEMA_PREFIX}-run-status.v1"
40
+ DOWNLOAD_SCHEMA = f"{THIN_SCHEMA_PREFIX}-download.v1"
41
+
42
+ #: The artifact kinds ``deploy-dataset`` brings back by default: the dataset itself, and nothing
43
+ #: else. A run also produces evidence, previews, profiles and lineage; those are fetched only when
44
+ #: a caller names them, because a folder that quietly filled with nine files is not a dataset.
45
+ DEFAULT_DOWNLOAD_KINDS = ("table_package", "candidate_parquet")
46
+
47
+
48
+ def _session(args: argparse.Namespace) -> StudioSession:
49
+ """Resolve this machine's credential and open a short-lived Studio session."""
50
+
51
+ credentials = resolve_cloud_credentials()
52
+ return open_studio_session(credentials)
53
+
54
+
55
+ def _client(args: argparse.Namespace) -> StudioRunClient:
56
+ return StudioRunClient(_session(args))
57
+
58
+
59
+ def _run_id(value: str) -> str:
60
+ """Refuse anything that is not a run identifier before it reaches a URL path."""
61
+
62
+ try:
63
+ return str(UUID(value))
64
+ except ValueError as error:
65
+ raise ThinLaneError("THIN_REQUEST_INVALID", f"{value!r} is not a run identifier") from error
66
+
67
+
68
+ def run_receipt(run: Mapping[str, Any]) -> dict[str, Any]:
69
+ """The Run fields a person reads, exactly as Studio reported them."""
70
+
71
+ return {
72
+ key: run[key]
73
+ for key in (
74
+ "run_id",
75
+ "status",
76
+ "kind",
77
+ "resource_class",
78
+ "table_id",
79
+ "dataset_id",
80
+ "recipe_digest",
81
+ "recipe_version",
82
+ "created_at",
83
+ "started_at",
84
+ "completed_at",
85
+ "failure_code",
86
+ )
87
+ if key in run
88
+ }
89
+
90
+
91
+ def submit(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
92
+ """Queue one hosted initial build and say where to watch it.
93
+
94
+ Backs ``mr-data build`` and ``mr-data run`` in the hosted lane. The two names stay distinct
95
+ everywhere else in this product and mean the same thing here, because a hosted submission is
96
+ one act: there is no local workbench to build in and no local plan to run.
97
+ """
98
+
99
+ selected = client or _client(args)
100
+ try:
101
+ run = selected.start_initial_build(
102
+ expected_recipe_digest=args.recipe_digest,
103
+ recipe_proposal_id=UUID(args.recipe_proposal),
104
+ resource_class=getattr(args, "resource_class", None),
105
+ )
106
+ except ThinLaneError as refusal:
107
+ started = _already_started(selected, args, refusal)
108
+ if started is None:
109
+ raise
110
+ return started
111
+ run_id = run.get("run_id")
112
+ if not isinstance(run_id, str):
113
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "Studio queued a run without an identifier")
114
+ return {
115
+ "schema_version": SUBMIT_SCHEMA,
116
+ "status": "run_queued",
117
+ "lane": "hosted",
118
+ "dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
119
+ "watch_command": f"mr-data watch {run_id} --hosted",
120
+ "run": run_receipt(run),
121
+ "studio": selected.session.to_receipt(),
122
+ }
123
+
124
+
125
+ def _already_started(
126
+ client: StudioRunClient, args: argparse.Namespace, refusal: ThinLaneError
127
+ ) -> dict[str, Any] | None:
128
+ """The run an approval already started, when that is why the submission was refused.
129
+
130
+ Since Studio #124 a person confirming a Table recipe in the dashboard can start its first
131
+ hosted Build in the same transaction. The proposal is then ``activation_started``, and
132
+ ``POST /v3/runs`` refuses it with ``RELEASE_GATE_BLOCKED`` -- before it reaches the activation
133
+ receipt that would otherwise replay the run. So a build command printed before the person
134
+ decided is not wrong, it is late: the run it asked for exists. This reads the proposal, and
135
+ if the approval started the Build, reads that Build back and reports it as already queued. Any
136
+ other refusal, or a proposal in any other state, is the refusal it was.
137
+ """
138
+
139
+ if refusal.code != "THIN_STUDIO_RELEASE_GATE_BLOCKED":
140
+ return None
141
+ proposal = client.recipe_proposal(args.recipe_proposal)
142
+ approval_request_id = proposal.get("approval_request_id")
143
+ if proposal.get("status") != "activation_started" or not isinstance(approval_request_id, str):
144
+ return None
145
+ if proposal.get("recipe_digest") != args.recipe_digest:
146
+ # The run that exists was started from a different recipe than the one asked for, which
147
+ # is the refusal Studio gave, not a run to hand back.
148
+ return None
149
+ started = client.approval_start(approval_request_id)
150
+ if started is None:
151
+ return None
152
+ run = started.get("run")
153
+ run_id = run.get("run_id") if isinstance(run, dict) else None
154
+ if not isinstance(run_id, str):
155
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "Studio named a started Build without a run")
156
+ return {
157
+ "schema_version": SUBMIT_SCHEMA,
158
+ "status": "run_already_queued",
159
+ "lane": "hosted",
160
+ "approval_request_id": approval_request_id,
161
+ "dashboard_url": run_dashboard_url(client.session.cloud_url, run_id),
162
+ "watch_command": f"mr-data watch {run_id} --hosted",
163
+ "run": run_receipt(run),
164
+ "note": (
165
+ "The approval that confirmed this recipe started its first hosted Build in the same "
166
+ "act, so there was nothing left for this command to queue. This is that Build."
167
+ ),
168
+ "studio": client.session.to_receipt(),
169
+ }
170
+
171
+
172
+ def watch(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
173
+ """Stream one run's event log to the terminal, resuming across stream cuts.
174
+
175
+ Lines are written as they arrive rather than collected and printed at the end -- the point of
176
+ a live view is that it is live -- and the returned payload is the summary of what was seen, so
177
+ ``--json`` gets one object and a person gets a timeline followed by that object.
178
+ """
179
+
180
+ # The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
181
+ # a run id should cost a sentence, not a network round trip and a Studio 404.
182
+ run_id = _run_id(args.run_id)
183
+ selected = client or _client(args)
184
+ stream = RunEventStream(selected.session, run_id)
185
+ announce = getattr(args, "json", False) is False
186
+
187
+ def _write(line: str) -> None:
188
+ if announce:
189
+ print(line, flush=True)
190
+
191
+ if announce:
192
+ print(f"watching {run_id}", flush=True)
193
+ print(run_dashboard_url(selected.session.cloud_url, run_id), flush=True)
194
+ progress = stream.follow(
195
+ from_seq=int(getattr(args, "from_seq", 0) or 0),
196
+ last_event_id=getattr(args, "last_event_id", None),
197
+ on_line=_write,
198
+ )
199
+ run = selected.get_run(run_id)
200
+ return {
201
+ "schema_version": WATCH_SCHEMA,
202
+ "status": "run_terminal" if run.get("status") in TERMINAL_RUN_STATUSES else "watch_ended",
203
+ "lane": "hosted",
204
+ "dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
205
+ "run": run_receipt(run),
206
+ "stream": progress.to_receipt(),
207
+ }
208
+
209
+
210
+ def status(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
211
+ """Report Studio's own reading of one run, with its receipts when it has any.
212
+
213
+ The execution summary and the candidate evidence are read only once the run has something to
214
+ say: asking for candidate evidence on a queued run is a 404 by design, and reporting that 404
215
+ as a failure would be reporting the ordinary state of every new run as an error.
216
+ """
217
+
218
+ # The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
219
+ # a run id should cost a sentence, not a network round trip and a Studio 404.
220
+ run_id = _run_id(args.run_id)
221
+ selected = client or _client(args)
222
+ run = selected.get_run(run_id)
223
+ payload: dict[str, Any] = {
224
+ "schema_version": STATUS_SCHEMA,
225
+ "status": "run_reported",
226
+ "lane": "hosted",
227
+ "dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
228
+ "run": run_receipt(run),
229
+ }
230
+ if not getattr(args, "receipts", False):
231
+ return payload
232
+ unavailable: dict[str, str] = {}
233
+ payload["execution_summary"] = optional_receipt(
234
+ "execution_summary", lambda: selected.execution_summary(run_id), unavailable
235
+ )
236
+ evidence = optional_receipt(
237
+ "candidate_evidence", lambda: selected.candidate_evidence(run_id), unavailable
238
+ )
239
+ if isinstance(evidence, Mapping):
240
+ payload["candidate_evidence"] = {
241
+ key: evidence[key]
242
+ for key in ("execution_scope_digest", "release_policy_digest", "admission")
243
+ if key in evidence
244
+ }
245
+ payload["artifacts"] = [
246
+ {
247
+ key: artifact[key]
248
+ for key in (
249
+ "artifact_id",
250
+ "kind",
251
+ "purpose",
252
+ "media_type",
253
+ "size_bytes",
254
+ "content_digest",
255
+ "classification",
256
+ )
257
+ if key in artifact
258
+ }
259
+ for artifact in artifact_records(evidence)
260
+ ]
261
+ else:
262
+ payload["candidate_evidence"] = None
263
+ payload["artifacts"] = []
264
+ payload["receipts_unavailable"] = unavailable
265
+ return payload
266
+
267
+
268
+ def download(args: argparse.Namespace, *, client: StudioRunClient | None = None) -> dict[str, Any]:
269
+ """Bring one released run's dataset back to this machine, verified.
270
+
271
+ Backs ``mr-data deploy-dataset`` in the hosted lane, which is the command that already means
272
+ "bring the Build a deployment released back here". Under the thin profile it brings back the
273
+ bytes and their proof; it does not write a dataset notebook, because rendering one is
274
+ server-side work under ADR 0021, and a locally rendered notebook beside a hosted build is
275
+ exactly the kind of second answer this architecture exists to remove.
276
+ """
277
+
278
+ # The identifier is checked BEFORE the credential is resolved and a token is minted. A typo in
279
+ # a run id should cost a sentence, not a network round trip and a Studio 404.
280
+ run_id = _run_id(args.run_id)
281
+ selected = client or _client(args)
282
+ evidence = selected.candidate_evidence(run_id)
283
+ wanted = tuple(getattr(args, "kind", None) or DEFAULT_DOWNLOAD_KINDS)
284
+ output = Path(getattr(args, "output", None) or "hosted-dataset").expanduser()
285
+ selectedartifact_records = [
286
+ artifact for artifact in artifact_records(evidence) if artifact.get("kind") in wanted
287
+ ]
288
+ if not selectedartifact_records:
289
+ available = sorted({str(item.get("kind")) for item in artifact_records(evidence)})
290
+ raise ThinLaneError(
291
+ "THIN_NO_MATCHING_ARTIFACT",
292
+ f"this run published no artifact of kind {', '.join(wanted)}"
293
+ + (f"; it published {', '.join(available)}" if available else ""),
294
+ )
295
+ downloaded = []
296
+ for artifact in selectedartifact_records:
297
+ artifact_id = artifact.get("artifact_id")
298
+ if not isinstance(artifact_id, str):
299
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "an artifact carried no identifier")
300
+ session = selected.artifact_download_session(artifact_id)
301
+ destination = output / f"{artifact_id}{suffix_for(artifact.get('media_type'))}"
302
+ downloaded.append(download_signed_artifact(session, destination).to_receipt())
303
+ return {
304
+ "schema_version": DOWNLOAD_SCHEMA,
305
+ "status": "artifacts_downloaded",
306
+ "lane": "hosted",
307
+ "dashboard_url": run_dashboard_url(selected.session.cloud_url, run_id),
308
+ "run_id": run_id,
309
+ "output": str(output),
310
+ "artifacts": downloaded,
311
+ }
312
+
313
+
314
+ def artifact_records(evidence: Mapping[str, Any]) -> list[Mapping[str, Any]]:
315
+ artifacts = evidence.get("artifacts")
316
+ if not isinstance(artifacts, list):
317
+ return []
318
+ return [item for item in artifacts if isinstance(item, Mapping)]
319
+
320
+
321
+ #: Media type to file extension, derived from the one format table rather than restated. Both
322
+ #: halves come from :mod:`mostlyright.data_harness.formats`, so a format added there reaches a
323
+ #: downloaded file's name without anybody remembering this line exists.
324
+ SUFFIX_BY_MEDIA_TYPE = {
325
+ media_type: min(FORMAT_SUFFIXES[data_format])
326
+ for data_format, media_types in FORMAT_MEDIA_TYPES.items()
327
+ if data_format in FORMAT_SUFFIXES
328
+ for media_type in media_types
329
+ }
330
+
331
+
332
+ def media_type_essence(media_type: Any) -> str:
333
+ """The type/subtype half of a media type, with its parameters and its casing removed.
334
+
335
+ ⚠ A MEDIA TYPE IS NOT A STRING TO COMPARE. RFC 9110 §8.3 makes everything after the first
336
+ ``;`` a parameter list, and Studio sends ``text/html; charset=utf-8`` where this repository
337
+ had written ``text/html``. An equality test against the whole header therefore missed, the
338
+ rendered notebook was named ``.bin``, and nothing failed loudly -- which is exactly the shape
339
+ of defect a comparison against a parsed value cannot have. Type and subtype are
340
+ case-insensitive in that same section, so they are lowered here rather than at each caller.
341
+
342
+ Only the essence is returned. No caller in this lane acts on a parameter, and returning them
343
+ would invite the next comparison to be made against the whole string again.
344
+ """
345
+
346
+ if not isinstance(media_type, str):
347
+ return ""
348
+ return media_type.partition(";")[0].strip().lower()
349
+
350
+
351
+ def suffix_for(media_type: Any) -> str:
352
+ """A file extension chosen from the media type, and ``.bin`` when it is not one we name.
353
+
354
+ Deliberately the project's own table rather than ``mimetypes``: the stdlib mapping differs
355
+ between hosts, and a dataset whose extension depends on the machine that downloaded it is a
356
+ support question waiting to happen.
357
+
358
+ The lookup is on the parsed essence, so ``text/csv`` and ``text/csv; charset=utf-8`` name the
359
+ same file. The table's own keys carry no parameters, so this widens what is recognised and
360
+ can never change what an already-recognised type resolves to.
361
+ """
362
+
363
+ return SUFFIX_BY_MEDIA_TYPE.get(media_type_essence(media_type), ".bin")
364
+
365
+
366
+ #: The two refusals a receipt read may legitimately answer with, and what each one means.
367
+ #: ``THIN_NOT_FOUND`` is the ordinary state of every run that has not produced that record yet;
368
+ #: ``THIN_AUTHORIZATION_DENIED`` is a real answer too -- candidate evidence needs `review:read`,
369
+ #: which a viewer may not hold. Neither is reported as absence, because "there is nothing" and
370
+ #: "you may not see it" are different sentences and a reader must be told which one they got.
371
+ RECEIPT_UNAVAILABLE_CODES = ("THIN_AUTHORIZATION_DENIED", "THIN_NOT_FOUND")
372
+
373
+
374
+ def optional_receipt(name: str, call: Any, unavailable: dict[str, str]) -> Any:
375
+ """Return what ``call`` answers, recording WHY it could not be read when it could not."""
376
+
377
+ try:
378
+ return call()
379
+ except ThinLaneError as refusal:
380
+ if refusal.code in RECEIPT_UNAVAILABLE_CODES:
381
+ unavailable[name] = refusal.code
382
+ return None
383
+ raise
384
+
385
+
386
+ def print_stderr(text: str) -> None:
387
+ print(text, file=sys.stderr, flush=True)
388
+
389
+
390
+ __all__ = [
391
+ "DEFAULT_DOWNLOAD_KINDS",
392
+ "DOWNLOAD_SCHEMA",
393
+ "RECEIPT_UNAVAILABLE_CODES",
394
+ "RESOURCE_CLASSES",
395
+ "STATUS_SCHEMA",
396
+ "SUBMIT_SCHEMA",
397
+ "WATCH_SCHEMA",
398
+ "artifact_records",
399
+ "download",
400
+ "optional_receipt",
401
+ "run_receipt",
402
+ "status",
403
+ "submit",
404
+ "suffix_for",
405
+ "watch",
406
+ ]
@@ -0,0 +1,194 @@
1
+ """Fetch one artifact through a signed URL, and refuse the bytes unless they are the bytes.
2
+
3
+ The signed URL is a short-lived bearer capability that points straight at object storage, so two
4
+ things are true at once: nothing here needs a Studio credential, and nothing here may trust what
5
+ comes back. Studio hands over ``expected_content_digest`` and ``expected_size_bytes`` alongside the
6
+ URL precisely so the client can settle that itself, and this module treats both as mandatory
7
+ rather than advisory -- a download whose digest does not match is deleted, not written with a
8
+ warning.
9
+
10
+ The file is written with ``O_EXCL``: a hosted download never overwrites something already on this
11
+ machine, because the one thing worse than not having the dataset is silently replacing a different
12
+ one with it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ import re
20
+ from collections.abc import Mapping
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from mostlyright.data_harness.thin.transport import (
26
+ REQUEST_TIMEOUT_SECONDS,
27
+ ThinLaneError,
28
+ Transport,
29
+ UrlLibTransport,
30
+ signed_transfer_url,
31
+ )
32
+
33
+ #: Read granularity. The same 64 KiB ``acquisition.result_download`` streams a crawl result at.
34
+ DOWNLOAD_CHUNK_BYTES = 65_536
35
+
36
+ #: A ceiling on any single artifact this lane will write, so a wrong ``expected_size_bytes`` cannot
37
+ #: fill the volume before the digest check gets a chance to refuse. Sixteen gibibytes is far above
38
+ #: any table this product builds and far below a disk.
39
+ MAX_ARTIFACT_BYTES = 16 * 1024 * 1024 * 1024
40
+
41
+ _PREFIXED_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DownloadedArtifact:
46
+ """What one verified download produced."""
47
+
48
+ path: Path
49
+ artifact_id: str
50
+ media_type: str
51
+ size_bytes: int
52
+ content_digest: str
53
+
54
+ def to_receipt(self) -> dict[str, Any]:
55
+ return {
56
+ "path": str(self.path),
57
+ "artifact_id": self.artifact_id,
58
+ "media_type": self.media_type,
59
+ "size_bytes": self.size_bytes,
60
+ "content_digest": self.content_digest,
61
+ }
62
+
63
+
64
+ def _required_headers(session: Mapping[str, Any]) -> dict[str, str]:
65
+ """The headers the signature covers. Sending fewer, or more, invalidates it."""
66
+
67
+ declared = session.get("required_headers")
68
+ if declared is None:
69
+ return {}
70
+ if not isinstance(declared, list):
71
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "required_headers is not a list")
72
+ headers: dict[str, str] = {}
73
+ for item in declared:
74
+ if not isinstance(item, Mapping):
75
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "a required header is not an object")
76
+ name = item.get("name")
77
+ value = item.get("value")
78
+ if not isinstance(name, str) or not isinstance(value, str) or not name:
79
+ raise ThinLaneError(
80
+ "THIN_RESPONSE_INVALID", "a required header is not a name/value pair"
81
+ )
82
+ headers[name] = value
83
+ return headers
84
+
85
+
86
+ def download_signed_artifact(
87
+ session: Mapping[str, Any],
88
+ destination: Path,
89
+ *,
90
+ transport: Transport | None = None,
91
+ ) -> DownloadedArtifact:
92
+ """Fetch the bytes one signed session points at, verify them, and write them once."""
93
+
94
+ url = signed_transfer_url(session.get("signed_url", ""), "the signed download URL")
95
+ expected_digest = session.get("expected_content_digest")
96
+ expected_size = session.get("expected_size_bytes")
97
+ if not isinstance(expected_digest, str) or _PREFIXED_DIGEST.fullmatch(expected_digest) is None:
98
+ raise ThinLaneError(
99
+ "THIN_RESPONSE_INVALID", "the signed session carried no sha256 content digest"
100
+ )
101
+ if not isinstance(expected_size, int) or isinstance(expected_size, bool) or expected_size < 0:
102
+ raise ThinLaneError("THIN_RESPONSE_INVALID", "the signed session carried no byte size")
103
+ if expected_size > MAX_ARTIFACT_BYTES:
104
+ raise ThinLaneError(
105
+ "THIN_ARTIFACT_TOO_LARGE",
106
+ f"the artifact declares {expected_size} bytes, above this lane's ceiling",
107
+ )
108
+
109
+ selected = transport or UrlLibTransport()
110
+ destination.parent.mkdir(parents=True, exist_ok=True)
111
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
112
+ if hasattr(os, "O_NOFOLLOW"):
113
+ flags |= os.O_NOFOLLOW
114
+ # Windows only. The artifact this writes is Parquet, and its digest is verified against the
115
+ # one the signed session declared -- so a CRLF translation on the way to disk would not be a
116
+ # cosmetic difference, it would be a download that always fails its own digest check.
117
+ flags |= getattr(os, "O_BINARY", 0)
118
+ try:
119
+ descriptor = os.open(destination, flags, 0o600)
120
+ except FileExistsError as error:
121
+ raise ThinLaneError(
122
+ "THIN_DESTINATION_EXISTS",
123
+ f"{destination} already exists; a hosted download never overwrites a local file",
124
+ ) from error
125
+
126
+ try:
127
+ handle = os.fdopen(descriptor, "wb", closefd=True)
128
+ except BaseException:
129
+ # `fdopen` owns the descriptor only once it succeeds, so this is the one window where
130
+ # closing it is still ours to do.
131
+ os.close(descriptor)
132
+ destination.unlink(missing_ok=True)
133
+ raise
134
+
135
+ digest = hashlib.sha256()
136
+ written = 0
137
+ try:
138
+ # The file handle enters FIRST so that a stream that fails to open still closes it.
139
+ with (
140
+ handle,
141
+ selected.stream(url, _required_headers(session), REQUEST_TIMEOUT_SECONDS) as response,
142
+ ):
143
+ status = int(getattr(response, "status", 200))
144
+ if status != 200:
145
+ raise ThinLaneError(
146
+ "THIN_DOWNLOAD_REFUSED",
147
+ f"the signed download answered HTTP {status}; the session may have expired",
148
+ )
149
+ while True:
150
+ chunk = response.read(DOWNLOAD_CHUNK_BYTES)
151
+ if not chunk:
152
+ break
153
+ written += len(chunk)
154
+ if written > expected_size:
155
+ raise ThinLaneError(
156
+ "THIN_DOWNLOAD_OVERRUN",
157
+ "the signed download returned more bytes than it declared",
158
+ )
159
+ digest.update(chunk)
160
+ handle.write(chunk)
161
+ if written != expected_size:
162
+ raise ThinLaneError(
163
+ "THIN_DOWNLOAD_TRUNCATED",
164
+ f"the signed download returned {written} of {expected_size} declared bytes",
165
+ )
166
+ observed = "sha256:" + digest.hexdigest()
167
+ if observed != expected_digest:
168
+ raise ThinLaneError(
169
+ "THIN_DOWNLOAD_DIGEST_MISMATCH",
170
+ "the downloaded bytes do not hash to the digest Studio declared",
171
+ )
172
+ except BaseException:
173
+ # Nothing partial and nothing unverified is left behind for a later command to mistake
174
+ # for a dataset.
175
+ destination.unlink(missing_ok=True)
176
+ raise
177
+
178
+ media_type = session.get("media_type")
179
+ artifact_id = session.get("artifact_id")
180
+ return DownloadedArtifact(
181
+ path=destination,
182
+ artifact_id=artifact_id if isinstance(artifact_id, str) else "",
183
+ media_type=media_type if isinstance(media_type, str) else "application/octet-stream",
184
+ size_bytes=written,
185
+ content_digest=expected_digest,
186
+ )
187
+
188
+
189
+ __all__ = [
190
+ "DOWNLOAD_CHUNK_BYTES",
191
+ "MAX_ARTIFACT_BYTES",
192
+ "DownloadedArtifact",
193
+ "download_signed_artifact",
194
+ ]