tabalyst 0.4.2__tar.gz → 0.4.3__tar.gz

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 (141) hide show
  1. {tabalyst-0.4.2/src/tabalyst.egg-info → tabalyst-0.4.3}/PKG-INFO +35 -9
  2. {tabalyst-0.4.2 → tabalyst-0.4.3}/README.md +31 -7
  3. {tabalyst-0.4.2 → tabalyst-0.4.3}/pyproject.toml +4 -2
  4. tabalyst-0.4.3/src/tabalyst/cache_service.py +114 -0
  5. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/app.py +2 -0
  6. tabalyst-0.4.3/src/tabalyst/cli/cache.py +64 -0
  7. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/report.py +2 -0
  8. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/scan.py +3 -0
  9. tabalyst-0.4.3/src/tabalyst/project_open_service.py +179 -0
  10. tabalyst-0.4.3/src/tabalyst/project_scan_service.py +204 -0
  11. tabalyst-0.4.3/src/tabalyst/projects/__init__.py +48 -0
  12. tabalyst-0.4.3/src/tabalyst/projects/_bounded_values.py +449 -0
  13. tabalyst-0.4.3/src/tabalyst/projects/_cache.py +370 -0
  14. tabalyst-0.4.3/src/tabalyst/projects/_codec.py +116 -0
  15. tabalyst-0.4.3/src/tabalyst/projects/_generation.py +118 -0
  16. tabalyst-0.4.3/src/tabalyst/projects/_locks.py +144 -0
  17. tabalyst-0.4.3/src/tabalyst/projects/_publication.py +278 -0
  18. tabalyst-0.4.3/src/tabalyst/projects/_queries.py +481 -0
  19. tabalyst-0.4.3/src/tabalyst/projects/_query_budget.py +83 -0
  20. tabalyst-0.4.3/src/tabalyst/projects/_recovery.py +59 -0
  21. tabalyst-0.4.3/src/tabalyst/projects/_retention.py +70 -0
  22. tabalyst-0.4.3/src/tabalyst/projects/_schema.py +115 -0
  23. tabalyst-0.4.3/src/tabalyst/projects/_session.py +226 -0
  24. tabalyst-0.4.3/src/tabalyst/projects/_staging.py +439 -0
  25. tabalyst-0.4.3/src/tabalyst/projects/_sync.py +42 -0
  26. tabalyst-0.4.3/src/tabalyst/projects/_validation.py +459 -0
  27. tabalyst-0.4.3/src/tabalyst/projects/catalog.py +75 -0
  28. tabalyst-0.4.3/src/tabalyst/projects/freshness.py +54 -0
  29. tabalyst-0.4.3/src/tabalyst/projects/identity.py +42 -0
  30. tabalyst-0.4.3/src/tabalyst/projects/index.py +175 -0
  31. tabalyst-0.4.3/src/tabalyst/projects/location.py +118 -0
  32. tabalyst-0.4.3/src/tabalyst/projects/models.py +110 -0
  33. tabalyst-0.4.3/src/tabalyst/projects/store.py +117 -0
  34. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/report_service.py +13 -2
  35. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scan_reuse.py +51 -16
  36. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scan_service.py +65 -25
  37. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/records.py +20 -13
  38. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/service.py +34 -4
  39. tabalyst-0.4.3/src/tabalyst/shared_scan_service.py +149 -0
  40. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/static/report.js +35 -24
  41. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/static/theme.css +6 -1
  42. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/templates/report.html +12 -6
  43. {tabalyst-0.4.2 → tabalyst-0.4.3/src/tabalyst.egg-info}/PKG-INFO +35 -9
  44. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst.egg-info/SOURCES.txt +43 -1
  45. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst.egg-info/requires.txt +2 -0
  46. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_cli.py +7 -2
  47. tabalyst-0.4.3/tests/test_project_cache.py +439 -0
  48. tabalyst-0.4.3/tests/test_project_database.py +525 -0
  49. tabalyst-0.4.3/tests/test_project_generations.py +481 -0
  50. tabalyst-0.4.3/tests/test_project_identity.py +55 -0
  51. tabalyst-0.4.3/tests/test_project_index.py +172 -0
  52. tabalyst-0.4.3/tests/test_project_location.py +89 -0
  53. tabalyst-0.4.3/tests/test_project_open_service.py +300 -0
  54. tabalyst-0.4.3/tests/test_project_queries.py +718 -0
  55. tabalyst-0.4.3/tests/test_project_rescan.py +536 -0
  56. tabalyst-0.4.3/tests/test_project_scan.py +238 -0
  57. tabalyst-0.4.3/tests/test_project_sessions.py +441 -0
  58. tabalyst-0.4.3/tests/test_project_store.py +238 -0
  59. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_public_api.py +12 -15
  60. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scan_command.py +43 -17
  61. tabalyst-0.4.3/tests/test_shared_scan_cli.py +130 -0
  62. tabalyst-0.4.3/tests/test_source_freshness.py +79 -0
  63. {tabalyst-0.4.2 → tabalyst-0.4.3}/LICENSE +0 -0
  64. {tabalyst-0.4.2 → tabalyst-0.4.3}/MANIFEST.in +0 -0
  65. {tabalyst-0.4.2 → tabalyst-0.4.3}/examples/README.md +0 -0
  66. {tabalyst-0.4.2 → tabalyst-0.4.3}/examples/config.json +0 -0
  67. {tabalyst-0.4.2 → tabalyst-0.4.3}/examples/input/basic.csv +0 -0
  68. {tabalyst-0.4.2 → tabalyst-0.4.3}/examples/input/insurance-customers.csv +0 -0
  69. {tabalyst-0.4.2 → tabalyst-0.4.3}/setup.cfg +0 -0
  70. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/__init__.py +0 -0
  71. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/__main__.py +0 -0
  72. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/_version.py +0 -0
  73. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/batch.py +0 -0
  74. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/__init__.py +0 -0
  75. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/sample.py +0 -0
  76. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/cli/terminal.py +0 -0
  77. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/config.py +0 -0
  78. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/errors.py +0 -0
  79. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/execution_log.py +0 -0
  80. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/models.py +0 -0
  81. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/progress.py +0 -0
  82. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/report_config.py +0 -0
  83. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/report_profile.py +0 -0
  84. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/reporting.py +0 -0
  85. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/sampling.py +0 -0
  86. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/sampling_service.py +0 -0
  87. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/__init__.py +0 -0
  88. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/api.py +0 -0
  89. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/config.py +0 -0
  90. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/__init__.py +0 -0
  91. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/amount.py +0 -0
  92. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/base.py +0 -0
  93. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/boolean.py +0 -0
  94. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/currency.py +0 -0
  95. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/email.py +0 -0
  96. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/enumeration.py +0 -0
  97. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/ip.py +0 -0
  98. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/names.py +0 -0
  99. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/number.py +0 -0
  100. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/pattern.py +0 -0
  101. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/percentage.py +0 -0
  102. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/phone.py +0 -0
  103. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/postal.py +0 -0
  104. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/quantity.py +0 -0
  105. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/registry.py +0 -0
  106. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/shape.py +0 -0
  107. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/temporal.py +0 -0
  108. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/url.py +0 -0
  109. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/detectors/uuid.py +0 -0
  110. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/diagnostics.py +0 -0
  111. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/engine.py +0 -0
  112. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/exposure.py +0 -0
  113. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/field.py +0 -0
  114. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/measures.py +0 -0
  115. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/models.py +0 -0
  116. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/normalization.py +0 -0
  117. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/observations.py +0 -0
  118. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/paths.py +0 -0
  119. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/readers/__init__.py +0 -0
  120. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/readers/base.py +0 -0
  121. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/readers/csv_reader.py +0 -0
  122. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/readers/json_reader.py +0 -0
  123. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/structure.py +0 -0
  124. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/technical.py +0 -0
  125. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/values.py +0 -0
  126. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst/scanner/workers.py +0 -0
  127. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst.egg-info/dependency_links.txt +0 -0
  128. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst.egg-info/entry_points.txt +0 -0
  129. {tabalyst-0.4.2 → tabalyst-0.4.3}/src/tabalyst.egg-info/top_level.txt +0 -0
  130. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_reporting.py +0 -0
  131. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_sampling.py +0 -0
  132. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_amounts.py +0 -0
  133. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_batches.py +0 -0
  134. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_config.py +0 -0
  135. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_csv.py +0 -0
  136. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_email_url.py +0 -0
  137. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_exposure.py +0 -0
  138. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_paths.py +0 -0
  139. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_phone.py +0 -0
  140. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_postal.py +0 -0
  141. {tabalyst-0.4.2 → tabalyst-0.4.3}/tests/test_scanner_uuid_ip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabalyst
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Open-source toolkit for understanding and working with unfamiliar data.
5
5
  Author: Gregory Borelli
6
6
  License-Expression: MIT
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://github.com/loribel-labs/tabalyst
8
8
  Project-URL: Repository, https://github.com/loribel-labs/tabalyst.git
9
9
  Project-URL: Issues, https://github.com/loribel-labs/tabalyst/issues
10
10
  Keywords: csv,data-analysis,data-profiling,data-quality,eda,html-report,json
11
- Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: Science/Research
14
14
  Classifier: Operating System :: OS Independent
@@ -21,8 +21,10 @@ Classifier: Topic :: Scientific/Engineering :: Information Analysis
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: duckdb==1.5.5
24
25
  Requires-Dist: ijson>=3.3
25
26
  Requires-Dist: jinja2>=3.1
27
+ Requires-Dist: platformdirs>=4
26
28
  Requires-Dist: pydantic>=2.11
27
29
  Requires-Dist: typer>=0.12
28
30
  Provides-Extra: dev
@@ -42,8 +44,10 @@ with structured data.
42
44
  Its first tool is **Tabalyst Report**. The current CSV implementation,
43
45
  **Tabalyst CSV Report**, analyzes a CSV file and produces both a structured JSON
44
46
  profile and a self-contained interactive HTML report.
47
+ **Tabalyst Scan** describes CSV and JSON files in a scan document, and
48
+ **Tabalyst Sample** creates smaller CSV files.
45
49
 
46
- Tabalyst is in active alpha development. Its interfaces may still change while
50
+ Tabalyst is in beta. Its interfaces may still change while
47
51
  the shared toolkit architecture is being established.
48
52
 
49
53
  ## Install
@@ -54,7 +58,7 @@ Tabalyst supports Python 3.11, 3.12, 3.13, and 3.14.
54
58
  pip install --upgrade tabalyst
55
59
  ```
56
60
 
57
- The same command installs Tabalyst or updates it. Tabalyst is in alpha and
61
+ The same command installs Tabalyst or updates it. Tabalyst is in beta and
58
62
  changes often: update it before each new test.
59
63
 
60
64
  ## Tabalyst Report
@@ -225,8 +229,10 @@ tabalyst scan customers.csv
225
229
  tabalyst scan orders.json --collection "$.customers[]"
226
230
  ```
227
231
 
228
- The default output is `customers.scan.json` beside the source; `-o`, `-d` and
229
- `--force` work as for the other commands. Tabalyst Scan reads the file once as
232
+ For a CSV, the default scan is the project's generation-bound `scan.json` under
233
+ Tabalyst's local storage directory (or `TABALYST_HOME`). The project also holds
234
+ the database and disposable `cache/` used by future tools. `-o` or `-d` writes
235
+ a standalone `<stem>.scan.json` export instead. Tabalyst Scan reads the file once as
230
236
  a stream, with memory bounded by configurable limits, and records for each
231
237
  field its presence, native types, missing values, frequencies, exact
232
238
  statistics, normalization variants, technical type and the result of every
@@ -238,7 +244,14 @@ interrupted scan never leaves a partial file. Files of 16 MiB or more are
238
244
  analyzed by several worker processes, with the same result; `--workers`
239
245
  chooses their number, `--workers 1` keeps one process.
240
246
 
241
- Build the report from a scan document without reading the source again:
247
+ `tabalyst report customers.csv` reuses the verified project scan when the CSV
248
+ content and requested scan settings are current. It creates a project scan when
249
+ none exists. If the CSV changed, it publishes a new generation, removes the old
250
+ generation and its owned cache when no reader holds it, then builds the report.
251
+ The source check uses size, modification time and SHA-256. An old generation
252
+ held by a reader is retained with a warning.
253
+
254
+ Build the report from a standalone scan document without reading the source again:
242
255
 
243
256
  ```console
244
257
  tabalyst report --scan customers.scan.json
@@ -247,6 +260,18 @@ tabalyst report --scan customers.scan.json
247
260
  Tabalyst refuses a scan whose source changed since it was written, or whose
248
261
  settings differ from the `scan` settings of `--config`.
249
262
 
263
+ Inspect or clean disposable project query caches for every project, or for one
264
+ CSV file:
265
+
266
+ ```console
267
+ tabalyst cache info
268
+ tabalyst cache info customers.csv
269
+ tabalyst cache clean
270
+ tabalyst cache clean customers.csv
271
+ ```
272
+
273
+ `cache clean` leaves project scans and databases in place.
274
+
250
275
  See [Scan CSV and JSON files](https://github.com/loribel-labs/tabalyst/blob/main/docs/en/how-to/scan-files.md)
251
276
  and the [scan format](https://github.com/loribel-labs/tabalyst/blob/main/docs/en/reference/scan-format.md).
252
277
 
@@ -301,8 +326,9 @@ reports = tabalyst.generate_reports(["scans/*.scan.json"], from_scan=True)
301
326
  `analyze()` returns the JSON-serializable profile for one report.
302
327
  `generate_reports()` returns the complete batch plan, successes, and failures.
303
328
  `scan()` returns a scan result without writing anything; `generate_scans()`
304
- writes one `.scan.json` document per source; `from_scan=True` builds reports
305
- from such documents.
329
+ writes one standalone `.scan.json` document per source by default. Pass
330
+ `project_storage=True` to use project storage for CSV files. `from_scan=True`
331
+ builds reports from standalone scan documents.
306
332
  Expected failures derive from `tabalyst.TabalystError`.
307
333
 
308
334
  Existing artifacts are never replaced silently. Pass `force=True` when
@@ -8,8 +8,10 @@ with structured data.
8
8
  Its first tool is **Tabalyst Report**. The current CSV implementation,
9
9
  **Tabalyst CSV Report**, analyzes a CSV file and produces both a structured JSON
10
10
  profile and a self-contained interactive HTML report.
11
+ **Tabalyst Scan** describes CSV and JSON files in a scan document, and
12
+ **Tabalyst Sample** creates smaller CSV files.
11
13
 
12
- Tabalyst is in active alpha development. Its interfaces may still change while
14
+ Tabalyst is in beta. Its interfaces may still change while
13
15
  the shared toolkit architecture is being established.
14
16
 
15
17
  ## Install
@@ -20,7 +22,7 @@ Tabalyst supports Python 3.11, 3.12, 3.13, and 3.14.
20
22
  pip install --upgrade tabalyst
21
23
  ```
22
24
 
23
- The same command installs Tabalyst or updates it. Tabalyst is in alpha and
25
+ The same command installs Tabalyst or updates it. Tabalyst is in beta and
24
26
  changes often: update it before each new test.
25
27
 
26
28
  ## Tabalyst Report
@@ -191,8 +193,10 @@ tabalyst scan customers.csv
191
193
  tabalyst scan orders.json --collection "$.customers[]"
192
194
  ```
193
195
 
194
- The default output is `customers.scan.json` beside the source; `-o`, `-d` and
195
- `--force` work as for the other commands. Tabalyst Scan reads the file once as
196
+ For a CSV, the default scan is the project's generation-bound `scan.json` under
197
+ Tabalyst's local storage directory (or `TABALYST_HOME`). The project also holds
198
+ the database and disposable `cache/` used by future tools. `-o` or `-d` writes
199
+ a standalone `<stem>.scan.json` export instead. Tabalyst Scan reads the file once as
196
200
  a stream, with memory bounded by configurable limits, and records for each
197
201
  field its presence, native types, missing values, frequencies, exact
198
202
  statistics, normalization variants, technical type and the result of every
@@ -204,7 +208,14 @@ interrupted scan never leaves a partial file. Files of 16 MiB or more are
204
208
  analyzed by several worker processes, with the same result; `--workers`
205
209
  chooses their number, `--workers 1` keeps one process.
206
210
 
207
- Build the report from a scan document without reading the source again:
211
+ `tabalyst report customers.csv` reuses the verified project scan when the CSV
212
+ content and requested scan settings are current. It creates a project scan when
213
+ none exists. If the CSV changed, it publishes a new generation, removes the old
214
+ generation and its owned cache when no reader holds it, then builds the report.
215
+ The source check uses size, modification time and SHA-256. An old generation
216
+ held by a reader is retained with a warning.
217
+
218
+ Build the report from a standalone scan document without reading the source again:
208
219
 
209
220
  ```console
210
221
  tabalyst report --scan customers.scan.json
@@ -213,6 +224,18 @@ tabalyst report --scan customers.scan.json
213
224
  Tabalyst refuses a scan whose source changed since it was written, or whose
214
225
  settings differ from the `scan` settings of `--config`.
215
226
 
227
+ Inspect or clean disposable project query caches for every project, or for one
228
+ CSV file:
229
+
230
+ ```console
231
+ tabalyst cache info
232
+ tabalyst cache info customers.csv
233
+ tabalyst cache clean
234
+ tabalyst cache clean customers.csv
235
+ ```
236
+
237
+ `cache clean` leaves project scans and databases in place.
238
+
216
239
  See [Scan CSV and JSON files](https://github.com/loribel-labs/tabalyst/blob/main/docs/en/how-to/scan-files.md)
217
240
  and the [scan format](https://github.com/loribel-labs/tabalyst/blob/main/docs/en/reference/scan-format.md).
218
241
 
@@ -267,8 +290,9 @@ reports = tabalyst.generate_reports(["scans/*.scan.json"], from_scan=True)
267
290
  `analyze()` returns the JSON-serializable profile for one report.
268
291
  `generate_reports()` returns the complete batch plan, successes, and failures.
269
292
  `scan()` returns a scan result without writing anything; `generate_scans()`
270
- writes one `.scan.json` document per source; `from_scan=True` builds reports
271
- from such documents.
293
+ writes one standalone `.scan.json` document per source by default. Pass
294
+ `project_storage=True` to use project storage for CSV files. `from_scan=True`
295
+ builds reports from standalone scan documents.
272
296
  Expected failures derive from `tabalyst.TabalystError`.
273
297
 
274
298
  Existing artifacts are never replaced silently. Pass `force=True` when
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tabalyst"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Open-source toolkit for understanding and working with unfamiliar data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -22,7 +22,7 @@ keywords = [
22
22
  "json",
23
23
  ]
24
24
  classifiers = [
25
- "Development Status :: 3 - Alpha",
25
+ "Development Status :: 4 - Beta",
26
26
  "Intended Audience :: Developers",
27
27
  "Intended Audience :: Science/Research",
28
28
  "Operating System :: OS Independent",
@@ -34,8 +34,10 @@ classifiers = [
34
34
  "Topic :: Scientific/Engineering :: Information Analysis",
35
35
  ]
36
36
  dependencies = [
37
+ "duckdb==1.5.5",
37
38
  "ijson>=3.3",
38
39
  "jinja2>=3.1",
40
+ "platformdirs>=4",
39
41
  "pydantic>=2.11",
40
42
  "typer>=0.12",
41
43
  ]
@@ -0,0 +1,114 @@
1
+ """Public cleanup of disposable project query caches."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from tabalyst.errors import InputError
7
+ from tabalyst.projects._cache import clean_query_caches, inventory_query_caches
8
+ from tabalyst.projects._locks import ProjectBusyError
9
+ from tabalyst.projects.identity import is_project_id
10
+ from tabalyst.projects.index import (
11
+ document_key,
12
+ find_project,
13
+ read_index,
14
+ source_key,
15
+ )
16
+ from tabalyst.projects.location import StorageLocation
17
+ from tabalyst.projects.store import read_project
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CacheCleanResult:
22
+ projects: int
23
+ removed: int
24
+ removed_bytes: int
25
+ skipped: tuple[str, ...]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CacheInfo:
30
+ projects: int
31
+ directories: int
32
+ logical_bytes: int
33
+ skipped: tuple[str, ...]
34
+
35
+
36
+ def _selected_projects(
37
+ source: str | Path | None = None,
38
+ *,
39
+ location: StorageLocation | None = None,
40
+ ) -> tuple[StorageLocation, list]:
41
+ location = location or StorageLocation.local()
42
+ if source is None:
43
+ try:
44
+ directories = sorted(location.projects_dir.iterdir())
45
+ except FileNotFoundError:
46
+ directories = []
47
+ projects = []
48
+ for directory in directories:
49
+ if is_project_id(directory.name):
50
+ try:
51
+ projects.append(read_project(location, directory.name))
52
+ except InputError:
53
+ continue
54
+ else:
55
+ key = source_key(Path(source))
56
+ index = read_index(location)
57
+ project_id = index.projects.get(key) if index else None
58
+ try:
59
+ selected = read_project(location, project_id) if project_id else None
60
+ except InputError:
61
+ selected = None
62
+ if selected is None or document_key(selected) != key:
63
+ selected = find_project(location, Path(source))
64
+ if selected is None:
65
+ raise InputError(f"No Tabalyst project for source: {source}")
66
+ projects = [selected]
67
+ return location, projects
68
+
69
+
70
+ def cache_info(
71
+ source: str | Path | None = None,
72
+ *,
73
+ location: StorageLocation | None = None,
74
+ ) -> CacheInfo:
75
+ """Summarize owned disposable cache files without modifying them."""
76
+ location, projects = _selected_projects(source, location=location)
77
+ directories = 0
78
+ logical_bytes = 0
79
+ skipped = []
80
+ for project in projects:
81
+ inventory = inventory_query_caches(location, project.project_id)
82
+ for entry in inventory.entries:
83
+ if entry.status == "candidate":
84
+ directories += 1
85
+ logical_bytes += entry.logical_bytes
86
+ else:
87
+ skipped.append(f"{project.project_id}/{entry.path}: {entry.reason}")
88
+ return CacheInfo(len(projects), directories, logical_bytes, tuple(skipped))
89
+
90
+
91
+ def clean_cache(
92
+ source: str | Path | None = None,
93
+ *,
94
+ location: StorageLocation | None = None,
95
+ ) -> CacheCleanResult:
96
+ """Clean owned disposable cache files for one source or all projects."""
97
+ location, projects = _selected_projects(source, location=location)
98
+ removed = 0
99
+ removed_bytes = 0
100
+ skipped = []
101
+ for project in projects:
102
+ plan = inventory_query_caches(location, project.project_id)
103
+ try:
104
+ result = clean_query_caches(location, project.project_id, plan=plan)
105
+ except ProjectBusyError as exc:
106
+ skipped.append(f"{project.project_id}/cache: {exc}")
107
+ continue
108
+ for entry in result.entries:
109
+ if entry.status == "removed":
110
+ removed += 1
111
+ removed_bytes += entry.removed_bytes
112
+ elif entry.status in ("skipped", "error"):
113
+ skipped.append(f"{project.project_id}/{entry.path}: {entry.reason}")
114
+ return CacheCleanResult(len(projects), removed, removed_bytes, tuple(skipped))
@@ -5,6 +5,7 @@ from typing import Annotated
5
5
  import typer
6
6
 
7
7
  from tabalyst import __version__
8
+ from tabalyst.cli.cache import cache_app
8
9
  from tabalyst.cli.report import report_command
9
10
  from tabalyst.cli.sample import sample_command
10
11
  from tabalyst.cli.scan import scan_command
@@ -41,6 +42,7 @@ def root(
41
42
  app.command("report")(report_command)
42
43
  app.command("sample")(sample_command)
43
44
  app.command("scan")(scan_command)
45
+ app.add_typer(cache_app, name="cache")
44
46
 
45
47
 
46
48
  def main() -> None:
@@ -0,0 +1,64 @@
1
+ """CLI adapter for disposable project cache cleanup."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from tabalyst.cache_service import cache_info, clean_cache
9
+ from tabalyst.cli.terminal import error_exit_code
10
+ from tabalyst.errors import TabalystError
11
+
12
+ cache_app = typer.Typer(help="Manage disposable project caches.")
13
+
14
+
15
+ def _count(value: int, singular: str) -> str:
16
+ if value == 1:
17
+ return f"{value} {singular}"
18
+ plural = singular.removesuffix("y") + "ies" if singular.endswith("y") else singular + "s"
19
+ return f"{value} {plural}"
20
+
21
+
22
+ @cache_app.command("info")
23
+ def info_command(
24
+ source: Annotated[
25
+ Path | None,
26
+ typer.Argument(help="CSV file whose project cache should be inspected."),
27
+ ] = None,
28
+ ) -> None:
29
+ """Show the size and directory count of all caches or one source cache."""
30
+ try:
31
+ result = cache_info(source)
32
+ except TabalystError as exc:
33
+ typer.echo(f"Error: {exc}", err=True)
34
+ raise typer.Exit(error_exit_code(exc)) from exc
35
+ typer.echo(
36
+ f"Cache: {_count(result.directories, 'directory')} in "
37
+ f"{_count(result.projects, 'project')} "
38
+ f"({result.logical_bytes:,} bytes)."
39
+ )
40
+ for warning in result.skipped:
41
+ typer.echo(f"Retained: {warning}")
42
+
43
+
44
+ @cache_app.command("clean")
45
+ def clean_command(
46
+ source: Annotated[
47
+ Path | None,
48
+ typer.Argument(help="CSV file whose project cache should be cleaned."),
49
+ ] = None,
50
+ ) -> None:
51
+ """Clean all project caches, or the cache for one source file."""
52
+ try:
53
+ result = clean_cache(source)
54
+ except TabalystError as exc:
55
+ typer.echo(f"Error: {exc}", err=True)
56
+ raise typer.Exit(error_exit_code(exc)) from exc
57
+ typer.echo(
58
+ f"Cleaned {_count(result.removed, 'cache directory')} in "
59
+ f"{_count(result.projects, 'project')} "
60
+ f"({result.removed_bytes:,} bytes).",
61
+ err=True,
62
+ )
63
+ for warning in result.skipped:
64
+ typer.echo(f"Warning: retained {warning}", err=True)
@@ -58,6 +58,8 @@ def _print_batch_result(
58
58
  typer.echo(" " + " | ".join(details), err=True)
59
59
 
60
60
  for success in batch.successes:
61
+ for warning in success.warnings:
62
+ typer.echo(f"Warning [{success.job.source}]: {warning}", err=True)
61
63
  if success.source_checked is False:
62
64
  typer.echo(
63
65
  f"Warning [{success.job.source}]: source "
@@ -76,6 +76,8 @@ def _print_batch_result(
76
76
  for success in batch.successes:
77
77
  if success.result.status == "partial":
78
78
  _print_partial(success)
79
+ for warning in success.warnings:
80
+ typer.echo(f"Warning [{success.job.source}]: {warning}", err=True)
79
81
  for failure in batch.failures:
80
82
  typer.echo(f"Error [{failure.job.source}]: {failure.error}", err=True)
81
83
 
@@ -180,6 +182,7 @@ def scan_command(
180
182
  force=force,
181
183
  on_progress=progress,
182
184
  workers=workers,
185
+ project_storage=True,
183
186
  )
184
187
  except TabalystError as exc:
185
188
  typer.echo(f"Error: {exc}", err=True)
@@ -0,0 +1,179 @@
1
+ """Private first-open policy for an eventual Explore adapter (S18).
2
+
3
+ The decision is transient and advisory. A caller must retain it for an explicit
4
+ snapshot or rescan choice; every action revalidates the selected generation.
5
+ No CLI, public API, automatic scan or presentation is provided here.
6
+ """
7
+
8
+ from contextlib import ExitStack, contextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from tabalyst.errors import InputError
14
+ from tabalyst.project_scan_service import rescan_project
15
+ from tabalyst.projects._generation import GenerationConflictError
16
+ from tabalyst.projects._session import (
17
+ ProjectSession,
18
+ SessionAssessment,
19
+ SessionRefusedError,
20
+ inspect_project,
21
+ open_project,
22
+ )
23
+ from tabalyst.projects.location import StorageLocation
24
+ from tabalyst.scan_reuse import SourceState
25
+ from tabalyst.scanner import ScanConfig
26
+ from tabalyst.scanner.config import config_sha256
27
+
28
+ OpeningAction = Literal["open_current", "choose", "resolve_config"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ProjectOpenDecision:
33
+ storage_root: Path
34
+ assessment: SessionAssessment
35
+ action: OpeningAction
36
+ choices: tuple[str, ...]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProjectEntry:
41
+ decision: ProjectOpenDecision
42
+ session: ProjectSession | None
43
+
44
+
45
+ def _decision(
46
+ location: StorageLocation, assessment: SessionAssessment
47
+ ) -> ProjectOpenDecision:
48
+ root = location.root.resolve()
49
+ if assessment.errors:
50
+ choices = ("use_recorded_config",)
51
+ if (
52
+ assessment.source_check is not None
53
+ and assessment.source_check.state is not SourceState.MISSING
54
+ ):
55
+ choices += ("rescan",)
56
+ return ProjectOpenDecision(root, assessment, "resolve_config", choices)
57
+ if assessment.current_ready:
58
+ return ProjectOpenDecision(
59
+ root, assessment, "open_current", ("open_current", "rescan")
60
+ )
61
+ if assessment.source_check is None:
62
+ return ProjectOpenDecision(
63
+ root, assessment, "choose", ("snapshot", "reinspect")
64
+ )
65
+ if assessment.source_check.state is SourceState.MISSING:
66
+ return ProjectOpenDecision(root, assessment, "choose", ("snapshot",))
67
+ return ProjectOpenDecision(root, assessment, "choose", ("snapshot", "rescan"))
68
+
69
+
70
+ def _check_decision(
71
+ location: StorageLocation, decision: ProjectOpenDecision
72
+ ) -> SessionAssessment:
73
+ if not isinstance(decision, ProjectOpenDecision):
74
+ raise TypeError("A ProjectOpenDecision from inspection is required")
75
+ assessment = decision.assessment
76
+ if assessment.workspace_id != location.workspace_id:
77
+ raise GenerationConflictError("Opening decision belongs to another workspace")
78
+ if decision.storage_root != location.root.resolve():
79
+ raise GenerationConflictError(
80
+ "Opening decision belongs to another storage root"
81
+ )
82
+ if decision != _decision(location, assessment):
83
+ raise ValueError("Inconsistent project opening decision")
84
+ return assessment
85
+
86
+
87
+ def inspect_opening(
88
+ location: StorageLocation,
89
+ project_id: str,
90
+ *,
91
+ requested_config: ScanConfig | None = None,
92
+ ) -> ProjectOpenDecision:
93
+ """Inspect one verified project, without opening a query session or scanning."""
94
+ return _decision(
95
+ location,
96
+ inspect_project(location, project_id, requested_config=requested_config),
97
+ )
98
+
99
+
100
+ @contextmanager
101
+ def enter_project(
102
+ location: StorageLocation,
103
+ project_id: str,
104
+ *,
105
+ requested_config: ScanConfig | None = None,
106
+ ):
107
+ """Open a fresh source as current; return a decision for every other state."""
108
+ decision = inspect_opening(location, project_id, requested_config=requested_config)
109
+ if decision.action != "open_current":
110
+ yield ProjectEntry(decision, None)
111
+ return
112
+ with ExitStack() as resources:
113
+ try:
114
+ session = resources.enter_context(
115
+ open_project(
116
+ location,
117
+ project_id,
118
+ requested_config=requested_config,
119
+ expected_generation_id=decision.assessment.generation_id,
120
+ )
121
+ )
122
+ except SessionRefusedError as exc:
123
+ # The source changed between inspection and opening. Report the
124
+ # newer assessment instead of yielding a stale current session.
125
+ yield ProjectEntry(_decision(location, exc.assessment), None)
126
+ return
127
+ if session.assessment.source != decision.assessment.source:
128
+ raise GenerationConflictError("Selected project source binding changed")
129
+ yield ProjectEntry(_decision(location, session.assessment), session)
130
+
131
+
132
+ @contextmanager
133
+ def open_snapshot(location: StorageLocation, decision: ProjectOpenDecision):
134
+ """Explicitly query the stored generation, carrying its warning and id."""
135
+ assessment = _check_decision(location, decision)
136
+ if "snapshot" not in decision.choices:
137
+ raise InputError("Snapshot is not an available choice for this assessment")
138
+ with open_project(
139
+ location,
140
+ assessment.project_id,
141
+ intent="snapshot",
142
+ expected_generation_id=assessment.generation_id,
143
+ ) as session:
144
+ if session.assessment.source != assessment.source:
145
+ raise GenerationConflictError("Selected project source binding changed")
146
+ yield session
147
+
148
+
149
+ @contextmanager
150
+ def rescan_from_decision(
151
+ location: StorageLocation,
152
+ decision: ProjectOpenDecision,
153
+ *,
154
+ config: ScanConfig | None = None,
155
+ value_limit: int = 10000,
156
+ intent: Literal["require_current", "snapshot"] = "require_current",
157
+ ):
158
+ """Explicitly rescan only the assessed project/source/generation."""
159
+ assessment = _check_decision(location, decision)
160
+ if "rescan" not in decision.choices:
161
+ raise InputError("Rescan is not an available choice for this assessment")
162
+ if decision.action == "resolve_config":
163
+ if config is None or not isinstance(config, ScanConfig):
164
+ raise InputError("A complete replacement ScanConfig is required")
165
+ validated = ScanConfig.model_validate(
166
+ config.model_dump(mode="json", by_alias=True)
167
+ )
168
+ if config_sha256(validated) != assessment.requested_config_sha256:
169
+ raise InputError("Replacement config differs from the inspected request")
170
+ with rescan_project(
171
+ location,
172
+ assessment.project_id,
173
+ expected_generation_id=assessment.generation_id,
174
+ expected_source=Path(assessment.source),
175
+ config=config,
176
+ value_limit=value_limit,
177
+ intent=intent,
178
+ ) as result:
179
+ yield result