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,514 @@
1
+ """Allowlisted source-adapter registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import hashlib
7
+ import os
8
+ import stat
9
+ from dataclasses import dataclass, field, replace
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from mostlyright.data_harness.acquisition.http import ProbeTransport
14
+ from mostlyright.data_harness.sources.contracts import (
15
+ AcquisitionReceipt,
16
+ AcquisitionReceiptV2,
17
+ AcquisitionReceiptV3,
18
+ AcquisitionRequest,
19
+ AdapterDescriptor,
20
+ SourceContractError,
21
+ SourceObservation,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AcquisitionContext:
27
+ """Trusted coordinator inputs that source content cannot override."""
28
+
29
+ snapshot_root: Path
30
+ trusted_clock: str
31
+ attempt_id: str
32
+ max_source_bytes: int
33
+ max_rows: int
34
+ max_columns: int
35
+ crawler_sandbox_attestation_digest: str | None = None
36
+ # The decode/normalize output budget, distinct from the fetched-byte budget above. It
37
+ # narrows Reader decode output and bounds the sealed snapshot; ``None`` means "no separate
38
+ # budget was stated" and every consumer falls back to ``max_source_bytes``, which is the
39
+ # historical behaviour.
40
+ max_normalized_bytes: int | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.snapshot_root.is_absolute():
44
+ raise SourceContractError(
45
+ "TRUSTED_ROOT",
46
+ "context.snapshot_root",
47
+ "trusted snapshot root must be absolute",
48
+ )
49
+ if type(self.max_source_bytes) is not int or not 1 <= self.max_source_bytes <= 1 << 34:
50
+ raise SourceContractError(
51
+ "LIMIT",
52
+ "context.max_source_bytes",
53
+ "must be a positive bounded integer",
54
+ )
55
+ if self.max_normalized_bytes is not None and (
56
+ type(self.max_normalized_bytes) is not int
57
+ or not 1 <= self.max_normalized_bytes <= 1 << 34
58
+ ):
59
+ raise SourceContractError(
60
+ "LIMIT",
61
+ "context.max_normalized_bytes",
62
+ "must be a positive bounded integer",
63
+ )
64
+ if type(self.max_rows) is not int or not 1 <= self.max_rows <= 10_000_000:
65
+ raise SourceContractError(
66
+ "LIMIT",
67
+ "context.max_rows",
68
+ "must be a positive bounded integer",
69
+ )
70
+ if type(self.max_columns) is not int or not 1 <= self.max_columns <= 10_000:
71
+ raise SourceContractError(
72
+ "LIMIT",
73
+ "context.max_columns",
74
+ "must be a positive bounded integer",
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class AcquisitionOutput:
80
+ """Immutable adapter result binding one receipt, its observation, and the sealed snapshot."""
81
+
82
+ receipt: AcquisitionReceipt | AcquisitionReceiptV2 | AcquisitionReceiptV3
83
+ observation: SourceObservation
84
+ parsed_schema_digest: str | None = None
85
+ snapshot_path: Path | None = None
86
+ next_watermark: str | None = None
87
+ snapshot_content: bytes | None = field(default=None, repr=False)
88
+ # What the terminal response stated, sealed inside the Clean room and carried out hashed.
89
+ # It is not on the receipt: a receipt is sealed evidence whose digest is bound across the
90
+ # repository, and this fact belongs to the empirical observation the probe produces rather
91
+ # than to the artifact it acquired. ``None`` for every adapter that settles on no single
92
+ # response, which is every adapter but the whole-document public HTTPS one.
93
+ probe_transport: ProbeTransport | None = None
94
+ # The parsed geometry an empirical observation states beside the schema digest. Both are
95
+ # facts about the same parse ``parsed_schema_digest`` already comes from, carried rather
96
+ # than recomputed so a consumer cannot derive a second answer from a second parse.
97
+ parsed_row_count: int | None = None
98
+ parsed_row_digest: str | None = None
99
+
100
+ def __post_init__(self) -> None:
101
+ if self.receipt.source_id != self.observation.source_id:
102
+ raise SourceContractError(
103
+ "SOURCE_BINDING",
104
+ "output",
105
+ "receipt and observation source IDs must match",
106
+ )
107
+ if self.receipt.observation_digest != self.observation.digest:
108
+ raise SourceContractError(
109
+ "OBSERVATION_BINDING",
110
+ "output.receipt.observation_digest",
111
+ "receipt does not bind the exact source observation",
112
+ )
113
+ if self.receipt.observed_at != self.observation.observed_at:
114
+ raise SourceContractError(
115
+ "OBSERVATION_TIME_BINDING",
116
+ "output.receipt.observed_at",
117
+ "receipt and observation times differ",
118
+ )
119
+ if self.receipt.event_time_field != self.observation.event_time_field:
120
+ raise SourceContractError(
121
+ "EVENT_TIME_BINDING",
122
+ "output.receipt.event_time_field",
123
+ "receipt and observation event-time fields differ",
124
+ )
125
+ if self.parsed_schema_digest is not None and (
126
+ len(self.parsed_schema_digest) != 64
127
+ or any(char not in "0123456789abcdef" for char in self.parsed_schema_digest)
128
+ ):
129
+ raise SourceContractError(
130
+ "PARSED_SCHEMA_DIGEST",
131
+ "output.parsed_schema_digest",
132
+ "must be a lowercase SHA-256",
133
+ )
134
+ if self.snapshot_path is not None and not self.snapshot_path.is_absolute():
135
+ raise SourceContractError(
136
+ "SNAPSHOT_PATH",
137
+ "output.snapshot_path",
138
+ "must be an absolute coordinator-owned path",
139
+ )
140
+ if self.snapshot_content is not None:
141
+ if not isinstance(self.snapshot_content, bytes):
142
+ raise SourceContractError(
143
+ "SNAPSHOT_CONTENT",
144
+ "output.snapshot_content",
145
+ "must be immutable bytes",
146
+ )
147
+ if (
148
+ len(self.snapshot_content) != self.receipt.snapshot.size_bytes
149
+ or hashlib.sha256(self.snapshot_content).hexdigest()
150
+ != self.receipt.snapshot.content_sha256
151
+ ):
152
+ raise SourceContractError(
153
+ "SNAPSHOT_CONTENT",
154
+ "output.snapshot_content",
155
+ "must match the exact acquisition receipt",
156
+ )
157
+ if self.probe_transport is not None and not isinstance(
158
+ self.probe_transport, ProbeTransport
159
+ ):
160
+ raise SourceContractError(
161
+ "PROBE_TRANSPORT",
162
+ "output.probe_transport",
163
+ "must be sealed transport evidence",
164
+ )
165
+ row_facts = (self.parsed_row_count, self.parsed_row_digest)
166
+ if any(item is None for item in row_facts) and not all(item is None for item in row_facts):
167
+ raise SourceContractError(
168
+ "PARSED_ROW_FACTS",
169
+ "output.parsed_row_count",
170
+ "row count and row digest are both present or both absent",
171
+ )
172
+ if self.parsed_row_digest is not None and (
173
+ len(self.parsed_row_digest) != 64
174
+ or any(char not in "0123456789abcdef" for char in self.parsed_row_digest)
175
+ ):
176
+ raise SourceContractError(
177
+ "PARSED_ROW_FACTS",
178
+ "output.parsed_row_digest",
179
+ "must be a lowercase SHA-256",
180
+ )
181
+ if self.next_watermark is not None and (
182
+ not isinstance(self.next_watermark, str)
183
+ or not 1 <= len(self.next_watermark.encode("utf-8")) <= 512
184
+ ):
185
+ raise SourceContractError(
186
+ "WATERMARK",
187
+ "output.next_watermark",
188
+ "must be bounded nonempty text",
189
+ )
190
+
191
+
192
+ class SourceAdapter(Protocol):
193
+ @property
194
+ def descriptor(self) -> AdapterDescriptor:
195
+ """Return the immutable adapter identity."""
196
+
197
+ def acquire(
198
+ self,
199
+ request: AcquisitionRequest,
200
+ context: AcquisitionContext,
201
+ ) -> AcquisitionOutput:
202
+ """Acquire through the adapter's deterministic boundary."""
203
+
204
+
205
+ class AdapterRegistry:
206
+ """Coordinator-owned adapter allowlist."""
207
+
208
+ def __init__(self, adapters: tuple[SourceAdapter, ...] = ()) -> None:
209
+ self._adapters: dict[tuple[str, str], SourceAdapter] = {}
210
+ for adapter in adapters:
211
+ self.register(adapter)
212
+
213
+ def register(self, adapter: SourceAdapter) -> None:
214
+ descriptor = adapter.descriptor
215
+ if not isinstance(descriptor, AdapterDescriptor):
216
+ raise SourceContractError(
217
+ "ADAPTER_DESCRIPTOR",
218
+ "adapter.descriptor",
219
+ "adapter descriptor must use the strict contract",
220
+ )
221
+ key = (descriptor.adapter_id, descriptor.adapter_version)
222
+ if key in self._adapters:
223
+ coordinate = f"{descriptor.adapter_id}@{descriptor.adapter_version}"
224
+ raise SourceContractError(
225
+ "ADAPTER_DUPLICATE",
226
+ "registry",
227
+ f"adapter {coordinate} is already registered",
228
+ )
229
+ self._adapters[key] = adapter
230
+
231
+ def resolve(self, adapter_id: str, adapter_version: str) -> SourceAdapter:
232
+ try:
233
+ return self._adapters[(adapter_id, adapter_version)]
234
+ except KeyError:
235
+ raise SourceContractError(
236
+ "ADAPTER_UNAVAILABLE",
237
+ "request.adapter_id",
238
+ f"adapter {adapter_id}@{adapter_version} is not allowlisted",
239
+ ) from None
240
+
241
+ def acquire(
242
+ self,
243
+ request: AcquisitionRequest,
244
+ context: AcquisitionContext,
245
+ ) -> AcquisitionOutput:
246
+ adapter = self.resolve(request.adapter_id, request.adapter_version)
247
+ output = adapter.acquire(request, context)
248
+ descriptor = adapter.descriptor
249
+ if output.receipt.request_digest != request.digest:
250
+ raise SourceContractError(
251
+ "REQUEST_BINDING",
252
+ "output.receipt.request_digest",
253
+ "adapter receipt does not bind the exact request",
254
+ )
255
+ if output.receipt.query_digest != request.query_digest:
256
+ raise SourceContractError(
257
+ "QUERY_BINDING",
258
+ "output.receipt.query_digest",
259
+ "adapter receipt does not bind the exact query",
260
+ )
261
+ if output.receipt.adapter != descriptor:
262
+ raise SourceContractError(
263
+ "ADAPTER_BINDING",
264
+ "output.receipt.adapter",
265
+ "adapter receipt identity differs from the registered adapter",
266
+ )
267
+ trusted_path, trusted_content = _authenticate_acquisition_snapshot(context, output)
268
+ return replace(
269
+ output,
270
+ snapshot_path=trusted_path,
271
+ snapshot_content=trusted_content,
272
+ )
273
+
274
+ def inventory(self) -> tuple[AdapterDescriptor, ...]:
275
+ return tuple(self._adapters[key].descriptor for key in sorted(self._adapters))
276
+
277
+
278
+ def _authenticate_acquisition_snapshot(
279
+ context: AcquisitionContext,
280
+ output: AcquisitionOutput,
281
+ ) -> tuple[Path, bytes]:
282
+ """Return receipt-addressed bytes authenticated beneath the exact trusted root."""
283
+
284
+ reference = output.receipt.snapshot
285
+ expected_name = f"{reference.content_sha256}.{reference.data_format}"
286
+ if reference.storage_name != expected_name:
287
+ raise SourceContractError(
288
+ "ACQUISITION_SNAPSHOT",
289
+ "output.receipt.snapshot.storage_name",
290
+ "must be the exact receipt-addressed snapshot name",
291
+ )
292
+ trusted_path = context.snapshot_root / expected_name
293
+ if output.snapshot_path is not None and output.snapshot_path != trusted_path:
294
+ raise SourceContractError(
295
+ "ACQUISITION_SNAPSHOT",
296
+ "output.snapshot_path",
297
+ "adapter path differs from the receipt-addressed trusted-root path",
298
+ )
299
+ # The sealed snapshot is the normalized acquisition output, so its bound is the
300
+ # normalized-output budget when one was stated, falling back to the fetched-byte
301
+ # budget exactly as the hosted query composition does.
302
+ snapshot_limit = (
303
+ context.max_source_bytes
304
+ if context.max_normalized_bytes is None
305
+ else context.max_normalized_bytes
306
+ )
307
+ if reference.size_bytes > snapshot_limit:
308
+ raise SourceContractError(
309
+ "ACQUISITION_SNAPSHOT",
310
+ "output.receipt.snapshot.size_bytes",
311
+ "snapshot exceeds the trusted acquisition byte limit",
312
+ )
313
+ directory_flags = (
314
+ os.O_RDONLY
315
+ | getattr(os, "O_DIRECTORY", 0)
316
+ | getattr(os, "O_NOFOLLOW", 0)
317
+ | getattr(os, "O_CLOEXEC", 0)
318
+ )
319
+ try:
320
+ root_fd = os.open(context.snapshot_root, directory_flags)
321
+ except OSError as error:
322
+ raise SourceContractError(
323
+ "ACQUISITION_SNAPSHOT",
324
+ "context.snapshot_root",
325
+ "trusted snapshot root is unavailable or unsafe"
326
+ f" (open refused: {_describe_os_error(error)})",
327
+ ) from None
328
+ descriptor: int | None = None
329
+ try:
330
+ root_info = os.fstat(root_fd)
331
+ flags = (
332
+ os.O_RDONLY
333
+ | getattr(os, "O_NOFOLLOW", 0)
334
+ | getattr(os, "O_NONBLOCK", 0)
335
+ | getattr(os, "O_CLOEXEC", 0)
336
+ )
337
+ try:
338
+ descriptor = os.open(expected_name, flags, dir_fd=root_fd)
339
+ except OSError as error:
340
+ raise SourceContractError(
341
+ "ACQUISITION_SNAPSHOT",
342
+ "output.receipt.snapshot.storage_name",
343
+ "receipt-addressed snapshot is unavailable or unsafe"
344
+ f" (open refused: {_describe_os_error(error)})",
345
+ ) from None
346
+ opened = os.fstat(descriptor)
347
+ _require_authenticated_snapshot_stat(
348
+ opened,
349
+ root_info=root_info,
350
+ expected_size=reference.size_bytes,
351
+ )
352
+ try:
353
+ named = os.stat(expected_name, dir_fd=root_fd, follow_symlinks=False)
354
+ except OSError as error:
355
+ raise SourceContractError(
356
+ "ACQUISITION_SNAPSHOT",
357
+ "output.receipt.snapshot.storage_name",
358
+ "receipt-addressed snapshot changed during authentication"
359
+ f" (before the read: {_describe_os_error(error)})",
360
+ ) from None
361
+ _require_stable_snapshot_identity(opened, named, observed_between=_OPEN_VERSUS_NAME)
362
+ raw = bytearray()
363
+ while len(raw) <= reference.size_bytes:
364
+ chunk = os.read(
365
+ descriptor,
366
+ min(65_536, reference.size_bytes + 1 - len(raw)),
367
+ )
368
+ if not chunk:
369
+ break
370
+ raw.extend(chunk)
371
+ _require_stable_snapshot_identity(
372
+ opened,
373
+ os.fstat(descriptor),
374
+ observed_between=_OPEN_VERSUS_READ,
375
+ )
376
+ os.close(descriptor)
377
+ descriptor = None
378
+ try:
379
+ after = os.stat(expected_name, dir_fd=root_fd, follow_symlinks=False)
380
+ except OSError as error:
381
+ raise SourceContractError(
382
+ "ACQUISITION_SNAPSHOT",
383
+ "output.receipt.snapshot.storage_name",
384
+ "receipt-addressed snapshot changed during authentication"
385
+ f" (after the read: {_describe_os_error(error)})",
386
+ ) from None
387
+ _require_stable_snapshot_identity(named, after, observed_between=_NAME_VERSUS_READ)
388
+ finally:
389
+ if descriptor is not None:
390
+ os.close(descriptor)
391
+ os.close(root_fd)
392
+ content = bytes(raw)
393
+ if (
394
+ len(content) != reference.size_bytes
395
+ or hashlib.sha256(content).hexdigest() != reference.content_sha256
396
+ ):
397
+ raise SourceContractError(
398
+ "ACQUISITION_SNAPSHOT",
399
+ "output.receipt.snapshot",
400
+ "authenticated bytes do not match the exact acquisition receipt",
401
+ )
402
+ return trusted_path, content
403
+
404
+
405
+ def _describe_os_error(error: OSError) -> str:
406
+ """Name an errno without putting a path or any other caller string in the message."""
407
+
408
+ return f"{errno.errorcode.get(error.errno or 0, 'errno')} {error.strerror or ''}".strip()
409
+
410
+
411
+ def _require_authenticated_snapshot_stat(
412
+ info: os.stat_result,
413
+ *,
414
+ root_info: os.stat_result,
415
+ expected_size: int,
416
+ ) -> None:
417
+ """Refuse an entry that is not one immutable, singly-linked, root-owned regular file.
418
+
419
+ Every field is evaluated rather than short-circuited so the refusal can name each one that
420
+ failed with its observed and its required value. This is the reader's half of the check
421
+ ``acquisition/http.py`` runs when it seals the snapshot, and it carried the same defect that
422
+ half did before #229: six distinct causes sharing one sentence, so an ``ACQUISITION_SNAPSHOT``
423
+ read off a machine nobody can attach a debugger to said only that the entry was unsafe.
424
+ ``st_nlink`` is the one a concurrent same-digest sealer can move, which is exactly why #209's
425
+ first diagnosis named the wrong gate.
426
+ """
427
+
428
+ faults: list[str] = []
429
+ if not stat.S_ISREG(info.st_mode):
430
+ faults.append(f"kind {stat.S_IFMT(info.st_mode):#o} is not a regular file")
431
+ if stat.S_IMODE(info.st_mode) != 0o444:
432
+ faults.append(f"st_mode {stat.S_IMODE(info.st_mode):#o} != 0o444")
433
+ if info.st_nlink != 1:
434
+ faults.append(f"st_nlink {info.st_nlink} != 1")
435
+ if info.st_size != expected_size:
436
+ faults.append(f"st_size {info.st_size} != {expected_size}")
437
+ if info.st_uid != root_info.st_uid:
438
+ faults.append(f"st_uid {info.st_uid} != {root_info.st_uid}")
439
+ if info.st_gid != root_info.st_gid:
440
+ faults.append(f"st_gid {info.st_gid} != {root_info.st_gid}")
441
+ if faults:
442
+ raise SourceContractError(
443
+ "ACQUISITION_SNAPSHOT",
444
+ "output.receipt.snapshot.storage_name",
445
+ "snapshot is not a root-owned single-link mode-0444 regular file of the exact size"
446
+ f" ({', '.join(faults)})",
447
+ )
448
+
449
+
450
+ # The three points at which the bound stat signature is re-observed. They are named in the
451
+ # refusal so a field report says which observation drifted rather than only that one did.
452
+ _OPEN_VERSUS_NAME = "the opened descriptor and the named entry"
453
+ _OPEN_VERSUS_READ = "the opened descriptor before and after the read"
454
+ _NAME_VERSUS_READ = "the named entry before and after the read"
455
+
456
+ # The one drift with a single cause, said in words rather than left to be read off a field. Kept
457
+ # byte-identical to its counterpart in ``acquisition/http.py``: two copies of one guard that say
458
+ # different things about one field is the drift #237 was about.
459
+ _LAST_NAME_REMOVED = "; the last name for it was removed while it was open"
460
+
461
+ #: The anti-TOCTOU binding, held field by field so a refusal can name what moved. It is the same
462
+ #: set ``acquisition/http.py`` binds at seal time; ``tests/h3/test_integration.py`` holds the two
463
+ #: to each other so neither can quietly narrow.
464
+ _SNAPSHOT_STAT_FIELDS = (
465
+ "st_dev",
466
+ "st_ino",
467
+ "st_uid",
468
+ "st_gid",
469
+ "st_mode",
470
+ "st_nlink",
471
+ "st_size",
472
+ "st_mtime_ns",
473
+ "st_ctime_ns",
474
+ )
475
+
476
+
477
+ def _require_stable_snapshot_identity(
478
+ before: os.stat_result,
479
+ after: os.stat_result,
480
+ *,
481
+ observed_between: str,
482
+ ) -> None:
483
+ """Refuse a snapshot whose bound identity moved, naming where it moved and by what.
484
+
485
+ The signature is a deliberate anti-TOCTOU binding, so it stays exactly as wide as it was.
486
+ What changes is only what the refusal says: which of the three observation pairs disagreed,
487
+ and every field that differs with its old and its new value. ``st_nlink`` moving means
488
+ another name was attached to or detached from this inode -- an unlink of the published name
489
+ reads as ``st_nlink 1 -> 0`` -- ``st_ino`` or ``st_dev`` moving means the name was rebound to
490
+ a different file, and ``st_ctime_ns`` alone means metadata was touched. Those are different
491
+ incidents and a field recurrence has to be able to tell them apart.
492
+
493
+ ``st_nlink`` reaching zero is the one of them with a single cause, so it is named rather than
494
+ left to be inferred: the last name for this inode was removed while this descriptor held it
495
+ open, which is a deletion of the snapshot rather than a change to it. The remover is not
496
+ thereby identified. Governed deletion takes the sealer's per-name lock, but this reader holds
497
+ no lock at all, so the harness's own retention sweep can be the remover here just as an
498
+ unowned process can -- the sealer's copy of this guard is the one whose refusal rules the
499
+ harness out, because the sealer reads inside the held name.
500
+ """
501
+
502
+ moved = tuple(
503
+ f"{field} {getattr(before, field)} -> {getattr(after, field)}"
504
+ for field in _SNAPSHOT_STAT_FIELDS
505
+ if getattr(before, field) != getattr(after, field)
506
+ )
507
+ if moved:
508
+ raise SourceContractError(
509
+ "ACQUISITION_SNAPSHOT",
510
+ "output.receipt.snapshot.storage_name",
511
+ "snapshot changed during authentication"
512
+ f" (between {observed_between}: {', '.join(moved)})"
513
+ f"{_LAST_NAME_REMOVED if before.st_nlink and not after.st_nlink else ''}",
514
+ )
@@ -0,0 +1,59 @@
1
+ """The single repository-wide rights-status disposition authority.
2
+
3
+ One rule decides what a rights status *means*: admit, escalate, or refuse. The fitness
4
+ adjudicator (:mod:`mostlyright.data_harness.sources.fitness`) and the source-catalog gate both
5
+ read this module, so the two cannot drift into disagreeing adjudicators.
6
+
7
+ This module deliberately imports almost nothing. It depends on ``SourceContractError`` and
8
+ ``RIGHTS_STATUSES`` from :mod:`mostlyright.data_harness.sources.contracts` and on nothing else --
9
+ not ``fitness``, not ``local_contracts``, not anything under ``catalog``. That import poverty is
10
+ what lets the catalog gate consume the rule without dragging the tenant proposal layer in.
11
+
12
+ The mapping is exhaustive by assertion at import time. A rights status added to the vocabulary
13
+ without a disposition is an import-time failure, never a silent admit.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from mostlyright.data_harness.sources.contracts import RIGHTS_STATUSES, SourceContractError
19
+
20
+ RIGHTS_DISPOSITIONS: frozenset[str] = frozenset({"admit", "escalate", "refuse"})
21
+
22
+ _DISPOSITION_BY_STATUS: dict[str, str] = {
23
+ "approved": "admit",
24
+ "conditional": "admit",
25
+ "unclear": "escalate",
26
+ "prohibited": "refuse",
27
+ }
28
+
29
+ if set(_DISPOSITION_BY_STATUS) != set(RIGHTS_STATUSES): # pragma: no cover - import-time guard
30
+ _missing = sorted(set(RIGHTS_STATUSES) - set(_DISPOSITION_BY_STATUS))
31
+ _extra = sorted(set(_DISPOSITION_BY_STATUS) - set(RIGHTS_STATUSES))
32
+ raise SourceContractError(
33
+ "RIGHTS_DISPOSITION_COVERAGE",
34
+ "rights_rule",
35
+ f"every rights status requires a disposition; missing={_missing}, extra={_extra}",
36
+ )
37
+
38
+ if not set(_DISPOSITION_BY_STATUS.values()) <= RIGHTS_DISPOSITIONS: # pragma: no cover
39
+ raise SourceContractError(
40
+ "RIGHTS_DISPOSITION_VALUE",
41
+ "rights_rule",
42
+ f"dispositions must be drawn from {sorted(RIGHTS_DISPOSITIONS)}",
43
+ )
44
+
45
+
46
+ def rights_disposition(status: str) -> str:
47
+ """Return ``admit``, ``escalate``, or ``refuse`` for one rights status.
48
+
49
+ Raises ``SourceContractError`` with code ``RIGHTS_STATUS`` for anything outside
50
+ ``RIGHTS_STATUSES``; an unknown status is never admitted.
51
+ """
52
+
53
+ if not isinstance(status, str) or status not in _DISPOSITION_BY_STATUS:
54
+ raise SourceContractError(
55
+ "RIGHTS_STATUS",
56
+ "rights.status",
57
+ f"must be one of {sorted(RIGHTS_STATUSES)}",
58
+ )
59
+ return _DISPOSITION_BY_STATUS[status]