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,1017 @@
1
+ """Closed immutable contracts for deterministic preparation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+ from mostlyright.data_harness.canonical import canonical_sha256
12
+ from mostlyright.data_harness.local_contracts import LOGICAL_TYPES
13
+ from mostlyright.data_harness.preparation.errors import PreparationError
14
+
15
+ PREPARATION_CONTRACT_VERSION = "preparation.v1"
16
+ PROFILE_VERSION = "table-profile.v1"
17
+ OPERATION_AUDIT_VERSION = "operation-audit.v1"
18
+ JOIN_AUDIT_VERSION = "join-audit.v1"
19
+ TEMPORAL_REPORT_VERSION = "temporal-report.v1"
20
+ TARGET_RATIONALE_VERSION = "target-rationale.v1"
21
+ QUALITY_REPORT_VERSION = "quality-report.v1"
22
+
23
+ # Ordered because the quality report serializes checks in this policy-owned order. Repair derives
24
+ # its canonically sorted rerun inventory from the same immutable mapping.
25
+ QUALITY_CHECK_POLICY = MappingProxyType(
26
+ {
27
+ "schema": ("blocker", "schema.exact.v1"),
28
+ "row_bounds": ("critical", "rows.declared-bounds.v1"),
29
+ "missingness": ("high", "missingness.absolute.v1"),
30
+ "grain": ("critical", "grain.non-null-unique.v1"),
31
+ "duplicates": ("high", "rows.no-exact-duplicates.v1"),
32
+ "join_multiplier": ("critical", "join.maximum-multiplier.v1"),
33
+ "temporal": ("blocker", "temporal.cutoff-and-revision.v1"),
34
+ "outliers": ("high", "outlier.tukey-count.v1"),
35
+ "missing_periods": ("high", "temporal.frequency-gap-count.v1"),
36
+ }
37
+ )
38
+
39
+ MAX_FIELDS = 1_024
40
+ MAX_ROWS = 1_000_000
41
+ MAX_OPERATIONS = 256
42
+ MAX_JOINS = 32
43
+ MAX_ALIASES = 10_000
44
+ MAX_TEXT = 4_096
45
+
46
+ PHYSICAL_TYPES = {
47
+ "string": "utf8",
48
+ "int64": "int64",
49
+ "float64": "double",
50
+ "boolean": "bool",
51
+ "date": "date32",
52
+ "timestamp_utc": "timestamp[us, tz=UTC]",
53
+ }
54
+ SEMANTIC_TYPES = frozenset(
55
+ {
56
+ "identifier",
57
+ "entity",
58
+ "category",
59
+ "measure",
60
+ "dimension",
61
+ "target",
62
+ "other",
63
+ }
64
+ )
65
+ TEMPORAL_ROLES = frozenset(
66
+ {
67
+ "none",
68
+ "event_time",
69
+ "observed_at",
70
+ "available_at",
71
+ "ingested_at",
72
+ "cutoff",
73
+ "revision_time",
74
+ }
75
+ )
76
+ UNITS = frozenset(
77
+ {
78
+ "none",
79
+ "count",
80
+ "ratio",
81
+ "percent",
82
+ "celsius",
83
+ "fahrenheit",
84
+ "kelvin",
85
+ "meter",
86
+ "kilometer",
87
+ "mile",
88
+ "second",
89
+ "minute",
90
+ "hour",
91
+ "hectopascal",
92
+ }
93
+ )
94
+ OPERATION_KINDS = frozenset(
95
+ {
96
+ "trim",
97
+ "case_normalize",
98
+ "null_tokens",
99
+ "strict_cast",
100
+ "unit_convert",
101
+ "entity_resolve",
102
+ "rename",
103
+ "project",
104
+ "deduplicate",
105
+ }
106
+ )
107
+ CARDINALITIES = frozenset({"one_to_one", "one_to_many", "many_to_one", "many_to_many"})
108
+ TEMPORAL_FIDELITIES = frozenset({"exact", "reconstructed", "latest_only", "unknown"})
109
+ # The blocking severities only. Deliberately not the same set as
110
+ # ``repair.contracts.SEVERITIES``, which is all five levels; this is the same subset the
111
+ # repair package spells ``BLOCKING_SEVERITIES``. Nothing in ``src`` reads it today.
112
+ BLOCKING_SEVERITIES = frozenset({"blocker", "critical", "high"})
113
+ _IDENT = re.compile(r"\A[A-Za-z][A-Za-z0-9_.-]{0,127}\Z")
114
+ _DIGEST = re.compile(r"\A[0-9a-f]{64}\Z")
115
+ _UTC = re.compile(r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z\Z")
116
+
117
+ _OPERATION_PARAMETERS: dict[str, tuple[str, ...]] = {
118
+ "trim": ("column",),
119
+ "case_normalize": ("column", "mode"),
120
+ "null_tokens": ("column", "tokens"),
121
+ "strict_cast": ("column", "to_type"),
122
+ "unit_convert": ("column", "from_unit", "to_unit"),
123
+ "entity_resolve": ("column", "entity_type", "aliases"),
124
+ "rename": ("mappings",),
125
+ "project": ("columns",),
126
+ "deduplicate": ("keys", "authority"),
127
+ }
128
+
129
+
130
+ def _text(value: Any, path: str, *, maximum: int = MAX_TEXT) -> str:
131
+ if not isinstance(value, str) or not value or len(value) > maximum:
132
+ raise PreparationError("CONTRACT_TEXT", path, f"must be 1..{maximum} characters")
133
+ if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
134
+ raise PreparationError("CONTRACT_UNICODE", path, "contains an unpaired surrogate")
135
+ return value
136
+
137
+
138
+ def _identifier(value: Any, path: str) -> str:
139
+ if not isinstance(value, str) or _IDENT.fullmatch(value) is None:
140
+ raise PreparationError("CONTRACT_IDENTIFIER", path, "must be a canonical identifier")
141
+ return value
142
+
143
+
144
+ def _digest(value: Any, path: str) -> str:
145
+ if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
146
+ raise PreparationError("CONTRACT_DIGEST", path, "must be a lowercase SHA-256 digest")
147
+ return value
148
+
149
+
150
+ def parse_utc(value: Any, path: str) -> datetime:
151
+ if not isinstance(value, str) or _UTC.fullmatch(value) is None:
152
+ raise PreparationError(
153
+ "CONTRACT_TIMESTAMP",
154
+ path,
155
+ "must be a canonical UTC RFC 3339 timestamp",
156
+ )
157
+ try:
158
+ return datetime.fromisoformat(value[:-1] + "+00:00")
159
+ except ValueError:
160
+ raise PreparationError(
161
+ "CONTRACT_TIMESTAMP",
162
+ path,
163
+ "timestamp is not a real instant",
164
+ ) from None
165
+
166
+
167
+ def _exact_keys(value: Any, keys: set[str], path: str) -> dict[str, Any]:
168
+ if not isinstance(value, dict) or set(value) != keys:
169
+ raise PreparationError(
170
+ "CONTRACT_FIELDS",
171
+ path,
172
+ f"fields must be exactly {sorted(keys)}",
173
+ )
174
+ return value
175
+
176
+
177
+ def _tuple(
178
+ value: Any,
179
+ path: str,
180
+ *,
181
+ minimum: int = 0,
182
+ maximum: int = MAX_FIELDS,
183
+ ) -> tuple[Any, ...]:
184
+ if not isinstance(value, tuple) or not minimum <= len(value) <= maximum:
185
+ raise PreparationError(
186
+ "CONTRACT_COLLECTION",
187
+ path,
188
+ f"must be an immutable tuple with {minimum}..{maximum} members",
189
+ )
190
+ return value
191
+
192
+
193
+ def _identifiers(
194
+ value: Any,
195
+ path: str,
196
+ *,
197
+ minimum: int = 1,
198
+ maximum: int = MAX_FIELDS,
199
+ ) -> tuple[str, ...]:
200
+ values = _tuple(value, path, minimum=minimum, maximum=maximum)
201
+ result = tuple(_identifier(item, f"{path}[{index}]") for index, item in enumerate(values))
202
+ if len(set(result)) != len(result):
203
+ raise PreparationError("CONTRACT_DUPLICATE", path, "contains duplicate identifiers")
204
+ return result
205
+
206
+
207
+ def _pairs(
208
+ value: Any,
209
+ path: str,
210
+ *,
211
+ maximum: int,
212
+ require_sorted: bool = True,
213
+ ) -> tuple[tuple[str, str], ...]:
214
+ pairs = _tuple(value, path, minimum=1, maximum=maximum)
215
+ result: list[tuple[str, str]] = []
216
+ for index, pair in enumerate(pairs):
217
+ if not isinstance(pair, tuple) or len(pair) != 2:
218
+ raise PreparationError(
219
+ "CONTRACT_PAIR",
220
+ f"{path}[{index}]",
221
+ "must be an immutable two-member tuple",
222
+ )
223
+ left = _text(pair[0], f"{path}[{index}][0]", maximum=256)
224
+ right = _text(pair[1], f"{path}[{index}][1]", maximum=256)
225
+ result.append((left, right))
226
+ if len({left for left, _ in result}) != len(result):
227
+ raise PreparationError("CONTRACT_DUPLICATE", path, "contains duplicate left values")
228
+ if require_sorted and tuple(result) != tuple(sorted(result)):
229
+ raise PreparationError("CONTRACT_ORDER", path, "must be sorted canonically")
230
+ return tuple(result)
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class FieldSpec:
235
+ """Declared physical, logical, semantic, unit, entity, and temporal field state."""
236
+
237
+ name: str
238
+ logical_type: str
239
+ physical_type: str
240
+ semantic_type: str
241
+ unit: str
242
+ entity: str | None
243
+ temporal_role: str
244
+ nullable: bool
245
+ source_id: str
246
+ availability_column: str | None = None
247
+
248
+ def __post_init__(self) -> None:
249
+ _identifier(self.name, "field.name")
250
+ if self.logical_type not in LOGICAL_TYPES:
251
+ raise PreparationError("FIELD_LOGICAL_TYPE", "field.logical_type", "is unsupported")
252
+ if self.physical_type != PHYSICAL_TYPES[self.logical_type]:
253
+ raise PreparationError(
254
+ "FIELD_PHYSICAL_TYPE",
255
+ "field.physical_type",
256
+ "does not match the logical type",
257
+ )
258
+ if self.semantic_type not in SEMANTIC_TYPES:
259
+ raise PreparationError("FIELD_SEMANTIC_TYPE", "field.semantic_type", "is unsupported")
260
+ if self.unit not in UNITS:
261
+ raise PreparationError("FIELD_UNIT", "field.unit", "is unsupported")
262
+ if self.semantic_type == "measure" and self.unit == "none":
263
+ raise PreparationError("FIELD_UNIT", "field.unit", "measure fields require a unit")
264
+ if self.semantic_type != "measure" and self.unit not in {"none", "count"}:
265
+ raise PreparationError(
266
+ "FIELD_UNIT",
267
+ "field.unit",
268
+ "non-measure fields cannot carry a measurement unit",
269
+ )
270
+ if self.entity is not None:
271
+ _identifier(self.entity, "field.entity")
272
+ if self.semantic_type == "entity" and self.entity is None:
273
+ raise PreparationError("FIELD_ENTITY", "field.entity", "entity fields require a type")
274
+ if self.temporal_role not in TEMPORAL_ROLES:
275
+ raise PreparationError("FIELD_TEMPORAL_ROLE", "field.temporal_role", "is unsupported")
276
+ if self.temporal_role != "none" and self.logical_type not in {"date", "timestamp_utc"}:
277
+ raise PreparationError(
278
+ "FIELD_TEMPORAL_TYPE",
279
+ "field.logical_type",
280
+ "temporal roles require date or UTC timestamp values",
281
+ )
282
+ if type(self.nullable) is not bool:
283
+ raise PreparationError("FIELD_NULLABLE", "field.nullable", "must be Boolean")
284
+ _identifier(self.source_id, "field.source_id")
285
+ if self.availability_column is not None:
286
+ _identifier(self.availability_column, "field.availability_column")
287
+ if (
288
+ self.temporal_role == "none"
289
+ and self.semantic_type != "target"
290
+ and self.availability_column is None
291
+ ):
292
+ raise PreparationError(
293
+ "FIELD_TEMPORAL_AMBIGUITY",
294
+ "field.availability_column",
295
+ "selected non-temporal fields require explicit availability semantics",
296
+ )
297
+
298
+ def to_dict(self) -> dict[str, Any]:
299
+ return {
300
+ "name": self.name,
301
+ "logical_type": self.logical_type,
302
+ "physical_type": self.physical_type,
303
+ "semantic_type": self.semantic_type,
304
+ "unit": self.unit,
305
+ "entity": self.entity,
306
+ "temporal_role": self.temporal_role,
307
+ "nullable": self.nullable,
308
+ "source_id": self.source_id,
309
+ "availability_column": self.availability_column,
310
+ }
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class LineageInput:
315
+ source_id: str
316
+ snapshot_digest: str
317
+ column: str
318
+
319
+ def __post_init__(self) -> None:
320
+ _identifier(self.source_id, "lineage.source_id")
321
+ _digest(self.snapshot_digest, "lineage.snapshot_digest")
322
+ _identifier(self.column, "lineage.column")
323
+
324
+ def to_dict(self) -> dict[str, str]:
325
+ return {
326
+ "source_id": self.source_id,
327
+ "snapshot_digest": self.snapshot_digest,
328
+ "column": self.column,
329
+ }
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class ColumnLineage:
334
+ output_column: str
335
+ inputs: tuple[LineageInput, ...]
336
+ operation_ids: tuple[str, ...]
337
+
338
+ def __post_init__(self) -> None:
339
+ _identifier(self.output_column, "lineage.output_column")
340
+ _tuple(self.inputs, "lineage.inputs", minimum=1, maximum=MAX_FIELDS)
341
+ if len(set(self.inputs)) != len(self.inputs):
342
+ raise PreparationError("CONTRACT_DUPLICATE", "lineage.inputs", "contains duplicates")
343
+ _identifiers(self.operation_ids, "lineage.operation_ids", minimum=0, maximum=MAX_OPERATIONS)
344
+
345
+ def to_dict(self) -> dict[str, Any]:
346
+ return {
347
+ "output_column": self.output_column,
348
+ "inputs": [item.to_dict() for item in self.inputs],
349
+ "operation_ids": list(self.operation_ids),
350
+ }
351
+
352
+
353
+ @dataclass(frozen=True)
354
+ class OperationSpec:
355
+ """One allowlisted operation with a kind-specific immutable parameter shape."""
356
+
357
+ operation_id: str
358
+ source_id: str
359
+ kind: str
360
+ parameters: tuple[tuple[str, Any], ...]
361
+
362
+ def __post_init__(self) -> None:
363
+ _identifier(self.operation_id, "operation.operation_id")
364
+ _identifier(self.source_id, "operation.source_id")
365
+ if self.kind not in OPERATION_KINDS:
366
+ raise PreparationError("OPERATION_KIND", "operation.kind", "is not allowlisted")
367
+ if not isinstance(self.parameters, tuple):
368
+ raise PreparationError(
369
+ "OPERATION_PARAMETERS",
370
+ "operation.parameters",
371
+ "must be an immutable tuple",
372
+ )
373
+ names = tuple(name for name, _ in self.parameters if isinstance(name, str))
374
+ if len(names) != len(self.parameters) or names != _OPERATION_PARAMETERS[self.kind]:
375
+ raise PreparationError(
376
+ "OPERATION_PARAMETERS",
377
+ "operation.parameters",
378
+ f"{self.kind} parameters must be exactly {_OPERATION_PARAMETERS[self.kind]}",
379
+ )
380
+ values = dict(self.parameters)
381
+ if self.kind in {"trim", "case_normalize", "null_tokens", "strict_cast", "unit_convert"}:
382
+ _identifier(values["column"], "operation.parameters.column")
383
+ if self.kind == "case_normalize" and values["mode"] not in {"lower", "upper", "casefold"}:
384
+ raise PreparationError("OPERATION_MODE", "operation.parameters.mode", "is unsupported")
385
+ if self.kind == "null_tokens":
386
+ tokens = _tuple(
387
+ values["tokens"],
388
+ "operation.parameters.tokens",
389
+ minimum=1,
390
+ maximum=128,
391
+ )
392
+ checked_values: list[str] = []
393
+ for index, item in enumerate(tokens):
394
+ path = f"operation.parameters.tokens[{index}]"
395
+ if not isinstance(item, str) or len(item) > 256:
396
+ raise PreparationError(
397
+ "CONTRACT_TEXT",
398
+ path,
399
+ "must be 0..256 characters",
400
+ )
401
+ if any(0xD800 <= ord(character) <= 0xDFFF for character in item):
402
+ raise PreparationError(
403
+ "CONTRACT_UNICODE",
404
+ path,
405
+ "contains an unpaired surrogate",
406
+ )
407
+ checked_values.append(item)
408
+ checked = tuple(checked_values)
409
+ if checked != tuple(sorted(set(checked))):
410
+ raise PreparationError(
411
+ "OPERATION_TOKENS",
412
+ "operation.parameters.tokens",
413
+ "must be unique and canonically sorted",
414
+ )
415
+ if self.kind == "strict_cast" and values["to_type"] not in LOGICAL_TYPES:
416
+ raise PreparationError(
417
+ "OPERATION_CAST_TYPE",
418
+ "operation.parameters.to_type",
419
+ "is unsupported",
420
+ )
421
+ if self.kind == "unit_convert":
422
+ if values["from_unit"] not in UNITS or values["to_unit"] not in UNITS:
423
+ raise PreparationError(
424
+ "OPERATION_UNIT",
425
+ "operation.parameters",
426
+ "contains an unsupported unit",
427
+ )
428
+ if values["from_unit"] == values["to_unit"]:
429
+ raise PreparationError(
430
+ "OPERATION_UNIT",
431
+ "operation.parameters",
432
+ "source and target units must differ",
433
+ )
434
+ if self.kind == "entity_resolve":
435
+ _identifier(values["column"], "operation.parameters.column")
436
+ _identifier(values["entity_type"], "operation.parameters.entity_type")
437
+ aliases = _pairs(
438
+ values["aliases"],
439
+ "operation.parameters.aliases",
440
+ maximum=MAX_ALIASES,
441
+ )
442
+ canonical_values = {canonical for _, canonical in aliases}
443
+ if any(
444
+ alias in canonical_values and alias != canonical for alias, canonical in aliases
445
+ ):
446
+ raise PreparationError(
447
+ "ENTITY_AMBIGUOUS",
448
+ "operation.parameters.aliases",
449
+ "a canonical entity is also mapped to a different entity",
450
+ )
451
+ if self.kind == "rename":
452
+ mappings = _pairs(
453
+ values["mappings"],
454
+ "operation.parameters.mappings",
455
+ maximum=MAX_FIELDS,
456
+ )
457
+ for index, (old, new) in enumerate(mappings):
458
+ _identifier(old, f"operation.parameters.mappings[{index}][0]")
459
+ _identifier(new, f"operation.parameters.mappings[{index}][1]")
460
+ if len({new for _, new in mappings}) != len(mappings):
461
+ raise PreparationError(
462
+ "OPERATION_RENAME",
463
+ "operation.parameters.mappings",
464
+ "target names must be unique",
465
+ )
466
+ if self.kind == "project":
467
+ _identifiers(values["columns"], "operation.parameters.columns")
468
+ if self.kind == "deduplicate":
469
+ _identifiers(values["keys"], "operation.parameters.keys")
470
+ authority = _pairs(
471
+ values["authority"],
472
+ "operation.parameters.authority",
473
+ maximum=MAX_FIELDS,
474
+ require_sorted=False,
475
+ )
476
+ for index, (column, direction) in enumerate(authority):
477
+ _identifier(column, f"operation.parameters.authority[{index}][0]")
478
+ if direction not in {"ascending", "descending"}:
479
+ raise PreparationError(
480
+ "OPERATION_DIRECTION",
481
+ f"operation.parameters.authority[{index}][1]",
482
+ "must be ascending or descending",
483
+ )
484
+ if len({column for column, _ in authority}) != len(authority):
485
+ raise PreparationError(
486
+ "CONTRACT_DUPLICATE",
487
+ "operation.parameters.authority",
488
+ "contains duplicate columns",
489
+ )
490
+
491
+ def parameter(self, name: str) -> Any:
492
+ return dict(self.parameters)[name]
493
+
494
+ def to_dict(self) -> dict[str, Any]:
495
+ return {
496
+ "operation_id": self.operation_id,
497
+ "source_id": self.source_id,
498
+ "kind": self.kind,
499
+ "parameters": {name: _thaw(value) for name, value in self.parameters},
500
+ }
501
+
502
+ @property
503
+ def parameters_digest(self) -> str:
504
+ return canonical_sha256(self.to_dict()["parameters"])
505
+
506
+
507
+ @dataclass(frozen=True)
508
+ class EqualityJoinSpec:
509
+ join_id: str
510
+ right_source_id: str
511
+ left_keys: tuple[str, ...]
512
+ right_keys: tuple[str, ...]
513
+ cardinality: str
514
+ suffix: str
515
+
516
+ def __post_init__(self) -> None:
517
+ _identifier(self.join_id, "join.join_id")
518
+ _identifier(self.right_source_id, "join.right_source_id")
519
+ left = _identifiers(self.left_keys, "join.left_keys")
520
+ right = _identifiers(self.right_keys, "join.right_keys")
521
+ if len(left) != len(right):
522
+ raise PreparationError("JOIN_KEYS", "join", "left and right key counts differ")
523
+ if self.cardinality not in CARDINALITIES:
524
+ raise PreparationError("JOIN_CARDINALITY", "join.cardinality", "is unsupported")
525
+ _identifier(self.suffix, "join.suffix")
526
+
527
+ def to_dict(self) -> dict[str, Any]:
528
+ return {
529
+ "kind": "equality_left",
530
+ "join_id": self.join_id,
531
+ "right_source_id": self.right_source_id,
532
+ "left_keys": list(self.left_keys),
533
+ "right_keys": list(self.right_keys),
534
+ "cardinality": self.cardinality,
535
+ "suffix": self.suffix,
536
+ }
537
+
538
+
539
+ @dataclass(frozen=True)
540
+ class AsOfJoinSpec:
541
+ join_id: str
542
+ right_source_id: str
543
+ left_by: tuple[str, ...]
544
+ right_by: tuple[str, ...]
545
+ left_time: str
546
+ left_cutoff: str
547
+ right_time: str
548
+ right_available_at: str
549
+ right_revision_time: str
550
+ direction: str
551
+ tolerance_seconds: int
552
+ revision_policy: str
553
+ question_cutoff: str
554
+ cardinality: str
555
+ suffix: str
556
+
557
+ def __post_init__(self) -> None:
558
+ _identifier(self.join_id, "join.join_id")
559
+ _identifier(self.right_source_id, "join.right_source_id")
560
+ left = _identifiers(self.left_by, "join.left_by")
561
+ right = _identifiers(self.right_by, "join.right_by")
562
+ if len(left) != len(right):
563
+ raise PreparationError("JOIN_KEYS", "join", "partition key counts differ")
564
+ for name in (
565
+ "left_time",
566
+ "left_cutoff",
567
+ "right_time",
568
+ "right_available_at",
569
+ "right_revision_time",
570
+ ):
571
+ _identifier(getattr(self, name), f"join.{name}")
572
+ if self.direction != "backward":
573
+ raise PreparationError("JOIN_DIRECTION", "join.direction", "only backward is allowed")
574
+ if type(self.tolerance_seconds) is not int or not 0 <= self.tolerance_seconds <= 31_536_000:
575
+ raise PreparationError(
576
+ "JOIN_TOLERANCE",
577
+ "join.tolerance_seconds",
578
+ "must be an integer between 0 and 31536000",
579
+ )
580
+ if self.revision_policy not in {"latest_available", "earliest_available"}:
581
+ raise PreparationError(
582
+ "JOIN_REVISION_POLICY",
583
+ "join.revision_policy",
584
+ "is unsupported",
585
+ )
586
+ parse_utc(self.question_cutoff, "join.question_cutoff")
587
+ if self.cardinality not in {"one_to_one", "many_to_one"}:
588
+ raise PreparationError(
589
+ "JOIN_CARDINALITY",
590
+ "join.cardinality",
591
+ "as-of joins require one_to_one or many_to_one",
592
+ )
593
+ _identifier(self.suffix, "join.suffix")
594
+
595
+ def to_dict(self) -> dict[str, Any]:
596
+ return {
597
+ "kind": "asof_left",
598
+ "join_id": self.join_id,
599
+ "right_source_id": self.right_source_id,
600
+ "left_by": list(self.left_by),
601
+ "right_by": list(self.right_by),
602
+ "left_time": self.left_time,
603
+ "left_cutoff": self.left_cutoff,
604
+ "right_time": self.right_time,
605
+ "right_available_at": self.right_available_at,
606
+ "right_revision_time": self.right_revision_time,
607
+ "direction": self.direction,
608
+ "tolerance_seconds": self.tolerance_seconds,
609
+ "revision_policy": self.revision_policy,
610
+ "question_cutoff": self.question_cutoff,
611
+ "cardinality": self.cardinality,
612
+ "suffix": self.suffix,
613
+ }
614
+
615
+
616
+ JoinSpec = EqualityJoinSpec | AsOfJoinSpec
617
+
618
+
619
+ @dataclass(frozen=True)
620
+ class SourceTemporalEvidence:
621
+ source_id: str
622
+ snapshot_digest: str
623
+ fidelity: str
624
+ retrieval_time: str
625
+ event_time_column: str | None
626
+ available_at_column: str
627
+ revision_time_column: str | None
628
+
629
+ def __post_init__(self) -> None:
630
+ _identifier(self.source_id, "temporal.source_id")
631
+ _digest(self.snapshot_digest, "temporal.snapshot_digest")
632
+ if self.fidelity not in TEMPORAL_FIDELITIES:
633
+ raise PreparationError("TEMPORAL_FIDELITY", "temporal.fidelity", "is unsupported")
634
+ parse_utc(self.retrieval_time, "temporal.retrieval_time")
635
+ if self.event_time_column is not None:
636
+ _identifier(self.event_time_column, "temporal.event_time_column")
637
+ _identifier(self.available_at_column, "temporal.available_at_column")
638
+ if self.revision_time_column is not None:
639
+ _identifier(self.revision_time_column, "temporal.revision_time_column")
640
+
641
+ def to_dict(self) -> dict[str, Any]:
642
+ return {
643
+ "source_id": self.source_id,
644
+ "snapshot_digest": self.snapshot_digest,
645
+ "fidelity": self.fidelity,
646
+ "retrieval_time": self.retrieval_time,
647
+ "event_time_column": self.event_time_column,
648
+ "available_at_column": self.available_at_column,
649
+ "revision_time_column": self.revision_time_column,
650
+ }
651
+
652
+
653
+ @dataclass(frozen=True)
654
+ class TemporalPolicy:
655
+ analysis_kind: str
656
+ question_cutoff: str
657
+ row_cutoff_column: str | None
658
+
659
+ def __post_init__(self) -> None:
660
+ if self.analysis_kind not in {"point_in_time", "current_retrospective"}:
661
+ raise PreparationError(
662
+ "TEMPORAL_ANALYSIS_KIND",
663
+ "temporal.analysis_kind",
664
+ "is unsupported",
665
+ )
666
+ parse_utc(self.question_cutoff, "temporal.question_cutoff")
667
+ if self.row_cutoff_column is not None:
668
+ _identifier(self.row_cutoff_column, "temporal.row_cutoff_column")
669
+
670
+ def to_dict(self) -> dict[str, Any]:
671
+ return {
672
+ "analysis_kind": self.analysis_kind,
673
+ "question_cutoff": self.question_cutoff,
674
+ "row_cutoff_column": self.row_cutoff_column,
675
+ }
676
+
677
+
678
+ @dataclass(frozen=True)
679
+ class CategoryMapping:
680
+ input_value: str
681
+ output_value: str
682
+
683
+ def __post_init__(self) -> None:
684
+ _text(self.input_value, "target.mapping.input_value", maximum=256)
685
+ _text(self.output_value, "target.mapping.output_value", maximum=256)
686
+
687
+ def to_dict(self) -> dict[str, str]:
688
+ return {"input_value": self.input_value, "output_value": self.output_value}
689
+
690
+
691
+ @dataclass(frozen=True)
692
+ class TargetSpec:
693
+ operation: str
694
+ output_column: str
695
+ source_column: str
696
+ availability_time_column: str
697
+ cutoff_column: str
698
+ source_snapshot_digest: str
699
+ rationale: str
700
+ resolution_rule: str
701
+ horizon_seconds: int
702
+ mapping: tuple[CategoryMapping, ...] = ()
703
+ allow_null: bool = False
704
+
705
+ def __post_init__(self) -> None:
706
+ if self.operation not in {"exact_copy", "categorical_map"}:
707
+ raise PreparationError("TARGET_OPERATION", "target.operation", "is unsupported")
708
+ for name in (
709
+ "output_column",
710
+ "source_column",
711
+ "availability_time_column",
712
+ "cutoff_column",
713
+ ):
714
+ _identifier(getattr(self, name), f"target.{name}")
715
+ if self.output_column == self.source_column:
716
+ raise PreparationError("TARGET_COLUMN", "target.output_column", "must be a new column")
717
+ _digest(self.source_snapshot_digest, "target.source_snapshot_digest")
718
+ _text(self.rationale, "target.rationale")
719
+ _text(self.resolution_rule, "target.resolution_rule")
720
+ if type(self.horizon_seconds) is not int or not 0 <= self.horizon_seconds <= 315_360_000:
721
+ raise PreparationError(
722
+ "TARGET_HORIZON",
723
+ "target.horizon_seconds",
724
+ "must be an integer between 0 and 315360000",
725
+ )
726
+ _tuple(self.mapping, "target.mapping", maximum=1_024)
727
+ if self.operation == "exact_copy" and self.mapping:
728
+ raise PreparationError(
729
+ "TARGET_MAPPING",
730
+ "target.mapping",
731
+ "must be empty for exact_copy",
732
+ )
733
+ if self.operation == "categorical_map":
734
+ if not self.mapping:
735
+ raise PreparationError(
736
+ "TARGET_MAPPING",
737
+ "target.mapping",
738
+ "must be non-empty for categorical_map",
739
+ )
740
+ inputs = tuple(item.input_value for item in self.mapping)
741
+ if inputs != tuple(sorted(set(inputs))):
742
+ raise PreparationError(
743
+ "TARGET_MAPPING",
744
+ "target.mapping",
745
+ "must be unique and canonically sorted",
746
+ )
747
+ if type(self.allow_null) is not bool:
748
+ raise PreparationError("TARGET_NULL", "target.allow_null", "must be Boolean")
749
+
750
+ def to_dict(self) -> dict[str, Any]:
751
+ return {
752
+ "operation": self.operation,
753
+ "output_column": self.output_column,
754
+ "source_column": self.source_column,
755
+ "availability_time_column": self.availability_time_column,
756
+ "cutoff_column": self.cutoff_column,
757
+ "source_snapshot_digest": self.source_snapshot_digest,
758
+ "rationale": self.rationale,
759
+ "resolution_rule": self.resolution_rule,
760
+ "horizon_seconds": self.horizon_seconds,
761
+ "mapping": [item.to_dict() for item in self.mapping],
762
+ "allow_null": self.allow_null,
763
+ }
764
+
765
+
766
+ @dataclass(frozen=True)
767
+ class MissingnessLimit:
768
+ column: str
769
+ maximum_nulls: int
770
+
771
+ def __post_init__(self) -> None:
772
+ _identifier(self.column, "quality.missingness.column")
773
+ if type(self.maximum_nulls) is not int or not 0 <= self.maximum_nulls <= MAX_ROWS:
774
+ raise PreparationError(
775
+ "QUALITY_THRESHOLD",
776
+ "quality.missingness.maximum_nulls",
777
+ "is outside the supported range",
778
+ )
779
+
780
+ def to_dict(self) -> dict[str, Any]:
781
+ return {"column": self.column, "maximum_nulls": self.maximum_nulls}
782
+
783
+
784
+ @dataclass(frozen=True)
785
+ class QualitySpec:
786
+ expected_fields: tuple[FieldSpec, ...]
787
+ grain: tuple[str, ...]
788
+ missingness: tuple[MissingnessLimit, ...]
789
+ maximum_outliers: int
790
+ temporal_column: str | None
791
+ expected_frequency: str
792
+ maximum_missing_periods: int
793
+ maximum_join_multiplier_numerator: int
794
+ maximum_join_multiplier_denominator: int
795
+ minimum_rows: int
796
+ maximum_rows: int
797
+
798
+ def __post_init__(self) -> None:
799
+ _tuple(self.expected_fields, "quality.expected_fields", minimum=1, maximum=MAX_FIELDS)
800
+ names = tuple(field.name for field in self.expected_fields)
801
+ if len(set(names)) != len(names):
802
+ raise PreparationError(
803
+ "CONTRACT_DUPLICATE",
804
+ "quality.expected_fields",
805
+ "duplicates names",
806
+ )
807
+ _identifiers(self.grain, "quality.grain")
808
+ _tuple(self.missingness, "quality.missingness", maximum=MAX_FIELDS)
809
+ missing_columns = tuple(item.column for item in self.missingness)
810
+ if missing_columns != tuple(sorted(set(missing_columns))):
811
+ raise PreparationError(
812
+ "CONTRACT_ORDER",
813
+ "quality.missingness",
814
+ "must be unique and sorted by column",
815
+ )
816
+ if any(column not in names for column in (*self.grain, *missing_columns)):
817
+ raise PreparationError("QUALITY_REFERENCE", "quality", "references an unknown column")
818
+ if type(self.maximum_outliers) is not int or not 0 <= self.maximum_outliers <= MAX_ROWS:
819
+ raise PreparationError("QUALITY_THRESHOLD", "quality.maximum_outliers", "is invalid")
820
+ if self.temporal_column is not None and self.temporal_column not in names:
821
+ raise PreparationError("QUALITY_REFERENCE", "quality.temporal_column", "is unknown")
822
+ if self.expected_frequency not in {"none", "daily", "hourly"}:
823
+ raise PreparationError(
824
+ "QUALITY_FREQUENCY",
825
+ "quality.expected_frequency",
826
+ "is unsupported",
827
+ )
828
+ if (self.temporal_column is None) != (self.expected_frequency == "none"):
829
+ raise PreparationError(
830
+ "QUALITY_FREQUENCY",
831
+ "quality",
832
+ "temporal column and frequency must be declared together",
833
+ )
834
+ for name in (
835
+ "maximum_missing_periods",
836
+ "maximum_join_multiplier_numerator",
837
+ "maximum_join_multiplier_denominator",
838
+ "minimum_rows",
839
+ "maximum_rows",
840
+ ):
841
+ value = getattr(self, name)
842
+ minimum = 1 if name == "maximum_join_multiplier_denominator" else 0
843
+ if type(value) is not int or not minimum <= value <= MAX_ROWS:
844
+ raise PreparationError("QUALITY_THRESHOLD", f"quality.{name}", "is invalid")
845
+ if self.minimum_rows > self.maximum_rows:
846
+ raise PreparationError("QUALITY_THRESHOLD", "quality", "row bounds are reversed")
847
+
848
+ def to_dict(self) -> dict[str, Any]:
849
+ return {
850
+ "expected_fields": [item.to_dict() for item in self.expected_fields],
851
+ "grain": list(self.grain),
852
+ "missingness": [item.to_dict() for item in self.missingness],
853
+ "maximum_outliers": self.maximum_outliers,
854
+ "temporal_column": self.temporal_column,
855
+ "expected_frequency": self.expected_frequency,
856
+ "maximum_missing_periods": self.maximum_missing_periods,
857
+ "maximum_join_multiplier_numerator": self.maximum_join_multiplier_numerator,
858
+ "maximum_join_multiplier_denominator": self.maximum_join_multiplier_denominator,
859
+ "minimum_rows": self.minimum_rows,
860
+ "maximum_rows": self.maximum_rows,
861
+ }
862
+
863
+
864
+ @dataclass(frozen=True)
865
+ class PreparationPlan:
866
+ contract_version: str
867
+ plan_id: str
868
+ primary_source_id: str
869
+ operations: tuple[OperationSpec, ...]
870
+ joins: tuple[JoinSpec, ...]
871
+ output_columns: tuple[str, ...]
872
+ temporal_evidence: tuple[SourceTemporalEvidence, ...]
873
+ temporal_policy: TemporalPolicy
874
+ target_policy: str
875
+ target: TargetSpec | None
876
+ quality: QualitySpec
877
+
878
+ def __post_init__(self) -> None:
879
+ if self.contract_version != PREPARATION_CONTRACT_VERSION:
880
+ raise PreparationError("PLAN_VERSION", "plan.contract_version", "is unsupported")
881
+ _identifier(self.plan_id, "plan.plan_id")
882
+ _identifier(self.primary_source_id, "plan.primary_source_id")
883
+ _tuple(self.operations, "plan.operations", maximum=MAX_OPERATIONS)
884
+ operation_ids = tuple(item.operation_id for item in self.operations)
885
+ if len(set(operation_ids)) != len(operation_ids):
886
+ raise PreparationError(
887
+ "CONTRACT_DUPLICATE",
888
+ "plan.operations",
889
+ "duplicates operation IDs",
890
+ )
891
+ _tuple(self.joins, "plan.joins", maximum=MAX_JOINS)
892
+ join_ids = tuple(item.join_id for item in self.joins)
893
+ if len(set(join_ids)) != len(join_ids):
894
+ raise PreparationError("CONTRACT_DUPLICATE", "plan.joins", "duplicates join IDs")
895
+ _identifiers(self.output_columns, "plan.output_columns")
896
+ _tuple(
897
+ self.temporal_evidence,
898
+ "plan.temporal_evidence",
899
+ minimum=1,
900
+ maximum=MAX_FIELDS,
901
+ )
902
+ source_ids = tuple(item.source_id for item in self.temporal_evidence)
903
+ if source_ids != tuple(sorted(set(source_ids))):
904
+ raise PreparationError(
905
+ "CONTRACT_ORDER",
906
+ "plan.temporal_evidence",
907
+ "must be unique and sorted by source ID",
908
+ )
909
+ if self.target_policy not in {"none", "justified"}:
910
+ raise PreparationError("TARGET_POLICY", "plan.target_policy", "is unsupported")
911
+ if (self.target_policy == "none") != (self.target is None):
912
+ raise PreparationError(
913
+ "TARGET_POLICY",
914
+ "plan.target",
915
+ "must be absent only when target policy is none",
916
+ )
917
+ if self.target is not None:
918
+ forbidden = set(self.quality.grain)
919
+ for operation in self.operations:
920
+ if operation.kind == "deduplicate":
921
+ forbidden.update(operation.parameter("keys"))
922
+ elif operation.kind not in {"project", "rename"}:
923
+ forbidden.add(operation.parameter("column"))
924
+ for join in self.joins:
925
+ if isinstance(join, EqualityJoinSpec):
926
+ forbidden.update(join.left_keys)
927
+ forbidden.update(join.right_keys)
928
+ else:
929
+ forbidden.update(join.left_by)
930
+ forbidden.update(join.right_by)
931
+ forbidden.update(
932
+ {
933
+ join.left_time,
934
+ join.left_cutoff,
935
+ join.right_time,
936
+ join.right_available_at,
937
+ join.right_revision_time,
938
+ }
939
+ )
940
+ if self.target.source_column in forbidden:
941
+ raise PreparationError(
942
+ "TARGET_ISOLATION",
943
+ "plan.target.source_column",
944
+ "cannot be a grain, join, temporal, or cleaning input",
945
+ )
946
+
947
+ @property
948
+ def digest(self) -> str:
949
+ """Hash the ``to_dict`` encoding of this plan.
950
+
951
+ Distinct from ``repair.adapters.plan_digest``, which hashes the raw dataclass
952
+ fields instead. ``to_dict`` adds a ``kind`` discriminator to each join and turns
953
+ each operation's ``parameters`` pair-tuple into a mapping, so the two digests never
954
+ agree for the same plan. Use this one wherever a stored digest was produced by
955
+ ``PreparationResult`` or the Studio wire boundary.
956
+ """
957
+
958
+ return canonical_sha256(self.to_dict())
959
+
960
+ def to_dict(self) -> dict[str, Any]:
961
+ return {
962
+ "contract_version": self.contract_version,
963
+ "plan_id": self.plan_id,
964
+ "primary_source_id": self.primary_source_id,
965
+ "operations": [item.to_dict() for item in self.operations],
966
+ "joins": [item.to_dict() for item in self.joins],
967
+ "output_columns": list(self.output_columns),
968
+ "temporal_evidence": [item.to_dict() for item in self.temporal_evidence],
969
+ "temporal_policy": self.temporal_policy.to_dict(),
970
+ "target_policy": self.target_policy,
971
+ "target": self.target.to_dict() if self.target is not None else None,
972
+ "quality": self.quality.to_dict(),
973
+ }
974
+
975
+
976
+ def _thaw(value: Any) -> Any:
977
+ """Convert frozen tuples to lists so a value can be canonically serialized.
978
+
979
+ Both branches return the same thing; the pair-tuple test is vestigial.
980
+ """
981
+
982
+ if isinstance(value, tuple):
983
+ if value and all(isinstance(item, tuple) and len(item) == 2 for item in value):
984
+ return [_thaw(item) for item in value]
985
+ return [_thaw(item) for item in value]
986
+ return value
987
+
988
+
989
+ __all__ = [
990
+ "BLOCKING_SEVERITIES",
991
+ "CARDINALITIES",
992
+ "JOIN_AUDIT_VERSION",
993
+ "MAX_FIELDS",
994
+ "MAX_ROWS",
995
+ "OPERATION_AUDIT_VERSION",
996
+ "PHYSICAL_TYPES",
997
+ "PREPARATION_CONTRACT_VERSION",
998
+ "PROFILE_VERSION",
999
+ "QUALITY_REPORT_VERSION",
1000
+ "TARGET_RATIONALE_VERSION",
1001
+ "TEMPORAL_REPORT_VERSION",
1002
+ "AsOfJoinSpec",
1003
+ "CategoryMapping",
1004
+ "ColumnLineage",
1005
+ "EqualityJoinSpec",
1006
+ "FieldSpec",
1007
+ "JoinSpec",
1008
+ "LineageInput",
1009
+ "MissingnessLimit",
1010
+ "OperationSpec",
1011
+ "PreparationPlan",
1012
+ "QualitySpec",
1013
+ "SourceTemporalEvidence",
1014
+ "TargetSpec",
1015
+ "TemporalPolicy",
1016
+ "parse_utc",
1017
+ ]