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,501 @@
1
+ """The run-event stream a hosted ``mr-data watch`` consumes, and how it survives a cut.
2
+
3
+ Studio publishes one run's append-only log as ``text/event-stream`` (ADR 0021). Three
4
+ properties of that endpoint decide the shape of everything below:
5
+
6
+ * **The SSE ``id`` is the log cursor.** Resume is exclusive and happens in Studio's own cursor
7
+ space, so a reconnect sends ``Last-Event-ID`` and never a sequence number it invented.
8
+ * **The stream closes itself.** Studio sends ``event: end`` before the Cloud Run request timeout
9
+ rather than letting the platform truncate the response, and names why: ``run_terminal`` means
10
+ the run will never append again and reconnecting would be a lie; anything else is a deadline and
11
+ the client reconnects from its last cursor. A dropped connection with no ``end`` is the same
12
+ case as a deadline.
13
+ * **Facts live only in the unsealed adjunct.** A durable run event is a sealed record -- type,
14
+ time, sequence, digest -- and carries no facts by design. What the worker measured rides beside
15
+ it in ``unsealed.payload``. A renderer that reads only the sealed half draws a correctly ordered,
16
+ correctly framed, entirely factless timeline, which is the failure this module exists to avoid.
17
+
18
+ The frame NAME is upstream's two-way partition and is preserved rather than collapsed:
19
+ ``run_progress`` is an incremental tick, ``run_event`` is a bulk-append proof or a lifecycle
20
+ transition. Five of the six producer command types collapse onto one durable event type, so the
21
+ partition is not recoverable from the sealed record and a consumer that discards the name has
22
+ destroyed information nothing downstream can rebuild.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import time
29
+ from collections.abc import Callable, Iterable, Iterator, Mapping
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ from mostlyright.data_harness.progress_events import PROGRESS_EVENT_FACTS, PROGRESS_SCHEMA_VERSION
34
+ from mostlyright.data_harness.thin.runs import RUN_EVENTS_STREAM_PATH
35
+ from mostlyright.data_harness.thin.session import StudioSession
36
+ from mostlyright.data_harness.thin.transport import ThinLaneError, Transport, UrlLibTransport
37
+
38
+ #: Upstream's frame names. The first two are the tick/proof partition; ``end`` is its
39
+ #: end-of-stream notice.
40
+ RUN_EVENT_FRAME = "run_event"
41
+ RUN_PROGRESS_FRAME = "run_progress"
42
+ RUN_END_FRAME = "end"
43
+
44
+ #: The one ``end`` reason that means "do not reconnect".
45
+ RUN_END_REASON_TERMINAL = "run_terminal"
46
+
47
+ #: The four producer command types Studio frames as ``run_progress``. Used only as a fallback when
48
+ #: a frame arrived with no name this build knows -- when upstream named the frame, that name wins.
49
+ PROGRESS_PRODUCER_EVENT_TYPES = frozenset(
50
+ {"attempt_progress", "run_stage_reported", "source_probe_completed", "diagnostic_emitted"}
51
+ )
52
+
53
+ #: Statuses after which a run appends nothing further. Mirrors Studio's own terminal set.
54
+ TERMINAL_RUN_STATUSES = frozenset({"released", "failed", "cancelled", "abandoned"})
55
+
56
+ #: One SSE line may not exceed this. A stream is untrusted input like any other network body, and
57
+ #: ``readline`` with no bound is how a hostile or broken producer turns a watch into an OOM.
58
+ MAX_STREAM_LINE_BYTES = 1024 * 1024
59
+
60
+ #: Studio closes at 3300 s and expects the client to come back; the socket read timeout has to
61
+ #: exceed the 15 s heartbeat by enough that a slow hop is not mistaken for a cut.
62
+ STREAM_READ_TIMEOUT_SECONDS = 90.0
63
+
64
+ #: How long a reconnect waits, and the ceiling the doubling stops at.
65
+ FIRST_RECONNECT_DELAY_SECONDS = 1.0
66
+ MAX_RECONNECT_DELAY_SECONDS = 30.0
67
+
68
+ #: Consecutive failures to re-establish the stream before the watch gives up and says so. A
69
+ #: successful reconnect resets the count, so a long run that is cut hourly never exhausts it.
70
+ MAX_CONSECUTIVE_RECONNECTS = 8
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class SseFrame:
75
+ """One dispatched Server-Sent-Events frame: its name, its data, and its id."""
76
+
77
+ name: str
78
+ data: str
79
+ event_id: str | None = None
80
+
81
+ def json(self) -> dict[str, Any]:
82
+ try:
83
+ parsed = json.loads(self.data)
84
+ except ValueError as error:
85
+ raise ThinLaneError(
86
+ "THIN_STREAM_FRAME_INVALID", "a stream frame carried a body that is not JSON"
87
+ ) from error
88
+ if not isinstance(parsed, dict):
89
+ raise ThinLaneError(
90
+ "THIN_STREAM_FRAME_INVALID", "a stream frame carried a body that is not an object"
91
+ )
92
+ return parsed
93
+
94
+
95
+ def parse_sse(lines: Iterable[bytes]) -> Iterator[SseFrame]:
96
+ """Turn a byte-line stream into dispatched frames, dropping comments and heartbeats.
97
+
98
+ Implements only the part of the SSE grammar this endpoint uses: ``id``, ``event``, ``data``
99
+ (possibly repeated, joined with newlines) and comment lines, dispatched on a blank line. A
100
+ ``retry:`` field is read and discarded -- the reconnect policy here is the client's, and a
101
+ server-supplied delay is advice this lane does not need. A frame with no ``event:`` defaults to
102
+ ``message`` per the specification; Studio always names its frames, so such a frame is a signal
103
+ that something between us rewrote the stream, and it is passed through under that name rather
104
+ than being guessed at.
105
+ """
106
+
107
+ name: str | None = None
108
+ event_id: str | None = None
109
+ data: list[str] = []
110
+ for raw in lines:
111
+ if len(raw) > MAX_STREAM_LINE_BYTES:
112
+ raise ThinLaneError(
113
+ "THIN_STREAM_LINE_TOO_LONG", "a stream line exceeded its byte ceiling"
114
+ )
115
+ line = raw.rstrip(b"\n").rstrip(b"\r").decode("utf-8", "replace")
116
+ if not line:
117
+ if name is not None or data:
118
+ yield SseFrame(name=name or "message", data="\n".join(data), event_id=event_id)
119
+ name, data = None, []
120
+ continue
121
+ if line.startswith(":"):
122
+ continue
123
+ field_name, _, value = line.partition(":")
124
+ if value.startswith(" "):
125
+ value = value[1:]
126
+ if field_name == "event":
127
+ name = value
128
+ elif field_name == "data":
129
+ data.append(value)
130
+ elif field_name == "id" and "\x00" not in value:
131
+ event_id = value
132
+ if name is not None or data:
133
+ yield SseFrame(name=name or "message", data="\n".join(data), event_id=event_id)
134
+
135
+
136
+ def frame_name_for(frame: Mapping[str, Any], upstream_name: str) -> str:
137
+ """The tick/proof partition for one event body.
138
+
139
+ Precedence: upstream's own name when it is one of the two the partition defines, then the
140
+ producer command type in the adjunct applying Studio's rule, then ``run_event`` -- the honest
141
+ answer for a record with neither, because a lifecycle transition is not a tick.
142
+ """
143
+
144
+ if upstream_name in {RUN_EVENT_FRAME, RUN_PROGRESS_FRAME}:
145
+ return upstream_name
146
+ unsealed = frame.get("unsealed")
147
+ if isinstance(unsealed, Mapping):
148
+ producer_event_type = unsealed.get("producer_event_type")
149
+ if producer_event_type in PROGRESS_PRODUCER_EVENT_TYPES:
150
+ return RUN_PROGRESS_FRAME
151
+ return RUN_EVENT_FRAME
152
+
153
+
154
+ @dataclass
155
+ class RunProgress:
156
+ """What one watch has learned so far: the last cursor, the stage rail, and the last facts."""
157
+
158
+ last_event_id: str | None = None
159
+ last_sequence: int = 0
160
+ events_seen: int = 0
161
+ progress_seen: int = 0
162
+ stage: str | None = None
163
+ stage_index: int | None = None
164
+ stage_total: int | None = None
165
+ status: str | None = None
166
+ end_reason: str | None = None
167
+ reconnects: int = 0
168
+ #: The follow stopped because the caller said so -- its observer saw the frame it was waiting
169
+ #: for, or its ``stop`` check answered yes between frames. Distinct from ``end_reason``, which
170
+ #: is what STUDIO said: a follow halted here left the stream open on Studio's side, and the
171
+ #: run is still appending.
172
+ halted: bool = False
173
+ #: The follow stopped because the caller's deadline passed first. The run is unaffected and the
174
+ #: frame being waited for may still arrive; a caller reads the resource itself to find out.
175
+ expired: bool = False
176
+ lines: list[str] = field(default_factory=list)
177
+
178
+ def to_receipt(self) -> dict[str, Any]:
179
+ return {
180
+ "last_event_id": self.last_event_id,
181
+ "last_sequence": self.last_sequence,
182
+ "events_seen": self.events_seen,
183
+ "progress_seen": self.progress_seen,
184
+ "stage": self.stage,
185
+ "stage_index": self.stage_index,
186
+ "stage_total": self.stage_total,
187
+ "status": self.status,
188
+ "end_reason": self.end_reason,
189
+ "reconnects": self.reconnects,
190
+ "halted": self.halted,
191
+ "expired": self.expired,
192
+ }
193
+
194
+
195
+ def progress_facts(frame: Mapping[str, Any]) -> tuple[str, dict[str, Any]] | None:
196
+ """The progress event name and its facts, from the unsealed adjunct, or ``None``.
197
+
198
+ ⚠ NEVER EVIDENCE. ``payload_digest`` on a record whose adjunct carries a payload is the content
199
+ address of THAT payload: it says the facts were not rewritten in transit and says nothing about
200
+ the Build. Nothing here is verified against a candidate, and nothing here reaches a receipt
201
+ that claims to be attestation.
202
+
203
+ An unknown event name is rendered rather than dropped -- the vocabulary is the harness's and it
204
+ grows, and a client that has not been taught a name still knows the facts came with it.
205
+ """
206
+
207
+ unsealed = frame.get("unsealed")
208
+ if not isinstance(unsealed, Mapping):
209
+ return None
210
+ payload = unsealed.get("payload")
211
+ if not isinstance(payload, Mapping):
212
+ return None
213
+ if payload.get("schema_version") != PROGRESS_SCHEMA_VERSION:
214
+ return None
215
+ name = payload.get("event")
216
+ facts = payload.get("facts")
217
+ if not isinstance(name, str) or not isinstance(facts, Mapping):
218
+ return None
219
+ known = PROGRESS_EVENT_FACTS.get(name)
220
+ ordered = known if known is not None else tuple(sorted(str(key) for key in facts))
221
+ return name, {key: facts[key] for key in ordered if key in facts}
222
+
223
+
224
+ def render_frame(frame: Mapping[str, Any], upstream_name: str) -> str:
225
+ """One timeline line for one stream frame.
226
+
227
+ A tick renders its facts; a proof renders its durable event type. Both carry the sequence
228
+ number, which is the only ordering authority the log has and the thing a person reads back to
229
+ us when something looks wrong.
230
+ """
231
+
232
+ sequence = frame.get("sequence")
233
+ marker = f"{sequence:>5}" if isinstance(sequence, int) else " ?"
234
+ named = progress_facts(frame)
235
+ if frame_name_for(frame, upstream_name) == RUN_PROGRESS_FRAME and named is not None:
236
+ name, facts = named
237
+ rendered = " ".join(f"{key}={_scalar(value)}" for key, value in facts.items())
238
+ return f"{marker} {name}{' ' + rendered if rendered else ''}"
239
+ event_type = frame.get("event_type")
240
+ return f"{marker} {event_type if isinstance(event_type, str) else 'run_event'}"
241
+
242
+
243
+ def _scalar(value: Any) -> str:
244
+ if isinstance(value, bool):
245
+ return "true" if value else "false"
246
+ if isinstance(value, int | float | str):
247
+ return str(value)
248
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
249
+
250
+
251
+ class RunEventStream:
252
+ """Replay-then-tail one run's event log, reconnecting across Cloud Run stream cuts."""
253
+
254
+ def __init__(
255
+ self,
256
+ session: StudioSession,
257
+ run_id: str,
258
+ *,
259
+ transport: Transport | None = None,
260
+ sleep: Callable[[float], None] = time.sleep,
261
+ max_reconnects: int = MAX_CONSECUTIVE_RECONNECTS,
262
+ ) -> None:
263
+ self._session = session
264
+ self._run_id = run_id
265
+ self._transport = transport or UrlLibTransport()
266
+ self._sleep = sleep
267
+ self._max_reconnects = max_reconnects
268
+
269
+ def follow(
270
+ self,
271
+ *,
272
+ from_seq: int = 0,
273
+ last_event_id: str | None = None,
274
+ on_line: Callable[[str], None] | None = None,
275
+ observe: Callable[[Mapping[str, Any], str], bool] | None = None,
276
+ stop: Callable[[], bool] | None = None,
277
+ deadline: float | None = None,
278
+ clock: Callable[[], float] = time.monotonic,
279
+ ) -> RunProgress:
280
+ """Consume the stream to its terminal end, or until reconnects are exhausted.
281
+
282
+ Returns the accumulated :class:`RunProgress` in every case that is not a refusal, so a
283
+ caller can report how far it got even when the run outlived the watch.
284
+
285
+ Three optional ways to stop before the run does, all written for a caller that is waiting
286
+ for ONE record on a run that will keep appending -- a probe settling on a research
287
+ session's run, whose stream only ends when the session closes. ``observe`` sees every new
288
+ frame body with its frame name, after it is folded into ``progress``, and answers whether
289
+ the follow is over. ``stop`` is asked between frames -- after every frame boundary and
290
+ every heartbeat on a quiet stream, and before every reconnect -- so a caller whose answer
291
+ may arrive somewhere OTHER than this stream can go and look there; it is never asked in
292
+ the middle of a frame. ``deadline`` is a ``clock()`` reading past which the follow gives
293
+ up. The receipt says what happened under ``halted`` (the first two) and ``expired`` (the
294
+ third), and none of them is an ``end_reason``: Studio did not end anything, the caller
295
+ stopped listening.
296
+ """
297
+
298
+ progress = RunProgress(last_event_id=last_event_id)
299
+ delay = FIRST_RECONNECT_DELAY_SECONDS
300
+ consecutive = 0
301
+ while True:
302
+ delivered = self._one_connection(
303
+ progress,
304
+ from_seq=from_seq,
305
+ on_line=on_line,
306
+ observe=observe,
307
+ stop=stop,
308
+ deadline=deadline,
309
+ clock=clock,
310
+ )
311
+ if progress.end_reason == RUN_END_REASON_TERMINAL:
312
+ return progress
313
+ if progress.status in TERMINAL_RUN_STATUSES:
314
+ return progress
315
+ if progress.halted:
316
+ return progress
317
+ if deadline is not None and clock() >= deadline:
318
+ progress.expired = True
319
+ return progress
320
+ if stop is not None and stop():
321
+ # Asked before the reconnect wait rather than after it: a connection that just
322
+ # closed is exactly the gap in which an answer can have landed elsewhere.
323
+ progress.halted = True
324
+ return progress
325
+ if delivered:
326
+ consecutive = 0
327
+ delay = FIRST_RECONNECT_DELAY_SECONDS
328
+ else:
329
+ consecutive += 1
330
+ if consecutive > self._max_reconnects:
331
+ raise ThinLaneError(
332
+ "THIN_STREAM_UNAVAILABLE",
333
+ f"the run event stream could not be re-established after {consecutive} "
334
+ "attempts; the run is unaffected and watch can be resumed",
335
+ )
336
+ progress.reconnects += 1
337
+ self._sleep(delay)
338
+ delay = min(delay * 2, MAX_RECONNECT_DELAY_SECONDS)
339
+
340
+ def _one_connection(
341
+ self,
342
+ progress: RunProgress,
343
+ *,
344
+ from_seq: int,
345
+ on_line: Callable[[str], None] | None,
346
+ observe: Callable[[Mapping[str, Any], str], bool] | None = None,
347
+ stop: Callable[[], bool] | None = None,
348
+ deadline: float | None = None,
349
+ clock: Callable[[], float] = time.monotonic,
350
+ ) -> bool:
351
+ """Hold one connection until it ends. Returns whether it delivered any frame."""
352
+
353
+ def _between_frames() -> bool:
354
+ # The deadline first, because it costs nothing; the caller's check second, because it
355
+ # may cost a round trip and a deadline that has passed makes the answer moot.
356
+ if deadline is not None and clock() >= deadline:
357
+ return True
358
+ if stop is not None and stop():
359
+ progress.halted = True
360
+ return True
361
+ return False
362
+
363
+ headers = {"Accept": "text/event-stream", **self._session.authorization()}
364
+ # Resume in Studio's own cursor space when we have one. `from_seq` is only ever the
365
+ # starting position of a watch that has not yet seen a frame.
366
+ if progress.last_event_id:
367
+ headers["Last-Event-ID"] = progress.last_event_id
368
+ query = ""
369
+ else:
370
+ query = f"?from_seq={int(from_seq)}" if from_seq else ""
371
+ url = (
372
+ f"{self._session.studio_base_url}"
373
+ f"{RUN_EVENTS_STREAM_PATH.format(run_id=self._run_id)}{query}"
374
+ )
375
+ delivered = False
376
+ try:
377
+ with self._transport.stream(url, headers, STREAM_READ_TIMEOUT_SECONDS) as response:
378
+ status = int(getattr(response, "status", 200))
379
+ if status in {401, 403}:
380
+ raise ThinLaneError(
381
+ "THIN_AUTHORIZATION_DENIED",
382
+ "the Studio token does not carry authority to read this run's events",
383
+ )
384
+ if status == 404:
385
+ raise ThinLaneError("THIN_NOT_FOUND", "Studio has no run with that identifier")
386
+ if status != 200:
387
+ raise ThinLaneError(
388
+ "THIN_STREAM_REFUSED",
389
+ f"Studio answered the event stream with HTTP {status}",
390
+ )
391
+ for frame in parse_sse(_lines_until(response, _between_frames)):
392
+ delivered = True
393
+ if self._consume(frame, progress, on_line, observe):
394
+ return delivered
395
+ except ThinLaneError:
396
+ raise
397
+ except Exception:
398
+ # A cut connection is the ordinary case this endpoint is designed around, not an
399
+ # error: Studio closes at its own deadline and every hop between us may close sooner.
400
+ # The caller reconnects from the last cursor.
401
+ return delivered
402
+ return delivered
403
+
404
+ def _consume(
405
+ self,
406
+ frame: SseFrame,
407
+ progress: RunProgress,
408
+ on_line: Callable[[str], None] | None,
409
+ observe: Callable[[Mapping[str, Any], str], bool] | None = None,
410
+ ) -> bool:
411
+ """Fold one frame into ``progress``. Returns whether this connection is over."""
412
+
413
+ if frame.name == RUN_END_FRAME:
414
+ body = frame.json()
415
+ reason = body.get("reason")
416
+ progress.end_reason = reason if isinstance(reason, str) else None
417
+ status = body.get("status")
418
+ if isinstance(status, str):
419
+ progress.status = status
420
+ trailing = body.get("last_event_id")
421
+ if isinstance(trailing, str) and trailing:
422
+ progress.last_event_id = trailing
423
+ return True
424
+ body = frame.json()
425
+ if frame.event_id:
426
+ progress.last_event_id = frame.event_id
427
+ sequence = body.get("sequence")
428
+ if isinstance(sequence, int):
429
+ if sequence <= progress.last_sequence:
430
+ # Replay is exclusive by contract, so a repeat means a retried delivery rather
431
+ # than a new record. Counting it twice would make the receipt lie.
432
+ return False
433
+ progress.last_sequence = sequence
434
+ progress.events_seen += 1
435
+ named = progress_facts(body)
436
+ if named is not None:
437
+ progress.progress_seen += 1
438
+ name, facts = named
439
+ if name in {"stage_started", "stage_completed"}:
440
+ progress.stage = facts.get("stage") if isinstance(facts.get("stage"), str) else None
441
+ progress.stage_index = _as_int(facts.get("index"))
442
+ progress.stage_total = _as_int(facts.get("total"))
443
+ line = render_frame(body, frame.name)
444
+ progress.lines.append(line)
445
+ if on_line is not None:
446
+ on_line(line)
447
+ if observe is not None and observe(body, frame.name):
448
+ progress.halted = True
449
+ return True
450
+ return False
451
+
452
+
453
+ def _lines_until(response: Iterable[bytes], should_stop: Callable[[], bool]) -> Iterator[bytes]:
454
+ """The response's lines, stopping where the caller says rather than at the stream's end.
455
+
456
+ Asked per LINE rather than per frame on purpose. Studio writes a heartbeat comment at most
457
+ fifteen seconds apart on a quiet stream, and a comment is a line the frame parser drops without
458
+ dispatching anything -- so a check made only when a frame arrived would, on a quiet session,
459
+ be made at Studio's own stream deadline and not before. A line is the finest grain the socket
460
+ offers, and the heartbeat is what makes it arrive.
461
+
462
+ ⚠ NEVER BETWEEN A FRAME'S OWN LINES. A frame is ``id``, ``event`` and ``data`` lines closed by
463
+ a blank one, and stopping after the ``event`` line would hand the parser a frame with no body
464
+ -- which it refuses as a malformed frame, so a deadline would have reported itself as a broken
465
+ stream. The check is made only at a blank line, which closes a frame, or at a comment that
466
+ arrived while no frame was open.
467
+ """
468
+
469
+ mid_frame = False
470
+ for raw in response:
471
+ yield raw
472
+ line = raw.rstrip(b"\r\n")
473
+ if not line:
474
+ mid_frame = False
475
+ elif not line.startswith(b":"):
476
+ mid_frame = True
477
+ if not mid_frame and should_stop():
478
+ return
479
+
480
+
481
+ def _as_int(value: Any) -> int | None:
482
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
483
+
484
+
485
+ __all__ = [
486
+ "MAX_CONSECUTIVE_RECONNECTS",
487
+ "MAX_STREAM_LINE_BYTES",
488
+ "PROGRESS_PRODUCER_EVENT_TYPES",
489
+ "RUN_END_FRAME",
490
+ "RUN_END_REASON_TERMINAL",
491
+ "RUN_EVENT_FRAME",
492
+ "RUN_PROGRESS_FRAME",
493
+ "TERMINAL_RUN_STATUSES",
494
+ "RunEventStream",
495
+ "RunProgress",
496
+ "SseFrame",
497
+ "frame_name_for",
498
+ "parse_sse",
499
+ "progress_facts",
500
+ "render_frame",
501
+ ]
@@ -0,0 +1,187 @@
1
+ """One bounded HTTPS transport for the thin lane, and the origin check every URL passes first.
2
+
3
+ ``urllib`` rather than a client library, for the reason the rest of this repository already uses
4
+ it: the thin profile must install with no third-party runtime dependency at all, so the transport
5
+ that reaches Cloud and Studio has to come out of the standard library. The hardening is the same
6
+ hardening ``hosted_deploy.UrlLibTokenExchangeTransport`` applies -- ambient proxies off, redirects
7
+ off, a byte ceiling on every response, a request timeout -- because a thin client is not a reason
8
+ to relax any of it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import ssl
15
+ from collections.abc import Iterator, Mapping
16
+ from contextlib import contextmanager
17
+ from typing import Any, Protocol
18
+ from urllib.error import HTTPError
19
+ from urllib.parse import urlsplit
20
+ from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
21
+
22
+ #: The same thirty seconds ``hosted_deploy.REQUEST_TIMEOUT_SECONDS`` gives an ordinary request. A
23
+ #: held-open event stream is not an ordinary request and passes its own, much larger, timeout.
24
+ REQUEST_TIMEOUT_SECONDS = 30.0
25
+
26
+ #: A JSON answer from Cloud or Studio that exceeds this is refused unread. Every response this
27
+ #: lane parses is a small object; a signed download session is the largest of them.
28
+ MAX_JSON_RESPONSE_BYTES = 1024 * 1024
29
+
30
+
31
+ class ThinLaneError(RuntimeError):
32
+ """A typed, credential-safe refusal from the hosted lane.
33
+
34
+ ``code`` is a stable token a script may branch on and ``detail`` is one sentence a person
35
+ reads. Neither ever carries a credential: the raw ``mr_cli_`` key and the short-lived Studio
36
+ token are the two secrets this lane holds, and no refusal below is built from either.
37
+ """
38
+
39
+ def __init__(self, code: str, detail: str) -> None:
40
+ self.code = code
41
+ self.detail = detail
42
+ super().__init__(f"{code}: {detail}")
43
+
44
+
45
+ class Transport(Protocol):
46
+ """The one seam every test substitutes. Returns status, body bytes, and lowercased headers."""
47
+
48
+ def request(
49
+ self,
50
+ method: str,
51
+ url: str,
52
+ headers: Mapping[str, str],
53
+ body: bytes | None,
54
+ maximum: int,
55
+ ) -> tuple[int, bytes, Mapping[str, str]]: ...
56
+
57
+ def stream(
58
+ self,
59
+ url: str,
60
+ headers: Mapping[str, str],
61
+ timeout: float,
62
+ ) -> Any: ...
63
+
64
+
65
+ class _NoRedirect(HTTPRedirectHandler):
66
+ """Refuse every redirect. A bearer token must not be replayed at an origin we did not choose."""
67
+
68
+ def redirect_request(
69
+ self,
70
+ request: Request,
71
+ file_pointer: Any,
72
+ code: int,
73
+ message: str,
74
+ headers: Any,
75
+ new_url: str,
76
+ ) -> None:
77
+ return None
78
+
79
+
80
+ class UrlLibTransport:
81
+ """Bounded HTTPS with ambient proxies and redirects disabled."""
82
+
83
+ def __init__(self) -> None:
84
+ self._opener = build_opener(
85
+ ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
86
+ )
87
+
88
+ def request(
89
+ self,
90
+ method: str,
91
+ url: str,
92
+ headers: Mapping[str, str],
93
+ body: bytes | None,
94
+ maximum: int,
95
+ ) -> tuple[int, bytes, Mapping[str, str]]:
96
+ request = Request(url, data=body, headers=dict(headers), method=method)
97
+ try:
98
+ response = self._opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS)
99
+ except HTTPError as error:
100
+ response = error
101
+ with response:
102
+ raw = response.read(maximum + 1)
103
+ status = int(response.status)
104
+ response_headers = {key.lower(): value for key, value in response.headers.items()}
105
+ if len(raw) > maximum:
106
+ raise ThinLaneError(
107
+ "THIN_RESPONSE_TOO_LARGE", "the response exceeded the byte ceiling for its route"
108
+ )
109
+ return status, raw, response_headers
110
+
111
+ @contextmanager
112
+ def stream(self, url: str, headers: Mapping[str, str], timeout: float) -> Iterator[Any]:
113
+ """Open a long-lived response whose body the caller reads incrementally.
114
+
115
+ Separate from :meth:`request` because everything about it differs: no byte ceiling (the
116
+ caller bounds each line instead), a timeout measured in the length of a run rather than of
117
+ a request, and a body that must never be read whole.
118
+ """
119
+
120
+ request = Request(url, headers=dict(headers), method="GET")
121
+ try:
122
+ response = self._opener.open(request, timeout=timeout)
123
+ except HTTPError as error:
124
+ response = error
125
+ try:
126
+ yield response
127
+ finally:
128
+ response.close()
129
+
130
+
131
+ def service_origin(value: str, label: str, *, allow_loopback_http: bool) -> str:
132
+ """Return ``value`` when it is a bare HTTPS origin, and refuse it otherwise.
133
+
134
+ Mirrors ``hosted_deploy._service_url``: no user information, no path, no query, no fragment,
135
+ and plain HTTP only for a loopback host, which is what a developer running Studio locally
136
+ needs and what nothing in production is allowed to be.
137
+ """
138
+
139
+ if not isinstance(value, str) or not 1 <= len(value) <= 2048 or value.endswith("/"):
140
+ raise ThinLaneError("THIN_CONFIG_INVALID", f"{label} is not a usable origin")
141
+ parsed = urlsplit(value)
142
+ loopback = False
143
+ if parsed.hostname:
144
+ try:
145
+ loopback = ipaddress.ip_address(parsed.hostname).is_loopback
146
+ except ValueError:
147
+ loopback = parsed.hostname == "localhost"
148
+ if (
149
+ parsed.scheme not in ({"https", "http"} if allow_loopback_http else {"https"})
150
+ or (parsed.scheme == "http" and not loopback)
151
+ or not parsed.hostname
152
+ or parsed.username is not None
153
+ or parsed.password is not None
154
+ or parsed.fragment
155
+ or parsed.path not in {"", "/"}
156
+ or parsed.query
157
+ ):
158
+ raise ThinLaneError(
159
+ "THIN_CONFIG_INVALID",
160
+ f"{label} must be an HTTPS origin without user information, path, query, or fragment",
161
+ )
162
+ return value
163
+
164
+
165
+ def signed_transfer_url(value: str, label: str) -> str:
166
+ """Return ``value`` when it is an HTTPS URL a signed transfer may be performed against.
167
+
168
+ A signed download URL is the one URL in this lane that legitimately carries a path and a
169
+ query, so it cannot pass :func:`service_origin`. Everything else stays refused: no plain HTTP,
170
+ no embedded credentials, no fragment.
171
+ """
172
+
173
+ if not isinstance(value, str) or not 1 <= len(value) <= 8192:
174
+ raise ThinLaneError("THIN_RESPONSE_INVALID", f"{label} is not a usable URL")
175
+ parsed = urlsplit(value)
176
+ if (
177
+ parsed.scheme != "https"
178
+ or not parsed.hostname
179
+ or parsed.username is not None
180
+ or parsed.password is not None
181
+ or parsed.fragment
182
+ ):
183
+ raise ThinLaneError(
184
+ "THIN_RESPONSE_INVALID",
185
+ f"{label} must be an HTTPS URL without user information or a fragment",
186
+ )
187
+ return value