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,809 @@
1
+ """Closed, bounded parsers for untrusted tabular source bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import datetime
7
+ import decimal
8
+ import hashlib
9
+ import io
10
+ import json
11
+ import math
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from mostlyright.data_harness.acquisition.url_policy import AcquisitionSecurityError
17
+ from mostlyright.data_harness.canonical import (
18
+ CanonicalJSONError,
19
+ canonical_json_bytes,
20
+ canonical_sha256,
21
+ sha256_bytes,
22
+ )
23
+ from mostlyright.data_harness.formats import (
24
+ ARCHIVE_OR_EXECUTABLE_MAGICS,
25
+ DIRECT_FETCH_MEDIA_TYPES,
26
+ FORMAT_MEDIA_TYPES,
27
+ FORMAT_SUFFIXES,
28
+ )
29
+
30
+ # Read from the single closed table in ``mostlyright.data_harness.formats``; this module
31
+ # keeps the private names it has always used so its call sites are unchanged.
32
+ _FORMAT_MEDIA_TYPES = FORMAT_MEDIA_TYPES
33
+ # The parse admits what the fetch admitted: a direct fetch may carry the weak label, so
34
+ # refusing it here would refuse at the parse exactly what was just let through the door.
35
+ _ADMITTED_MEDIA_TYPES = DIRECT_FETCH_MEDIA_TYPES
36
+ _FORMAT_SUFFIXES = FORMAT_SUFFIXES
37
+ # The archive prefixes are read from the leaf too, for the reason recorded beside them there:
38
+ # the container Readers refuse a nested archive on the same list, ``readers/`` may not import
39
+ # this module, and two copies of what an archive looks like would drift.
40
+ _ARCHIVE_OR_EXECUTABLE_MAGICS = ARCHIVE_OR_EXECUTABLE_MAGICS
41
+ _FORBIDDEN_REFERENCE_KEYS = frozenset(
42
+ {"$ref", "$schema", "external_reference", "external_references"}
43
+ )
44
+ _SAFE_COLUMN = re.compile(r"^[^\x00-\x1f\x7f]{1,256}$")
45
+
46
+
47
+ # PyArrow is imported on first Parquet use, never at module import. It is the single most
48
+ # expensive import in the sandbox worker's dependency graph -- around 50 ms of the ~110 ms this
49
+ # module used to add to a child interpreter's startup, and every millisecond of it is paid by
50
+ # every sandbox child, including the ones that only probe isolation or parse a CSV. Deferring it
51
+ # costs one dictionary lookup per Parquet parse and gives the operations that never touch Parquet
52
+ # a child that starts in roughly half the time. The module object is cached rather than re-bound
53
+ # into module globals so there is exactly one place that decides whether the import has happened.
54
+ _PYARROW: tuple[Any, Any] | None = None
55
+
56
+
57
+ def _pyarrow() -> tuple[Any, Any]:
58
+ """Return ``(pyarrow, pyarrow.parquet)``, importing them on first use."""
59
+
60
+ global _PYARROW
61
+ if _PYARROW is None:
62
+ import pyarrow
63
+ import pyarrow.parquet
64
+
65
+ _PYARROW = (pyarrow, pyarrow.parquet)
66
+ return _PYARROW
67
+
68
+
69
+ def prime_parquet_support() -> None:
70
+ """Pay the deferred PyArrow import now, before any request is bound.
71
+
72
+ The lazy import above moves the cost off every child that does not parse Parquet; this
73
+ moves it off the request latency of the children that do. A warm sandbox child calls this
74
+ while it is still idle and unbound, so the first Parquet parse it is later asked for pays
75
+ an already-imported module rather than a cold one. Nothing else about the child changes:
76
+ priming reads no request, opens no socket, and touches no per-request state.
77
+ """
78
+
79
+ _pyarrow()
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ParseLimits:
84
+ """Coordinator-owned parser budgets."""
85
+
86
+ max_input_bytes: int = 16 * 1024 * 1024
87
+ max_uncompressed_bytes: int = 128 * 1024 * 1024
88
+ max_expansion_ratio: int = 100
89
+ max_rows: int = 1_000_000
90
+ max_columns: int = 1_024
91
+ max_field_bytes: int = 1 * 1024 * 1024
92
+ max_json_depth: int = 32
93
+ max_total_cells: int = 10_000_000
94
+
95
+ def __post_init__(self) -> None:
96
+ bounded = (
97
+ ("max_input_bytes", self.max_input_bytes, 1, 1 << 34),
98
+ ("max_uncompressed_bytes", self.max_uncompressed_bytes, 1, 1 << 36),
99
+ ("max_expansion_ratio", self.max_expansion_ratio, 1, 10_000),
100
+ ("max_rows", self.max_rows, 1, 10_000_000),
101
+ ("max_columns", self.max_columns, 1, 10_000),
102
+ ("max_field_bytes", self.max_field_bytes, 1, 1 << 30),
103
+ ("max_json_depth", self.max_json_depth, 1, 128),
104
+ ("max_total_cells", self.max_total_cells, 1, 1_000_000_000),
105
+ )
106
+ for name, value, minimum, maximum in bounded:
107
+ if type(value) is not int or not minimum <= value <= maximum:
108
+ raise AcquisitionSecurityError(
109
+ "PARSE_LIMIT",
110
+ f"{name} must be an integer in [{minimum}, {maximum}]",
111
+ )
112
+ if self.max_uncompressed_bytes < self.max_input_bytes:
113
+ raise AcquisitionSecurityError(
114
+ "PARSE_LIMIT",
115
+ "max_uncompressed_bytes must not be smaller than max_input_bytes",
116
+ )
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ParsedTable:
121
+ """Immutable, source-independent parsed tabular result."""
122
+
123
+ data_format: str
124
+ input_sha256: str
125
+ columns: tuple[str, ...]
126
+ rows: tuple[tuple[Any, ...], ...]
127
+ schema_digest: str
128
+
129
+ def __post_init__(self) -> None:
130
+ if self.data_format not in _FORMAT_MEDIA_TYPES:
131
+ raise AcquisitionSecurityError("PARSE_FORMAT", "parsed format is not allowlisted")
132
+ if not self.columns or len(set(self.columns)) != len(self.columns):
133
+ raise AcquisitionSecurityError(
134
+ "PARSE_COLUMNS",
135
+ "parsed columns must be non-empty and unique",
136
+ )
137
+ if any(len(row) != len(self.columns) for row in self.rows):
138
+ raise AcquisitionSecurityError(
139
+ "PARSE_ROW_WIDTH",
140
+ "parsed row width differs from the schema",
141
+ )
142
+ expected = canonical_sha256(
143
+ {
144
+ "data_format": self.data_format,
145
+ "columns": list(self.columns),
146
+ "types": [_column_type(self.rows, index) for index in range(len(self.columns))],
147
+ }
148
+ )
149
+ if expected != self.schema_digest:
150
+ raise AcquisitionSecurityError(
151
+ "PARSE_SCHEMA_DIGEST",
152
+ "parsed schema digest does not match the exact result",
153
+ )
154
+
155
+ def dictionaries(self) -> tuple[dict[str, Any], ...]:
156
+ return tuple(dict(zip(self.columns, row, strict=True)) for row in self.rows)
157
+
158
+
159
+ def parse_tabular_bytes(
160
+ content: bytes,
161
+ *,
162
+ data_format: str,
163
+ media_type: str,
164
+ filename: str,
165
+ limits: ParseLimits,
166
+ ) -> ParsedTable:
167
+ """Parse one allowlisted format after exact media/suffix and budget validation.
168
+
169
+ Every value that comes back is one the canonical rule can hold exactly. A temporal, decimal, or
170
+ non-finite value is refused with ``PARSE_VALUE`` rather than turned into text, because what
171
+ this function returns can be sealed and a value nobody can round-trip must not be.
172
+ """
173
+
174
+ return _parse_tabular(
175
+ content,
176
+ data_format=data_format,
177
+ media_type=media_type,
178
+ filename=filename,
179
+ limits=limits,
180
+ display=False,
181
+ )
182
+
183
+
184
+ def parse_tabular_bytes_for_display(
185
+ content: bytes,
186
+ *,
187
+ data_format: str,
188
+ media_type: str,
189
+ filename: str,
190
+ limits: ParseLimits,
191
+ ) -> ParsedTable:
192
+ """The same parse, for bytes that are being looked at rather than built from.
193
+
194
+ Identical in every guard: the same media, suffix, magic-byte, encoding, row, column, cell,
195
+ field, and decompression budgets, in the same order, from the same code. One thing differs.
196
+ Where :func:`parse_tabular_bytes` refuses a value the canonical rule has no place for -- a
197
+ date, a time, a duration, a decimal, a not-a-number -- this one renders it into its exact text
198
+ and carries on, so a column of dates can be *shown* rather than refused.
199
+
200
+ That is safe here and only here, because nothing this function returns is ever sealed. It has
201
+ exactly one caller, ``ux.peek``, which writes no bytes at all; ``tests/h3/test_parsing.py``
202
+ pins that there is no second one. Exactness is kept by
203
+ the rendering rather than by the refusal: the text is the value's own ``isoformat`` or ``str``,
204
+ never a rounding, and the type of column a person is told about is unchanged -- a rendered
205
+ ``date`` is still reported as a ``date``, because the text carries the name of the kind it came
206
+ from.
207
+ """
208
+
209
+ return _parse_tabular(
210
+ content,
211
+ data_format=data_format,
212
+ media_type=media_type,
213
+ filename=filename,
214
+ limits=limits,
215
+ display=True,
216
+ )
217
+
218
+
219
+ def row_digest_for(parsed: ParsedTable) -> str:
220
+ """Return the canonical row digest of one drained parsed table.
221
+
222
+ The one formula, in one place. ``parse_csv_evidence`` accumulates the identical encoding a
223
+ row at a time for tables too large to hold, and the two are pinned equal by test; a second
224
+ spelling of it anywhere would be a second digest that could disagree with sealed evidence.
225
+ """
226
+
227
+ return sha256_bytes(
228
+ canonical_json_bytes(
229
+ {
230
+ "columns": list(parsed.columns),
231
+ "rows": [list(row) for row in parsed.rows],
232
+ }
233
+ )
234
+ )
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class ParsedTabularEvidence:
239
+ """The facts a receipt states about a parsed table, without the table.
240
+
241
+ ``parse_csv_evidence`` computes exactly what ``parse_tabular_bytes`` would have put in
242
+ a receipt -- the columns, the row count, the schema digest, and the canonical row
243
+ digest -- while holding one row at a time. A year-scale verification pass therefore
244
+ costs the size of a row, not the size of the table; the values are pinned equal to the
245
+ drained parse by test, because a digest that moved would move every sealed receipt.
246
+ """
247
+
248
+ data_format: str
249
+ input_sha256: str
250
+ columns: tuple[str, ...]
251
+ row_count: int
252
+ schema_digest: str
253
+ row_digest: str
254
+
255
+
256
+ def parse_csv_evidence(
257
+ content: bytes,
258
+ *,
259
+ media_type: str,
260
+ filename: str,
261
+ limits: ParseLimits,
262
+ ) -> ParsedTabularEvidence:
263
+ """Stream one CSV verification pass: the admissions of the drained parse, row by row.
264
+
265
+ The refusals are ``_parse_csv``'s own, at the same rows, in the same order. The one
266
+ check the drained path could only make after the loop -- the total-cell budget that
267
+ ``_validate_table`` applies to the whole table -- keeps that position: the flag is
268
+ raised at the end of the input, so a malformed row later in the file still wins.
269
+
270
+ The row digest is fed to the hash incrementally as the canonical encoding of
271
+ ``{"columns": [...], "rows": [[...], ...]}``. The framing bytes are literal because
272
+ the canonical encoder is compositional -- sorted keys, no spaces, context-free element
273
+ encoding -- and the equality with the one-shot encoding is pinned by test rather than
274
+ assumed.
275
+ """
276
+
277
+ _admit_tabular_input(content, data_format="csv", media_type=media_type, filename=filename)
278
+ if not content or len(content) > limits.max_input_bytes:
279
+ raise AcquisitionSecurityError(
280
+ "PARSE_INPUT_LIMIT",
281
+ "source bytes are empty or exceed the parser input budget",
282
+ )
283
+ if any(content.startswith(magic) for magic in _ARCHIVE_OR_EXECUTABLE_MAGICS):
284
+ raise AcquisitionSecurityError(
285
+ "PARSE_FORBIDDEN_CONTAINER",
286
+ "archives, executables, documents, and nested containers are forbidden",
287
+ )
288
+ text = _strict_utf8(content)
289
+ if "\x00" in text:
290
+ raise AcquisitionSecurityError("PARSE_ENCODING", "CSV contains a NUL byte")
291
+ reader = csv.reader(
292
+ io.StringIO(text, newline=""),
293
+ delimiter=",",
294
+ quotechar='"',
295
+ doublequote=True,
296
+ strict=True,
297
+ )
298
+ try:
299
+ header = next(reader)
300
+ except (StopIteration, csv.Error):
301
+ raise AcquisitionSecurityError("PARSE_CSV", "CSV has no valid header") from None
302
+ columns = _columns(header, limits)
303
+ width = len(columns)
304
+
305
+ row_hash = hashlib.sha256()
306
+ row_hash.update(b'{"columns":' + canonical_json_bytes(list(columns)) + b',"rows":[')
307
+ observed_types: list[set[str]] = [set() for _ in range(width)]
308
+ row_count = 0
309
+ over_cell_budget = False
310
+ canonical_error: CanonicalJSONError | None = None
311
+ try:
312
+ for raw in reader:
313
+ if row_count >= limits.max_rows:
314
+ raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "CSV exceeds the row budget")
315
+ if len(raw) != width:
316
+ raise AcquisitionSecurityError(
317
+ "PARSE_ROW_WIDTH",
318
+ "CSV row width differs from its header",
319
+ )
320
+ row = tuple(raw)
321
+ _validate_row(row, limits)
322
+ if (row_count + 1) * width > limits.max_total_cells:
323
+ over_cell_budget = True
324
+ # A cell the canonical encoder refuses -- reachable only if the process-wide
325
+ # ``csv.field_size_limit`` is raised above the canonical string bound -- keeps
326
+ # the position the one-shot digest gave it: after every parse refusal and the
327
+ # cell budget, never mid-loop. The hash is dead on that path, so it stops
328
+ # accumulating with the first error.
329
+ if canonical_error is None:
330
+ try:
331
+ encoded_row = canonical_json_bytes(list(row))
332
+ except CanonicalJSONError as error:
333
+ canonical_error = error
334
+ else:
335
+ if row_count:
336
+ row_hash.update(b",")
337
+ row_hash.update(encoded_row)
338
+ for index, value in enumerate(row):
339
+ observed_types[index].add("null" if value is None else type(value).__name__)
340
+ row_count += 1
341
+ except csv.Error:
342
+ raise AcquisitionSecurityError("PARSE_CSV", "CSV syntax is malformed") from None
343
+ if over_cell_budget:
344
+ raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "table exceeds the cell budget")
345
+ if canonical_error is not None:
346
+ raise canonical_error
347
+ row_hash.update(b"]}")
348
+
349
+ schema_digest = canonical_sha256(
350
+ {
351
+ "data_format": "csv",
352
+ "columns": list(columns),
353
+ "types": [
354
+ "|".join(sorted(observed)) if observed else "empty" for observed in observed_types
355
+ ],
356
+ }
357
+ )
358
+ return ParsedTabularEvidence(
359
+ data_format="csv",
360
+ input_sha256=sha256_bytes(content),
361
+ columns=columns,
362
+ row_count=row_count,
363
+ schema_digest=schema_digest,
364
+ row_digest=row_hash.hexdigest(),
365
+ )
366
+
367
+
368
+ def _admit_tabular_input(
369
+ content: bytes,
370
+ *,
371
+ data_format: str,
372
+ media_type: str,
373
+ filename: str,
374
+ ) -> None:
375
+ """The input admissions every tabular parse makes, in the order it makes them."""
376
+
377
+ if not isinstance(content, bytes):
378
+ raise AcquisitionSecurityError("PARSE_INPUT", "parser input must be exact bytes")
379
+ if data_format not in _FORMAT_MEDIA_TYPES:
380
+ raise AcquisitionSecurityError("PARSE_FORMAT", "data format is not allowlisted")
381
+ normalized_media = media_type.split(";", 1)[0].strip().lower()
382
+ if normalized_media not in _ADMITTED_MEDIA_TYPES[data_format]:
383
+ raise AcquisitionSecurityError(
384
+ "PARSE_MEDIA_MISMATCH",
385
+ "declared media type does not match the selected parser",
386
+ )
387
+ suffix = _suffix(filename)
388
+ if suffix not in _FORMAT_SUFFIXES[data_format]:
389
+ raise AcquisitionSecurityError(
390
+ "PARSE_SUFFIX_MISMATCH",
391
+ "filename suffix does not match the selected parser",
392
+ )
393
+
394
+
395
+ def _parse_tabular(
396
+ content: bytes,
397
+ *,
398
+ data_format: str,
399
+ media_type: str,
400
+ filename: str,
401
+ limits: ParseLimits,
402
+ display: bool,
403
+ ) -> ParsedTable:
404
+ _admit_tabular_input(content, data_format=data_format, media_type=media_type, filename=filename)
405
+ if not content or len(content) > limits.max_input_bytes:
406
+ raise AcquisitionSecurityError(
407
+ "PARSE_INPUT_LIMIT",
408
+ "source bytes are empty or exceed the parser input budget",
409
+ )
410
+ if any(content.startswith(magic) for magic in _ARCHIVE_OR_EXECUTABLE_MAGICS):
411
+ raise AcquisitionSecurityError(
412
+ "PARSE_FORBIDDEN_CONTAINER",
413
+ "archives, executables, documents, and nested containers are forbidden",
414
+ )
415
+
416
+ # Exhaustive over the format table, with no catch-all decoder. The admission checks
417
+ # above read ``mostlyright.data_harness.formats``, so widening that table admits a name
418
+ # here too; if the last branch were ``else: _parse_parquet(...)`` the new name's bytes
419
+ # would be handed to the Parquet reader, and the ``ParsedTable`` and its
420
+ # ``schema_digest`` -- which folds ``data_format`` in -- would then attest a decoder
421
+ # that never ran. A format the table admits and no branch claims is refused here
422
+ # instead, naming itself, so adding a Reader family to ``formats.py`` is a loud failure
423
+ # until a decoder is taught to honour it rather than a silent misparse.
424
+ if data_format == "csv":
425
+ columns, rows = _parse_csv(content, limits)
426
+ elif data_format in {"json", "ndjson"}:
427
+ columns, rows = _parse_json(content, data_format, limits)
428
+ elif data_format == "parquet":
429
+ columns, rows = _parse_parquet(content, limits, display=display)
430
+ else:
431
+ raise AcquisitionSecurityError(
432
+ "PARSE_FORMAT_UNHANDLED",
433
+ f"data format {data_format!r} is in the format table but no decoder claims it",
434
+ )
435
+ _validate_table(columns, rows, limits)
436
+ schema_digest = canonical_sha256(
437
+ {
438
+ "data_format": data_format,
439
+ "columns": list(columns),
440
+ "types": [_column_type(rows, index) for index in range(len(columns))],
441
+ }
442
+ )
443
+ return ParsedTable(
444
+ data_format=data_format,
445
+ input_sha256=sha256_bytes(content),
446
+ columns=columns,
447
+ rows=rows,
448
+ schema_digest=schema_digest,
449
+ )
450
+
451
+
452
+ def column_types(table: ParsedTable) -> tuple[str, ...]:
453
+ """Return the observed type of each column, in column order.
454
+
455
+ This is the exact list the schema digest already commits to, so it exposes a sealed fact
456
+ rather than inferring a new one.
457
+ """
458
+
459
+ return tuple(_column_type(table.rows, index) for index in range(len(table.columns)))
460
+
461
+
462
+ def _parse_csv(
463
+ content: bytes,
464
+ limits: ParseLimits,
465
+ ) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
466
+ text = _strict_utf8(content)
467
+ if "\x00" in text:
468
+ raise AcquisitionSecurityError("PARSE_ENCODING", "CSV contains a NUL byte")
469
+ reader = csv.reader(
470
+ io.StringIO(text, newline=""),
471
+ delimiter=",",
472
+ quotechar='"',
473
+ doublequote=True,
474
+ strict=True,
475
+ )
476
+ try:
477
+ header = next(reader)
478
+ except (StopIteration, csv.Error):
479
+ raise AcquisitionSecurityError("PARSE_CSV", "CSV has no valid header") from None
480
+ columns = _columns(header, limits)
481
+ rows: list[tuple[Any, ...]] = []
482
+ try:
483
+ for raw in reader:
484
+ if len(rows) >= limits.max_rows:
485
+ raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "CSV exceeds the row budget")
486
+ if len(raw) != len(columns):
487
+ raise AcquisitionSecurityError(
488
+ "PARSE_ROW_WIDTH",
489
+ "CSV row width differs from its header",
490
+ )
491
+ row = tuple(raw)
492
+ _validate_row(row, limits)
493
+ rows.append(row)
494
+ except csv.Error:
495
+ raise AcquisitionSecurityError("PARSE_CSV", "CSV syntax is malformed") from None
496
+ return columns, tuple(rows)
497
+
498
+
499
+ def _parse_json(
500
+ content: bytes,
501
+ data_format: str,
502
+ limits: ParseLimits,
503
+ ) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
504
+ text = _strict_utf8(content)
505
+ decoder = json.JSONDecoder(
506
+ object_pairs_hook=_unique_object,
507
+ parse_constant=lambda value: _invalid_json_number(value),
508
+ )
509
+ try:
510
+ if data_format == "json":
511
+ value = decoder.decode(text)
512
+ if isinstance(value, dict) and {"nbformat", "cells"} <= set(value):
513
+ raise AcquisitionSecurityError(
514
+ "PARSE_NOTEBOOK",
515
+ "notebook documents are not tabular JSON sources",
516
+ )
517
+ records = value
518
+ else:
519
+ records = []
520
+ for line_number, line in enumerate(text.splitlines(), start=1):
521
+ if not line.strip():
522
+ continue
523
+ if len(records) >= limits.max_rows:
524
+ raise AcquisitionSecurityError(
525
+ "PARSE_ROW_LIMIT",
526
+ "NDJSON exceeds the row budget",
527
+ )
528
+ try:
529
+ records.append(decoder.decode(line))
530
+ except json.JSONDecodeError:
531
+ raise AcquisitionSecurityError(
532
+ "PARSE_JSON",
533
+ f"NDJSON line {line_number} is malformed",
534
+ ) from None
535
+ except json.JSONDecodeError:
536
+ raise AcquisitionSecurityError("PARSE_JSON", "JSON syntax is malformed") from None
537
+ if not isinstance(records, list) or not records:
538
+ raise AcquisitionSecurityError(
539
+ "PARSE_JSON_SHAPE",
540
+ "JSON tabular input must be a non-empty array of objects",
541
+ )
542
+ if len(records) > limits.max_rows:
543
+ raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "JSON exceeds the row budget")
544
+ if not all(isinstance(item, dict) for item in records):
545
+ raise AcquisitionSecurityError(
546
+ "PARSE_JSON_SHAPE",
547
+ "every JSON row must be an object",
548
+ )
549
+ first = records[0]
550
+ assert isinstance(first, dict)
551
+ columns = _columns(list(first), limits)
552
+ expected = set(columns)
553
+ rows: list[tuple[Any, ...]] = []
554
+ for index, item in enumerate(records):
555
+ assert isinstance(item, dict)
556
+ _validate_json_value(item, depth=1, limits=limits)
557
+ if set(item) != expected:
558
+ raise AcquisitionSecurityError(
559
+ "PARSE_ROW_SCHEMA",
560
+ f"JSON row {index + 1} does not match the first-row schema",
561
+ )
562
+ row = tuple(item[column] for column in columns)
563
+ _validate_row(row, limits)
564
+ rows.append(row)
565
+ return columns, tuple(rows)
566
+
567
+
568
+ def _parse_parquet(
569
+ content: bytes,
570
+ limits: ParseLimits,
571
+ *,
572
+ display: bool = False,
573
+ ) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]:
574
+ if not (content.startswith(b"PAR1") and content.endswith(b"PAR1")):
575
+ raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet magic bytes are invalid")
576
+ pa, pq = _pyarrow()
577
+ try:
578
+ parquet = pq.ParquetFile(pa.BufferReader(content))
579
+ metadata = parquet.metadata
580
+ schema = parquet.schema_arrow
581
+ except (pa.ArrowException, OSError, ValueError):
582
+ raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet metadata is malformed") from None
583
+ if metadata.num_rows > limits.max_rows:
584
+ raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "Parquet exceeds the row budget")
585
+ if metadata.num_columns > limits.max_columns:
586
+ raise AcquisitionSecurityError("PARSE_COLUMN_LIMIT", "Parquet exceeds the column budget")
587
+ if metadata.num_rows * max(metadata.num_columns, 1) > limits.max_total_cells:
588
+ raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "Parquet exceeds the cell budget")
589
+ uncompressed = sum(
590
+ metadata.row_group(group).total_byte_size for group in range(metadata.num_row_groups)
591
+ )
592
+ if uncompressed > limits.max_uncompressed_bytes:
593
+ raise AcquisitionSecurityError(
594
+ "PARSE_DECOMPRESSION_LIMIT",
595
+ "Parquet decoded bytes exceed the configured budget",
596
+ )
597
+ if uncompressed > max(len(content), 1) * limits.max_expansion_ratio:
598
+ raise AcquisitionSecurityError(
599
+ "PARSE_EXPANSION_RATIO",
600
+ "Parquet expansion ratio exceeds the configured budget",
601
+ )
602
+ for field in schema:
603
+ if isinstance(field.type, pa.ExtensionType):
604
+ raise AcquisitionSecurityError(
605
+ "PARSE_EXTENSION_TYPE",
606
+ "Parquet extension types are not allowlisted",
607
+ )
608
+ columns = _columns(schema.names, limits)
609
+ try:
610
+ table = parquet.read()
611
+ records = table.to_pylist()
612
+ except (pa.ArrowException, OSError, ValueError):
613
+ raise AcquisitionSecurityError("PARSE_PARQUET", "Parquet row decoding failed") from None
614
+ rows = tuple(tuple(item[column] for column in columns) for item in records)
615
+ if display:
616
+ # Before the budget check, never instead of it: a rendered value is measured in bytes
617
+ # exactly like any other string, so the field budget still applies to every cell.
618
+ rows = tuple(
619
+ tuple(_displayed(value, depth=0, limits=limits) for value in row) for row in rows
620
+ )
621
+ for row in rows:
622
+ _validate_row(row, limits)
623
+ return columns, rows
624
+
625
+
626
+ # ------------------------------------------------------------------------------------------------
627
+ # Rendering for display, which is the one thing a look may do that a build may not
628
+ # ------------------------------------------------------------------------------------------------
629
+
630
+
631
+ class _ExactText(str):
632
+ """The exact text of a value the canonical rule has no place for.
633
+
634
+ A ``str`` subclass rather than a plain ``str`` so the kind of value it came from survives the
635
+ rendering. Each subclass is named after that kind and :func:`_column_type` reads a value's type
636
+ name, so a rendered ``datetime.date`` is still reported as a ``date`` column: a person and an
637
+ agent are told what the file holds, not what the display did to it.
638
+ """
639
+
640
+ __slots__ = ()
641
+
642
+
643
+ # The kinds that are rendered, and the text each is rendered into. Keyed by exact type rather than
644
+ # by `isinstance`, because `datetime` is a subclass of `date` and the two are different kinds of
645
+ # column. `isoformat` and `str` are the value's own exact spellings -- neither rounds, truncates,
646
+ # or reformats -- so the text is the value and not a summary of it.
647
+ _DISPLAY_TEXT: dict[type, Any] = {
648
+ datetime.date: datetime.date.isoformat,
649
+ datetime.datetime: datetime.datetime.isoformat,
650
+ datetime.time: datetime.time.isoformat,
651
+ datetime.timedelta: str,
652
+ decimal.Decimal: str,
653
+ }
654
+
655
+ # One named subclass per kind, built once.
656
+ _DISPLAY_CLASSES: dict[type, type[_ExactText]] = {
657
+ kind: type(kind.__name__, (_ExactText,), {"__slots__": ()}) for kind in _DISPLAY_TEXT
658
+ }
659
+
660
+ # A not-a-number or an infinity is a `float` like any other, and the column it sits in is a float
661
+ # column, so its rendering keeps that name. `repr` is the exact spelling Python reads back.
662
+ _DISPLAY_FLOAT: type[_ExactText] = type("float", (_ExactText,), {"__slots__": ()})
663
+
664
+
665
+ def _displayed(value: Any, *, depth: int, limits: ParseLimits) -> Any:
666
+ """``value`` unchanged when the canonical rule holds it, and its exact text when it does not.
667
+
668
+ Anything still unrenderable is left exactly as it is, so :func:`_validate_row` refuses it with
669
+ ``PARSE_VALUE`` as before: this widens what can be looked at, it does not remove the floor.
670
+ """
671
+
672
+ if depth > limits.max_json_depth:
673
+ raise AcquisitionSecurityError("PARSE_DEPTH_LIMIT", "value exceeds the nesting budget")
674
+ if isinstance(value, list | tuple):
675
+ return [_displayed(item, depth=depth + 1, limits=limits) for item in value]
676
+ if isinstance(value, dict):
677
+ return {
678
+ key: _displayed(item, depth=depth + 1, limits=limits) for key, item in value.items()
679
+ }
680
+ if type(value) is float and not math.isfinite(value):
681
+ return _DISPLAY_FLOAT(repr(value))
682
+ render = _DISPLAY_TEXT.get(type(value))
683
+ if render is None:
684
+ return value
685
+ return _DISPLAY_CLASSES[type(value)](render(value))
686
+
687
+
688
+ def _columns(values: list[Any], limits: ParseLimits) -> tuple[str, ...]:
689
+ if not values or len(values) > limits.max_columns:
690
+ raise AcquisitionSecurityError(
691
+ "PARSE_COLUMN_LIMIT",
692
+ "table must have a non-empty bounded column set",
693
+ )
694
+ if not all(isinstance(value, str) and _SAFE_COLUMN.fullmatch(value) for value in values):
695
+ raise AcquisitionSecurityError(
696
+ "PARSE_COLUMN_NAME",
697
+ "column names must be bounded non-control strings",
698
+ )
699
+ columns = tuple(values)
700
+ if len(set(columns)) != len(columns):
701
+ raise AcquisitionSecurityError("PARSE_COLUMN_DUPLICATE", "column names must be unique")
702
+ return columns
703
+
704
+
705
+ def _validate_table(
706
+ columns: tuple[str, ...],
707
+ rows: tuple[tuple[Any, ...], ...],
708
+ limits: ParseLimits,
709
+ ) -> None:
710
+ if len(rows) > limits.max_rows:
711
+ raise AcquisitionSecurityError("PARSE_ROW_LIMIT", "table exceeds the row budget")
712
+ if len(rows) * len(columns) > limits.max_total_cells:
713
+ raise AcquisitionSecurityError("PARSE_CELL_LIMIT", "table exceeds the cell budget")
714
+ for row in rows:
715
+ if len(row) != len(columns):
716
+ raise AcquisitionSecurityError(
717
+ "PARSE_ROW_WIDTH",
718
+ "parsed row width differs from its schema",
719
+ )
720
+
721
+
722
+ def _validate_row(row: tuple[Any, ...], limits: ParseLimits) -> None:
723
+ for value in row:
724
+ if isinstance(value, str):
725
+ size = len(value.encode("utf-8"))
726
+ elif isinstance(value, bytes):
727
+ size = len(value)
728
+ else:
729
+ try:
730
+ size = len(
731
+ json.dumps(
732
+ value,
733
+ ensure_ascii=False,
734
+ separators=(",", ":"),
735
+ allow_nan=False,
736
+ ).encode("utf-8")
737
+ )
738
+ except (TypeError, ValueError):
739
+ raise AcquisitionSecurityError(
740
+ "PARSE_VALUE",
741
+ "parsed field contains a non-canonical value",
742
+ ) from None
743
+ if size > limits.max_field_bytes:
744
+ raise AcquisitionSecurityError(
745
+ "PARSE_FIELD_LIMIT",
746
+ "parsed field exceeds the byte budget",
747
+ )
748
+
749
+
750
+ def _validate_json_value(value: Any, *, depth: int, limits: ParseLimits) -> None:
751
+ if depth > limits.max_json_depth:
752
+ raise AcquisitionSecurityError("PARSE_DEPTH_LIMIT", "JSON exceeds the nesting budget")
753
+ if isinstance(value, dict):
754
+ for key, item in value.items():
755
+ if key in _FORBIDDEN_REFERENCE_KEYS:
756
+ raise AcquisitionSecurityError(
757
+ "PARSE_EXTERNAL_REFERENCE",
758
+ "parser-driven external references are forbidden",
759
+ )
760
+ _validate_json_value(item, depth=depth + 1, limits=limits)
761
+ elif isinstance(value, list):
762
+ for item in value:
763
+ _validate_json_value(item, depth=depth + 1, limits=limits)
764
+ elif isinstance(value, float) and not math.isfinite(value):
765
+ raise AcquisitionSecurityError("PARSE_NUMBER", "non-finite JSON numbers are forbidden")
766
+
767
+
768
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
769
+ result: dict[str, Any] = {}
770
+ for key, value in pairs:
771
+ if key in result:
772
+ raise AcquisitionSecurityError("PARSE_JSON_DUPLICATE", "JSON keys must be unique")
773
+ result[key] = value
774
+ return result
775
+
776
+
777
+ def _invalid_json_number(value: str) -> Any:
778
+ raise AcquisitionSecurityError("PARSE_NUMBER", f"JSON number {value!r} is forbidden")
779
+
780
+
781
+ def _strict_utf8(content: bytes) -> str:
782
+ try:
783
+ return content.decode("utf-8", errors="strict")
784
+ except UnicodeDecodeError:
785
+ raise AcquisitionSecurityError(
786
+ "PARSE_ENCODING",
787
+ "source text must be strict UTF-8",
788
+ ) from None
789
+
790
+
791
+ def _suffix(filename: str) -> str:
792
+ if (
793
+ not isinstance(filename, str)
794
+ or not filename
795
+ or "/" in filename
796
+ or "\\" in filename
797
+ or filename.startswith(".")
798
+ ):
799
+ raise AcquisitionSecurityError(
800
+ "PARSE_FILENAME",
801
+ "source filename must be one non-hidden path component",
802
+ )
803
+ dot = filename.rfind(".")
804
+ return filename[dot:].lower() if dot >= 0 else ""
805
+
806
+
807
+ def _column_type(rows: tuple[tuple[Any, ...], ...], index: int) -> str:
808
+ observed = sorted({"null" if row[index] is None else type(row[index]).__name__ for row in rows})
809
+ return "|".join(observed) if observed else "empty"