localql 1.0.0__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 (133) hide show
  1. localql-1.0.0/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
  2. localql-1.0.0/.github/ISSUE_TEMPLATE/docs_issue.yml +25 -0
  3. localql-1.0.0/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
  4. localql-1.0.0/.github/pull_request_template.md +16 -0
  5. localql-1.0.0/.github/workflows/ci.yml +62 -0
  6. localql-1.0.0/.github/workflows/publish.yml +451 -0
  7. localql-1.0.0/.gitignore +24 -0
  8. localql-1.0.0/.python-version +1 -0
  9. localql-1.0.0/CHANGELOG.md +91 -0
  10. localql-1.0.0/CODE_OF_CONDUCT.md +29 -0
  11. localql-1.0.0/CONTRIBUTING.md +77 -0
  12. localql-1.0.0/LICENSE +21 -0
  13. localql-1.0.0/Makefile +21 -0
  14. localql-1.0.0/PKG-INFO +676 -0
  15. localql-1.0.0/README.md +639 -0
  16. localql-1.0.0/SECURITY.md +27 -0
  17. localql-1.0.0/SUPPORT.md +31 -0
  18. localql-1.0.0/docs/ARCHITECTURE.md +158 -0
  19. localql-1.0.0/docs/PRODUCT_DIRECTION.md +230 -0
  20. localql-1.0.0/docs/ROADMAP.md +164 -0
  21. localql-1.0.0/docs/assets/localql-terminal-project.svg +184 -0
  22. localql-1.0.0/docs/assets/localql-terminal-query.svg +104 -0
  23. localql-1.0.0/docs/assets/localql-tui-workbench.svg +213 -0
  24. localql-1.0.0/docs/benchmarking.md +30 -0
  25. localql-1.0.0/docs/development.md +63 -0
  26. localql-1.0.0/docs/failure-gallery.md +641 -0
  27. localql-1.0.0/docs/faq.md +44 -0
  28. localql-1.0.0/docs/getting-started.md +137 -0
  29. localql-1.0.0/docs/json-contracts.md +486 -0
  30. localql-1.0.0/docs/release-notes/v1.md +373 -0
  31. localql-1.0.0/docs/release-readiness.md +274 -0
  32. localql-1.0.0/docs/troubleshooting.md +178 -0
  33. localql-1.0.0/docs/tui-guide.md +154 -0
  34. localql-1.0.0/docs/tui-qol-qa.md +353 -0
  35. localql-1.0.0/docs/v1-manual-qa.md +193 -0
  36. localql-1.0.0/examples/saas_revenue/.csvql.yml +58 -0
  37. localql-1.0.0/examples/saas_revenue/README.md +73 -0
  38. localql-1.0.0/examples/saas_revenue/data/customers.csv +6 -0
  39. localql-1.0.0/examples/saas_revenue/data/revenue_movements.csv +12 -0
  40. localql-1.0.0/examples/saas_revenue/data/subscriptions.csv +6 -0
  41. localql-1.0.0/examples/saas_revenue/queries/revenue_health.sql +63 -0
  42. localql-1.0.0/examples/saas_revenue/scripts/regenerate_data.py +121 -0
  43. localql-1.0.0/examples/sales/.csvql.yml +33 -0
  44. localql-1.0.0/examples/sales/data/customers.csv +4 -0
  45. localql-1.0.0/examples/sales/data/orders.csv +5 -0
  46. localql-1.0.0/examples/sales/queries/customer_ltv.sql +9 -0
  47. localql-1.0.0/examples/sales/queries/revenue_by_month.sql +7 -0
  48. localql-1.0.0/pyproject.toml +78 -0
  49. localql-1.0.0/scripts/audit_package_contents.py +122 -0
  50. localql-1.0.0/scripts/benchmark_csvql.py +28 -0
  51. localql-1.0.0/scripts/render_benchmark_summary.py +29 -0
  52. localql-1.0.0/scripts/verify_release_readiness.py +38 -0
  53. localql-1.0.0/src/csvql/__init__.py +23 -0
  54. localql-1.0.0/src/csvql/__main__.py +6 -0
  55. localql-1.0.0/src/csvql/api.py +154 -0
  56. localql-1.0.0/src/csvql/atomic_write.py +81 -0
  57. localql-1.0.0/src/csvql/benchmark_data.py +167 -0
  58. localql-1.0.0/src/csvql/benchmark_runner.py +363 -0
  59. localql-1.0.0/src/csvql/benchmarking.py +218 -0
  60. localql-1.0.0/src/csvql/checks.py +747 -0
  61. localql-1.0.0/src/csvql/cli.py +527 -0
  62. localql-1.0.0/src/csvql/doctor.py +338 -0
  63. localql-1.0.0/src/csvql/engine.py +59 -0
  64. localql-1.0.0/src/csvql/exceptions.py +66 -0
  65. localql-1.0.0/src/csvql/export.py +129 -0
  66. localql-1.0.0/src/csvql/inspection.py +139 -0
  67. localql-1.0.0/src/csvql/models.py +180 -0
  68. localql-1.0.0/src/csvql/output.py +321 -0
  69. localql-1.0.0/src/csvql/profiling.py +128 -0
  70. localql-1.0.0/src/csvql/project_config.py +827 -0
  71. localql-1.0.0/src/csvql/quality.py +179 -0
  72. localql-1.0.0/src/csvql/query_workflow.py +164 -0
  73. localql-1.0.0/src/csvql/release_readiness.py +237 -0
  74. localql-1.0.0/src/csvql/source.py +86 -0
  75. localql-1.0.0/src/csvql/source_resolver.py +55 -0
  76. localql-1.0.0/src/csvql/sql_file.py +51 -0
  77. localql-1.0.0/src/csvql/sql_utils.py +7 -0
  78. localql-1.0.0/src/csvql/table_mapping.py +64 -0
  79. localql-1.0.0/src/csvql/terminal_text.py +26 -0
  80. localql-1.0.0/src/csvql/tui_app.py +2522 -0
  81. localql-1.0.0/src/csvql/tui_editor.py +177 -0
  82. localql-1.0.0/src/csvql/tui_help.py +49 -0
  83. localql-1.0.0/src/csvql/tui_launcher.py +29 -0
  84. localql-1.0.0/src/csvql/tui_native_picker.py +66 -0
  85. localql-1.0.0/src/csvql/tui_result_store.py +88 -0
  86. localql-1.0.0/src/csvql/tui_results.py +77 -0
  87. localql-1.0.0/src/csvql/tui_sql_assist.py +589 -0
  88. localql-1.0.0/src/csvql/tui_state.py +501 -0
  89. localql-1.0.0/src/csvql/tui_workflows.py +554 -0
  90. localql-1.0.0/tests/test_api.py +363 -0
  91. localql-1.0.0/tests/test_atomic_write.py +68 -0
  92. localql-1.0.0/tests/test_benchmark_data.py +111 -0
  93. localql-1.0.0/tests/test_benchmark_runner.py +65 -0
  94. localql-1.0.0/tests/test_benchmarking.py +113 -0
  95. localql-1.0.0/tests/test_checks.py +577 -0
  96. localql-1.0.0/tests/test_cli_check.py +319 -0
  97. localql-1.0.0/tests/test_cli_doctor.py +520 -0
  98. localql-1.0.0/tests/test_cli_inspect_sample.py +203 -0
  99. localql-1.0.0/tests/test_cli_menu.py +158 -0
  100. localql-1.0.0/tests/test_cli_profile.py +118 -0
  101. localql-1.0.0/tests/test_cli_project_catalog.py +258 -0
  102. localql-1.0.0/tests/test_cli_query.py +493 -0
  103. localql-1.0.0/tests/test_cli_run_export.py +305 -0
  104. localql-1.0.0/tests/test_doctor.py +176 -0
  105. localql-1.0.0/tests/test_example_project.py +200 -0
  106. localql-1.0.0/tests/test_export.py +208 -0
  107. localql-1.0.0/tests/test_failure_gallery.py +332 -0
  108. localql-1.0.0/tests/test_inspection.py +157 -0
  109. localql-1.0.0/tests/test_models.py +149 -0
  110. localql-1.0.0/tests/test_open_source_launch_docs.py +336 -0
  111. localql-1.0.0/tests/test_output.py +579 -0
  112. localql-1.0.0/tests/test_package_audit.py +114 -0
  113. localql-1.0.0/tests/test_profiling.py +118 -0
  114. localql-1.0.0/tests/test_project_config.py +936 -0
  115. localql-1.0.0/tests/test_quality.py +225 -0
  116. localql-1.0.0/tests/test_query_workflow.py +114 -0
  117. localql-1.0.0/tests/test_release_readiness.py +199 -0
  118. localql-1.0.0/tests/test_source.py +35 -0
  119. localql-1.0.0/tests/test_source_resolver.py +85 -0
  120. localql-1.0.0/tests/test_sql_file.py +62 -0
  121. localql-1.0.0/tests/test_sql_utils.py +8 -0
  122. localql-1.0.0/tests/test_table_mapping.py +66 -0
  123. localql-1.0.0/tests/test_terminal_text.py +42 -0
  124. localql-1.0.0/tests/test_tui_app.py +5090 -0
  125. localql-1.0.0/tests/test_tui_editor.py +79 -0
  126. localql-1.0.0/tests/test_tui_native_picker.py +68 -0
  127. localql-1.0.0/tests/test_tui_result_store.py +75 -0
  128. localql-1.0.0/tests/test_tui_results.py +192 -0
  129. localql-1.0.0/tests/test_tui_sql_assist.py +237 -0
  130. localql-1.0.0/tests/test_tui_state.py +548 -0
  131. localql-1.0.0/tests/test_tui_workflows.py +1007 -0
  132. localql-1.0.0/tests/test_v1_polish_docs.py +638 -0
  133. localql-1.0.0/uv.lock +536 -0
@@ -0,0 +1,50 @@
1
+ name: Bug report
2
+ description: Report a reproducible LocalQL problem
3
+ title: "[Bug]: "
4
+ labels: ["bug"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: "Thanks for helping improve LocalQL. Please include a small local CSV example when possible."
9
+ - type: textarea
10
+ id: command
11
+ attributes:
12
+ label: Command
13
+ description: Paste the exact command you ran.
14
+ render: shell
15
+ validations:
16
+ required: true
17
+ - type: textarea
18
+ id: expected
19
+ attributes:
20
+ label: Expected behavior
21
+ validations:
22
+ required: true
23
+ - type: textarea
24
+ id: actual
25
+ attributes:
26
+ label: Actual behavior
27
+ description: Include the full error output.
28
+ render: text
29
+ validations:
30
+ required: true
31
+ - type: input
32
+ id: version
33
+ attributes:
34
+ label: LocalQL version
35
+ description: Run `csvql --version`.
36
+ validations:
37
+ required: true
38
+ - type: input
39
+ id: python
40
+ attributes:
41
+ label: Python version
42
+ description: Run `python --version`.
43
+ validations:
44
+ required: false
45
+ - type: input
46
+ id: os
47
+ attributes:
48
+ label: Operating system
49
+ validations:
50
+ required: true
@@ -0,0 +1,25 @@
1
+ name: Documentation issue
2
+ description: Report confusing, stale, or missing docs
3
+ title: "[Docs]: "
4
+ labels: ["documentation"]
5
+ body:
6
+ - type: input
7
+ id: page
8
+ attributes:
9
+ label: Page or section
10
+ description: Link or name the page.
11
+ validations:
12
+ required: true
13
+ - type: textarea
14
+ id: issue
15
+ attributes:
16
+ label: What is confusing or missing?
17
+ validations:
18
+ required: true
19
+ - type: textarea
20
+ id: suggestion
21
+ attributes:
22
+ label: Suggested wording
23
+ description: Optional exact replacement text.
24
+ validations:
25
+ required: false
@@ -0,0 +1,30 @@
1
+ name: Feature request
2
+ description: Suggest a focused LocalQL improvement
3
+ title: "[Feature]: "
4
+ labels: ["enhancement"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: "LocalQL is local-first: CSV files, DuckDB SQL, CLI/TUI workflow, explicit exports, and project catalogs."
9
+ - type: textarea
10
+ id: problem
11
+ attributes:
12
+ label: Problem
13
+ description: What local CSV workflow problem would this solve?
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: proposal
18
+ attributes:
19
+ label: Proposed behavior
20
+ validations:
21
+ required: true
22
+ - type: checkboxes
23
+ id: scope
24
+ attributes:
25
+ label: Scope check
26
+ options:
27
+ - label: This stays within local CSV, DuckDB SQL, CLI/TUI workflow, explicit exports, or project catalogs.
28
+ required: true
29
+ - label: This does not require a web app, cloud connector, NLP execution, hidden cache, or sandbox-safe SQL claim.
30
+ required: true
@@ -0,0 +1,16 @@
1
+ ## Summary
2
+
3
+ ## Scope Check
4
+
5
+ - [ ] This change stays within LocalQL's local CSV, DuckDB, CLI/TUI, explicit export, project catalog, docs, or test scope.
6
+ - [ ] This change does not add web app, cloud connector, NLP execution, hidden cache, sandbox-safe SQL, or production-readiness claims.
7
+ - [ ] Public examples use installed `csvql ...` commands unless the section is specifically for source-checkout development.
8
+
9
+ ## Verification
10
+
11
+ - [ ] `uv run ruff format --check .`
12
+ - [ ] `uv run ruff check .`
13
+ - [ ] `uv run --all-extras mypy src`
14
+ - [ ] `uv run --all-extras pytest`
15
+
16
+ ## Notes
@@ -0,0 +1,62 @@
1
+ name: ci
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ${{ matrix.os }}
12
+ env:
13
+ UV_PYTHON: ${{ matrix.python-version }}
14
+ defaults:
15
+ run:
16
+ shell: bash
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ include:
21
+ - os: ubuntu-latest
22
+ python-version: "3.11"
23
+ - os: ubuntu-latest
24
+ python-version: "3.12"
25
+ - os: ubuntu-latest
26
+ python-version: "3.13"
27
+ - os: ubuntu-latest
28
+ python-version: "3.14"
29
+ - os: macos-latest
30
+ python-version: "3.12"
31
+ - os: windows-latest
32
+ python-version: "3.12"
33
+ steps:
34
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
35
+ with:
36
+ fetch-depth: 0
37
+ - name: Install uv
38
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86
39
+ with:
40
+ enable-cache: true
41
+ - name: Set up Python
42
+ run: uv python install ${{ matrix.python-version }}
43
+ - name: Install dependencies
44
+ run: uv sync --all-extras --frozen
45
+ - name: Baseline truth
46
+ run: |
47
+ pwd -P
48
+ git status --short --branch
49
+ git log -1 --oneline
50
+ git remote -v
51
+ git tag --points-at HEAD
52
+ uv --version
53
+ uv run python --version
54
+ uv run --all-extras csvql --version
55
+ - name: Check formatting
56
+ run: uv run ruff format --check .
57
+ - name: Lint
58
+ run: uv run ruff check .
59
+ - name: Type check
60
+ run: uv run --all-extras mypy src
61
+ - name: Test
62
+ run: uv run --all-extras pytest
@@ -0,0 +1,451 @@
1
+ name: publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ permissions:
7
+ contents: read
8
+
9
+ env:
10
+ EXPECTED_TAG: v1.0.0
11
+ EXPECTED_VERSION: "1.0.0"
12
+
13
+ jobs:
14
+ build-and-verify:
15
+ runs-on: ubuntu-latest
16
+ defaults:
17
+ run:
18
+ shell: bash
19
+ steps:
20
+ - name: Guard release tag
21
+ run: |
22
+ test "${GITHUB_REF}" = "refs/tags/${EXPECTED_TAG}"
23
+ test "${GITHUB_REF_TYPE}" = "tag"
24
+ test "${GITHUB_REF_NAME}" = "${EXPECTED_TAG}"
25
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
26
+ with:
27
+ fetch-depth: 0
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86
30
+ with:
31
+ enable-cache: true
32
+ - name: Set up Python
33
+ run: uv python install 3.12
34
+ - name: Install dependencies
35
+ run: uv sync --all-extras --frozen
36
+ - name: Check formatting
37
+ run: uv run ruff format --check .
38
+ - name: Lint
39
+ run: uv run ruff check .
40
+ - name: Type check
41
+ run: uv run --all-extras mypy src
42
+ - name: Test
43
+ run: uv run --all-extras pytest
44
+ - name: Build
45
+ run: uv build --sdist --wheel --out-dir dist
46
+ - name: Audit package contents
47
+ run: uv run python scripts/audit_package_contents.py dist
48
+ - name: Verify artifact identity and hashes
49
+ run: |
50
+ python - <<'PY'
51
+ from __future__ import annotations
52
+
53
+ import email.parser
54
+ import hashlib
55
+ import os
56
+ import tarfile
57
+ import zipfile
58
+ from pathlib import Path
59
+
60
+ dist = Path("dist")
61
+ expected_tag = os.environ["EXPECTED_TAG"]
62
+ expected_version = os.environ["EXPECTED_VERSION"]
63
+ if os.environ["GITHUB_REF"] != f"refs/tags/{expected_tag}":
64
+ raise SystemExit("publish workflow must run on the expected release tag")
65
+
66
+ wheels = sorted(dist.glob("localql-*.whl"))
67
+ sdists = sorted(dist.glob("localql-*.tar.gz"))
68
+ if len(wheels) != 1 or len(sdists) != 1:
69
+ raise SystemExit("expected exactly one localql wheel and one localql sdist")
70
+
71
+ def wheel_version(path: Path) -> str:
72
+ with zipfile.ZipFile(path) as archive:
73
+ metadata_names = [
74
+ name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
75
+ ]
76
+ if len(metadata_names) != 1:
77
+ raise SystemExit(f"{path}: expected exactly one wheel METADATA file")
78
+ metadata = archive.read(metadata_names[0]).decode("utf-8")
79
+ return str(email.parser.Parser().parsestr(metadata)["Version"])
80
+
81
+ def sdist_version(path: Path) -> str:
82
+ with tarfile.open(path) as archive:
83
+ members = [
84
+ member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")
85
+ ]
86
+ if len(members) != 1:
87
+ raise SystemExit(f"{path}: expected exactly one sdist PKG-INFO file")
88
+ extracted = archive.extractfile(members[0])
89
+ if extracted is None:
90
+ raise SystemExit(f"{path}: could not read sdist PKG-INFO")
91
+ metadata = extracted.read().decode("utf-8")
92
+ return str(email.parser.Parser().parsestr(metadata)["Version"])
93
+
94
+ versions = {wheel_version(wheels[0]), sdist_version(sdists[0])}
95
+ if versions != {expected_version}:
96
+ raise SystemExit(f"artifact versions do not match {expected_version}: {versions}")
97
+
98
+ hash_lines = []
99
+ for path in [*wheels, *sdists]:
100
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
101
+ hash_lines.append(f"{digest} {path.name}\n")
102
+ (dist / "SHA256SUMS.txt").write_text("".join(hash_lines), encoding="utf-8")
103
+ PY
104
+ - name: Installed wheel smoke
105
+ run: |
106
+ wheel="$(python - <<'PY'
107
+ from pathlib import Path
108
+
109
+ wheels = sorted(Path("dist").glob("localql-*.whl"))
110
+ if len(wheels) != 1:
111
+ raise SystemExit("expected exactly one localql wheel")
112
+ print(wheels[0].resolve())
113
+ PY
114
+ )"
115
+ smoke_root="$(mktemp -d)"
116
+ uv venv --seed "${smoke_root}/venv"
117
+ uv pip install --python "${smoke_root}/venv/bin/python" "${wheel}"
118
+ printf 'order_id,status\nORD-1,paid\n' > "${smoke_root}/orders.csv"
119
+ (
120
+ cd "${smoke_root}"
121
+ unset PYTHONPATH
122
+ "${smoke_root}/venv/bin/csvql" --version
123
+ "${smoke_root}/venv/bin/csvql" query "${smoke_root}/orders.csv" \
124
+ "SELECT COUNT(*) AS order_count FROM orders" --output json
125
+ )
126
+ - name: Upload distribution artifacts
127
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
128
+ with:
129
+ name: localql-1.0.0-dist
130
+ path: |
131
+ dist/*.whl
132
+ dist/*.tar.gz
133
+ dist/SHA256SUMS.txt
134
+ if-no-files-found: error
135
+
136
+ publish:
137
+ needs: build-and-verify
138
+ runs-on: ubuntu-latest
139
+ environment: pypi
140
+ permissions:
141
+ contents: read
142
+ id-token: write
143
+ defaults:
144
+ run:
145
+ shell: bash
146
+ steps:
147
+ - name: Guard release tag
148
+ run: |
149
+ test "${GITHUB_REF}" = "refs/tags/${EXPECTED_TAG}"
150
+ test "${GITHUB_REF_TYPE}" = "tag"
151
+ test "${GITHUB_REF_NAME}" = "${EXPECTED_TAG}"
152
+ - name: Download distribution artifacts
153
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
154
+ with:
155
+ name: localql-1.0.0-dist
156
+ path: release-artifacts
157
+ - name: Verify downloaded artifact hashes and metadata
158
+ run: |
159
+ cd release-artifacts
160
+ shasum -a 256 -c SHA256SUMS.txt
161
+ cd ..
162
+ python - <<'PY'
163
+ from __future__ import annotations
164
+
165
+ import email.parser
166
+ import os
167
+ import tarfile
168
+ import zipfile
169
+ from pathlib import Path
170
+
171
+ dist = Path("release-artifacts")
172
+ expected_version = os.environ["EXPECTED_VERSION"]
173
+ wheels = sorted(dist.glob("localql-*.whl"))
174
+ sdists = sorted(dist.glob("localql-*.tar.gz"))
175
+ if len(wheels) != 1 or len(sdists) != 1:
176
+ raise SystemExit("expected exactly one localql wheel and one localql sdist")
177
+
178
+ def wheel_version(path: Path) -> str:
179
+ with zipfile.ZipFile(path) as archive:
180
+ metadata_names = [
181
+ name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
182
+ ]
183
+ if len(metadata_names) != 1:
184
+ raise SystemExit(f"{path}: expected exactly one wheel METADATA file")
185
+ metadata = archive.read(metadata_names[0]).decode("utf-8")
186
+ return str(email.parser.Parser().parsestr(metadata)["Version"])
187
+
188
+ def sdist_version(path: Path) -> str:
189
+ with tarfile.open(path) as archive:
190
+ members = [
191
+ member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")
192
+ ]
193
+ if len(members) != 1:
194
+ raise SystemExit(f"{path}: expected exactly one sdist PKG-INFO file")
195
+ extracted = archive.extractfile(members[0])
196
+ if extracted is None:
197
+ raise SystemExit(f"{path}: could not read sdist PKG-INFO")
198
+ metadata = extracted.read().decode("utf-8")
199
+ return str(email.parser.Parser().parsestr(metadata)["Version"])
200
+
201
+ versions = {wheel_version(wheels[0]), sdist_version(sdists[0])}
202
+ if versions != {expected_version}:
203
+ raise SystemExit(f"artifact versions do not match {expected_version}: {versions}")
204
+ PY
205
+ mkdir -p dist
206
+ cp release-artifacts/*.whl release-artifacts/*.tar.gz dist/
207
+ - name: Publish to PyPI
208
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
209
+ with:
210
+ packages-dir: dist/
211
+ print-hash: true
212
+ attestations: true
213
+
214
+ post-publish-verification:
215
+ needs: publish
216
+ runs-on: ubuntu-latest
217
+ timeout-minutes: 10
218
+ permissions:
219
+ contents: read
220
+ defaults:
221
+ run:
222
+ shell: bash
223
+ steps:
224
+ - name: Download distribution artifacts
225
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
226
+ with:
227
+ name: localql-1.0.0-dist
228
+ path: release-artifacts
229
+ - name: Verify published artifacts and capture provenance
230
+ id: verify_pypi_release
231
+ timeout-minutes: 7
232
+ run: |
233
+ python - <<'PY'
234
+ from __future__ import annotations
235
+
236
+ import hashlib
237
+ import json
238
+ import os
239
+ import re
240
+ import time
241
+ from datetime import UTC, datetime
242
+ from pathlib import Path
243
+ from urllib.error import HTTPError, URLError
244
+ from urllib.parse import quote
245
+ from urllib.request import Request, urlopen
246
+
247
+ PROJECT = "localql"
248
+ VERSION = os.environ["EXPECTED_VERSION"]
249
+ ARTIFACT_DIR = Path("release-artifacts")
250
+ VERIFICATION_DIR = Path("pypi-verification")
251
+ MAX_ATTEMPTS = 8
252
+ POLL_SECONDS = 10
253
+ REQUEST_TIMEOUT_SECONDS = 10
254
+ INTEGRITY_ACCEPT = "application/vnd.pypi.integrity.v1+json"
255
+
256
+ VERIFICATION_DIR.mkdir(parents=True, exist_ok=True)
257
+ status_path = VERIFICATION_DIR / "verification-status.txt"
258
+ status_path.write_text("PyPI verification started.\n", encoding="utf-8")
259
+
260
+ def read_expected_digests() -> dict[str, str]:
261
+ manifest_path = ARTIFACT_DIR / "SHA256SUMS.txt"
262
+ digests: dict[str, str] = {}
263
+ for raw_line in manifest_path.read_text(encoding="utf-8").splitlines():
264
+ if not raw_line.strip():
265
+ continue
266
+ parts = raw_line.split()
267
+ if len(parts) != 2:
268
+ raise SystemExit(f"invalid SHA256SUMS.txt line: {raw_line!r}")
269
+ digest, filename = parts
270
+ if not re.fullmatch(r"[0-9a-f]{64}", digest):
271
+ raise SystemExit(f"invalid SHA-256 digest for {filename}")
272
+ if filename in digests:
273
+ raise SystemExit(f"duplicate SHA256SUMS.txt entry: {filename}")
274
+ if Path(filename).name != filename:
275
+ raise SystemExit(f"invalid artifact filename: {filename}")
276
+ artifact_path = ARTIFACT_DIR / filename
277
+ if not artifact_path.is_file():
278
+ raise SystemExit(f"downloaded artifact is missing: {filename}")
279
+ local_digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
280
+ if local_digest != digest:
281
+ raise SystemExit(f"downloaded artifact digest mismatch: {filename}")
282
+ digests[filename] = digest
283
+
284
+ wheel_names = [
285
+ name
286
+ for name in digests
287
+ if name.startswith(f"{PROJECT}-{VERSION}-") and name.endswith(".whl")
288
+ ]
289
+ sdist_names = [name for name in digests if name == f"{PROJECT}-{VERSION}.tar.gz"]
290
+ if len(wheel_names) != 1 or len(sdist_names) != 1:
291
+ raise SystemExit(
292
+ f"expected one {PROJECT} {VERSION} wheel and one sdist in SHA256SUMS.txt"
293
+ )
294
+ expected_names = {wheel_names[0], sdist_names[0]}
295
+ if set(digests) != expected_names:
296
+ raise SystemExit(f"unexpected artifacts in SHA256SUMS.txt: {sorted(digests)}")
297
+ return digests
298
+
299
+ def fetch_json(url: str, *, accept: str) -> dict[str, object] | None:
300
+ request = Request(
301
+ url,
302
+ headers={
303
+ "Accept": accept,
304
+ "User-Agent": "localql-post-publish-verification/1.0",
305
+ },
306
+ )
307
+ try:
308
+ with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
309
+ payload = json.load(response)
310
+ except HTTPError as exc:
311
+ if exc.code == 404 or exc.code == 429 or 500 <= exc.code < 600:
312
+ return None
313
+ raise
314
+ except (TimeoutError, URLError):
315
+ return None
316
+ if not isinstance(payload, dict):
317
+ raise SystemExit(f"expected a JSON object from {url}")
318
+ return payload
319
+
320
+ def write_receipt(path: Path, payload: dict[str, object]) -> None:
321
+ path.write_text(
322
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
323
+ encoding="utf-8",
324
+ )
325
+
326
+ def published_digests(entries: object) -> dict[str, str]:
327
+ if not isinstance(entries, list):
328
+ return {}
329
+ digests: dict[str, str] = {}
330
+ for entry in entries:
331
+ if not isinstance(entry, dict):
332
+ continue
333
+ filename = entry.get("filename")
334
+ hashes = entry.get("digests")
335
+ if not isinstance(filename, str) or not isinstance(hashes, dict):
336
+ continue
337
+ sha256 = hashes.get("sha256")
338
+ if isinstance(sha256, str):
339
+ digests[filename] = sha256
340
+ return digests
341
+
342
+ expected_digests = read_expected_digests()
343
+ project = PROJECT
344
+ version = VERSION
345
+ project_url = f"https://pypi.org/pypi/{project}/json"
346
+ release_url = f"https://pypi.org/pypi/{project}/{version}/json"
347
+ verified = False
348
+ last_issue = "PyPI metadata has not propagated"
349
+
350
+ for attempt in range(1, MAX_ATTEMPTS + 1):
351
+ project_metadata = fetch_json(project_url, accept="application/json")
352
+ release_metadata = fetch_json(release_url, accept="application/json")
353
+
354
+ if project_metadata is None or release_metadata is None:
355
+ last_issue = "project or release JSON is not available"
356
+ else:
357
+ write_receipt(VERIFICATION_DIR / "pypi-project.json", project_metadata)
358
+ write_receipt(
359
+ VERIFICATION_DIR / "pypi-release-1.0.0.json",
360
+ release_metadata,
361
+ )
362
+ release_digests = published_digests(release_metadata.get("urls"))
363
+
364
+ if release_digests != expected_digests:
365
+ last_issue = "release JSON filenames or SHA-256 digests do not match"
366
+ else:
367
+ provenance_ready = True
368
+ for filename in sorted(expected_digests):
369
+ quoted_filename = quote(filename, safe="")
370
+ integrity_base = f"https://pypi.org/integrity/{project}/{version}"
371
+ provenance_url = f"{integrity_base}/{quoted_filename}/provenance"
372
+ provenance = fetch_json(provenance_url, accept=INTEGRITY_ACCEPT)
373
+ if provenance is None:
374
+ provenance_ready = False
375
+ last_issue = f"provenance is not available for {filename}"
376
+ continue
377
+
378
+ write_receipt(
379
+ VERIFICATION_DIR / f"provenance-{filename}.json",
380
+ provenance,
381
+ )
382
+ bundles = provenance.get("attestation_bundles")
383
+ if not isinstance(bundles, list) or not bundles:
384
+ provenance_ready = False
385
+ last_issue = f"attestation bundles are missing for {filename}"
386
+ continue
387
+ if any(
388
+ not isinstance(bundle, dict)
389
+ or not isinstance(bundle.get("attestations"), list)
390
+ or not bundle.get("attestations")
391
+ for bundle in bundles
392
+ ):
393
+ provenance_ready = False
394
+ last_issue = f"an attestation bundle is empty for {filename}"
395
+
396
+ if provenance_ready:
397
+ summary = {
398
+ "project": PROJECT,
399
+ "version": VERSION,
400
+ "verified_at": datetime.now(UTC).isoformat(),
401
+ "sha256": expected_digests,
402
+ "release_metadata_and_digests_verified": True,
403
+ "provenance_and_non_empty_attestation_receipts_captured": True,
404
+ "cryptographic_attestation_verification_performed": False,
405
+ }
406
+ write_receipt(
407
+ VERIFICATION_DIR / "verification-summary.json",
408
+ summary,
409
+ )
410
+ status_path.write_text(
411
+ "PyPI release metadata and digests verified; "
412
+ "provenance and non-empty attestation receipts captured. "
413
+ "No cryptographic attestation verification was performed.\n",
414
+ encoding="utf-8",
415
+ )
416
+ verified = True
417
+ break
418
+
419
+ if attempt < MAX_ATTEMPTS:
420
+ print(
421
+ f"PyPI verification attempt {attempt}/{MAX_ATTEMPTS} incomplete: "
422
+ f"{last_issue}; retrying in {POLL_SECONDS}s"
423
+ )
424
+ time.sleep(POLL_SECONDS)
425
+
426
+ if not verified:
427
+ status_path.write_text(
428
+ f"PyPI verification failed after {MAX_ATTEMPTS} attempts: {last_issue}.\n",
429
+ encoding="utf-8",
430
+ )
431
+ raise SystemExit(status_path.read_text(encoding="utf-8").strip())
432
+ PY
433
+ - name: Record failed verification outcome
434
+ if: always() && steps.verify_pypi_release.outcome != 'success'
435
+ run: |
436
+ mkdir -p pypi-verification
437
+ printf '%s\n' \
438
+ 'PyPI post-publish verification failed, timed out, or skipped before completion. Release metadata and digest verification did not complete; inspect workflow logs and captured partial receipts. No cryptographic attestation verification was performed.' \
439
+ > pypi-verification/verification-outcome.txt
440
+ if test ! -s pypi-verification/verification-status.txt || \
441
+ grep -Fqx 'PyPI verification started.' pypi-verification/verification-status.txt; then
442
+ cp pypi-verification/verification-outcome.txt \
443
+ pypi-verification/verification-status.txt
444
+ fi
445
+ - name: Upload PyPI verification receipts
446
+ if: always()
447
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
448
+ with:
449
+ name: localql-1.0.0-pypi-verification
450
+ path: pypi-verification
451
+ if-no-files-found: error
@@ -0,0 +1,24 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ *.py[cod]
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ htmlcov/
9
+
10
+ .venv/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+
15
+ .local/
16
+ .superpowers/
17
+ .worktrees/
18
+
19
+ .csvql/
20
+ output/
21
+ keys.log
22
+
23
+ csvql_project_pack/
24
+ csvql_project_pack.zip
@@ -0,0 +1 @@
1
+ 3.12