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,964 @@
1
+ """The shared harvest boundary: fetch bounded bytes, admit them, then parse them.
2
+
3
+ Everything a harvester touches is attacker-influenceable. A public catalogue controls its own
4
+ status line, headers, size and body, and one of the instances this was tested against answers
5
+ HTTP 200 with a bot-wall HTML page. So the discipline here is uniform and not negotiable per
6
+ harvester:
7
+
8
+ * **Every fetch goes through the hardened retriever.** There is no ``urllib`` or ``requests`` call
9
+ anywhere in this package, which is what keeps DNS pinning, the egress allowlist and the per-hop
10
+ public-address check on the path.
11
+ * **Bytes are admitted before they are parsed.** ``admit_public_fact_bytes`` runs on every payload,
12
+ because this is the moment a stranger's document would become a durable shared fact.
13
+ * **Parsing takes bytes, never a URL.** That is what lets every harvester test run offline against
14
+ a recorded response, and it puts the untrusted-input boundary in one obvious place.
15
+ * **Caps sit at or below the admission cap**, so an oversized response is refused at the transport
16
+ rather than downloaded and then rejected. The observed Eurostat structure response is 37,166,239
17
+ bytes; that is the concrete reason this is enforced rather than advised.
18
+
19
+ A ``HarvestedRecord`` is deliberately weaker than a ``CatalogEntry``. It holds what a harvester can
20
+ honestly read off a response and nothing more -- no rights adjudication, no liveness observation.
21
+ Turning one into an entry is a separate, explicit step that runs the public-source checks.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+ from typing import Any, Protocol
30
+ from urllib.parse import urlsplit
31
+
32
+ from mostlyright.data_harness.acquisition.http import (
33
+ DatagovRateObservation,
34
+ DatagovResponseRefused,
35
+ DatagovV4Authorization,
36
+ PinnedHttpsRetriever,
37
+ RetrievalLimits,
38
+ RetrievedBytes,
39
+ )
40
+ from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
41
+ from mostlyright.data_harness.canonical import canonical_sha256
42
+ from mostlyright.data_harness.sources.catalog.admission import (
43
+ MAX_CATALOG_FACT_BYTES,
44
+ admit_public_fact_bytes,
45
+ )
46
+ from mostlyright.data_harness.sources.contracts import (
47
+ DATA_FORMATS,
48
+ EvidenceReference,
49
+ SourceContractError,
50
+ _CanonicalContract,
51
+ )
52
+
53
+ HARVESTED_RECORD_CONTRACT_VERSION = "harness-source-harvested-record.v1"
54
+ HARVEST_PAGE_CONTRACT_VERSION = "harness-source-harvest-page.v1"
55
+ HARVEST_RESPONSE_EVIDENCE_VERSION = "harness-source-harvest-response-evidence.v1"
56
+
57
+ HARVEST_PROTOCOLS = ("ckan", "datagov_v4", "stac", "sdmx")
58
+ DATAGOV_V4_HARVESTER_ID = "datagov_v4"
59
+ DATAGOV_V4_HARVESTER_VERSION = "1.0.0"
60
+ DATAGOV_V4_HARVESTER_COORDINATE = f"{DATAGOV_V4_HARVESTER_ID}@{DATAGOV_V4_HARVESTER_VERSION}"
61
+
62
+ MAX_RECORD_ID = 200
63
+ MAX_RECORD_TEXT = 4_000
64
+ MAX_RECORD_TITLE = 500
65
+ MAX_RECORD_PUBLISHER = 500
66
+ MAX_RECORD_LICENSE_ID = 200
67
+ MAX_RECORD_URI = 2_048
68
+ MAX_RECORD_FORMATS = 16
69
+
70
+ # Named verbatim so a 401 reads as a scope boundary rather than an invitation to add credentials.
71
+ AUTHENTICATED_ADAPTER_PARKED = (
72
+ "this endpoint is not open; authenticated source adapters are parked, and their trigger is the "
73
+ "first real credentialed need plus a broker trust review"
74
+ )
75
+
76
+ _STATUS_TAIL = re.compile(r"status (\d{3})$")
77
+ # Publisher id characters, deliberately wider than the harness identifier grammar and still
78
+ # closed. The parentheses are there because an SDMX dataflow's natural coordinate is
79
+ # `ESTAT:APRO_CPSH1(1.0)`; no whitespace, quote, angle bracket or control character is admitted.
80
+ _RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+()-]*$")
81
+ _TIMESTAMP = re.compile(
82
+ r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$"
83
+ )
84
+
85
+
86
+ class CatalogHarvestError(SourceContractError):
87
+ """A harvest refusal: the endpoint is not open, not what it claimed, or not bounded."""
88
+
89
+
90
+ class DatagovFetchRefused(CatalogHarvestError):
91
+ """A v4 refusal after an HTTP response, carrying only transport accounting."""
92
+
93
+ def __init__(
94
+ self,
95
+ code: str,
96
+ path: str,
97
+ detail: str,
98
+ *,
99
+ response_evidence: DatagovRefusedResponseEvidence,
100
+ ) -> None:
101
+ self.response_evidence = response_evidence
102
+ super().__init__(code, path, detail)
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DatagovRefusedResponseEvidence:
107
+ """Secret-free accounting for a response whose bytes may not contribute any digest."""
108
+
109
+ final_url: str
110
+ status: int
111
+ network_bytes: int
112
+ rate_observation: DatagovRateObservation | None
113
+ requests: int = 1
114
+ responses: int = 1
115
+ content_bytes: int = 0
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return {
119
+ "final_url": self.final_url,
120
+ "status": self.status,
121
+ "network_bytes": self.network_bytes,
122
+ "requests": self.requests,
123
+ "responses": self.responses,
124
+ "content_bytes": self.content_bytes,
125
+ "rate_observation": (
126
+ self.rate_observation.to_dict() if self.rate_observation is not None else None
127
+ ),
128
+ }
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class HarvesterDescriptor:
133
+ """The immutable identity of one harvester."""
134
+
135
+ protocol: str
136
+ harvester_id: str
137
+ harvester_version: str
138
+
139
+ def __post_init__(self) -> None:
140
+ if self.protocol not in HARVEST_PROTOCOLS:
141
+ raise CatalogHarvestError(
142
+ "ENUM",
143
+ "harvester.protocol",
144
+ f"must be one of {list(HARVEST_PROTOCOLS)}",
145
+ )
146
+ _identifier(self.harvester_id, "harvester.harvester_id")
147
+ if not re.fullmatch(r"[1-9][0-9]*\.[0-9]+\.[0-9]+", str(self.harvester_version)):
148
+ raise CatalogHarvestError(
149
+ "SEMVER",
150
+ "harvester.harvester_version",
151
+ "must be a positive-major semantic version",
152
+ )
153
+
154
+ @property
155
+ def coordinate(self) -> str:
156
+ return f"{self.harvester_id}@{self.harvester_version}"
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class HarvestCursor(_CanonicalContract):
161
+ """A closed protocol continuation coordinate, never an executable request."""
162
+
163
+ protocol: str
164
+ kind: str
165
+ value: str
166
+
167
+ def __post_init__(self) -> None:
168
+ if self.protocol not in HARVEST_PROTOCOLS:
169
+ raise CatalogHarvestError("ENUM", "cursor.protocol", "must name a harvest protocol")
170
+ expected = {
171
+ "ckan": "offset",
172
+ "datagov_v4": "after",
173
+ "stac": "next",
174
+ "sdmx": "terminal",
175
+ }[self.protocol]
176
+ if self.kind != expected:
177
+ raise CatalogHarvestError(
178
+ "HARVEST_CURSOR",
179
+ "cursor.kind",
180
+ f"{self.protocol} cursors must use {expected!r}",
181
+ )
182
+ _text(self.value, "cursor.value", maximum=MAX_RECORD_URI)
183
+ if self.protocol == "ckan":
184
+ match = re.fullmatch(r"(0|[1-9][0-9]*):([1-9][0-9]*)", self.value)
185
+ if match is None or int(match.group(2)) > 1_000:
186
+ raise CatalogHarvestError(
187
+ "HARVEST_CURSOR", "cursor.value", "CKAN cursor must be start:rows"
188
+ )
189
+ elif self.protocol == "stac":
190
+ _https_uri(self.value, "cursor.value")
191
+ elif self.protocol == "datagov_v4":
192
+ validate_datagov_cursor_text(self.value, "cursor.value")
193
+
194
+ def to_dict(self) -> dict[str, str]:
195
+ return {"protocol": self.protocol, "kind": self.kind, "value": self.value}
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class HarvestResponseEvidence(_CanonicalContract):
200
+ """Exact transport work behind one admitted response."""
201
+
202
+ final_url: str
203
+ media_type: str
204
+ content_sha256: str
205
+ content_bytes: int
206
+ requests: int
207
+ responses: int
208
+ network_bytes: int
209
+ transport_evidence_digest: str
210
+ status: int = 200
211
+ rate_observation: DatagovRateObservation | None = None
212
+ schema_version: str = HARVEST_RESPONSE_EVIDENCE_VERSION
213
+
214
+ def __post_init__(self) -> None:
215
+ if self.schema_version != HARVEST_RESPONSE_EVIDENCE_VERSION:
216
+ raise CatalogHarvestError("VERSION", "response.schema_version", "unsupported version")
217
+ _https_uri(self.final_url, "response.final_url")
218
+ if not re.fullmatch(r"[0-9a-f]{64}", self.content_sha256):
219
+ raise CatalogHarvestError("DIGEST", "response.content_sha256", "must be SHA-256")
220
+ if not re.fullmatch(r"[0-9a-f]{64}", self.transport_evidence_digest):
221
+ raise CatalogHarvestError(
222
+ "DIGEST", "response.transport_evidence_digest", "must be SHA-256"
223
+ )
224
+ for name, value, minimum in (
225
+ ("content_bytes", self.content_bytes, 0),
226
+ ("requests", self.requests, 1),
227
+ ("responses", self.responses, 1),
228
+ ("network_bytes", self.network_bytes, 0),
229
+ ):
230
+ if type(value) is not int or value < minimum:
231
+ raise CatalogHarvestError("LIMIT", f"response.{name}", "is outside its bound")
232
+ if self.content_bytes > self.network_bytes:
233
+ raise CatalogHarvestError(
234
+ "HARVEST_TRANSPORT_EVIDENCE",
235
+ "response.network_bytes",
236
+ "network bytes may not be smaller than final content bytes",
237
+ )
238
+ if type(self.status) is not int or not 100 <= self.status <= 599:
239
+ raise CatalogHarvestError("HARVEST_STATUS", "response.status", "status is invalid")
240
+ if self.rate_observation is not None and not isinstance(
241
+ self.rate_observation, DatagovRateObservation
242
+ ):
243
+ raise CatalogHarvestError(
244
+ "DATAGOV_RATE", "response.rate_observation", "rate observation is invalid"
245
+ )
246
+
247
+ @classmethod
248
+ def from_retrieval(cls, retrieved: RetrievedBytes) -> HarvestResponseEvidence:
249
+ requests = sum(max(1, len(hop.peer_attempts)) for hop in retrieved.hops)
250
+ return cls(
251
+ final_url=retrieved.final_url,
252
+ media_type=retrieved.media_type,
253
+ content_sha256=retrieved.content_sha256,
254
+ content_bytes=len(retrieved.content),
255
+ requests=requests,
256
+ responses=len(retrieved.hops),
257
+ network_bytes=retrieved.total_response_body_size_bytes,
258
+ transport_evidence_digest=retrieved.transport_evidence_digest,
259
+ status=retrieved.hops[-1].status,
260
+ rate_observation=retrieved.rate_observation,
261
+ )
262
+
263
+ def to_dict(self) -> dict[str, Any]:
264
+ result = {
265
+ "schema_version": self.schema_version,
266
+ "final_url": self.final_url,
267
+ "media_type": self.media_type,
268
+ "content_sha256": self.content_sha256,
269
+ "content_bytes": self.content_bytes,
270
+ "requests": self.requests,
271
+ "responses": self.responses,
272
+ "network_bytes": self.network_bytes,
273
+ "transport_evidence_digest": self.transport_evidence_digest,
274
+ }
275
+ if self.status != 200 or self.rate_observation is not None:
276
+ result["status"] = self.status
277
+ result["rate_observation"] = (
278
+ self.rate_observation.to_dict() if self.rate_observation is not None else None
279
+ )
280
+ return result
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class HarvestPage(_CanonicalContract):
285
+ """One strict page envelope returned by every supported harvester."""
286
+
287
+ protocol: str
288
+ records: tuple[Any, ...]
289
+ next_cursor: HarvestCursor | None
290
+ provider_count: int | None
291
+ count_basis: str
292
+ skipped_record_ids: tuple[str, ...]
293
+ evidence: EvidenceReference
294
+ response_evidence: HarvestResponseEvidence | None
295
+ schema_version: str = HARVEST_PAGE_CONTRACT_VERSION
296
+
297
+ def __post_init__(self) -> None:
298
+ if self.schema_version != HARVEST_PAGE_CONTRACT_VERSION:
299
+ raise CatalogHarvestError("VERSION", "page.schema_version", "unsupported version")
300
+ if self.protocol not in HARVEST_PROTOCOLS:
301
+ raise CatalogHarvestError("ENUM", "page.protocol", "must name a harvest protocol")
302
+ if not isinstance(self.records, tuple) or any(
303
+ not _same_protocol_record(record, self.protocol) for record in self.records
304
+ ):
305
+ raise CatalogHarvestError("TYPE", "page.records", "must be same-protocol records")
306
+ if self.next_cursor is not None and self.next_cursor.protocol != self.protocol:
307
+ raise CatalogHarvestError("HARVEST_CURSOR", "page.next_cursor", "protocol mismatch")
308
+ if self.provider_count is not None and (
309
+ type(self.provider_count) is not int or self.provider_count < 0
310
+ ):
311
+ raise CatalogHarvestError("HARVEST_COUNT", "page.provider_count", "must be nonnegative")
312
+ allowed_basis = {"reported_total", "not_reported", "one_shot_response"}
313
+ if self.count_basis not in allowed_basis:
314
+ raise CatalogHarvestError("ENUM", "page.count_basis", "unsupported count basis")
315
+ if self.count_basis == "reported_total" and self.provider_count is None:
316
+ raise CatalogHarvestError("HARVEST_COUNT", "page.provider_count", "count is required")
317
+ if not isinstance(self.skipped_record_ids, tuple):
318
+ raise CatalogHarvestError("TYPE", "page.skipped_record_ids", "must be a tuple")
319
+ if tuple(sorted(self.skipped_record_ids)) != self.skipped_record_ids:
320
+ raise CatalogHarvestError("ORDER", "page.skipped_record_ids", "must be sorted")
321
+ if not self.records and self.next_cursor is not None:
322
+ raise CatalogHarvestError(
323
+ "HARVEST_EMPTY_PAGE", "page.records", "an empty non-terminal page is refused"
324
+ )
325
+
326
+ def to_dict(self) -> dict[str, Any]:
327
+ return {
328
+ "schema_version": self.schema_version,
329
+ "protocol": self.protocol,
330
+ "records": [record.to_dict() for record in self.records],
331
+ "next_cursor": self.next_cursor.to_dict() if self.next_cursor else None,
332
+ "provider_count": self.provider_count,
333
+ "count_basis": self.count_basis,
334
+ "skipped_record_ids": list(self.skipped_record_ids),
335
+ "evidence": self.evidence.to_dict(),
336
+ "response_evidence": (
337
+ self.response_evidence.to_dict() if self.response_evidence is not None else None
338
+ ),
339
+ }
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class HarvestedRecord(_CanonicalContract):
344
+ """One dataset as a public catalogue describes it, read and nothing more."""
345
+
346
+ protocol: str
347
+ record_id: str
348
+ title: str
349
+ description: str
350
+ publisher: str
351
+ landing_uri: str
352
+ data_formats: tuple[str, ...]
353
+ evidence: EvidenceReference
354
+ license_id: str | None = None
355
+ license_uri: str | None = None
356
+ declared_updated_at: str | None = None
357
+ schema_version: str = HARVESTED_RECORD_CONTRACT_VERSION
358
+
359
+ def __post_init__(self) -> None:
360
+ if self.schema_version != HARVESTED_RECORD_CONTRACT_VERSION:
361
+ raise CatalogHarvestError(
362
+ "VERSION",
363
+ "record.schema_version",
364
+ f"must equal {HARVESTED_RECORD_CONTRACT_VERSION!r}",
365
+ )
366
+ if self.protocol not in HARVEST_PROTOCOLS:
367
+ raise CatalogHarvestError(
368
+ "ENUM",
369
+ "record.protocol",
370
+ f"must be one of {list(HARVEST_PROTOCOLS)}",
371
+ )
372
+ # A publisher's dataset id is not ours to shape, so it is bounded and character-checked
373
+ # rather than forced through the harness identifier regex.
374
+ record_id = _text(self.record_id, "record.record_id", maximum=MAX_RECORD_ID)
375
+ if not _RECORD_ID.fullmatch(record_id):
376
+ raise CatalogHarvestError(
377
+ "RECORD_ID",
378
+ "record.record_id",
379
+ "must contain only unreserved catalogue-id characters",
380
+ )
381
+ _text(self.title, "record.title", maximum=MAX_RECORD_TITLE)
382
+ _text(self.description, "record.description", maximum=MAX_RECORD_TEXT)
383
+ _text(self.publisher, "record.publisher", maximum=MAX_RECORD_PUBLISHER)
384
+ _https_uri(self.landing_uri, "record.landing_uri")
385
+ if not isinstance(self.data_formats, tuple):
386
+ raise CatalogHarvestError("TYPE", "record.data_formats", "must be an immutable tuple")
387
+ if len(self.data_formats) > MAX_RECORD_FORMATS:
388
+ raise CatalogHarvestError(
389
+ "LIMIT",
390
+ "record.data_formats",
391
+ f"must contain at most {MAX_RECORD_FORMATS} formats",
392
+ )
393
+ for position, value in enumerate(self.data_formats):
394
+ if value not in DATA_FORMATS:
395
+ raise CatalogHarvestError(
396
+ "ENUM",
397
+ f"record.data_formats[{position}]",
398
+ f"must be one of {sorted(DATA_FORMATS)}",
399
+ )
400
+ if len(set(self.data_formats)) != len(self.data_formats):
401
+ raise CatalogHarvestError("DUPLICATE", "record.data_formats", "formats must be unique")
402
+ if not isinstance(self.evidence, EvidenceReference):
403
+ raise CatalogHarvestError("TYPE", "record.evidence", "must be an EvidenceReference")
404
+ if self.license_id is not None:
405
+ _text(self.license_id, "record.license_id", maximum=MAX_RECORD_LICENSE_ID)
406
+ if self.license_uri is not None:
407
+ _https_uri(self.license_uri, "record.license_uri")
408
+ if self.declared_updated_at is not None:
409
+ if not _TIMESTAMP.fullmatch(str(self.declared_updated_at)):
410
+ raise CatalogHarvestError(
411
+ "TIMESTAMP",
412
+ "record.declared_updated_at",
413
+ "must be a canonical RFC 3339 UTC timestamp",
414
+ )
415
+
416
+ def to_dict(self) -> dict[str, Any]:
417
+ return {
418
+ "schema_version": self.schema_version,
419
+ "protocol": self.protocol,
420
+ "record_id": self.record_id,
421
+ "title": self.title,
422
+ "description": self.description,
423
+ "publisher": self.publisher,
424
+ "landing_uri": self.landing_uri,
425
+ "data_formats": list(self.data_formats),
426
+ "evidence": self.evidence.to_dict(),
427
+ "license_id": self.license_id,
428
+ "license_uri": self.license_uri,
429
+ "declared_updated_at": self.declared_updated_at,
430
+ }
431
+
432
+ @property
433
+ def facts_sha256(self) -> str:
434
+ """The digest of everything the provider *published*, with the observation excluded.
435
+
436
+ A rights decision binds to this rather than to ``digest``, and it has to: ``digest``
437
+ covers the evidence wrapper -- the page URL, the caller's observation timestamp, and
438
+ the digest of the whole response page -- so a byte-identical record re-observed at a
439
+ new time, or sharing a page with any unrelated changed record, would void every
440
+ standing decision and train the operator to re-stamp digests mechanically. Excluding
441
+ the evidence makes the binding mean what the person meant: these published facts,
442
+ whoever's page they arrived on, whenever they were fetched.
443
+ """
444
+
445
+ payload = self.to_dict()
446
+ del payload["evidence"]
447
+ return canonical_sha256(payload)
448
+
449
+
450
+ def declared_timestamp(value: str | None) -> str | None:
451
+ """A harvester's normalised update time, or ``None`` when it is not one.
452
+
453
+ One grammar, checked in one place. Each harvester normalises the spelling its publishers use
454
+ and hands the result here; anything that does not come out as the canonical RFC 3339 UTC
455
+ timestamp ``HarvestedRecord`` requires becomes an undeclared update time.
456
+
457
+ Returning ``None`` rather than raising is what keeps one record from denying a whole page.
458
+ ``HarvestedRecord.__post_init__`` raises ``TIMESTAMP`` on a bad value and that exception
459
+ escapes the enclosing ``parse`` call, so a single publisher field spelled
460
+ ``2026-07-31T23:59:59+02:00`` -- legal RFC 3339, and not the UTC spelling STAC requires -- or
461
+ ``not-a-real-timestamp-value`` would refuse every other record in the same response, in a
462
+ document a stranger controls. An undeclared update time is already a supported state that
463
+ both harvesters produce for an open-ended or absent value, so degrading this one field on this
464
+ one record is strictly less lossy than degrading the page.
465
+
466
+ A zone-bearing spelling is left undeclared rather than converted. Converting would need a zone
467
+ arithmetic path this boundary does not otherwise have, and the two protocols harvested here
468
+ both specify UTC, so an offset is a publisher deviation rather than a case to support. The rule
469
+ is only as good as what reaches this funnel, which is why :func:`carries_a_zone_offset` exists
470
+ and why both pre-normalisers call it before they touch the spelling -- see there.
471
+
472
+ The record-level check stays where it is. This is the funnel every harvester goes through; that
473
+ is the backstop for a record built by hand.
474
+ """
475
+
476
+ if value is None:
477
+ return None
478
+ return value if _TIMESTAMP.fullmatch(value) else None
479
+
480
+
481
+ def carries_a_zone_offset(value: str) -> bool:
482
+ """True when a timestamp spelling names a UTC offset rather than UTC itself.
483
+
484
+ Both pre-normalisers cut a fractional second down to six digits before handing the result to
485
+ :func:`declared_timestamp`. The offset must be identified first because it rides on the
486
+ fractional second and would otherwise go with the part that
487
+ was cut: ``2026-07-31T23:59:59.123456+02:00`` came out as ``2026-07-31T23:59:59.123456Z``, and
488
+ ``2026-07-31T23:59:59.123+02:00`` -- whose fraction is not six digits, so the whole remainder
489
+ was dropped -- came out as ``2026-07-31T23:59:59Z``. Both are canonical, both are accepted, and
490
+ both are a local time relabelled as UTC, by up to fourteen hours. Only the fraction-less
491
+ spelling was rejected, which is the one every test used.
492
+
493
+ That value becomes ``HarvestedRecord.declared_updated_at``, which feeds the health probe's
494
+ publication delay and the derived update frequency, which the facts gate then decides a
495
+ source's cadence on. A silently wrong hour is therefore a wrong catalog fact, not a wrong
496
+ display string, so this is checked before any spelling is normalised rather than after.
497
+
498
+ Everything after the date is inspected, because the date's own hyphens are not offsets and
499
+ every canonical UTC spelling this boundary accepts carries neither a plus nor a minus in its
500
+ time of day.
501
+ """
502
+
503
+ _, separator, time_of_day = value.partition("T")
504
+ if not separator:
505
+ return False
506
+ return "+" in time_of_day or "-" in time_of_day
507
+
508
+
509
+ def bounded_record_text(value: str, *, maximum: int) -> str:
510
+ """A publisher's free text, cut to the record bound rather than refused at it.
511
+
512
+ Same rule as :func:`declared_timestamp`, applied to the four fields it was never applied to.
513
+ ``HarvestedRecord.__post_init__`` raises ``TEXT`` on an over-length ``title``, ``description``
514
+ or ``publisher``, and that exception escapes the enclosing ``parse`` call -- so one publisher's
515
+ long prose refused every other record in the same response, in a document a stranger controls.
516
+ CKAN's ``notes`` has no server-side length limit and multi-thousand-character descriptions are
517
+ ordinary on real portals, so this was a first-contact failure rather than an adversarial edge.
518
+
519
+ Truncating is the smaller loss, and it is the loss the two other harvesters were already
520
+ taking on ``description`` before this was stated in one place. A cut description is still the
521
+ publisher's description; a refused page is every sibling record gone. This is a bound on a
522
+ descriptive field, never on an identifier: see :func:`usable_record_id` for those.
523
+
524
+ Trailing whitespace left by the cut is stripped, because a title ending mid-space is a worse
525
+ artefact than one ending mid-word, and an all-whitespace tail would leave the field empty --
526
+ which the record contract refuses. A value that is empty after stripping is returned as-is so
527
+ the caller's own fallback (``or record_id``, ``or "No description published."``) applies.
528
+ """
529
+
530
+ text = value[:maximum].rstrip()
531
+ return text if text else value[:maximum]
532
+
533
+
534
+ def usable_record_id(value: str | None) -> str | None:
535
+ """A publisher's dataset id, or ``None`` when it is not one this boundary can carry.
536
+
537
+ An identifier is not truncatable. Cutting one to the bound would mint a different dataset's
538
+ coordinate out of this dataset's, and every citation built on it afterwards would name
539
+ something the publisher never published -- which is worse than not carrying the record.
540
+
541
+ So the degradation for an id is the record, not the field: the caller records it in ``skipped``
542
+ and keeps the rest of the page, exactly as it already does for a package with no id at all and
543
+ for one whose landing page is not HTTPS. ``skipped`` is reported in the refusal message when a
544
+ response yields nothing usable, so a page lost this way is never lost silently.
545
+ """
546
+
547
+ if not isinstance(value, str):
548
+ return None
549
+ text = value.strip()
550
+ if not text or len(text) > MAX_RECORD_ID or not _RECORD_ID.fullmatch(text):
551
+ return None
552
+ return text
553
+
554
+
555
+ def usable_https_uri(value: str | None) -> str | None:
556
+ """An HTTPS URI within the record bound, or ``None``.
557
+
558
+ A URI is an identifier too, so it degrades the same way an id does rather than being cut. For
559
+ ``landing_uri`` the caller skips the record; for ``license_uri``, which is optional, ``None``
560
+ is the supported state and the licence remains readable from ``license_id``.
561
+ """
562
+
563
+ if not isinstance(value, str):
564
+ return None
565
+ text = value.strip()
566
+ if not text.startswith("https://") or len(text) <= len("https://"):
567
+ return None
568
+ return text if len(text) <= MAX_RECORD_URI else None
569
+
570
+
571
+ def usable_license_id(value: str | None) -> str | None:
572
+ """A licence identifier within the record bound, or ``None``, which reads as unclear rights.
573
+
574
+ Not truncated, for the reason ids are not truncated: a cut licence id is a different licence
575
+ id. Dropping it maps to ``unclear``, which the facts gate escalates to a human rather than
576
+ guessing at -- the correct outcome for terms this boundary could not read.
577
+ """
578
+
579
+ if not isinstance(value, str):
580
+ return None
581
+ text = value.strip()
582
+ if not text or len(text) > MAX_RECORD_LICENSE_ID:
583
+ return None
584
+ return text
585
+
586
+
587
+ class SourceHarvester(Protocol):
588
+ """One endpoint family, read into records. Takes bytes; never fetches."""
589
+
590
+ @property
591
+ def descriptor(self) -> HarvesterDescriptor:
592
+ """Return the immutable harvester identity."""
593
+
594
+ def parse(
595
+ self,
596
+ payload: bytes,
597
+ *,
598
+ uri: str,
599
+ observed_at: str,
600
+ evidence: EvidenceReference,
601
+ ) -> tuple[HarvestedRecord, ...]:
602
+ """Read this exact recorded response into records, or refuse it."""
603
+
604
+ def parse_page(
605
+ self,
606
+ payload: bytes,
607
+ *,
608
+ uri: str,
609
+ observed_at: str,
610
+ evidence: EvidenceReference,
611
+ response_evidence: HarvestResponseEvidence | None,
612
+ cursor: HarvestCursor | None,
613
+ ) -> HarvestPage:
614
+ """Read a response plus its closed continuation coordinate."""
615
+
616
+
617
+ class HarvestRegistry:
618
+ """Allowlist of harvesters, in the shape of the coordinator's adapter allowlist."""
619
+
620
+ def __init__(self, harvesters: tuple[SourceHarvester, ...] = ()) -> None:
621
+ self._harvesters: dict[tuple[str, str], SourceHarvester] = {}
622
+ for harvester in harvesters:
623
+ self.register(harvester)
624
+
625
+ def register(self, harvester: SourceHarvester) -> None:
626
+ descriptor = harvester.descriptor
627
+ if not isinstance(descriptor, HarvesterDescriptor):
628
+ raise CatalogHarvestError(
629
+ "HARVESTER_DESCRIPTOR",
630
+ "harvester.descriptor",
631
+ "harvester descriptor must use the strict contract",
632
+ )
633
+ key = (descriptor.harvester_id, descriptor.harvester_version)
634
+ if key in self._harvesters:
635
+ raise CatalogHarvestError(
636
+ "HARVESTER_DUPLICATE",
637
+ "registry",
638
+ f"harvester {descriptor.coordinate} is already registered",
639
+ )
640
+ self._harvesters[key] = harvester
641
+
642
+ def resolve(self, harvester_id: str, harvester_version: str) -> SourceHarvester:
643
+ try:
644
+ return self._harvesters[(harvester_id, harvester_version)]
645
+ except KeyError:
646
+ raise CatalogHarvestError(
647
+ "HARVESTER_UNAVAILABLE",
648
+ "harvester_id",
649
+ f"harvester {harvester_id}@{harvester_version} is not allowlisted",
650
+ ) from None
651
+
652
+ def inventory(self) -> tuple[HarvesterDescriptor, ...]:
653
+ return tuple(self._harvesters[key].descriptor for key in sorted(self._harvesters))
654
+
655
+
656
+ def harvest_limits(*media_types: str, max_response_bytes: int) -> RetrievalLimits:
657
+ """Transport caps for one harvester, never looser than the catalog admission cap."""
658
+
659
+ if not media_types:
660
+ raise CatalogHarvestError(
661
+ "EMPTY",
662
+ "media_types",
663
+ "a harvester must allow at least one media type",
664
+ )
665
+ if type(max_response_bytes) is not int or not 1 <= max_response_bytes <= MAX_CATALOG_FACT_BYTES:
666
+ raise CatalogHarvestError(
667
+ "HARVEST_RESPONSE_LIMIT",
668
+ "max_response_bytes",
669
+ f"must be an integer in [1, {MAX_CATALOG_FACT_BYTES}]",
670
+ )
671
+ return RetrievalLimits(
672
+ max_response_bytes=max_response_bytes,
673
+ max_redirects=2,
674
+ max_requests=3,
675
+ allowed_media_types=tuple(media_types),
676
+ )
677
+
678
+
679
+ def fetch_harvest_payload(
680
+ *,
681
+ retriever: PinnedHttpsRetriever,
682
+ url: str,
683
+ observed_at: str,
684
+ limits: RetrievalLimits,
685
+ ) -> tuple[bytes, EvidenceReference]:
686
+ """Fetch one bounded catalogue response and admit it as public fact bytes.
687
+
688
+ Returns the exact bytes and the citation for them. The caller parses; this function does not,
689
+ because a parser that also fetches cannot be tested against a recorded response.
690
+
691
+ ``limits`` governs the fetch. It is threaded into the transport through ``retriever.narrowed``
692
+ rather than merely checked here, because a cap that is validated and then not applied is worse
693
+ than no cap: ``CKAN_LIMITS`` would read as the control that refuses a bot-wall HTML page while
694
+ a retriever configured with a looser allowlist accepted it. Narrowing can only tighten what the
695
+ coordinator configured, so this never widens egress.
696
+ """
697
+
698
+ if limits.max_response_bytes > MAX_CATALOG_FACT_BYTES:
699
+ raise CatalogHarvestError(
700
+ "HARVEST_RESPONSE_LIMIT",
701
+ "limits.max_response_bytes",
702
+ f"a harvest fetch may not exceed {MAX_CATALOG_FACT_BYTES} bytes",
703
+ )
704
+ retrieved = _retrieve_harvest(
705
+ retriever=retriever,
706
+ url=url,
707
+ limits=limits,
708
+ )
709
+ evidence = EvidenceReference(
710
+ uri=retrieved.final_url,
711
+ observed_at=observed_at,
712
+ content_sha256=retrieved.content_sha256,
713
+ media_type=retrieved.media_type,
714
+ )
715
+ return retrieved.content, evidence
716
+
717
+
718
+ def fetch_harvest_response(
719
+ *,
720
+ retriever: PinnedHttpsRetriever,
721
+ url: str,
722
+ observed_at: str,
723
+ limits: RetrievalLimits,
724
+ ) -> tuple[bytes, EvidenceReference, HarvestResponseEvidence]:
725
+ """Fetch like :func:`fetch_harvest_payload`, retaining exact transport accounting."""
726
+
727
+ if limits.max_response_bytes > MAX_CATALOG_FACT_BYTES:
728
+ raise CatalogHarvestError(
729
+ "HARVEST_RESPONSE_LIMIT",
730
+ "limits.max_response_bytes",
731
+ f"a harvest fetch may not exceed {MAX_CATALOG_FACT_BYTES} bytes",
732
+ )
733
+ retrieved = _retrieve_harvest(retriever=retriever, url=url, limits=limits)
734
+ evidence = EvidenceReference(
735
+ uri=retrieved.final_url,
736
+ observed_at=observed_at,
737
+ content_sha256=retrieved.content_sha256,
738
+ media_type=retrieved.media_type,
739
+ )
740
+ return retrieved.content, evidence, HarvestResponseEvidence.from_retrieval(retrieved)
741
+
742
+
743
+ def fetch_datagov_v4_response(
744
+ *,
745
+ retriever: PinnedHttpsRetriever,
746
+ url: str,
747
+ observed_at: str,
748
+ limits: RetrievalLimits,
749
+ authorization: DatagovV4Authorization,
750
+ on_request_started: Callable[[], None] | None = None,
751
+ on_response_observed: (
752
+ Callable[[HarvestResponseEvidence | DatagovRefusedResponseEvidence], None] | None
753
+ ) = None,
754
+ on_response_received: (
755
+ Callable[[bytes, EvidenceReference, HarvestResponseEvidence], None] | None
756
+ ) = None,
757
+ ) -> tuple[bytes, EvidenceReference, HarvestResponseEvidence]:
758
+ """Fetch the exact authenticated v4 response without widening legacy admission caps."""
759
+
760
+ if not 1 <= limits.max_response_bytes <= 16 * 1024 * 1024 or limits.max_redirects != 0:
761
+ raise CatalogHarvestError(
762
+ "DATAGOV_RESPONSE_LIMIT",
763
+ "limits",
764
+ "Data.gov v4 requires the exact 16 MiB, zero-redirect transport boundary",
765
+ )
766
+ if not isinstance(authorization, DatagovV4Authorization):
767
+ raise CatalogHarvestError(
768
+ "DATAGOV_AUTHORIZATION", "authorization", "strict authorization is required"
769
+ )
770
+ bounded = retriever.narrowed(limits)
771
+ redacted_observation: DatagovRefusedResponseEvidence | None = None
772
+
773
+ def observe_transport(
774
+ status: int, network_bytes: int, rate: DatagovRateObservation | None
775
+ ) -> None:
776
+ nonlocal redacted_observation
777
+ redacted_observation = DatagovRefusedResponseEvidence(
778
+ final_url=url,
779
+ status=status,
780
+ network_bytes=network_bytes,
781
+ rate_observation=rate,
782
+ )
783
+ if on_response_observed is not None:
784
+ on_response_observed(redacted_observation)
785
+
786
+ try:
787
+ if on_request_started is not None:
788
+ on_request_started()
789
+ if isinstance(bounded, PinnedHttpsRetriever):
790
+ retrieved = bounded.retrieve_datagov_v4(
791
+ url, authorization, on_response_observed=observe_transport
792
+ )
793
+ else:
794
+ retrieved = bounded.retrieve_datagov_v4(url, authorization)
795
+ except DatagovResponseRefused as error:
796
+ redacted = DatagovRefusedResponseEvidence(
797
+ final_url=url,
798
+ status=error.status,
799
+ network_bytes=error.network_bytes,
800
+ rate_observation=None,
801
+ )
802
+ if on_response_observed is not None:
803
+ on_response_observed(redacted)
804
+ raise DatagovFetchRefused(
805
+ error.code,
806
+ "harvest.response",
807
+ error.detail,
808
+ response_evidence=redacted,
809
+ ) from None
810
+ except AcquisitionSecurityError as error:
811
+ if redacted_observation is not None:
812
+ raise DatagovFetchRefused(
813
+ error.code,
814
+ "harvest.response",
815
+ error.detail,
816
+ response_evidence=redacted_observation,
817
+ ) from None
818
+ raise _translate(error, url) from None
819
+ response_evidence = HarvestResponseEvidence.from_retrieval(retrieved)
820
+ redacted = redacted_observation or DatagovRefusedResponseEvidence(
821
+ final_url=response_evidence.final_url,
822
+ status=response_evidence.status,
823
+ network_bytes=response_evidence.network_bytes,
824
+ rate_observation=response_evidence.rate_observation,
825
+ )
826
+ if redacted_observation is None and on_response_observed is not None:
827
+ on_response_observed(redacted)
828
+ try:
829
+ authorization.reject_echoed_response(retrieved.content)
830
+ except AcquisitionSecurityError as error:
831
+ raise DatagovFetchRefused(
832
+ error.code,
833
+ "harvest.response",
834
+ error.detail,
835
+ response_evidence=redacted,
836
+ ) from None
837
+ if on_response_observed is not None:
838
+ on_response_observed(response_evidence)
839
+ evidence = EvidenceReference(
840
+ uri=retrieved.final_url,
841
+ observed_at=observed_at,
842
+ content_sha256=retrieved.content_sha256,
843
+ media_type=retrieved.media_type,
844
+ )
845
+ if on_response_received is not None:
846
+ on_response_received(retrieved.content, evidence, response_evidence)
847
+ return retrieved.content, evidence, response_evidence
848
+
849
+
850
+ def _retrieve_harvest(
851
+ *, retriever: PinnedHttpsRetriever, url: str, limits: RetrievalLimits
852
+ ) -> RetrievedBytes:
853
+ bounded = retriever.narrowed(limits)
854
+ try:
855
+ retrieved = bounded.retrieve(url)
856
+ except AcquisitionSecurityError as error:
857
+ raise _translate(error, url) from None
858
+ admit_public_fact_bytes(retrieved.content)
859
+ return retrieved
860
+
861
+
862
+ def _translate(error: AcquisitionSecurityError, url: str) -> CatalogHarvestError:
863
+ host = _hostname(url)
864
+ if error.code == "MEDIA_TYPE":
865
+ # The bot-wall case: a 200 carrying an interstitial HTML page. Naming the host turns a
866
+ # generic transport error into a durable fact about that instance.
867
+ return CatalogHarvestError(
868
+ "HARVEST_MEDIA_TYPE",
869
+ "harvest.response",
870
+ f"{host} answered with a media type this harvester does not accept: {error.detail}",
871
+ )
872
+ # A cycle survey treats 404 as an absent unpublished run. A catalog harvest still treats that
873
+ # same response as an endpoint that is not open;
874
+ # the type split must not bypass the catalog boundary's existing translation.
875
+ if error.code in {"HTTP_STATUS", "HTTP_NOT_FOUND"}:
876
+ match = _STATUS_TAIL.search(error.detail)
877
+ status = int(match.group(1)) if match else None
878
+ if status in {401, 403}:
879
+ return CatalogHarvestError(
880
+ "HARVEST_ENDPOINT_NOT_OPEN",
881
+ "harvest.response",
882
+ f"{host} answered HTTP {status}: {AUTHENTICATED_ADAPTER_PARKED}",
883
+ )
884
+ return CatalogHarvestError(
885
+ "HARVEST_ENDPOINT_NOT_OPEN",
886
+ "harvest.response",
887
+ f"{host} answered {error.detail}",
888
+ )
889
+ if error.code in {"RESPONSE_LIMIT", "CONTENT_LENGTH"}:
890
+ return CatalogHarvestError(
891
+ "HARVEST_RESPONSE_LIMIT",
892
+ "harvest.response",
893
+ f"{host} answered outside the harvest byte bound: {error.detail}",
894
+ )
895
+ # Egress, DNS, URL policy, TLS, concurrency and rate-limit failures are facts about this side
896
+ # of the connection, not about the catalogue. They propagate untouched.
897
+ raise error
898
+
899
+
900
+ def _hostname(url: str) -> str:
901
+ return (urlsplit(url).hostname or "the endpoint").lower()
902
+
903
+
904
+ def _same_protocol_record(record: Any, protocol: str) -> bool:
905
+ if isinstance(record, HarvestedRecord):
906
+ return record.protocol == protocol
907
+ return (
908
+ protocol == "datagov_v4"
909
+ and record.__class__.__name__ == "DatagovV4Record"
910
+ and record.__class__.__module__.endswith(".harvest.datagov_v4")
911
+ and getattr(record, "protocol", None) == protocol
912
+ and callable(getattr(record, "to_dict", None))
913
+ and isinstance(getattr(record, "digest", None), str)
914
+ )
915
+
916
+
917
+ def _text(value: Any, path: str, *, maximum: int) -> str:
918
+ if not isinstance(value, str) or not value or len(value) > maximum:
919
+ raise CatalogHarvestError(
920
+ "TEXT",
921
+ path,
922
+ f"must be a non-empty string no longer than {maximum} characters",
923
+ )
924
+ return value
925
+
926
+
927
+ def validate_datagov_cursor_text(value: Any, path: str) -> str:
928
+ """Validate the one closed Unicode/control boundary for v4 continuation text."""
929
+
930
+ text = _text(value, path, maximum=MAX_RECORD_URI)
931
+ try:
932
+ text.encode("utf-8", errors="strict")
933
+ except UnicodeEncodeError:
934
+ raise CatalogHarvestError(
935
+ "HARVEST_CURSOR", path, "Data.gov cursor must be valid Unicode"
936
+ ) from None
937
+ if any(
938
+ ord(character) < 0x20
939
+ or 0x7F <= ord(character) <= 0x9F
940
+ or (character.isspace() and character != " ")
941
+ for character in text
942
+ ):
943
+ raise CatalogHarvestError(
944
+ "HARVEST_CURSOR", path, "Data.gov cursor contains unsafe control or whitespace text"
945
+ )
946
+ return text
947
+
948
+
949
+ def _identifier(value: Any, path: str) -> str:
950
+ text = _text(value, path, maximum=128)
951
+ if not re.fullmatch(r"[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*", text):
952
+ raise CatalogHarvestError(
953
+ "IDENTIFIER",
954
+ path,
955
+ "must be a lowercase dotted/dashed/underscored identifier",
956
+ )
957
+ return text
958
+
959
+
960
+ def _https_uri(value: Any, path: str) -> str:
961
+ text = _text(value, path, maximum=2_048)
962
+ if not text.startswith("https://") or len(text) <= len("https://"):
963
+ raise CatalogHarvestError("HTTPS_URI", path, "must be an HTTPS URI")
964
+ return text