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,676 @@
1
+ """Exact public-crawler request/result protocol shared with hosted workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import Any, Protocol
10
+
11
+ from mostlyright.data_harness.canonical import (
12
+ canonical_json_bytes,
13
+ canonical_sha256,
14
+ sha256_bytes,
15
+ )
16
+
17
+ CRAWLER_REQUEST_SCHEMA_V1 = "mostlyright-hosted-crawler-request.v1"
18
+ CRAWLER_REQUEST_SCHEMA_V3 = "mostlyright-hosted-crawler-request.v3"
19
+ CRAWLER_REQUEST_SCHEMA = CRAWLER_REQUEST_SCHEMA_V1
20
+ CRAWLER_RESULT_SCHEMA_V1 = "mostlyright-hosted-crawler-result.v1"
21
+ CRAWLER_RESULT_SCHEMA_V3 = "mostlyright-hosted-crawler-result.v3"
22
+ # Compatibility export for consumers that only understand the original OpenLigaDB/V1 result.
23
+ CRAWLER_RESULT_SCHEMA = CRAWLER_RESULT_SCHEMA_V1
24
+ CRAWLER_FAILURE_SCHEMA = "mostlyright-hosted-crawler-failure.v1"
25
+ CRAWLER_RESULT_MEDIA_TYPE = "application/vnd.mostlyright.hosted-crawler-result+json"
26
+ MAX_CRAWLER_REQUEST_BYTES = 64 * 1024
27
+ MAX_CRAWLER_RESULT_BYTES = 6_442_450_944
28
+ MAX_NORMALIZED_SOURCE_BYTES = 4_294_967_296
29
+ MAX_CRAWLER_BASE64URL_CHUNK_CHARS = 768 * 1024
30
+ OPENLIGADB_EGRESS_POLICY_ATTESTATION = canonical_sha256(
31
+ {
32
+ "schema_version": "mostlyright-hosted-crawler-egress-policy.v1",
33
+ "adapter_id": "external.openligadb",
34
+ "adapter_version": "1.0.0",
35
+ "credential_free": True,
36
+ "allowed_hostnames": ["api.openligadb.de"],
37
+ "allowed_ports": [443],
38
+ "allowed_path_template": "/getmatchdata/{league_shortcut}/{league_season}",
39
+ "redirects": 0,
40
+ "ambient_proxy": False,
41
+ "source_retriever_private_metadata_internal_networks": "denied",
42
+ "wrapper_metadata_identity": "fixed-link-local-google-oidc-exact-session-bootstrap",
43
+ "wrapper_studio_authority": "bootstrap-only-no-attempt-token",
44
+ "wrapper_dumpability": "disabled-before-workload-identity",
45
+ "parser_network_syscalls": "linux-amd64-no-new-privs-seccomp-deny",
46
+ "parser_x32_syscalls": "denied-before-native-allowlist",
47
+ "parser_process_creation_and_parent_inspection": "denied",
48
+ "parser_network_fds": "closed-before-exec",
49
+ "isolation": "separate-crawler-job-and-networkless-parser-child",
50
+ "max_response_bytes": 8 * 1024 * 1024,
51
+ "max_requests": 1,
52
+ "connect_timeout_milliseconds": 5_000,
53
+ "read_timeout_milliseconds": 15_000,
54
+ "total_timeout_milliseconds": 20_000,
55
+ "max_concurrency": 2,
56
+ "min_interval_milliseconds": 1_000,
57
+ }
58
+ )
59
+ # The exact attested public-HTTPS egress policy. Named, rather than inlined into the
60
+ # digest call below, so a test can hold each attested figure against the constant that
61
+ # enforces it in hosted_crawler_fetch without restating the whole policy: the attestation
62
+ # must never claim an envelope the fetch path does not run under.
63
+ PUBLIC_HTTPS_EGRESS_POLICY: dict[str, Any] = {
64
+ "schema_version": "mostlyright-hosted-crawler-egress-policy.v1",
65
+ "adapter_id": "public.https",
66
+ "adapter_version": "1.0.0",
67
+ "credential_free": True,
68
+ "allowed_hostnames": "exact canonical hostname from the Studio-owned request",
69
+ "allowed_ports": [443],
70
+ "redirects": "at most 3; every hop stays on the exact hostname",
71
+ "ambient_proxy": False,
72
+ "dns_policy": "public addresses only; rebinding refused on every connection",
73
+ "source_retriever_private_metadata_internal_networks": "denied",
74
+ "wrapper_metadata_identity": "fixed-link-local-google-oidc-exact-session-bootstrap",
75
+ "wrapper_studio_authority": "bootstrap-only-no-user-or-attempt-token",
76
+ "wrapper_dumpability": "disabled-before-workload-identity",
77
+ "parser_network_syscalls": "linux-amd64-no-new-privs-seccomp-deny",
78
+ "parser_x32_syscalls": "denied-before-native-allowlist",
79
+ "parser_process_creation_and_parent_inspection": "denied",
80
+ "parser_network_fds": "closed-before-exec",
81
+ "isolation": "separate-crawler-job-and-networkless-parser-child",
82
+ # The enforced public-HTTPS fetch ceiling (hosted_crawler.MAX_RAW_RESPONSE_BYTES),
83
+ # equal to the hosted contract's max_source_bytes maximum. Decode output is bounded
84
+ # separately by MAX_NORMALIZED_SOURCE_BYTES.
85
+ "max_response_bytes": 268_435_456,
86
+ "max_requests": 4,
87
+ "connect_timeout_milliseconds": 5_000,
88
+ # Large public archives (EPA AirData) stall tens of seconds before the first
89
+ # byte for cloud egress addresses; the total budget, not the per-read stall,
90
+ # is the meaningful ceiling for a bulk fetch.
91
+ "read_timeout_milliseconds": 120_000,
92
+ "total_timeout_milliseconds": 600_000,
93
+ "max_concurrency": 2,
94
+ "min_interval_milliseconds": 1_000,
95
+ # A bulk object whose 0-0 probe names a total above threshold_bytes is fetched as
96
+ # consecutive range_bytes slices, at most `parallelism` in flight, each slice under
97
+ # its own tight stall timeouts with up to range_attempts fresh attempts. The
98
+ # total_timeout_milliseconds budget above bounds the ranged fetch at attempt
99
+ # admission: no new range attempt starts after it, and one already-admitted attempt
100
+ # may still run to range_total_timeout_milliseconds, so the wall-clock worst case is
101
+ # the sum of the two. range_validator_pinning means the ranged path runs only when
102
+ # the probe names an ETag or Last-Modified validator, and every range must answer
103
+ # with exactly the probed validators -- an origin that updates the object mid-fetch
104
+ # is refused rather than assembled across editions, and a validator-less origin keeps
105
+ # the single-shot path. Origins that ignore or refuse the range probe keep the
106
+ # single-shot behavior the rest of this policy describes, with the probe's own
107
+ # transfer sealed into the result's transport evidence.
108
+ "ranged_fetch": {
109
+ "threshold_bytes": 33_554_432,
110
+ "range_bytes": 16_777_216,
111
+ "parallelism": 2,
112
+ "range_read_timeout_milliseconds": 30_000,
113
+ "range_total_timeout_milliseconds": 120_000,
114
+ "range_attempts": 4,
115
+ "range_validator_pinning": True,
116
+ },
117
+ }
118
+ PUBLIC_HTTPS_EGRESS_POLICY_ATTESTATION = canonical_sha256(PUBLIC_HTTPS_EGRESS_POLICY)
119
+
120
+ # The OpenLigaDB query is validated twice against the same reviewed shape: once producer-side
121
+ # in ``hosted_worker`` before the intent leaves, and once crawler-side in ``hosted_crawler``
122
+ # before any egress. The two refusals keep their own codes (SOURCE_ADAPTER_QUERY_INVALID and
123
+ # CRAWLER_QUERY_INVALID) because they name different boundaries, but the detail text is shared
124
+ # so the two sides cannot drift into describing the same rejected query differently.
125
+ OPENLIGADB_QUERY_FIELDS_DETAIL = (
126
+ "OpenLigaDB query must contain only league_shortcut and league_season"
127
+ )
128
+ OPENLIGADB_QUERY_ALLOWLIST_DETAIL = "OpenLigaDB league or season is outside the reviewed allowlist"
129
+
130
+ _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
131
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
132
+ _PREFIXED_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
133
+ _BASE64URL_CHUNK = re.compile(r"^[A-Za-z0-9_-]+$")
134
+ _TIMESTAMP = re.compile(
135
+ r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])"
136
+ r"T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,6})?Z$"
137
+ )
138
+ HOSTED_CRAWLER_FAILURE_CODES = frozenset(
139
+ {
140
+ "HTTP_NOT_FOUND",
141
+ "HTTP_STATUS",
142
+ "MEDIA_TYPE",
143
+ "BYTE_LIMIT",
144
+ "TOTAL_TIMEOUT",
145
+ "CONNECT_TIMEOUT",
146
+ "READ_TIMEOUT",
147
+ "CONNECT_FAILED",
148
+ "TLS_FAILED",
149
+ "DNS_FAILED",
150
+ "REDIRECT_LIMIT",
151
+ "REDIRECT_POLICY",
152
+ "CRAWLER_ENDPOINT_MISMATCH",
153
+ "CRAWLER_MEDIA_TYPE_MISMATCH",
154
+ "CRAWLER_RESPONSE_INVALID",
155
+ "CRAWLER_NORMALIZATION_INPUT_INVALID",
156
+ "CRAWLER_MEMORY_BOUNDARY",
157
+ "READER_FAMILY_UNKNOWN",
158
+ "READER_DEPENDENCY_UNAVAILABLE",
159
+ "READER_CONTRACT",
160
+ "READER_OPTIONS",
161
+ "READER_ADMISSION",
162
+ "READER_BUDGET",
163
+ "READER_MEMBER_MISSING",
164
+ "READER_MEMBER_AMBIGUOUS",
165
+ "READER_MEMBER_TYPE",
166
+ "READER_NESTED_CONTAINER",
167
+ "READER_SHEET_MISSING",
168
+ "READER_RANGE",
169
+ "READER_GRID_UNKNOWN",
170
+ "READER_PACKING_UNKNOWN",
171
+ "READER_DECODE",
172
+ "READER_OUTPUT",
173
+ "READER_WARMUP",
174
+ }
175
+ )
176
+
177
+
178
+ class HostedCrawlerProtocolError(ValueError):
179
+ """One closed crawler request/result invariant failed."""
180
+
181
+ def __init__(self, code: str, detail: str) -> None:
182
+ self.code = code
183
+ self.detail = detail
184
+ super().__init__(f"{detail} [{code}]")
185
+
186
+
187
+ def _exact(value: Any, fields: set[str], label: str) -> dict[str, Any]:
188
+ if not isinstance(value, dict) or set(value) != fields:
189
+ raise HostedCrawlerProtocolError(
190
+ "CRAWLER_PROTOCOL_FIELDS",
191
+ f"{label} fields are not exact",
192
+ )
193
+ return value
194
+
195
+
196
+ def _identifier(value: Any, label: str) -> str:
197
+ if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
198
+ raise HostedCrawlerProtocolError(
199
+ "CRAWLER_PROTOCOL_IDENTIFIER",
200
+ f"{label} is not a canonical identifier",
201
+ )
202
+ return value
203
+
204
+
205
+ def _timestamp(value: Any, label: str) -> str:
206
+ if not isinstance(value, str) or _TIMESTAMP.fullmatch(value) is None:
207
+ raise HostedCrawlerProtocolError(
208
+ "CRAWLER_PROTOCOL_TIMESTAMP",
209
+ f"{label} is not a canonical UTC timestamp",
210
+ )
211
+ try:
212
+ parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
213
+ except ValueError as error:
214
+ raise HostedCrawlerProtocolError(
215
+ "CRAWLER_PROTOCOL_TIMESTAMP",
216
+ f"{label} is not a real UTC timestamp",
217
+ ) from error
218
+ if parsed.tzinfo is None or parsed.astimezone(UTC).utcoffset().total_seconds() != 0:
219
+ raise HostedCrawlerProtocolError(
220
+ "CRAWLER_PROTOCOL_TIMESTAMP",
221
+ f"{label} is not a canonical UTC timestamp",
222
+ )
223
+ return value
224
+
225
+
226
+ def _digest(value: Any, label: str, *, prefixed: bool = False) -> str:
227
+ pattern = _PREFIXED_DIGEST if prefixed else _DIGEST
228
+ if not isinstance(value, str) or pattern.fullmatch(value) is None:
229
+ raise HostedCrawlerProtocolError(
230
+ "CRAWLER_PROTOCOL_DIGEST",
231
+ f"{label} is not a canonical SHA-256 digest",
232
+ )
233
+ return value
234
+
235
+
236
+ def _json_object(value: Any, label: str) -> dict[str, Any]:
237
+ if not isinstance(value, dict):
238
+ raise HostedCrawlerProtocolError(
239
+ "CRAWLER_PROTOCOL_JSON",
240
+ f"{label} must be an object",
241
+ )
242
+ try:
243
+ encoded = canonical_json_bytes(value)
244
+ except (TypeError, ValueError) as error:
245
+ raise HostedCrawlerProtocolError(
246
+ "CRAWLER_PROTOCOL_JSON",
247
+ f"{label} contains unsupported JSON values",
248
+ ) from error
249
+ if len(encoded) > MAX_CRAWLER_REQUEST_BYTES:
250
+ raise HostedCrawlerProtocolError(
251
+ "CRAWLER_PROTOCOL_LIMIT",
252
+ f"{label} exceeds its byte bound",
253
+ )
254
+ return dict(value)
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class HostedCrawlerRequest:
259
+ request_id: str
260
+ source_id: str
261
+ adapter_id: str
262
+ adapter_version: str
263
+ requested_at: str
264
+ query: dict[str, Any]
265
+ egress_policy_attestation: str
266
+ schema_version: str | None = None
267
+
268
+ def __post_init__(self) -> None:
269
+ _identifier(self.request_id, "request_id")
270
+ _identifier(self.source_id, "source_id")
271
+ _identifier(self.adapter_id, "adapter_id")
272
+ if self.adapter_version != "1.0.0" or self.adapter_id not in {
273
+ "external.openligadb",
274
+ "public.https",
275
+ }:
276
+ raise HostedCrawlerProtocolError(
277
+ "CRAWLER_PROTOCOL_VERSION",
278
+ "adapter coordinate is not a reviewed hosted-crawler version",
279
+ )
280
+ _timestamp(self.requested_at, "requested_at")
281
+ object.__setattr__(self, "query", _json_object(self.query, "query"))
282
+ _digest(self.egress_policy_attestation, "egress_policy_attestation")
283
+ schema_version = self.schema_version or CRAWLER_REQUEST_SCHEMA_V1
284
+ if schema_version not in {CRAWLER_REQUEST_SCHEMA_V1, CRAWLER_REQUEST_SCHEMA_V3} or (
285
+ schema_version == CRAWLER_REQUEST_SCHEMA_V3 and self.adapter_id != "public.https"
286
+ ):
287
+ raise HostedCrawlerProtocolError(
288
+ "CRAWLER_PROTOCOL_VERSION",
289
+ "crawler request schema is unsupported for its adapter",
290
+ )
291
+ object.__setattr__(self, "schema_version", schema_version)
292
+ if len(canonical_json_bytes(self.to_dict())) > MAX_CRAWLER_REQUEST_BYTES:
293
+ raise HostedCrawlerProtocolError(
294
+ "CRAWLER_PROTOCOL_LIMIT",
295
+ "crawler request exceeds its byte bound",
296
+ )
297
+
298
+ @property
299
+ def digest(self) -> str:
300
+ return sha256_bytes(canonical_json_bytes(self.to_dict()))
301
+
302
+ def to_dict(self) -> dict[str, Any]:
303
+ return {
304
+ "schema_version": self.schema_version,
305
+ "request_id": self.request_id,
306
+ "source_id": self.source_id,
307
+ "adapter_id": self.adapter_id,
308
+ "adapter_version": self.adapter_version,
309
+ "requested_at": self.requested_at,
310
+ "query": dict(self.query),
311
+ "egress_policy_attestation": self.egress_policy_attestation,
312
+ }
313
+
314
+
315
+ @dataclass(frozen=True)
316
+ class HostedCrawlerFailure:
317
+ request_digest: str
318
+ source_id: str
319
+ adapter_id: str
320
+ adapter_version: str
321
+ failure_code: str
322
+ retryable: bool = False
323
+
324
+ def __post_init__(self) -> None:
325
+ _digest(self.request_digest, "request_digest")
326
+ _identifier(self.source_id, "source_id")
327
+ if (self.adapter_id, self.adapter_version) not in {
328
+ ("external.openligadb", "1.0.0"),
329
+ ("public.https", "1.0.0"),
330
+ }:
331
+ raise HostedCrawlerProtocolError(
332
+ "CRAWLER_PROTOCOL_VERSION", "failure adapter coordinate is not reviewed"
333
+ )
334
+ if self.failure_code not in HOSTED_CRAWLER_FAILURE_CODES or self.retryable is not False:
335
+ raise HostedCrawlerProtocolError(
336
+ "CRAWLER_PROTOCOL_FAILURE", "crawler failure code is not safely transportable"
337
+ )
338
+
339
+ def to_dict(self) -> dict[str, Any]:
340
+ return {
341
+ "schema_version": CRAWLER_FAILURE_SCHEMA,
342
+ "request_digest": self.request_digest,
343
+ "source_id": self.source_id,
344
+ "adapter_id": self.adapter_id,
345
+ "adapter_version": self.adapter_version,
346
+ "failure_code": self.failure_code,
347
+ "retryable": self.retryable,
348
+ }
349
+
350
+
351
+ def parse_crawler_failure(value: Any) -> HostedCrawlerFailure:
352
+ document = _exact(
353
+ value,
354
+ {
355
+ "schema_version",
356
+ "request_digest",
357
+ "source_id",
358
+ "adapter_id",
359
+ "adapter_version",
360
+ "failure_code",
361
+ "retryable",
362
+ },
363
+ "crawler failure",
364
+ )
365
+ if document["schema_version"] != CRAWLER_FAILURE_SCHEMA:
366
+ raise HostedCrawlerProtocolError(
367
+ "CRAWLER_PROTOCOL_SCHEMA", "crawler failure schema version is unsupported"
368
+ )
369
+ return HostedCrawlerFailure(
370
+ request_digest=document["request_digest"],
371
+ source_id=document["source_id"],
372
+ adapter_id=document["adapter_id"],
373
+ adapter_version=document["adapter_version"],
374
+ failure_code=document["failure_code"],
375
+ retryable=document["retryable"],
376
+ )
377
+
378
+
379
+ def parse_crawler_request(value: Any) -> HostedCrawlerRequest:
380
+ document = _exact(
381
+ value,
382
+ {
383
+ "schema_version",
384
+ "request_id",
385
+ "source_id",
386
+ "adapter_id",
387
+ "adapter_version",
388
+ "requested_at",
389
+ "query",
390
+ "egress_policy_attestation",
391
+ },
392
+ "crawler request",
393
+ )
394
+ if document["schema_version"] not in {
395
+ CRAWLER_REQUEST_SCHEMA_V1,
396
+ CRAWLER_REQUEST_SCHEMA_V3,
397
+ } or (
398
+ document["schema_version"] == CRAWLER_REQUEST_SCHEMA_V3
399
+ and document["adapter_id"] != "public.https"
400
+ ):
401
+ raise HostedCrawlerProtocolError(
402
+ "CRAWLER_PROTOCOL_VERSION",
403
+ "crawler request schema is unsupported",
404
+ )
405
+ return HostedCrawlerRequest(
406
+ request_id=document["request_id"],
407
+ source_id=document["source_id"],
408
+ adapter_id=document["adapter_id"],
409
+ adapter_version=document["adapter_version"],
410
+ requested_at=document["requested_at"],
411
+ query=document["query"],
412
+ egress_policy_attestation=document["egress_policy_attestation"],
413
+ schema_version=document["schema_version"],
414
+ )
415
+
416
+
417
+ @dataclass(frozen=True)
418
+ class HostedCrawlerIntent:
419
+ """Producer request fields that Studio expands into server-owned crawler authority."""
420
+
421
+ source_id: str
422
+ adapter_id: str
423
+ adapter_version: str
424
+ query: dict[str, Any]
425
+ egress_policy_attestation: str
426
+
427
+ def __post_init__(self) -> None:
428
+ _identifier(self.source_id, "source_id")
429
+ _identifier(self.adapter_id, "adapter_id")
430
+ if self.adapter_version != "1.0.0" or self.adapter_id not in {
431
+ "external.openligadb",
432
+ "public.https",
433
+ }:
434
+ raise HostedCrawlerProtocolError(
435
+ "CRAWLER_PROTOCOL_VERSION",
436
+ "intent adapter coordinate is unsupported",
437
+ )
438
+ object.__setattr__(self, "query", _json_object(self.query, "query"))
439
+ _digest(self.egress_policy_attestation, "egress_policy_attestation")
440
+
441
+
442
+ @dataclass(frozen=True)
443
+ class HostedCrawlerResult:
444
+ request_digest: str
445
+ source_id: str
446
+ adapter_id: str
447
+ adapter_version: str
448
+ normalized_content: bytes
449
+ acquisition_receipt: dict[str, Any]
450
+ observation: dict[str, Any]
451
+ next_watermark: str | None
452
+ schema_version: str | None = None
453
+
454
+ def __post_init__(self) -> None:
455
+ _digest(self.request_digest, "request_digest")
456
+ _identifier(self.source_id, "source_id")
457
+ _identifier(self.adapter_id, "adapter_id")
458
+ if self.adapter_version != "1.0.0" or self.adapter_id not in {
459
+ "external.openligadb",
460
+ "public.https",
461
+ }:
462
+ raise HostedCrawlerProtocolError(
463
+ "CRAWLER_PROTOCOL_VERSION",
464
+ "result adapter coordinate is unsupported",
465
+ )
466
+ if (
467
+ not isinstance(self.normalized_content, bytes)
468
+ or not 1 <= len(self.normalized_content) <= MAX_NORMALIZED_SOURCE_BYTES
469
+ ):
470
+ raise HostedCrawlerProtocolError(
471
+ "CRAWLER_PROTOCOL_LIMIT",
472
+ "normalized crawler content is empty or exceeds its byte bound",
473
+ )
474
+ object.__setattr__(
475
+ self,
476
+ "acquisition_receipt",
477
+ _json_object(self.acquisition_receipt, "acquisition_receipt"),
478
+ )
479
+ object.__setattr__(
480
+ self,
481
+ "observation",
482
+ _json_object(self.observation, "observation"),
483
+ )
484
+ if self.next_watermark is not None:
485
+ _timestamp(self.next_watermark, "next_watermark")
486
+ schema_version = self.schema_version or CRAWLER_RESULT_SCHEMA_V1
487
+ if schema_version not in {CRAWLER_RESULT_SCHEMA_V1, CRAWLER_RESULT_SCHEMA_V3} or (
488
+ schema_version == CRAWLER_RESULT_SCHEMA_V3 and self.adapter_id != "public.https"
489
+ ):
490
+ raise HostedCrawlerProtocolError(
491
+ "CRAWLER_PROTOCOL_VERSION",
492
+ "crawler result schema is unsupported for its adapter",
493
+ )
494
+ object.__setattr__(self, "schema_version", schema_version)
495
+
496
+ @property
497
+ def normalized_content_digest(self) -> str:
498
+ return "sha256:" + sha256_bytes(self.normalized_content)
499
+
500
+ @property
501
+ def result_digest(self) -> str:
502
+ return sha256_bytes(canonical_json_bytes(self._preimage()))
503
+
504
+ @property
505
+ def normalized_content_base64url_chunks(self) -> list[str]:
506
+ encoded = base64.urlsafe_b64encode(self.normalized_content).decode("ascii").rstrip("=")
507
+ return [
508
+ encoded[index : index + MAX_CRAWLER_BASE64URL_CHUNK_CHARS]
509
+ for index in range(0, len(encoded), MAX_CRAWLER_BASE64URL_CHUNK_CHARS)
510
+ ]
511
+
512
+ def _preimage(self) -> dict[str, Any]:
513
+ return {
514
+ "schema_version": self.schema_version,
515
+ "request_digest": self.request_digest,
516
+ "source_id": self.source_id,
517
+ "adapter_id": self.adapter_id,
518
+ "adapter_version": self.adapter_version,
519
+ "normalized_content_base64url_chunks": (self.normalized_content_base64url_chunks),
520
+ "normalized_content_digest": self.normalized_content_digest,
521
+ "normalized_size_bytes": len(self.normalized_content),
522
+ "acquisition_receipt": dict(self.acquisition_receipt),
523
+ "observation": dict(self.observation),
524
+ "next_watermark": self.next_watermark,
525
+ }
526
+
527
+ def to_dict(self) -> dict[str, Any]:
528
+ return {**self._preimage(), "result_digest": self.result_digest}
529
+
530
+
531
+ def parse_crawler_result(value: Any) -> HostedCrawlerResult:
532
+ document = _exact(
533
+ value,
534
+ {
535
+ "schema_version",
536
+ "request_digest",
537
+ "source_id",
538
+ "adapter_id",
539
+ "adapter_version",
540
+ "normalized_content_base64url_chunks",
541
+ "normalized_content_digest",
542
+ "normalized_size_bytes",
543
+ "acquisition_receipt",
544
+ "observation",
545
+ "next_watermark",
546
+ "result_digest",
547
+ },
548
+ "crawler result",
549
+ )
550
+ if document["schema_version"] not in {
551
+ CRAWLER_RESULT_SCHEMA_V1,
552
+ CRAWLER_RESULT_SCHEMA_V3,
553
+ } or (
554
+ document["schema_version"] == CRAWLER_RESULT_SCHEMA_V3
555
+ and document["adapter_id"] != "public.https"
556
+ ):
557
+ raise HostedCrawlerProtocolError(
558
+ "CRAWLER_PROTOCOL_VERSION",
559
+ "crawler result schema is unsupported",
560
+ )
561
+ chunks = document["normalized_content_base64url_chunks"]
562
+ complete_groups, remainder = divmod(MAX_NORMALIZED_SOURCE_BYTES, 3)
563
+ maximum_encoded_chars = 4 * complete_groups + (0 if remainder == 0 else remainder + 1)
564
+ maximum_chunks = (
565
+ maximum_encoded_chars + MAX_CRAWLER_BASE64URL_CHUNK_CHARS - 1
566
+ ) // MAX_CRAWLER_BASE64URL_CHUNK_CHARS
567
+ if (
568
+ not isinstance(chunks, list)
569
+ or not 1 <= len(chunks) <= maximum_chunks
570
+ or any(
571
+ not isinstance(chunk, str)
572
+ or not 1 <= len(chunk) <= MAX_CRAWLER_BASE64URL_CHUNK_CHARS
573
+ or _BASE64URL_CHUNK.fullmatch(chunk) is None
574
+ for chunk in chunks
575
+ )
576
+ or any(len(chunk) != MAX_CRAWLER_BASE64URL_CHUNK_CHARS for chunk in chunks[:-1])
577
+ ):
578
+ raise HostedCrawlerProtocolError(
579
+ "CRAWLER_PROTOCOL_LIMIT",
580
+ "normalized content chunk inventory is invalid or exceeds its bound",
581
+ )
582
+ encoded = "".join(chunks)
583
+ if len(encoded) > maximum_encoded_chars:
584
+ raise HostedCrawlerProtocolError(
585
+ "CRAWLER_PROTOCOL_LIMIT",
586
+ "normalized content encoding exceeds its byte bound",
587
+ )
588
+ try:
589
+ content = base64.b64decode(
590
+ encoded + "=" * (-len(encoded) % 4),
591
+ altchars=b"-_",
592
+ validate=True,
593
+ )
594
+ except (ValueError, TypeError) as error:
595
+ raise HostedCrawlerProtocolError(
596
+ "CRAWLER_PROTOCOL_BASE64",
597
+ "normalized content is not canonical base64url",
598
+ ) from error
599
+ if base64.urlsafe_b64encode(content).decode("ascii").rstrip("=") != encoded:
600
+ raise HostedCrawlerProtocolError(
601
+ "CRAWLER_PROTOCOL_BASE64",
602
+ "normalized content is not canonical base64url",
603
+ )
604
+ result = HostedCrawlerResult(
605
+ request_digest=document["request_digest"],
606
+ source_id=document["source_id"],
607
+ adapter_id=document["adapter_id"],
608
+ adapter_version=document["adapter_version"],
609
+ normalized_content=content,
610
+ acquisition_receipt=document["acquisition_receipt"],
611
+ observation=document["observation"],
612
+ next_watermark=document["next_watermark"],
613
+ schema_version=document["schema_version"],
614
+ )
615
+ if (
616
+ type(document["normalized_size_bytes"]) is not int
617
+ or document["normalized_content_digest"] != result.normalized_content_digest
618
+ or document["normalized_size_bytes"] != len(content)
619
+ or document["result_digest"] != result.result_digest
620
+ ):
621
+ raise HostedCrawlerProtocolError(
622
+ "CRAWLER_PROTOCOL_BINDING",
623
+ "crawler result digest or size binding mismatches",
624
+ )
625
+ if len(canonical_json_bytes(document)) > MAX_CRAWLER_RESULT_BYTES:
626
+ raise HostedCrawlerProtocolError(
627
+ "CRAWLER_PROTOCOL_LIMIT",
628
+ "crawler result exceeds its byte bound",
629
+ )
630
+ return result
631
+
632
+
633
+ @dataclass(frozen=True)
634
+ class HostedCrawlerExchange:
635
+ """Studio-returned server request plus its one-shot isolated crawler result."""
636
+
637
+ request: HostedCrawlerRequest
638
+ result: HostedCrawlerResult
639
+
640
+ def __post_init__(self) -> None:
641
+ expected_result_schema = (
642
+ CRAWLER_RESULT_SCHEMA_V3
643
+ if self.request.schema_version == CRAWLER_REQUEST_SCHEMA_V3
644
+ else CRAWLER_RESULT_SCHEMA_V1
645
+ )
646
+ if (
647
+ self.result.request_digest != self.request.digest
648
+ or self.result.source_id != self.request.source_id
649
+ or self.result.adapter_id != self.request.adapter_id
650
+ or self.result.adapter_version != self.request.adapter_version
651
+ or self.result.schema_version != expected_result_schema
652
+ ):
653
+ raise HostedCrawlerProtocolError(
654
+ "CRAWLER_PROTOCOL_BINDING",
655
+ "crawler result differs from its server-owned request authority",
656
+ )
657
+
658
+ def validate_intent(self, intent: HostedCrawlerIntent) -> None:
659
+ if (
660
+ self.request.source_id != intent.source_id
661
+ or self.request.adapter_id != intent.adapter_id
662
+ or self.request.adapter_version != intent.adapter_version
663
+ or self.request.query != intent.query
664
+ or self.request.egress_policy_attestation != intent.egress_policy_attestation
665
+ ):
666
+ raise HostedCrawlerProtocolError(
667
+ "CRAWLER_PROTOCOL_BINDING",
668
+ "Studio crawler request differs from the producer's frozen intent",
669
+ )
670
+
671
+
672
+ class HostedCrawlerClient(Protocol):
673
+ """Producer-side create/poll/download abstraction for one isolated crawler result."""
674
+
675
+ def acquire(self, intent: HostedCrawlerIntent) -> HostedCrawlerExchange:
676
+ """Create/poll/download one server-owned request and its exact claimed result."""