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,974 @@
1
+ """Requester-bound Studio acquisition for hosts without the reviewed local clean room."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import re
7
+ import ssl
8
+ import tempfile
9
+ import time
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import asdict, dataclass
12
+ from datetime import UTC, datetime
13
+ from typing import Any, Protocol
14
+ from urllib.error import HTTPError
15
+ from urllib.parse import urlsplit
16
+ from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
17
+ from uuid import UUID
18
+
19
+ from mostlyright.data_harness.acquisition.result_download import (
20
+ RESULT_CHUNK_BYTES,
21
+ ResultBytesVerifier,
22
+ ResultDownloadError,
23
+ ValidatedResultDownload,
24
+ validate_result_download,
25
+ )
26
+ from mostlyright.data_harness.canonical import (
27
+ CanonicalJSONError,
28
+ canonical_json_bytes,
29
+ canonical_sha256,
30
+ parse_foreign_json,
31
+ sha256_bytes,
32
+ )
33
+ from mostlyright.data_harness.formats import FORMAT_MEDIA_TYPES, FORMAT_SUFFIXES
34
+ from mostlyright.data_harness.hosted_crawler_protocol import (
35
+ CRAWLER_FAILURE_SCHEMA,
36
+ PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
37
+ HostedCrawlerProtocolError,
38
+ HostedCrawlerRequest,
39
+ parse_crawler_failure,
40
+ parse_crawler_result,
41
+ )
42
+ from mostlyright.data_harness.local_contracts import ContractError, validate_source_locator
43
+ from mostlyright.data_harness.readers.contracts import DECODE_FLAGS, ReaderPin
44
+ from mostlyright.data_harness.readers.registry import TOOLBOX
45
+ from mostlyright.data_harness.studio_boundary import (
46
+ StudioBoundaryError,
47
+ StudioClientIncompatibleError,
48
+ ensure_studio_client,
49
+ studio_wire_dict,
50
+ )
51
+ from mostlyright.data_harness.ux.credentials import ResolvedCloudCredentials, StoredCredentials
52
+ from mostlyright.data_harness.ux.login import DeviceFlowTransport, UrlLibDeviceFlowTransport
53
+
54
+ TOKEN_EXCHANGE_PATH = "/api/cli/public-acquisition-token"
55
+ TOKEN_EXCHANGE_MAX_BYTES = 16 * 1024
56
+ POLL_TIMEOUT_SECONDS = 1800.0
57
+ # A transferred result smaller than this never touches the disk; a larger one spills to a temporary
58
+ # file that is unlinked before a byte is written to it. Eight mebibytes is the threshold the
59
+ # viewer's spool already uses, and a hosted result is either a small refusal bundle or a very large
60
+ # one, so the value only decides which side of that gap the common cases land on.
61
+ DOWNLOAD_SPOOL_BYTES = 8 * 1024 * 1024
62
+ # One sentence for the three transfer bounds it covers, so the status, media-type and length
63
+ # refusals stay indistinguishable to a caller no matter which of them fired.
64
+ _DOWNLOAD_INVALID_DETAIL = (
65
+ "the hosted acquisition result violated its media type, status, or byte bound"
66
+ )
67
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
68
+ _FAILURE_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,127}$")
69
+ _BARE_FILENAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
70
+ _RECEIPT_FIELDS = {
71
+ "schema_version",
72
+ "source_id",
73
+ "adapter_id",
74
+ "adapter_version",
75
+ "request_digest",
76
+ "source_url",
77
+ "final_url",
78
+ "fetched_media_type",
79
+ "normalized_media_type",
80
+ "normalized_data_format",
81
+ "normalized_filename",
82
+ "acquired_at",
83
+ "raw_content_sha256",
84
+ "raw_size_bytes",
85
+ "normalized_content_sha256",
86
+ "normalized_size_bytes",
87
+ "row_count",
88
+ "column_names",
89
+ "parsed_schema_digest",
90
+ "family_id",
91
+ "family_version",
92
+ "decode_options_digest",
93
+ "decode_flags",
94
+ "resource_caps",
95
+ "reader_budgets",
96
+ "transport_evidence_digest",
97
+ "egress_policy_attestation",
98
+ "clean_room",
99
+ }
100
+
101
+
102
+ class HostedAcquisitionError(RuntimeError):
103
+ """One hosted acquisition boundary failed with a safe, stable code."""
104
+
105
+ def __init__(self, code: str, detail: str, *, retry_after_seconds: int | None = None) -> None:
106
+ self.code = code
107
+ self.detail = detail
108
+ self.retry_after_seconds = retry_after_seconds
109
+ super().__init__(f"{detail} [{code}]")
110
+
111
+
112
+ def _failure_code(value: Any, fallback: str) -> str:
113
+ """Admit Studio's bounded worker-result code, never an arbitrary failure body."""
114
+
115
+ return value if isinstance(value, str) and _FAILURE_CODE.fullmatch(value) else fallback
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class StudioToken:
120
+ base_url: str
121
+ workspace_id: UUID
122
+ token: str
123
+ expires_at: datetime
124
+ crawler_results_bucket: str | None = None
125
+ principal_id: UUID | None = None
126
+
127
+ def __repr__(self) -> str:
128
+ return (
129
+ f"StudioToken(base_url={self.base_url!r}, workspace_id={self.workspace_id!r}, "
130
+ f"expires_at={self.expires_at!r}, token='<redacted>')"
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class HostedAcquisition:
136
+ content: bytes
137
+ source_url: str
138
+ final_url: str
139
+ media_type: str
140
+ data_format: str
141
+ raw_content_sha256: str
142
+ transport_evidence_digest: str
143
+ parsed_schema_digest: str
144
+ family_id: str | None
145
+ family_version: str | None
146
+ decode_options_digest: str | None
147
+ decode_flags: tuple[str, ...]
148
+ egress_policy_attestation: str
149
+ request_digest: str
150
+
151
+
152
+ class StudioExchange(Protocol):
153
+ def acquire(
154
+ self,
155
+ *,
156
+ workspace_id: UUID,
157
+ source_id: str,
158
+ source_authority_digest: str,
159
+ acquisition_attempt_id: str,
160
+ query: Mapping[str, Any],
161
+ ) -> tuple[Mapping[str, Any], bytes]: ...
162
+
163
+
164
+ def _https_origin(value: Any, label: str) -> str:
165
+ if not isinstance(value, str) or not 1 <= len(value) <= 2_048 or value.endswith("/"):
166
+ raise HostedAcquisitionError(
167
+ "HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
168
+ )
169
+ try:
170
+ parsed = urlsplit(value)
171
+ port = parsed.port
172
+ except ValueError as error:
173
+ raise HostedAcquisitionError(
174
+ "HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
175
+ ) from error
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.path not in {"", "/"}
182
+ or parsed.query
183
+ or parsed.fragment
184
+ or port not in {None, 443}
185
+ ):
186
+ raise HostedAcquisitionError(
187
+ "HOSTED_CONFIG_INVALID", f"{label} is not a plain HTTPS origin"
188
+ )
189
+ return value
190
+
191
+
192
+ def exchange_studio_token(
193
+ credentials: StoredCredentials | ResolvedCloudCredentials,
194
+ *,
195
+ transport: DeviceFlowTransport | None = None,
196
+ ) -> StudioToken:
197
+ """Exchange the selected cloud key without ever sending it to Studio."""
198
+
199
+ cloud = _https_origin(credentials.cloud_url, "configured cloud URL")
200
+ try:
201
+ status, raw = (transport or UrlLibDeviceFlowTransport()).request(
202
+ "POST",
203
+ cloud + TOKEN_EXCHANGE_PATH,
204
+ {
205
+ "Authorization": f"Bearer {credentials.raw_key}",
206
+ "Accept": "application/json",
207
+ "Content-Type": "application/json",
208
+ },
209
+ b"{}",
210
+ TOKEN_EXCHANGE_MAX_BYTES,
211
+ )
212
+ except Exception as error:
213
+ raise HostedAcquisitionError(
214
+ "HOSTED_TOKEN_EXCHANGE_UNAVAILABLE",
215
+ "the Mostly Right cloud could not prepare a short-lived Studio credential",
216
+ ) from error
217
+ try:
218
+ value = parse_foreign_json(raw)
219
+ except CanonicalJSONError as error:
220
+ raise HostedAcquisitionError(
221
+ "HOSTED_TOKEN_EXCHANGE_INVALID",
222
+ "the Mostly Right cloud returned an invalid Studio credential response",
223
+ ) from error
224
+ if (
225
+ status != 200
226
+ or not isinstance(value, dict)
227
+ or set(value)
228
+ != {
229
+ "studio_base_url",
230
+ "workspace_id",
231
+ "token",
232
+ "expires_at",
233
+ "crawler_results_bucket",
234
+ "studio_principal_id",
235
+ }
236
+ ):
237
+ if status == 401 and value == {"error": "missing or invalid access key"}:
238
+ code = "HOSTED_AUTHENTICATION_REQUIRED"
239
+ elif status == 402 and value == {"error": "subscription required"}:
240
+ code = "HOSTED_ENTITLEMENT_REQUIRED"
241
+ else:
242
+ code = "HOSTED_TOKEN_EXCHANGE_UNAVAILABLE"
243
+ retry_after_seconds = None
244
+ if (
245
+ status == 503
246
+ and isinstance(value, dict)
247
+ and set(value) == {"error", "retry_after_seconds"}
248
+ and value.get("error") == "temporarily unavailable"
249
+ and isinstance(value.get("retry_after_seconds"), int)
250
+ and not isinstance(value.get("retry_after_seconds"), bool)
251
+ and 1 <= value["retry_after_seconds"] <= 3_600
252
+ ):
253
+ retry_after_seconds = value["retry_after_seconds"]
254
+ retry_detail = (
255
+ f"; retry after {retry_after_seconds} seconds"
256
+ if retry_after_seconds is not None
257
+ else ""
258
+ )
259
+ raise HostedAcquisitionError(
260
+ code,
261
+ "the Mostly Right cloud did not issue a usable Studio credential" + retry_detail,
262
+ retry_after_seconds=retry_after_seconds,
263
+ )
264
+ try:
265
+ workspace_id = UUID(value["workspace_id"])
266
+ principal_id = UUID(value["studio_principal_id"])
267
+ expires_at = datetime.fromisoformat(value["expires_at"].replace("Z", "+00:00"))
268
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
269
+ raise HostedAcquisitionError(
270
+ "HOSTED_TOKEN_EXCHANGE_INVALID",
271
+ "the Mostly Right cloud returned an invalid Studio credential response",
272
+ ) from error
273
+ token = value["token"]
274
+ results_bucket = value["crawler_results_bucket"]
275
+ if (
276
+ not isinstance(token, str)
277
+ or not 32 <= len(token) <= 16_384
278
+ or expires_at.tzinfo is None
279
+ or expires_at.astimezone(UTC) <= datetime.now(UTC)
280
+ or (
281
+ results_bucket is not None
282
+ and (
283
+ not isinstance(results_bucket, str)
284
+ or re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]", results_bucket) is None
285
+ )
286
+ )
287
+ ):
288
+ raise HostedAcquisitionError(
289
+ "HOSTED_TOKEN_EXCHANGE_INVALID",
290
+ "the Mostly Right cloud returned an invalid Studio credential response",
291
+ )
292
+ return StudioToken(
293
+ base_url=_https_origin(value["studio_base_url"], "Studio base URL"),
294
+ workspace_id=workspace_id,
295
+ token=token,
296
+ expires_at=expires_at.astimezone(UTC),
297
+ crawler_results_bucket=results_bucket,
298
+ principal_id=principal_id,
299
+ )
300
+
301
+
302
+ class _NoRedirect(HTTPRedirectHandler):
303
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
304
+ return None
305
+
306
+
307
+ def _download_bundle(download: ValidatedResultDownload) -> bytes:
308
+ """Transfer, bound, and verify one hosted result without holding a second copy of it.
309
+
310
+ The transfer used to be a single ``response.read(download.size + 1)``. That asks the runtime
311
+ for one contiguous allocation sized by a number Studio supplied -- up to
312
+ ``MAX_CRAWLER_RESULT_BYTES`` -- before any check has run, and on a chunked response
313
+ ``http.client`` satisfies it by accumulating every chunk in a list and joining it, so the
314
+ 2.6 GB result that opened issue #204 peaked at roughly twice itself in the interpreter heap
315
+ with nothing verified yet. Every check that ran on that buffer is accumulative, so the result
316
+ is now transferred in ``RESULT_CHUNK_BYTES`` units into a spool while
317
+ :class:`ResultBytesVerifier` folds each unit into the length and the pinned integrity value.
318
+
319
+ The verdict is unchanged in what it accepts. The same ``size + 1`` transfer bound still
320
+ detects a longer body, the status and media type still gate, the length is still decided
321
+ before the integrity value is, and acceptance still requires the exact generation-pinned
322
+ digest -- through the same code ``verify_result_bytes`` runs. What changed is when the
323
+ process has to be able to hold the result: a refused download now never materialises it at
324
+ all, and an accepted one materialises it once, at the end, from bytes already verified.
325
+
326
+ Status and media type are read from the head and refused before the body is transferred
327
+ rather than after it. The refusal is the same ``HOSTED_DOWNLOAD_INVALID`` it was; only the
328
+ quantity of untrusted bytes pulled across before it fires changes, from the whole body to
329
+ none.
330
+
331
+ The spool is a :class:`tempfile.SpooledTemporaryFile`, so a result under
332
+ ``DOWNLOAD_SPOOL_BYTES`` stays in memory -- the residency claims above are about results
333
+ larger than that, which is every result the claims matter for -- and a larger one rolls over
334
+ to a ``tempfile.TemporaryFile``, which is unlinked at creation. A partial or refused
335
+ transfer therefore leaves nothing behind on any exit path, including a crash: there is no
336
+ name for it to be left under.
337
+
338
+ What the spill buys is bounded by where it lands. The staging area is the temporary
339
+ directory, so that filesystem must have room for one result -- up to
340
+ ``MAX_CRAWLER_RESULT_BYTES`` -- and a ``TMPDIR`` on a memory-backed filesystem stages in RAM,
341
+ which keeps the transfer bounded and the refusal cheap but gives back most of the accepted
342
+ case's saving. A staging write that cannot complete is refused with its own detail rather
343
+ than the transport one, because a full disk and a dead connection are different things to be
344
+ told.
345
+ """
346
+
347
+ opener = build_opener(
348
+ ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
349
+ )
350
+ request = Request(download.url, method="GET", headers={"Accept": download.media_type})
351
+ try:
352
+ response = opener.open(request, timeout=30.0)
353
+ except HTTPError as error:
354
+ response = error
355
+ except Exception as error:
356
+ raise HostedAcquisitionError(
357
+ "HOSTED_DOWNLOAD_FAILED", "the hosted acquisition result could not be downloaded"
358
+ ) from error
359
+ # The response is the outermost context deliberately: nothing constructed after the open --
360
+ # not the verifier, not the spool -- can strand the socket by raising before a `with` that
361
+ # would have closed it.
362
+ with response:
363
+ media_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
364
+ status = int(response.status)
365
+ if status != 200 or media_type != download.media_type:
366
+ raise HostedAcquisitionError("HOSTED_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
367
+ verifier = ResultBytesVerifier(download)
368
+ with tempfile.SpooledTemporaryFile(max_size=DOWNLOAD_SPOOL_BYTES, mode="w+b") as spool:
369
+ remaining = download.size + 1
370
+ while remaining > 0:
371
+ # Each guard covers one call and no more. A wider one would rewrite the code of
372
+ # any refusal that later moves inside this loop into the transport code below.
373
+ try:
374
+ chunk = response.read(min(RESULT_CHUNK_BYTES, remaining))
375
+ except Exception as error:
376
+ raise HostedAcquisitionError(
377
+ "HOSTED_DOWNLOAD_FAILED",
378
+ "the hosted acquisition result could not be downloaded",
379
+ ) from error
380
+ if not chunk:
381
+ break
382
+ try:
383
+ spool.write(chunk)
384
+ except OSError as error:
385
+ raise HostedAcquisitionError(
386
+ "HOSTED_DOWNLOAD_FAILED",
387
+ "the hosted acquisition result could not be staged for verification",
388
+ ) from error
389
+ remaining -= len(chunk)
390
+ verifier.update(chunk)
391
+ if verifier.length != download.size:
392
+ raise HostedAcquisitionError("HOSTED_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
393
+ try:
394
+ verifier.verify()
395
+ except ResultDownloadError as error:
396
+ raise HostedAcquisitionError(
397
+ "HOSTED_DOWNLOAD_INTEGRITY",
398
+ "the hosted acquisition result violated its generation-pinned integrity",
399
+ ) from error
400
+ spool.seek(0)
401
+ return spool.read()
402
+
403
+
404
+ class GeneratedStudioExchange:
405
+ """The generated-client-only Studio HTTP boundary plus signed-result transfer."""
406
+
407
+ def __init__(
408
+ self,
409
+ token: StudioToken,
410
+ *,
411
+ downloader: Callable[[ValidatedResultDownload], bytes] = _download_bundle,
412
+ clock: Callable[[], float] = time.monotonic,
413
+ utc_now: Callable[[], datetime] = lambda: datetime.now(UTC),
414
+ sleep: Callable[[float], None] = time.sleep,
415
+ ) -> None:
416
+ self.token = token
417
+ self.downloader = downloader
418
+ self.clock = clock
419
+ self.utc_now = utc_now
420
+ self.sleep = sleep
421
+ try:
422
+ ensure_studio_client()
423
+ client_module = importlib.import_module("mostlyright_studio.client")
424
+ self.create_endpoint = importlib.import_module(
425
+ "mostlyright_studio.api.sources.create_public_acquisition"
426
+ )
427
+ self.get_endpoint = importlib.import_module(
428
+ "mostlyright_studio.api.sources.get_public_acquisition"
429
+ )
430
+ self.models = importlib.import_module("mostlyright_studio.models")
431
+ self.client = client_module.AuthenticatedClient(
432
+ base_url=token.base_url,
433
+ token=token.token,
434
+ timeout=30.0,
435
+ follow_redirects=False,
436
+ )
437
+ except StudioBoundaryError as error:
438
+ raise HostedAcquisitionError(
439
+ "HOSTED_CLIENT_UNAVAILABLE",
440
+ "the installed Studio client cannot perform hosted public acquisition",
441
+ ) from error
442
+ except (ModuleNotFoundError, AttributeError, TypeError):
443
+ incompatible = StudioClientIncompatibleError(
444
+ "generated Studio client lacks hosted public acquisition support"
445
+ )
446
+ raise HostedAcquisitionError(
447
+ "HOSTED_CLIENT_UNAVAILABLE",
448
+ "the installed Studio client cannot perform hosted public acquisition",
449
+ ) from incompatible
450
+
451
+ def acquire(
452
+ self,
453
+ *,
454
+ workspace_id: UUID,
455
+ source_id: str,
456
+ source_authority_digest: str,
457
+ acquisition_attempt_id: str,
458
+ query: Mapping[str, Any],
459
+ ) -> tuple[Mapping[str, Any], bytes]:
460
+ command_wire = {
461
+ "schema_version": "3.0.0",
462
+ "workspace_id": str(workspace_id),
463
+ "source_id": source_id,
464
+ "source_authority_digest": source_authority_digest,
465
+ "query": dict(query),
466
+ }
467
+ try:
468
+ command = self.models.CreateHostedPublicHTTPSAcquisitionCommand.from_dict(command_wire)
469
+ response = self.create_endpoint.sync_detailed(
470
+ workspace_id,
471
+ client=self.client,
472
+ body=command,
473
+ idempotency_key=_acquisition_idempotency_key(
474
+ workspace_id=workspace_id,
475
+ source_authority_digest=source_authority_digest,
476
+ acquisition_attempt_id=acquisition_attempt_id,
477
+ ),
478
+ )
479
+ except Exception as error:
480
+ raise HostedAcquisitionError(
481
+ "HOSTED_REQUEST_INVALID", "the hosted acquisition request was not admitted"
482
+ ) from error
483
+ if int(response.status_code) != 202 or type(response.parsed).__name__ != (
484
+ "HostedPublicHTTPSAcquisitionSession"
485
+ ):
486
+ if type(response.parsed).__name__ in {"StableAPIError", "StudioV3Error"}:
487
+ error_wire = studio_wire_dict(response.parsed)
488
+ error_code = error_wire.get("code")
489
+ retry_after = error_wire.get("retry_after_seconds")
490
+ if retry_after is None:
491
+ header = response.headers.get("Retry-After")
492
+ if header is not None and header.isascii() and header.isdecimal():
493
+ retry_after = int(header)
494
+ if (
495
+ isinstance(error_code, str)
496
+ and error_code
497
+ and (
498
+ retry_after is None
499
+ or (
500
+ isinstance(retry_after, int)
501
+ and not isinstance(retry_after, bool)
502
+ and 1 <= retry_after <= 3_600
503
+ )
504
+ )
505
+ ):
506
+ retry_detail = (
507
+ f"; retry after {retry_after} seconds" if retry_after is not None else ""
508
+ )
509
+ raise HostedAcquisitionError(
510
+ error_code,
511
+ "Studio refused the hosted public acquisition request" + retry_detail,
512
+ retry_after_seconds=retry_after,
513
+ )
514
+ raise HostedAcquisitionError(
515
+ "HOSTED_REQUEST_REFUSED", "Studio refused the hosted public acquisition request"
516
+ )
517
+ session = studio_wire_dict(response.parsed)
518
+ _validate_initial_session(
519
+ session,
520
+ workspace_id=workspace_id,
521
+ source_id=source_id,
522
+ source_authority_digest=source_authority_digest,
523
+ principal_id=self.token.principal_id,
524
+ query=query,
525
+ now=self.utc_now(),
526
+ )
527
+ deadline = self.clock() + POLL_TIMEOUT_SECONDS
528
+ delay = 0.5
529
+ while session["status"] not in {"result_ready", "failed"}:
530
+ if self.clock() >= deadline:
531
+ raise HostedAcquisitionError(
532
+ "HOSTED_ACQUISITION_TIMEOUT", "the hosted public acquisition did not finish"
533
+ )
534
+ self.sleep(delay)
535
+ delay = min(delay * 2, 5.0)
536
+ try:
537
+ observed = self.get_endpoint.sync_detailed(
538
+ workspace_id,
539
+ UUID(session["crawler_session_id"]),
540
+ client=self.client,
541
+ )
542
+ except Exception as error:
543
+ raise HostedAcquisitionError(
544
+ "HOSTED_POLL_UNAVAILABLE",
545
+ "the hosted acquisition status could not be checked",
546
+ ) from error
547
+ if int(observed.status_code) != 200 or type(observed.parsed).__name__ != (
548
+ "HostedPublicHTTPSAcquisitionSession"
549
+ ):
550
+ raise HostedAcquisitionError(
551
+ "HOSTED_POLL_REFUSED", "Studio refused the hosted acquisition status check"
552
+ )
553
+ next_session = studio_wire_dict(observed.parsed)
554
+ for field in (
555
+ "schema_version",
556
+ "crawler_session_id",
557
+ "workspace_id",
558
+ "requester_principal_id",
559
+ "source_id",
560
+ "source_authority_digest",
561
+ "adapter_id",
562
+ "adapter_version",
563
+ "request_id",
564
+ "request_digest",
565
+ "egress_policy_attestation",
566
+ "created_at",
567
+ "expires_at",
568
+ ):
569
+ if next_session[field] != session[field]:
570
+ raise HostedAcquisitionError(
571
+ "HOSTED_SESSION_BINDING",
572
+ "Studio changed an immutable hosted acquisition coordinate",
573
+ )
574
+ session = next_session
575
+ if session["status"] == "failed":
576
+ raise HostedAcquisitionError(
577
+ _failure_code(session.get("failure_code"), "HOSTED_ACQUISITION_FAILED"),
578
+ "the hosted public acquisition failed inside the isolated Courier",
579
+ )
580
+ try:
581
+ validated_download = validate_result_download(
582
+ session.get("result_download"),
583
+ base_url=self.token.base_url,
584
+ crawler_session_id=session["crawler_session_id"],
585
+ request_digest=session["request_digest"],
586
+ source_id=source_id,
587
+ source_authority_digest=source_authority_digest,
588
+ now=self.utc_now(),
589
+ expected_gcs_bucket=self.token.crawler_results_bucket,
590
+ )
591
+ except (KeyError, ResultDownloadError) as error:
592
+ raise HostedAcquisitionError(
593
+ "HOSTED_DOWNLOAD_BINDING", "Studio returned an unbound result download"
594
+ ) from error
595
+ return session, self.downloader(validated_download)
596
+
597
+
598
+ def _validate_initial_session(
599
+ session: Mapping[str, Any],
600
+ *,
601
+ workspace_id: UUID,
602
+ source_id: str,
603
+ source_authority_digest: str,
604
+ principal_id: UUID | None,
605
+ query: Mapping[str, Any],
606
+ now: datetime,
607
+ ) -> None:
608
+ """Refuse an unbound Studio session before polling or touching its download URL."""
609
+
610
+ try:
611
+ crawler_session_id = UUID(session["crawler_session_id"])
612
+ requester_principal_id = UUID(session["requester_principal_id"])
613
+ request_id = UUID(session["request_id"])
614
+ created = datetime.fromisoformat(session["created_at"].replace("Z", "+00:00"))
615
+ expires = datetime.fromisoformat(session["expires_at"].replace("Z", "+00:00"))
616
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
617
+ raise HostedAcquisitionError(
618
+ "HOSTED_SESSION_BINDING", "Studio returned an invalid hosted acquisition session"
619
+ ) from error
620
+ if (
621
+ session.get("schema_version") != "mostlyright-public-acquisition-session.v1"
622
+ or session.get("workspace_id") != str(workspace_id)
623
+ or session.get("source_id") != source_id
624
+ or session.get("source_authority_digest") != source_authority_digest
625
+ or session.get("adapter_id") != "public.https"
626
+ or session.get("adapter_version") != "1.0.0"
627
+ or session.get("egress_policy_attestation") != PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION
628
+ or str(crawler_session_id) != session.get("crawler_session_id")
629
+ or str(requester_principal_id) != session.get("requester_principal_id")
630
+ or principal_id is None
631
+ or requester_principal_id != principal_id
632
+ or str(request_id) != session.get("request_id")
633
+ or not isinstance(session.get("request_digest"), str)
634
+ or _DIGEST.fullmatch(session["request_digest"]) is None
635
+ or session.get("status") not in {"enqueue_pending", "enqueued", "result_ready", "failed"}
636
+ or created.tzinfo is None
637
+ or expires.tzinfo is None
638
+ or created.astimezone(UTC) >= expires.astimezone(UTC)
639
+ or now.astimezone(UTC) >= expires.astimezone(UTC)
640
+ ):
641
+ raise HostedAcquisitionError(
642
+ "HOSTED_SESSION_BINDING", "Studio returned an unbound hosted acquisition session"
643
+ )
644
+ try:
645
+ expected_request = HostedCrawlerRequest(
646
+ request_id=str(request_id),
647
+ source_id=source_id,
648
+ adapter_id="public.https",
649
+ adapter_version="1.0.0",
650
+ requested_at=session["created_at"],
651
+ query=dict(query),
652
+ egress_policy_attestation=PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
653
+ )
654
+ except (HostedCrawlerProtocolError, TypeError, ValueError) as error:
655
+ raise HostedAcquisitionError(
656
+ "HOSTED_SESSION_BINDING", "the exact hosted Courier request is invalid"
657
+ ) from error
658
+ if expected_request.digest != session["request_digest"]:
659
+ raise HostedAcquisitionError(
660
+ "HOSTED_SESSION_BINDING", "Studio changed the exact hosted Courier request"
661
+ )
662
+
663
+
664
+ def acquire_public_https(
665
+ *,
666
+ exchange: StudioExchange,
667
+ workspace_id: UUID,
668
+ source_id: str,
669
+ source_authority_digest: str,
670
+ acquisition_attempt_id: str,
671
+ query: Mapping[str, Any],
672
+ ) -> HostedAcquisition:
673
+ """Acquire and independently bind the exact Studio session, result, and Reader evidence."""
674
+
675
+ try:
676
+ expected_url = validate_source_locator(
677
+ query.get("url"), "https_url", "hosted_acquisition.query.url"
678
+ )
679
+ except ContractError as error:
680
+ raise HostedAcquisitionError(
681
+ "HOSTED_RESULT_BINDING",
682
+ "the hosted public acquisition locator is not credential-free and canonical",
683
+ ) from error
684
+ if urlsplit(expected_url).port is not None:
685
+ raise HostedAcquisitionError(
686
+ "HOSTED_RESULT_BINDING",
687
+ "the hosted public acquisition locator must use implicit HTTPS port 443",
688
+ )
689
+
690
+ session, bundle = exchange.acquire(
691
+ workspace_id=workspace_id,
692
+ source_id=source_id,
693
+ source_authority_digest=source_authority_digest,
694
+ acquisition_attempt_id=acquisition_attempt_id,
695
+ query=query,
696
+ )
697
+ if (
698
+ session.get("workspace_id") != str(workspace_id)
699
+ or session.get("source_id") != source_id
700
+ or session.get("source_authority_digest") != source_authority_digest
701
+ or session.get("adapter_id") != "public.https"
702
+ or session.get("adapter_version") != "1.0.0"
703
+ or session.get("egress_policy_attestation") != PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION
704
+ ):
705
+ raise HostedAcquisitionError(
706
+ "HOSTED_SESSION_BINDING", "hosted acquisition session differs from the request"
707
+ )
708
+ try:
709
+ document = parse_foreign_json(bundle)
710
+ if isinstance(document, dict) and document.get("schema_version") == CRAWLER_FAILURE_SCHEMA:
711
+ failure = parse_crawler_failure(document)
712
+ if (
713
+ failure.request_digest != session.get("request_digest")
714
+ or failure.source_id != source_id
715
+ or failure.adapter_id != "public.https"
716
+ or failure.adapter_version != "1.0.0"
717
+ ):
718
+ raise HostedAcquisitionError(
719
+ "HOSTED_RESULT_BINDING", "hosted Courier failure differs from its session"
720
+ )
721
+ raise HostedAcquisitionError(
722
+ failure.failure_code,
723
+ "the hosted public acquisition was safely refused inside the isolated Courier",
724
+ )
725
+ result = parse_crawler_result(document)
726
+ except HostedAcquisitionError:
727
+ raise
728
+ except (CanonicalJSONError, HostedCrawlerProtocolError) as error:
729
+ raise HostedAcquisitionError(
730
+ "HOSTED_RESULT_INVALID", "hosted Courier result is not a valid bound bundle"
731
+ ) from error
732
+ if (
733
+ result.request_digest != session.get("request_digest")
734
+ or result.source_id != source_id
735
+ or result.adapter_id != "public.https"
736
+ or result.adapter_version != "1.0.0"
737
+ ):
738
+ raise HostedAcquisitionError(
739
+ "HOSTED_RESULT_BINDING", "hosted Courier result differs from its Studio session"
740
+ )
741
+ receipt = result.acquisition_receipt
742
+ reader_value = query.get("reader_pin")
743
+ pin = None
744
+ family = None
745
+ if reader_value is not None and not isinstance(reader_value, Mapping):
746
+ raise HostedAcquisitionError(
747
+ "HOSTED_READER_BINDING", "hosted acquisition Reader pin is invalid"
748
+ )
749
+ if isinstance(reader_value, Mapping):
750
+ try:
751
+ pin = ReaderPin(
752
+ reader_value["family_id"],
753
+ reader_value["family_version"],
754
+ reader_value["decode_options"],
755
+ )
756
+ except (KeyError, TypeError, ValueError) as error:
757
+ raise HostedAcquisitionError(
758
+ "HOSTED_READER_BINDING", "hosted acquisition Reader pin is invalid"
759
+ ) from error
760
+ expected_decode = (
761
+ (None, None, None)
762
+ if pin is None
763
+ else (pin.family_id, pin.family_version, pin.options_digest)
764
+ )
765
+ returned_decode = (
766
+ receipt.get("family_id"),
767
+ receipt.get("family_version"),
768
+ receipt.get("decode_options_digest"),
769
+ )
770
+ resource_caps = query.get("resource_caps")
771
+ if pin is None:
772
+ if resource_caps is not None:
773
+ raise HostedAcquisitionError(
774
+ "HOSTED_READER_BINDING", "resource caps require a hosted Reader pin"
775
+ )
776
+ expected_resource_caps = None
777
+ expected_reader_budgets = None
778
+ else:
779
+ if resource_caps is not None and not isinstance(resource_caps, Mapping):
780
+ raise HostedAcquisitionError(
781
+ "HOSTED_READER_BINDING", "hosted Reader resource caps are invalid"
782
+ )
783
+ try:
784
+ family = TOOLBOX.resolve(pin.family_id, pin.family_version)
785
+ expected_reader_budgets = asdict(
786
+ family.default_budgets.narrowed_by(
787
+ {
788
+ "max_input_bytes": query["limits"]["max_source_bytes"],
789
+ "max_output_bytes": query["limits"]["max_normalized_bytes"],
790
+ "max_rows": query["limits"]["max_rows"],
791
+ "max_columns": query["limits"]["max_columns"],
792
+ **({} if resource_caps is None else dict(resource_caps)),
793
+ }
794
+ )
795
+ )
796
+ except (KeyError, TypeError, ValueError) as error:
797
+ raise HostedAcquisitionError(
798
+ "HOSTED_READER_BINDING", "hosted Reader budgets are invalid"
799
+ ) from error
800
+ expected_resource_caps = None if resource_caps is None else dict(resource_caps)
801
+ try:
802
+ final_url = validate_source_locator(
803
+ receipt.get("final_url"), "https_url", "hosted_acquisition.receipt.final_url"
804
+ )
805
+ original_parsed = urlsplit(expected_url)
806
+ final_parsed = urlsplit(final_url)
807
+ original_host = original_parsed.hostname
808
+ final_host = final_parsed.hostname
809
+ except (ContractError, ValueError) as error:
810
+ raise HostedAcquisitionError(
811
+ "HOSTED_RESULT_BINDING", "hosted Courier returned an invalid final locator"
812
+ ) from error
813
+ limits = query.get("limits")
814
+ if not isinstance(limits, Mapping):
815
+ raise HostedAcquisitionError("HOSTED_RESULT_BINDING", "hosted limits are unavailable")
816
+ data_format = query.get("data_format")
817
+ filename = query.get("filename")
818
+ if data_format not in FORMAT_MEDIA_TYPES or not isinstance(filename, str):
819
+ raise HostedAcquisitionError(
820
+ "HOSTED_RESULT_BINDING", "hosted output format coordinates are unavailable"
821
+ )
822
+ expected_fetched_media_types = (
823
+ FORMAT_MEDIA_TYPES[data_format]
824
+ if family is None
825
+ else frozenset(family.accepted_media_types)
826
+ )
827
+ expected_output_format = data_format if family is None else family.output_format
828
+ normalized_filename = receipt.get("normalized_filename")
829
+ decode_flags = receipt.get("decode_flags")
830
+ try:
831
+ acquired_at = datetime.fromisoformat(receipt.get("acquired_at", "").replace("Z", "+00:00"))
832
+ session_created_at = datetime.fromisoformat(session["created_at"].replace("Z", "+00:00"))
833
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
834
+ raise HostedAcquisitionError(
835
+ "HOSTED_RESULT_BINDING", "hosted Courier returned an invalid acquisition time"
836
+ ) from error
837
+ required_receipt = {
838
+ "schema_version": "hosted-public-https-acquisition-receipt.v1",
839
+ "source_id": source_id,
840
+ "adapter_id": "public.https",
841
+ "adapter_version": "1.0.0",
842
+ "request_digest": result.request_digest,
843
+ "source_url": expected_url,
844
+ "normalized_content_sha256": sha256_bytes(result.normalized_content),
845
+ "normalized_size_bytes": len(result.normalized_content),
846
+ "normalized_data_format": expected_output_format,
847
+ "egress_policy_attestation": PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION,
848
+ }
849
+ if (
850
+ set(receipt) != _RECEIPT_FIELDS
851
+ or any(receipt.get(key) != value for key, value in required_receipt.items())
852
+ or original_host is None
853
+ or final_host != original_host
854
+ or original_parsed.port is not None
855
+ or final_parsed.port is not None
856
+ or acquired_at.tzinfo is None
857
+ or session_created_at.tzinfo is None
858
+ or acquired_at.astimezone(UTC) != session_created_at.astimezone(UTC)
859
+ or receipt.get("fetched_media_type") not in expected_fetched_media_types
860
+ or receipt.get("normalized_media_type") not in FORMAT_MEDIA_TYPES[expected_output_format]
861
+ or not isinstance(normalized_filename, str)
862
+ or _BARE_FILENAME.fullmatch(normalized_filename) is None
863
+ or not any(
864
+ normalized_filename.endswith(suffix)
865
+ for suffix in FORMAT_SUFFIXES[expected_output_format]
866
+ )
867
+ or (family is None and normalized_filename != filename)
868
+ or returned_decode != expected_decode
869
+ or receipt.get("resource_caps") != expected_resource_caps
870
+ or receipt.get("reader_budgets") != expected_reader_budgets
871
+ or type(receipt.get("normalized_size_bytes")) is not int
872
+ or type(receipt.get("raw_size_bytes")) is not int
873
+ or not 1 <= receipt["raw_size_bytes"] <= limits.get("max_source_bytes", 0)
874
+ # The normalized decode output is bounded by its own budget, not the fetched-byte
875
+ # budget, with the same fallback the query composition uses.
876
+ or not 1
877
+ <= len(result.normalized_content)
878
+ <= limits.get("max_normalized_bytes", limits.get("max_source_bytes", 0))
879
+ or not isinstance(receipt.get("raw_content_sha256"), str)
880
+ or _DIGEST.fullmatch(receipt["raw_content_sha256"]) is None
881
+ or not isinstance(receipt.get("transport_evidence_digest"), str)
882
+ or _DIGEST.fullmatch(receipt["transport_evidence_digest"]) is None
883
+ or not isinstance(receipt.get("parsed_schema_digest"), str)
884
+ or _DIGEST.fullmatch(receipt["parsed_schema_digest"]) is None
885
+ or type(receipt.get("row_count")) is not int
886
+ or not 1 <= receipt["row_count"] <= limits.get("max_rows", 0)
887
+ or not isinstance(receipt.get("column_names"), list)
888
+ or not 1 <= len(receipt["column_names"]) <= limits.get("max_columns", 0)
889
+ or not all(
890
+ isinstance(column, str) and 1 <= len(column) <= 255
891
+ for column in receipt["column_names"]
892
+ )
893
+ or receipt.get("clean_room")
894
+ != {
895
+ "host_platform": "linux-amd64",
896
+ "parser_network_syscalls": "denied",
897
+ "parser_process_creation": "denied",
898
+ "no_new_privs": True,
899
+ }
900
+ or not isinstance(decode_flags, list)
901
+ or not all(isinstance(flag, str) and flag in DECODE_FLAGS for flag in decode_flags)
902
+ or decode_flags != sorted(set(decode_flags))
903
+ or (family is None and decode_flags != [])
904
+ or (
905
+ pin is None
906
+ and (
907
+ receipt["raw_content_sha256"] != receipt["normalized_content_sha256"]
908
+ or receipt["raw_size_bytes"] != receipt["normalized_size_bytes"]
909
+ )
910
+ )
911
+ ):
912
+ raise HostedAcquisitionError(
913
+ "HOSTED_RESULT_BINDING", "hosted Courier evidence differs from the exact request"
914
+ )
915
+ return HostedAcquisition(
916
+ content=result.normalized_content,
917
+ source_url=receipt["source_url"],
918
+ final_url=receipt["final_url"],
919
+ media_type=receipt["normalized_media_type"],
920
+ data_format=receipt["normalized_data_format"],
921
+ raw_content_sha256=receipt["raw_content_sha256"],
922
+ transport_evidence_digest=receipt["transport_evidence_digest"],
923
+ parsed_schema_digest=receipt["parsed_schema_digest"],
924
+ family_id=receipt["family_id"],
925
+ family_version=receipt["family_version"],
926
+ decode_options_digest=receipt["decode_options_digest"],
927
+ decode_flags=tuple(receipt["decode_flags"]),
928
+ egress_policy_attestation=receipt["egress_policy_attestation"],
929
+ request_digest=result.request_digest,
930
+ )
931
+
932
+
933
+ def source_authority_digest(source_id: str, query: Mapping[str, Any]) -> str:
934
+ return sha256_bytes(
935
+ canonical_json_bytes(
936
+ {
937
+ "schema_version": "mr-data-hosted-public-source-authority.v1",
938
+ "source_id": source_id,
939
+ "adapter_id": "public.https",
940
+ "adapter_version": "1.0.0",
941
+ "query": dict(query),
942
+ }
943
+ )
944
+ )
945
+
946
+
947
+ def _acquisition_idempotency_key(
948
+ *,
949
+ workspace_id: UUID,
950
+ source_authority_digest: str,
951
+ acquisition_attempt_id: str,
952
+ ) -> str:
953
+ """Retry one attempt consistently without replaying an expired prior acquisition."""
954
+
955
+ return "mr-data-acquire-" + canonical_sha256(
956
+ {
957
+ "schema_version": "hosted-public-acquisition-idempotency.v1",
958
+ "workspace_id": str(workspace_id),
959
+ "source_authority_digest": source_authority_digest,
960
+ "acquisition_attempt_id": acquisition_attempt_id,
961
+ }
962
+ )
963
+
964
+
965
+ __all__ = [
966
+ "GeneratedStudioExchange",
967
+ "HostedAcquisition",
968
+ "HostedAcquisitionError",
969
+ "StudioExchange",
970
+ "StudioToken",
971
+ "acquire_public_https",
972
+ "exchange_studio_token",
973
+ "source_authority_digest",
974
+ ]