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,963 @@
1
+ """The container Readers: an archive opened one layer deep, in memory, never to disk.
2
+
3
+ A container Reader takes the bytes of an archive and returns the rows of exactly one member
4
+ of it. Four clauses govern every family in this module, and each one deletes a bug class
5
+ rather than mitigating one. They are written here as a list a reviewer can check the code
6
+ against, in the order the code applies them.
7
+
8
+ 1. **Depth 1.** After the member is selected, its first bytes are checked against the one
9
+ archive-and-executable prefix table in ``formats``. A member that is itself an archive is
10
+ refused as ``READER_NESTED_CONTAINER``. Nothing in this module recurses, under any
11
+ option, ever. The recipe layer is the first of the two layers: ``max_nesting_depth`` is
12
+ fixed at 1 by the Reader contract and a recipe that asks for 2 is refused where the budget
13
+ is built, so depth cannot be raised by configuration. This module is the second layer,
14
+ and it catches the different thing -- an archive whose *contents* are nested rather than a
15
+ recipe that asked to descend.
16
+ 2. **Exactly one recipe-named member.** The recipe supplies the member name in its sealed
17
+ decode options. No family here picks a member by heuristic: "the only CSV in the archive"
18
+ is non-deterministic by construction, because an archive that later gains a second CSV
19
+ would silently change the dataset while the recipe digest stayed the same. A missing name
20
+ is ``READER_OPTIONS``; a name matching no member is ``READER_MEMBER_MISSING``; a name
21
+ matching more than one member -- which zip and tar both permit -- is
22
+ ``READER_MEMBER_AMBIGUOUS``; a member that is not a regular file is
23
+ ``READER_MEMBER_TYPE``, naming the type found.
24
+ 3. **Streamed in memory.** The input is exact bytes, the archive is opened over
25
+ ``io.BytesIO``, and the member is read in bounded chunks through ``ZipFile.open``,
26
+ ``TarFile.extractfile``, or a bounded inflate. Those three plus nothing else: no API
27
+ taking a filesystem path is named anywhere in this module, which is how zip-slip and path
28
+ traversal are deleted rather than defended against.
29
+ 4. **Never extracted to disk.** A decode creates no file, no directory, and no temporary
30
+ anything. ``tests/h3/test_readers_container.py`` asserts it by observation -- a watched
31
+ working directory and a watched temporary directory, empty before and empty after --
32
+ because the absence of a spill is not provable by reading the source.
33
+
34
+ Where a clause degenerates for a format, it is said here rather than left for a reader to
35
+ notice. A gzip stream has exactly one member and no member name, so "exactly one
36
+ recipe-named member" becomes "exactly one stream, whose format the recipe still names": the
37
+ ``member`` option is refused for that family rather than ignored, because silently accepting
38
+ a meaningless option is how two different recipes come to look identical, and a concatenated
39
+ second stream -- which is legal gzip -- is refused rather than appended to the first. The
40
+ optional filename field a gzip header may carry is never read: it is attacker-controlled text
41
+ that names nothing this family selects on.
42
+
43
+ A member is decoded by the same helper the delimited family uses, and it is decoded under the
44
+ same rules: the member's first row is its heading row, and ``header: false`` is refused here in
45
+ the delimited family's own words rather than answered with positional column names. These
46
+ families call ``decode_delimited_stream`` directly rather than through ``DelimitedTextReader``, so a
47
+ rule stated only on that class is a rule the wrapped route does not have -- which is how one
48
+ product came to give two answers to "are columns ever named by position", depending on whether
49
+ the file arrived inside a wrapper.
50
+
51
+ Budgets run before allocation, and the order is visible in the code because that is the only
52
+ way the claim is checkable. Zip and tar declare their members' uncompressed sizes in a
53
+ directory, so those families decide against the declaration before a member is opened and a
54
+ bomb is refused with nothing allocated. Gzip declares nothing at all, so its inflate is
55
+ bounded by a byte counter and abandoned mid-stream rather than after. A declaration is only
56
+ a claim either way, so the read itself is bounded independently of it.
57
+
58
+ The tar member-type check is the one that matters most and the easiest to get subtly wrong.
59
+ ``tarfile``'s ``filter="data"`` is a second layer and is not the first: this module never
60
+ extracts, so that filter never runs on our path. The explicit ``isfile()`` check is the real
61
+ gate, and the refusal enumerates every type it refuses so the message is actionable.
62
+
63
+ The decoder pin is the CPython standard library -- ``zipfile``, ``tarfile``, ``zlib`` -- and
64
+ that is deliberate. We never extract, so the memory-safety argument is about the
65
+ decompressor, and CPython's zlib is the same battle-tested C whichever route we take. A
66
+ third-party or Rust decompressor here would buy nothing and would import the whole
67
+ supply-chain problem on Reader number one. These families therefore carry no third-party
68
+ dependency at all; the Certification record states the pin as the interpreter range the
69
+ project supports.
70
+
71
+ Import discipline: this module reads ``formats`` and ``readers.tabular`` and nothing from
72
+ ``acquisition``. The archive prefix table lives in the leaf precisely so both the tabular
73
+ parser (which refuses an archive outright) and these families (which refuse a nested one) can
74
+ read one declaration; a ``readers -> acquisition`` edge would close an import cycle.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import dataclasses
80
+ import io
81
+ import tarfile
82
+ import zipfile
83
+ import zlib
84
+ from collections.abc import Iterator, Mapping
85
+ from dataclasses import dataclass, field
86
+ from types import MappingProxyType
87
+ from typing import Any, BinaryIO
88
+
89
+ from mostlyright.data_harness.formats import (
90
+ ARCHIVE_OR_EXECUTABLE_MAGICS,
91
+ FORMAT_MEDIA_TYPES,
92
+ FORMAT_SUFFIXES,
93
+ READER_CONTRACT_VERSION,
94
+ READER_OUTPUT_FORMATS,
95
+ )
96
+ from mostlyright.data_harness.readers.contracts import (
97
+ ReaderBudgets,
98
+ ReaderError,
99
+ ReaderPin,
100
+ ReaderResult,
101
+ bulk_default_budgets,
102
+ )
103
+ from mostlyright.data_harness.readers.tabular import (
104
+ FALLBACK_STEM,
105
+ decode_delimited_stream,
106
+ encode_canonical_csv,
107
+ sealed_filename,
108
+ )
109
+
110
+ __all__ = [
111
+ "CHUNK_BYTES",
112
+ "GZIP_MAGICS",
113
+ "ZIP_MAGICS",
114
+ "GzipReader",
115
+ "TarReader",
116
+ "ZipReader",
117
+ "inflate_one_gzip_stream",
118
+ "read_bounded",
119
+ ]
120
+
121
+ # The one encoding a Reader may seal, unpacked rather than restated. A second member in the
122
+ # leaf's table makes this line raise at import, which is the loud failure that forces a
123
+ # family to choose its output encoding deliberately instead of inheriting whichever name
124
+ # happened to sort first.
125
+ (_OUTPUT_FORMAT,) = READER_OUTPUT_FORMATS
126
+ _OUTPUT_MEDIA_TYPE = sorted(FORMAT_MEDIA_TYPES[_OUTPUT_FORMAT])[0]
127
+ _OUTPUT_SUFFIX = sorted(FORMAT_SUFFIXES[_OUTPUT_FORMAT])[0]
128
+
129
+ # The admission prefixes, derived from the one leaf table rather than copied out of it. A
130
+ # second literal list would let this module and the tabular parser drift into disagreeing
131
+ # about what an archive is, which is a security-relevant disagreement and not a cosmetic one.
132
+ ZIP_MAGICS: tuple[bytes, ...] = tuple(
133
+ magic for magic in ARCHIVE_OR_EXECUTABLE_MAGICS if magic.startswith(b"PK")
134
+ )
135
+ GZIP_MAGICS: tuple[bytes, ...] = tuple(
136
+ magic for magic in ARCHIVE_OR_EXECUTABLE_MAGICS if magic.startswith(b"\x1f\x8b")
137
+ )
138
+
139
+ # Tar is the one family with no leading magic: a tar file begins with its first member's
140
+ # name. The POSIX ``ustar`` marker sits at offset 257 of the first header block, and the two
141
+ # spellings below are the two this family admits -- POSIX writes ``ustar\0`` and GNU writes
142
+ # ``ustar``-space. A pre-POSIX v7 tar carries no marker at all and is therefore not admitted;
143
+ # that is a residual recorded in the Certification record, not an oversight.
144
+ _TAR_MAGIC_OFFSET = 257
145
+ _TAR_MAGICS: tuple[bytes, ...] = (b"ustar\x00", b"ustar ")
146
+
147
+ # The window read at an admission offset. Bounded so a 16 MiB input is not copied to compare
148
+ # six bytes.
149
+ _MAGIC_WINDOW_BYTES = 16
150
+
151
+ # The compression methods a zip member may use, as a closed list rather than as whatever the
152
+ # running interpreter happens to support. ``zipfile`` decodes bzip2 and lzma only when the
153
+ # interpreter was built with those modules and raises an untyped ``NotImplementedError`` when
154
+ # it was not, so admitting them would make a member's decodability a property of the host. A
155
+ # Reader that answers differently on two hosts cannot promise the same rows forever.
156
+ _ZIP_METHODS: Mapping[int, str] = MappingProxyType(
157
+ {zipfile.ZIP_STORED: "stored", zipfile.ZIP_DEFLATED: "deflated"}
158
+ )
159
+
160
+ # Bit 0 of the general-purpose flag: the member is encrypted. Read from the directory so an
161
+ # encrypted member is refused with a typed refusal rather than escaping as the ``RuntimeError``
162
+ # ``ZipFile.open`` raises for one.
163
+ _ZIP_ENCRYPTED_FLAG = 0x01
164
+
165
+ # A zip member made on a unix host stores its st_mode in the high 16 bits of ``external_attr``.
166
+ # ``ZipInfo.is_dir()`` only reads the trailing-slash naming convention, so it says nothing about
167
+ # a symbolic link, a fifo, or a device node -- and a symlink entry's *content* is the link
168
+ # target text, which without this check would be decoded as if it were the member's data and
169
+ # sealed as rows. Tar has had this gate since it shipped (``_tar_member_type``); clause 2 says
170
+ # the rule governs every family in this module, so zip reads the mode the same way.
171
+ _ZIP_CREATE_SYSTEM_UNIX = 3
172
+ _UNIX_MODE_TYPE_MASK = 0o170000
173
+ _UNIX_MODE_REGULAR = 0o100000
174
+ _UNIX_MODE_DIRECTORY = 0o040000
175
+ _ZIP_MEMBER_TYPES: Mapping[int, str] = MappingProxyType(
176
+ {
177
+ _UNIX_MODE_DIRECTORY: "directory",
178
+ 0o120000: "symbolic link",
179
+ 0o010000: "fifo",
180
+ 0o020000: "character device",
181
+ 0o060000: "block device",
182
+ 0o140000: "socket",
183
+ }
184
+ )
185
+
186
+ # gzip, and gzip only: 16 selects the gzip wrapper rather than raw or zlib-wrapped deflate,
187
+ # so a stream that is not gzip fails in the decompressor rather than being read as something
188
+ # else.
189
+ _GZIP_WBITS = 16 + zlib.MAX_WBITS
190
+
191
+ # Which delimiter each member form uses. The delimited decoder takes the delimiter as an
192
+ # argument, so this table is the whole of "which form is this member": a second delimited
193
+ # decoder would be a second implementation of one thing.
194
+ _DELIMITER_BY_MEMBER_FORM: Mapping[str, str] = MappingProxyType(
195
+ {"csv": ",", "semicolon": ";", "tsv": "\t"}
196
+ )
197
+ _V1_MEMBER_FORMS = frozenset({"csv", "tsv"})
198
+ _V1_1_MEMBER_FORMS = frozenset(_DELIMITER_BY_MEMBER_FORM)
199
+
200
+ # The settings every container family takes, and the one more that a named-member family adds.
201
+ _SHARED_OPTION_KEYS = ("header", "member_format")
202
+ _MEMBER_OPTION_KEY = "member"
203
+
204
+ # A member name is bounded text. It is never joined to a path and never opened as one, so
205
+ # this bound is about keeping a refusal message readable rather than about traversal.
206
+ _MAX_MEMBER_NAME_BYTES = 1024
207
+
208
+ # The read granularity. Public because it is the tolerance the gzip bomb assertion is
209
+ # written against: "abandoned mid-stream" means no more than one chunk came out past the
210
+ # ceiling, and a test that invented its own tolerance would be asserting a different claim.
211
+ # Small enough that a bomb is abandoned after one chunk rather than after one member, large
212
+ # enough that an ordinary member costs a handful of calls.
213
+ CHUNK_BYTES = 64 * 1024
214
+
215
+
216
+ def read_bounded(stream: BinaryIO, *, declared: int, subject: str) -> bytes:
217
+ """Read exactly the declared number of bytes, and refuse a stream that is not that long.
218
+
219
+ Public because it is the second layer of the size invariant and a second layer nobody can
220
+ exercise is a second layer nobody knows works. The declared size is a claim made by the
221
+ archive's own directory, so the read is bounded independently of it: one byte past the
222
+ declaration is read deliberately, and getting it is the refusal.
223
+ """
224
+
225
+ if type(declared) is not int or declared < 0:
226
+ raise ReaderError("READER_BUDGET", subject, "a declared member size must be a count")
227
+ chunks: list[bytes] = []
228
+ remaining = declared + 1
229
+ while remaining > 0:
230
+ chunk = stream.read(min(CHUNK_BYTES, remaining))
231
+ if not chunk:
232
+ break
233
+ remaining -= len(chunk)
234
+ chunks.append(chunk)
235
+ payload = b"".join(chunks)
236
+ if len(payload) > declared:
237
+ raise ReaderError(
238
+ "READER_BUDGET",
239
+ subject,
240
+ f"holds more bytes than the {declared} its directory declares",
241
+ )
242
+ if len(payload) < declared:
243
+ raise ReaderError(
244
+ "READER_ADMISSION",
245
+ subject,
246
+ f"ends after {len(payload)} bytes, short of the {declared} its directory declares",
247
+ )
248
+ return payload
249
+
250
+
251
+ def screen_zip_entry(entry: zipfile.ZipInfo, *, subject: str, described: str) -> None:
252
+ """Refuse from the central directory anything ``ZipFile.open`` would not refuse typed.
253
+
254
+ Three screens, all read from the directory record, all before the entry is opened:
255
+
256
+ * a member type that is not a regular file -- see ``_zip_member_type``;
257
+ * the encryption flag, because ``ZipFile.open`` raises a bare ``RuntimeError`` for one,
258
+ and a refusal with no code is a refusal the remediation map cannot bind a fix to;
259
+ * a compression method outside the closed two, because ``zipfile`` decodes bzip2 and
260
+ lzma only when the interpreter was built with those modules and raises an untyped
261
+ ``NotImplementedError`` when it was not, which would make a file's decodability a
262
+ property of the host.
263
+
264
+ Public and shared rather than inlined per caller. ``spreadsheet`` opens the parts of a
265
+ workbook package with the same standard-library engine and had none of these screens, so
266
+ an encrypted or oddly-compressed part escaped as an untyped ``RuntimeError``: the same
267
+ class this module had already learned once, re-entering through a second call site. One
268
+ implementation means a third call site cannot re-enter it a third time.
269
+
270
+ ``described`` is how the refusal names the entry, because "the member the recipe named"
271
+ and "a part of the workbook package" are different things to a person reading a halt.
272
+ """
273
+
274
+ found = _zip_member_type(entry)
275
+ if found is not None:
276
+ raise ReaderError(
277
+ "READER_MEMBER_TYPE",
278
+ subject,
279
+ f"{described}, which is a {found}. A container Reader opens a regular file only; "
280
+ "a directory, symbolic link, hard link, fifo, character device, block device, or "
281
+ "socket is refused. The bytes a symbolic link entry stores are the path it points "
282
+ "at, so reading one as data would seal that path as the dataset's rows",
283
+ )
284
+ if entry.flag_bits & _ZIP_ENCRYPTED_FLAG:
285
+ raise ReaderError(
286
+ "READER_ADMISSION",
287
+ subject,
288
+ f"{described}, which is encrypted; a recipe seals no secret and a Reader holds "
289
+ "none, so an encrypted member cannot be opened",
290
+ )
291
+ if entry.compress_type not in _ZIP_METHODS:
292
+ raise ReaderError(
293
+ "READER_ADMISSION",
294
+ subject,
295
+ f"{described}, whose compression method {entry.compress_type} is outside the "
296
+ f"admitted list: {', '.join(sorted(_ZIP_METHODS.values()))}",
297
+ )
298
+
299
+
300
+ def _admit_container_bytes(
301
+ content: Any,
302
+ *,
303
+ magics: tuple[bytes, ...],
304
+ budgets: ReaderBudgets,
305
+ subject: str,
306
+ described: str,
307
+ offset: int = 0,
308
+ ) -> bytes:
309
+ """Admit the archive's own bytes, before any decoder object is constructed.
310
+
311
+ ``offset`` exists for tar, whose marker sits inside the first header block rather than at
312
+ the front. The window compared is bounded, so admission never copies the input.
313
+ """
314
+
315
+ if not isinstance(content, (bytes, bytearray)):
316
+ raise ReaderError("READER_ADMISSION", subject, "must be exact bytes")
317
+ raw = bytes(content)
318
+ if len(raw) > budgets.max_input_bytes:
319
+ raise ReaderError("READER_BUDGET", subject, "exceeds the input byte budget")
320
+ window = raw[offset : offset + _MAGIC_WINDOW_BYTES]
321
+ if not any(window.startswith(magic) for magic in magics):
322
+ raise ReaderError("READER_ADMISSION", subject, f"does not begin as {described}")
323
+ return raw
324
+
325
+
326
+ def inflate_one_gzip_stream(raw: bytes, *, ceiling: int, subject: str) -> bytes:
327
+ """Inflate exactly one gzip stream, bounded by a counter and abandoned when it is passed.
328
+
329
+ A gzip stream declares nothing about how large it will become, so there is no declaration
330
+ to decide against and the counter is the whole defence. Output is taken one chunk at a
331
+ time and the total is checked after every chunk, so a bomb is refused with at most one
332
+ chunk of it in memory rather than after the whole thing has been produced.
333
+
334
+ Exactly one stream: a gzip file may legally carry a second stream concatenated onto the
335
+ first, and reading both would silently append one table to another. Trailing bytes after
336
+ the first stream ends are therefore a refusal, not a continuation.
337
+ """
338
+
339
+ decompressor = zlib.decompressobj(wbits=_GZIP_WBITS)
340
+ chunks: list[bytes] = []
341
+ produced = 0
342
+ offset = 0
343
+ # A progress bound rather than a trust in the loop's own termination: every pass either
344
+ # consumes input or produces output, so a pass that does neither is a decompressor
345
+ # behaving in a way this loop did not foresee, and spinning is the one outcome a bounded
346
+ # decode may not have.
347
+ steps = 0
348
+ max_steps = 4 * (len(raw) // CHUNK_BYTES + ceiling // CHUNK_BYTES + 4)
349
+
350
+ while not decompressor.eof:
351
+ steps += 1
352
+ if steps > max_steps:
353
+ raise ReaderError(
354
+ "READER_BUDGET",
355
+ subject,
356
+ "made no progress inflating; the decode is abandoned rather than retried",
357
+ )
358
+ if decompressor.unconsumed_tail:
359
+ piece = decompressor.unconsumed_tail
360
+ elif offset < len(raw):
361
+ piece = raw[offset : offset + CHUNK_BYTES]
362
+ offset += len(piece)
363
+ else:
364
+ raise ReaderError(
365
+ "READER_ADMISSION",
366
+ subject,
367
+ "ends before its gzip stream is complete",
368
+ )
369
+ try:
370
+ produced_chunk = decompressor.decompress(piece, CHUNK_BYTES)
371
+ except zlib.error as error:
372
+ raise ReaderError("READER_ADMISSION", subject, "is not a readable gzip stream") from (
373
+ error
374
+ )
375
+ if produced_chunk:
376
+ produced += len(produced_chunk)
377
+ if produced > ceiling:
378
+ raise ReaderError(
379
+ "READER_BUDGET",
380
+ subject,
381
+ f"inflates past the {ceiling} bytes this stream may produce -- the "
382
+ "smaller of the uncompressed byte budget and the expansion ratio applied "
383
+ "to its compressed size -- and is abandoned mid-stream",
384
+ )
385
+ chunks.append(produced_chunk)
386
+
387
+ if decompressor.unused_data or offset < len(raw):
388
+ raise ReaderError(
389
+ "READER_ADMISSION",
390
+ subject,
391
+ "carries more than one gzip stream; exactly one stream is opened, because a "
392
+ "concatenated second one would silently append a second table to the first",
393
+ )
394
+ return b"".join(chunks)
395
+
396
+
397
+ def _validate_container_options(
398
+ options: Any,
399
+ *,
400
+ names_a_member: bool,
401
+ subject: str,
402
+ admitted_member_forms: frozenset[str] = _V1_MEMBER_FORMS,
403
+ ) -> dict[str, Any]:
404
+ """Admit this family's closed option set, refusing an unknown key by name.
405
+
406
+ Strict on purpose. The options digest is sealed into the recipe forever, so a key that
407
+ was accepted and ignored would be an unreviewable difference between two recipes that
408
+ look identical to everyone who reads them.
409
+ """
410
+
411
+ if not isinstance(options, Mapping):
412
+ raise ReaderError("READER_OPTIONS", subject, "must be an object")
413
+ allowed = set(_SHARED_OPTION_KEYS)
414
+ if names_a_member:
415
+ allowed.add(_MEMBER_OPTION_KEY)
416
+ unknown = sorted(str(key) for key in options if key not in allowed)
417
+ if unknown:
418
+ raise ReaderError(
419
+ "READER_OPTIONS",
420
+ subject,
421
+ f"names no such setting: {', '.join(unknown)}; this family takes "
422
+ f"{', '.join(sorted(allowed))}",
423
+ )
424
+
425
+ member_format = options.get("member_format")
426
+ if member_format not in admitted_member_forms or not isinstance(member_format, str):
427
+ raise ReaderError(
428
+ "READER_OPTIONS",
429
+ f"{subject}.member_format",
430
+ "must name the delimited form the member's bytes use, one of "
431
+ f"{', '.join(sorted(admitted_member_forms))}",
432
+ )
433
+ header = options.get("header", True)
434
+ if not isinstance(header, bool):
435
+ raise ReaderError("READER_OPTIONS", f"{subject}.header", "must be true or false")
436
+ if not header:
437
+ # The same ruling the delimited family gives, in the same words, because it is the
438
+ # same question about the same bytes. These families hand their member to
439
+ # ``decode_delimited_stream`` directly rather than through ``DelimitedTextReader``, so the
440
+ # rule has to be stated on both routes or the product answers "are columns ever
441
+ # named by position" two ways depending on whether the file arrived in a wrapper.
442
+ raise ReaderError(
443
+ "READER_OPTIONS",
444
+ f"{subject}.header",
445
+ "must be true: a headerless source needs the recipe to supply its column names, "
446
+ "this family's settings have no way to state them, and naming columns by position "
447
+ "would put names into the dataset that nobody wrote down. Add a header row to the "
448
+ "source, or widen this family with a reviewed columns option",
449
+ )
450
+
451
+ admitted: dict[str, Any] = {"member_format": member_format, "header": header}
452
+ if names_a_member:
453
+ member = options.get(_MEMBER_OPTION_KEY)
454
+ if (
455
+ not isinstance(member, str)
456
+ or not member
457
+ or "\x00" in member
458
+ or len(member.encode("utf-8", errors="replace")) > _MAX_MEMBER_NAME_BYTES
459
+ ):
460
+ raise ReaderError(
461
+ "READER_OPTIONS",
462
+ f"{subject}.member",
463
+ "must name exactly one member of the archive; this family never picks a "
464
+ "member by heuristic",
465
+ )
466
+ admitted[_MEMBER_OPTION_KEY] = member
467
+ return admitted
468
+
469
+
470
+ def _check_member_count(count: int, budgets: ReaderBudgets, subject: str) -> None:
471
+ """Refuse an archive with too many members, while the directory is scanned."""
472
+
473
+ if count > budgets.max_container_members:
474
+ raise ReaderError(
475
+ "READER_BUDGET",
476
+ subject,
477
+ f"holds {count} members, over the member budget of {budgets.max_container_members}",
478
+ )
479
+
480
+
481
+ def _check_declared_member_size(
482
+ *,
483
+ declared: int,
484
+ compressed: int,
485
+ budgets: ReaderBudgets,
486
+ subject: str,
487
+ ) -> None:
488
+ """Decide against the directory's declaration, before the member is opened.
489
+
490
+ This is the whole reason zip and tar are the safe two of the three: the size a member
491
+ claims is readable without decompressing a byte, so a bomb is refused with nothing
492
+ allocated rather than detected once it is too late.
493
+ """
494
+
495
+ if type(declared) is not int or declared < 0:
496
+ raise ReaderError("READER_BUDGET", subject, "declares a size that is not a count")
497
+ if declared > budgets.max_uncompressed_bytes:
498
+ raise ReaderError(
499
+ "READER_BUDGET",
500
+ subject,
501
+ f"declares {declared} uncompressed bytes, over the uncompressed byte budget",
502
+ )
503
+ if declared > budgets.max_output_bytes:
504
+ raise ReaderError(
505
+ "READER_BUDGET",
506
+ subject,
507
+ f"declares {declared} uncompressed bytes, over the decode output byte budget "
508
+ "the extracted member becomes",
509
+ )
510
+ if compressed <= 0:
511
+ if declared > 0:
512
+ raise ReaderError(
513
+ "READER_BUDGET",
514
+ subject,
515
+ "declares content it stores no compressed bytes for",
516
+ )
517
+ return
518
+ if declared > compressed * budgets.max_expansion_ratio:
519
+ raise ReaderError(
520
+ "READER_BUDGET",
521
+ subject,
522
+ f"declares an expansion of {declared} from {compressed} bytes, over the "
523
+ f"expansion ratio budget of {budgets.max_expansion_ratio}",
524
+ )
525
+
526
+
527
+ def _refuse_a_nested_container(payload: bytes, subject: str) -> None:
528
+ """Clause 1, as its own refusal: a member that is itself an archive is never opened."""
529
+
530
+ for magic in ARCHIVE_OR_EXECUTABLE_MAGICS:
531
+ if payload.startswith(magic):
532
+ raise ReaderError(
533
+ "READER_NESTED_CONTAINER",
534
+ subject,
535
+ "is itself an archive or an executable; containers are opened one layer "
536
+ "deep and nesting is refused rather than followed",
537
+ )
538
+
539
+
540
+ def _emit(
541
+ payload: bytes,
542
+ *,
543
+ options: Mapping[str, Any],
544
+ budgets: ReaderBudgets,
545
+ stem_source: str,
546
+ ) -> ReaderResult:
547
+ """Decode the member's bytes through the shared delimited reader and canonical emitter.
548
+
549
+ One emitter, shared by every family in the package. Byte-stability of the sealed
550
+ artifact is a Reader-contract obligation, so it is not re-implemented per family.
551
+ """
552
+
553
+ # The payload is container OUTPUT: it was admitted against the declared-member-size
554
+ # check, which bounds it by the output byte budget. The inner delimited stage would
555
+ # otherwise re-judge it as INPUT and refuse any member larger than the fetched-bytes
556
+ # budget, which for a compressed archive is almost every legitimate member.
557
+ member_budgets = dataclasses.replace(budgets, max_input_bytes=budgets.max_output_bytes)
558
+ # Parsed rows feed the canonical encoder one at a time rather than being drained into a
559
+ # table first. A year-scale member is millions of rows, and the table form would hold
560
+ # every cell as a live object at once -- the decode's memory ceiling is the row, not the
561
+ # member.
562
+ columns, rows = decode_delimited_stream(
563
+ payload,
564
+ delimiter=_DELIMITER_BY_MEMBER_FORM[options["member_format"]],
565
+ header=bool(options["header"]),
566
+ budgets=member_budgets,
567
+ )
568
+ row_count = 0
569
+
570
+ def counted() -> Iterator[tuple[str, ...]]:
571
+ nonlocal row_count
572
+ for row in rows:
573
+ row_count += 1
574
+ yield row
575
+
576
+ payload_csv = encode_canonical_csv(columns, counted(), budgets=budgets)
577
+ return ReaderResult(
578
+ content=payload_csv,
579
+ data_format=_OUTPUT_FORMAT,
580
+ media_type=_OUTPUT_MEDIA_TYPE,
581
+ filename=sealed_filename(stem_source, suffix=_OUTPUT_SUFFIX),
582
+ row_count=row_count,
583
+ column_names=columns,
584
+ declared_cell_count=row_count * len(columns),
585
+ )
586
+
587
+
588
+ @dataclass(frozen=True)
589
+ class ZipReader:
590
+ """``archive.zip``: one named member of a zip archive, at depth one, in memory.
591
+
592
+ The order of the work is admission, budget, selection, nesting, decode, emit, and the
593
+ function below is written in that order because "budgets before allocation" is only true
594
+ if the code says so. ``ZipInfo`` comes from the central directory, which is what makes
595
+ every size decision here a decision taken before a byte is decompressed.
596
+
597
+ Two further refusals are taken from the same directory, both found by adversarial review
598
+ and both before the member is opened: an encrypted member (``ZipFile.open`` raises an
599
+ untyped ``RuntimeError`` for one, and a refusal with no code is a refusal the remediation
600
+ map cannot bind a fix to), and a member stored by a compression method outside the closed
601
+ admitted list.
602
+ """
603
+
604
+ family_id: str = "archive.zip"
605
+ family_version: str = "1.0.0"
606
+ contract_version: str = READER_CONTRACT_VERSION
607
+ output_format: str = _OUTPUT_FORMAT
608
+ accepted_media_types: tuple[str, ...] = ("application/x-zip-compressed", "application/zip")
609
+ default_budgets: ReaderBudgets = field(default_factory=bulk_default_budgets)
610
+
611
+ def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
612
+ return _validate_container_options(
613
+ options, names_a_member=True, subject="reader.archive.zip.decode_options"
614
+ )
615
+
616
+ def decode(self, content: bytes, pin: ReaderPin, budgets: ReaderBudgets) -> ReaderResult:
617
+ subject = "reader.archive.zip"
618
+ options = self.validate_options(pin.decode_options)
619
+ raw = _admit_container_bytes(
620
+ content,
621
+ magics=ZIP_MAGICS,
622
+ budgets=budgets,
623
+ subject=subject,
624
+ described="a zip archive",
625
+ )
626
+ member = str(options[_MEMBER_OPTION_KEY])
627
+ where = f"{subject}.member"
628
+
629
+ try:
630
+ archive = zipfile.ZipFile(io.BytesIO(raw))
631
+ except (zipfile.BadZipFile, EOFError, zlib.error) as error:
632
+ raise ReaderError(
633
+ "READER_ADMISSION", subject, "is not a readable zip archive"
634
+ ) from error
635
+
636
+ with archive:
637
+ entries = archive.infolist()
638
+ _check_member_count(len(entries), budgets, subject)
639
+
640
+ matches = [entry for entry in entries if entry.filename == member]
641
+ if not matches:
642
+ raise ReaderError(
643
+ "READER_MEMBER_MISSING",
644
+ where,
645
+ f"names {member!r}, which this archive does not contain",
646
+ )
647
+ if len(matches) > 1:
648
+ raise ReaderError(
649
+ "READER_MEMBER_AMBIGUOUS",
650
+ where,
651
+ f"names {member!r}, which this archive holds {len(matches)} times; a "
652
+ "recipe names exactly one member and this one names more",
653
+ )
654
+ entry = matches[0]
655
+ if entry.is_dir():
656
+ raise ReaderError(
657
+ "READER_MEMBER_TYPE",
658
+ where,
659
+ f"names {member!r}, which is not a regular file",
660
+ )
661
+ screen_zip_entry(entry, subject=where, described=f"names {member!r}")
662
+ _check_declared_member_size(
663
+ declared=entry.file_size,
664
+ compressed=entry.compress_size,
665
+ budgets=budgets,
666
+ subject=where,
667
+ )
668
+
669
+ try:
670
+ with archive.open(entry, "r") as stream:
671
+ payload = read_bounded(stream, declared=entry.file_size, subject=where)
672
+ except (zipfile.BadZipFile, EOFError, zlib.error) as error:
673
+ raise ReaderError(
674
+ "READER_ADMISSION",
675
+ where,
676
+ f"names {member!r}, whose stored bytes do not match what the archive "
677
+ "records for it",
678
+ ) from error
679
+
680
+ _refuse_a_nested_container(payload, where)
681
+ return _emit(payload, options=options, budgets=budgets, stem_source=member)
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class ZipReaderV1_1(ZipReader):
686
+ """``archive.zip@1.1.0`` adds explicit semicolon-delimited members.
687
+
688
+ Version 1.0.0 remains closed to CSV and TSV, so an already-approved recipe cannot silently
689
+ acquire new parsing behavior. The new coordinate exists for publisher archives such as DWD
690
+ historical observations, whose selected text member uses a semicolon delimiter.
691
+ """
692
+
693
+ family_version: str = "1.1.0"
694
+
695
+ def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
696
+ return _validate_container_options(
697
+ options,
698
+ names_a_member=True,
699
+ subject="reader.archive.zip.decode_options",
700
+ admitted_member_forms=_V1_1_MEMBER_FORMS,
701
+ )
702
+
703
+
704
+ @dataclass(frozen=True)
705
+ class ZipReaderV1_2(ZipReaderV1_1):
706
+ """``archive.zip@1.2.0`` admits the byte-stream label, and opens nothing new.
707
+
708
+ ``application/octet-stream`` is a weak label and is treated as one -- ``acquisition/http``
709
+ records the rule for the slice path: it says only that a server declined to say anything,
710
+ and it is what an object store or a plain file server answers for a zip it was told
711
+ nothing about. ``weather.grib2`` already admits it for exactly that reason. The refusal
712
+ it removes landed at the fetch, before this family's own admission ever ran -- and that
713
+ admission is decisive where the label is a guess: the first bytes must be a zip prefix
714
+ from the one leaf table, so a response that is not a zip is refused by signature whatever
715
+ its label said.
716
+
717
+ A new coordinate in the ``archive.zip@1.1.0`` lineage, so the semicolon member form is
718
+ inherited and ``1.1.0`` stays closed to the weak label: an already-approved recipe cannot
719
+ silently begin admitting responses its review never saw. ``decode`` is inherited
720
+ untouched; what moves is only which responses reach it.
721
+ """
722
+
723
+ family_version: str = "1.2.0"
724
+ accepted_media_types: tuple[str, ...] = (
725
+ "application/octet-stream",
726
+ "application/x-zip-compressed",
727
+ "application/zip",
728
+ )
729
+
730
+
731
+ @dataclass(frozen=True)
732
+ class GzipReader:
733
+ """``archive.gzip``: one gzip stream, inflated under a counter, in memory.
734
+
735
+ The degenerate case of clause 2, and the reason it is stated rather than left implicit: a
736
+ gzip stream has exactly one member and no name for it, so "exactly one recipe-named
737
+ member" becomes "exactly one stream, whose format the recipe still names". A ``member``
738
+ option is therefore refused rather than ignored, and the header's optional filename field
739
+ is never read -- it is attacker-controlled text naming nothing this family selects on.
740
+ """
741
+
742
+ family_id: str = "archive.gzip"
743
+ family_version: str = "1.0.0"
744
+ contract_version: str = READER_CONTRACT_VERSION
745
+ output_format: str = _OUTPUT_FORMAT
746
+ accepted_media_types: tuple[str, ...] = ("application/gzip", "application/x-gzip")
747
+ default_budgets: ReaderBudgets = field(default_factory=bulk_default_budgets)
748
+
749
+ def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
750
+ return _validate_container_options(
751
+ options, names_a_member=False, subject="reader.archive.gzip.decode_options"
752
+ )
753
+
754
+ def decode(self, content: bytes, pin: ReaderPin, budgets: ReaderBudgets) -> ReaderResult:
755
+ subject = "reader.archive.gzip"
756
+ options = self.validate_options(pin.decode_options)
757
+ raw = _admit_container_bytes(
758
+ content,
759
+ magics=GZIP_MAGICS,
760
+ budgets=budgets,
761
+ subject=subject,
762
+ described="a gzip stream",
763
+ )
764
+ # The ceiling is the narrowest of the three bounds that apply: what the budgets allow
765
+ # uncompressed, what the decode output budget allows the stream to become, and what
766
+ # this many compressed bytes are allowed to expand into. The last is what turns a
767
+ # 1 KiB stream claiming to be 128 MiB into a refusal rather than a permitted
768
+ # allocation.
769
+ ceiling = min(
770
+ budgets.max_uncompressed_bytes,
771
+ budgets.max_output_bytes,
772
+ max(len(raw), 1) * budgets.max_expansion_ratio,
773
+ )
774
+ payload = inflate_one_gzip_stream(raw, ceiling=ceiling, subject=subject)
775
+ _refuse_a_nested_container(payload, subject)
776
+ return _emit(payload, options=options, budgets=budgets, stem_source=FALLBACK_STEM)
777
+
778
+
779
+ def _zip_member_type(entry: zipfile.ZipInfo) -> str | None:
780
+ """Name the non-regular type a zip directory entry declares, or ``None`` for a file.
781
+
782
+ The unix mode lives in the high 16 bits of ``external_attr``. Three cases, and the
783
+ middle one is why this returns ``None`` rather than a name for everything it does not
784
+ recognise: a zip written by a DOS or Windows toolchain -- and one written by CPython's
785
+ own ``ZipFile.writestr``, which sets ``0o600 << 16`` -- carries no type bits at all, so
786
+ zero means "this entry does not state a type" and is read as a regular file exactly as
787
+ it was before this check existed. A stated type is either regular, or one of the named
788
+ kinds, or a value no format names; the last of those is refused rather than admitted,
789
+ because a type nobody can name is not a type this Reader has been checked against.
790
+
791
+ ``create_system`` is deliberately not consulted. It is attacker-controlled text in the
792
+ same directory record, so gating on it would let a crafted archive turn the check off by
793
+ claiming to have been made on DOS.
794
+ """
795
+
796
+ mode = (entry.external_attr >> 16) & 0xFFFF
797
+ stated = mode & _UNIX_MODE_TYPE_MASK
798
+ if stated in (0, _UNIX_MODE_REGULAR):
799
+ return None
800
+ return _ZIP_MEMBER_TYPES.get(stated, "member of a type this archive format does not name")
801
+
802
+
803
+ def _tar_member_type(entry: tarfile.TarInfo) -> str:
804
+ """Name the member type found, in the words the refusal message uses."""
805
+
806
+ for holds, described in (
807
+ (entry.isdir, "directory"),
808
+ (entry.issym, "symbolic link"),
809
+ (entry.islnk, "hard link"),
810
+ (entry.isfifo, "fifo"),
811
+ (entry.ischr, "character device"),
812
+ (entry.isblk, "block device"),
813
+ ):
814
+ if holds():
815
+ return described
816
+ return "member of a type this archive format does not name"
817
+
818
+
819
+ @dataclass(frozen=True)
820
+ class GzipReaderV1_1(GzipReader):
821
+ """``archive.gzip@1.1.0`` admits the byte-stream label, and inflates nothing new.
822
+
823
+ The same one-label delta as ``archive.zip@1.2.0``, for the same reason, and the same
824
+ decisive admission behind it: the first bytes must be the gzip prefix from the leaf
825
+ table, so the weak label buys a response nothing but the signature check it then fails.
826
+ ``1.0.0`` stays closed to the weak label.
827
+ """
828
+
829
+ family_version: str = "1.1.0"
830
+ accepted_media_types: tuple[str, ...] = (
831
+ "application/gzip",
832
+ "application/octet-stream",
833
+ "application/x-gzip",
834
+ )
835
+
836
+
837
+ @dataclass(frozen=True)
838
+ class TarReader:
839
+ """``archive.tar``: one named regular-file member of an uncompressed tar, in memory.
840
+
841
+ Opened with ``mode="r:"`` and never ``mode="r"``. The difference is the whole of clause
842
+ 1 for this family: the transparent modes make ``tarfile`` decompress a gzip, bzip2, or xz
843
+ wrapper on our behalf, which is a second layer opened without anyone deciding to. A
844
+ compressed tar is therefore refused at admission, and a recipe that wants one states the
845
+ wrapper's own Reader instead.
846
+ """
847
+
848
+ family_id: str = "archive.tar"
849
+ family_version: str = "1.0.0"
850
+ contract_version: str = READER_CONTRACT_VERSION
851
+ output_format: str = _OUTPUT_FORMAT
852
+ accepted_media_types: tuple[str, ...] = ("application/x-tar",)
853
+ default_budgets: ReaderBudgets = field(default_factory=bulk_default_budgets)
854
+
855
+ def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
856
+ return _validate_container_options(
857
+ options, names_a_member=True, subject="reader.archive.tar.decode_options"
858
+ )
859
+
860
+ def decode(self, content: bytes, pin: ReaderPin, budgets: ReaderBudgets) -> ReaderResult:
861
+ subject = "reader.archive.tar"
862
+ options = self.validate_options(pin.decode_options)
863
+ raw = _admit_container_bytes(
864
+ content,
865
+ magics=_TAR_MAGICS,
866
+ budgets=budgets,
867
+ subject=subject,
868
+ described="a POSIX tar archive",
869
+ offset=_TAR_MAGIC_OFFSET,
870
+ )
871
+ member = str(options[_MEMBER_OPTION_KEY])
872
+ where = f"{subject}.member"
873
+
874
+ try:
875
+ archive = tarfile.open(fileobj=io.BytesIO(raw), mode="r:")
876
+ except tarfile.TarError as error:
877
+ raise ReaderError(
878
+ "READER_ADMISSION", subject, "is not a readable tar archive"
879
+ ) from error
880
+
881
+ with archive:
882
+ matches: list[tarfile.TarInfo] = []
883
+ scanned = 0
884
+ try:
885
+ for entry in archive:
886
+ scanned += 1
887
+ _check_member_count(scanned, budgets, subject)
888
+ if entry.name == member:
889
+ matches.append(entry)
890
+ except tarfile.TarError as error:
891
+ raise ReaderError(
892
+ "READER_ADMISSION", subject, "holds a member header it cannot read"
893
+ ) from error
894
+
895
+ if not matches:
896
+ raise ReaderError(
897
+ "READER_MEMBER_MISSING",
898
+ where,
899
+ f"names {member!r}, which this archive does not contain",
900
+ )
901
+ if len(matches) > 1:
902
+ raise ReaderError(
903
+ "READER_MEMBER_AMBIGUOUS",
904
+ where,
905
+ f"names {member!r}, which this archive holds {len(matches)} times; a "
906
+ "recipe names exactly one member and this one names more",
907
+ )
908
+ entry = matches[0]
909
+ if not entry.isfile():
910
+ raise ReaderError(
911
+ "READER_MEMBER_TYPE",
912
+ where,
913
+ f"names {member!r}, which is a {_tar_member_type(entry)}. A container "
914
+ "Reader opens a regular file only; a directory, symbolic link, hard "
915
+ "link, fifo, character device, or block device is refused",
916
+ )
917
+ # Tar stores its members uncompressed, so the declared size is both the size and
918
+ # the stored size and the expansion ratio is one by construction.
919
+ _check_declared_member_size(
920
+ declared=entry.size,
921
+ compressed=entry.size,
922
+ budgets=budgets,
923
+ subject=where,
924
+ )
925
+
926
+ try:
927
+ stream = archive.extractfile(entry)
928
+ except tarfile.TarError as error:
929
+ raise ReaderError(
930
+ "READER_ADMISSION", where, f"names {member!r}, which could not be read"
931
+ ) from error
932
+ if stream is None:
933
+ raise ReaderError(
934
+ "READER_MEMBER_TYPE",
935
+ where,
936
+ f"names {member!r}, which is a {_tar_member_type(entry)}. A container "
937
+ "Reader opens a regular file only; a directory, symbolic link, hard "
938
+ "link, fifo, character device, or block device is refused",
939
+ )
940
+ with stream:
941
+ payload = read_bounded(stream, declared=entry.size, subject=where)
942
+
943
+ _refuse_a_nested_container(payload, where)
944
+ return _emit(payload, options=options, budgets=budgets, stem_source=member)
945
+
946
+
947
+ @dataclass(frozen=True)
948
+ class TarReaderV1_1(TarReader):
949
+ """``archive.tar@1.1.0`` admits the byte-stream label, and extracts nothing new.
950
+
951
+ The same one-label delta as the zip and gzip coordinates above. A tar's signature sits
952
+ at offset 257 rather than at the first byte, and this family checks that POSIX ``ustar``
953
+ marker itself, before ``tarfile`` parses anything -- so a response that is not a tar is
954
+ refused by signature whatever its label said, and the nested-container refusal still
955
+ reads every selected member's own first bytes against the leaf table. ``1.0.0`` stays
956
+ closed to the weak label.
957
+ """
958
+
959
+ family_version: str = "1.1.0"
960
+ accepted_media_types: tuple[str, ...] = (
961
+ "application/octet-stream",
962
+ "application/x-tar",
963
+ )