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.
- localql-1.0.0/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
- localql-1.0.0/.github/ISSUE_TEMPLATE/docs_issue.yml +25 -0
- localql-1.0.0/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
- localql-1.0.0/.github/pull_request_template.md +16 -0
- localql-1.0.0/.github/workflows/ci.yml +62 -0
- localql-1.0.0/.github/workflows/publish.yml +451 -0
- localql-1.0.0/.gitignore +24 -0
- localql-1.0.0/.python-version +1 -0
- localql-1.0.0/CHANGELOG.md +91 -0
- localql-1.0.0/CODE_OF_CONDUCT.md +29 -0
- localql-1.0.0/CONTRIBUTING.md +77 -0
- localql-1.0.0/LICENSE +21 -0
- localql-1.0.0/Makefile +21 -0
- localql-1.0.0/PKG-INFO +676 -0
- localql-1.0.0/README.md +639 -0
- localql-1.0.0/SECURITY.md +27 -0
- localql-1.0.0/SUPPORT.md +31 -0
- localql-1.0.0/docs/ARCHITECTURE.md +158 -0
- localql-1.0.0/docs/PRODUCT_DIRECTION.md +230 -0
- localql-1.0.0/docs/ROADMAP.md +164 -0
- localql-1.0.0/docs/assets/localql-terminal-project.svg +184 -0
- localql-1.0.0/docs/assets/localql-terminal-query.svg +104 -0
- localql-1.0.0/docs/assets/localql-tui-workbench.svg +213 -0
- localql-1.0.0/docs/benchmarking.md +30 -0
- localql-1.0.0/docs/development.md +63 -0
- localql-1.0.0/docs/failure-gallery.md +641 -0
- localql-1.0.0/docs/faq.md +44 -0
- localql-1.0.0/docs/getting-started.md +137 -0
- localql-1.0.0/docs/json-contracts.md +486 -0
- localql-1.0.0/docs/release-notes/v1.md +373 -0
- localql-1.0.0/docs/release-readiness.md +274 -0
- localql-1.0.0/docs/troubleshooting.md +178 -0
- localql-1.0.0/docs/tui-guide.md +154 -0
- localql-1.0.0/docs/tui-qol-qa.md +353 -0
- localql-1.0.0/docs/v1-manual-qa.md +193 -0
- localql-1.0.0/examples/saas_revenue/.csvql.yml +58 -0
- localql-1.0.0/examples/saas_revenue/README.md +73 -0
- localql-1.0.0/examples/saas_revenue/data/customers.csv +6 -0
- localql-1.0.0/examples/saas_revenue/data/revenue_movements.csv +12 -0
- localql-1.0.0/examples/saas_revenue/data/subscriptions.csv +6 -0
- localql-1.0.0/examples/saas_revenue/queries/revenue_health.sql +63 -0
- localql-1.0.0/examples/saas_revenue/scripts/regenerate_data.py +121 -0
- localql-1.0.0/examples/sales/.csvql.yml +33 -0
- localql-1.0.0/examples/sales/data/customers.csv +4 -0
- localql-1.0.0/examples/sales/data/orders.csv +5 -0
- localql-1.0.0/examples/sales/queries/customer_ltv.sql +9 -0
- localql-1.0.0/examples/sales/queries/revenue_by_month.sql +7 -0
- localql-1.0.0/pyproject.toml +78 -0
- localql-1.0.0/scripts/audit_package_contents.py +122 -0
- localql-1.0.0/scripts/benchmark_csvql.py +28 -0
- localql-1.0.0/scripts/render_benchmark_summary.py +29 -0
- localql-1.0.0/scripts/verify_release_readiness.py +38 -0
- localql-1.0.0/src/csvql/__init__.py +23 -0
- localql-1.0.0/src/csvql/__main__.py +6 -0
- localql-1.0.0/src/csvql/api.py +154 -0
- localql-1.0.0/src/csvql/atomic_write.py +81 -0
- localql-1.0.0/src/csvql/benchmark_data.py +167 -0
- localql-1.0.0/src/csvql/benchmark_runner.py +363 -0
- localql-1.0.0/src/csvql/benchmarking.py +218 -0
- localql-1.0.0/src/csvql/checks.py +747 -0
- localql-1.0.0/src/csvql/cli.py +527 -0
- localql-1.0.0/src/csvql/doctor.py +338 -0
- localql-1.0.0/src/csvql/engine.py +59 -0
- localql-1.0.0/src/csvql/exceptions.py +66 -0
- localql-1.0.0/src/csvql/export.py +129 -0
- localql-1.0.0/src/csvql/inspection.py +139 -0
- localql-1.0.0/src/csvql/models.py +180 -0
- localql-1.0.0/src/csvql/output.py +321 -0
- localql-1.0.0/src/csvql/profiling.py +128 -0
- localql-1.0.0/src/csvql/project_config.py +827 -0
- localql-1.0.0/src/csvql/quality.py +179 -0
- localql-1.0.0/src/csvql/query_workflow.py +164 -0
- localql-1.0.0/src/csvql/release_readiness.py +237 -0
- localql-1.0.0/src/csvql/source.py +86 -0
- localql-1.0.0/src/csvql/source_resolver.py +55 -0
- localql-1.0.0/src/csvql/sql_file.py +51 -0
- localql-1.0.0/src/csvql/sql_utils.py +7 -0
- localql-1.0.0/src/csvql/table_mapping.py +64 -0
- localql-1.0.0/src/csvql/terminal_text.py +26 -0
- localql-1.0.0/src/csvql/tui_app.py +2522 -0
- localql-1.0.0/src/csvql/tui_editor.py +177 -0
- localql-1.0.0/src/csvql/tui_help.py +49 -0
- localql-1.0.0/src/csvql/tui_launcher.py +29 -0
- localql-1.0.0/src/csvql/tui_native_picker.py +66 -0
- localql-1.0.0/src/csvql/tui_result_store.py +88 -0
- localql-1.0.0/src/csvql/tui_results.py +77 -0
- localql-1.0.0/src/csvql/tui_sql_assist.py +589 -0
- localql-1.0.0/src/csvql/tui_state.py +501 -0
- localql-1.0.0/src/csvql/tui_workflows.py +554 -0
- localql-1.0.0/tests/test_api.py +363 -0
- localql-1.0.0/tests/test_atomic_write.py +68 -0
- localql-1.0.0/tests/test_benchmark_data.py +111 -0
- localql-1.0.0/tests/test_benchmark_runner.py +65 -0
- localql-1.0.0/tests/test_benchmarking.py +113 -0
- localql-1.0.0/tests/test_checks.py +577 -0
- localql-1.0.0/tests/test_cli_check.py +319 -0
- localql-1.0.0/tests/test_cli_doctor.py +520 -0
- localql-1.0.0/tests/test_cli_inspect_sample.py +203 -0
- localql-1.0.0/tests/test_cli_menu.py +158 -0
- localql-1.0.0/tests/test_cli_profile.py +118 -0
- localql-1.0.0/tests/test_cli_project_catalog.py +258 -0
- localql-1.0.0/tests/test_cli_query.py +493 -0
- localql-1.0.0/tests/test_cli_run_export.py +305 -0
- localql-1.0.0/tests/test_doctor.py +176 -0
- localql-1.0.0/tests/test_example_project.py +200 -0
- localql-1.0.0/tests/test_export.py +208 -0
- localql-1.0.0/tests/test_failure_gallery.py +332 -0
- localql-1.0.0/tests/test_inspection.py +157 -0
- localql-1.0.0/tests/test_models.py +149 -0
- localql-1.0.0/tests/test_open_source_launch_docs.py +336 -0
- localql-1.0.0/tests/test_output.py +579 -0
- localql-1.0.0/tests/test_package_audit.py +114 -0
- localql-1.0.0/tests/test_profiling.py +118 -0
- localql-1.0.0/tests/test_project_config.py +936 -0
- localql-1.0.0/tests/test_quality.py +225 -0
- localql-1.0.0/tests/test_query_workflow.py +114 -0
- localql-1.0.0/tests/test_release_readiness.py +199 -0
- localql-1.0.0/tests/test_source.py +35 -0
- localql-1.0.0/tests/test_source_resolver.py +85 -0
- localql-1.0.0/tests/test_sql_file.py +62 -0
- localql-1.0.0/tests/test_sql_utils.py +8 -0
- localql-1.0.0/tests/test_table_mapping.py +66 -0
- localql-1.0.0/tests/test_terminal_text.py +42 -0
- localql-1.0.0/tests/test_tui_app.py +5090 -0
- localql-1.0.0/tests/test_tui_editor.py +79 -0
- localql-1.0.0/tests/test_tui_native_picker.py +68 -0
- localql-1.0.0/tests/test_tui_result_store.py +75 -0
- localql-1.0.0/tests/test_tui_results.py +192 -0
- localql-1.0.0/tests/test_tui_sql_assist.py +237 -0
- localql-1.0.0/tests/test_tui_state.py +548 -0
- localql-1.0.0/tests/test_tui_workflows.py +1007 -0
- localql-1.0.0/tests/test_v1_polish_docs.py +638 -0
- 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
|
localql-1.0.0/.gitignore
ADDED
|
@@ -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
|