pinky-core 0.1.0.dev3__tar.gz → 0.1.0.dev4__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.
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/ci.yml +4 -1
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/PKG-INFO +2 -1
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/README.md +1 -0
- pinky_core-0.1.0.dev4/docs/explanation/2-features.md +44 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/coverage_badge.svg +1 -1
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/interrogate_badge.svg +4 -4
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/pyproject.toml +16 -8
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/generate-docs +21 -4
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/__init__.py +24 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/finance/fec.py +9 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/fmt.py +28 -1
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/security.py +5 -4
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/sql.py +48 -2
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/xml.py +6 -6
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_fmt.py +18 -0
- pinky_core-0.1.0.dev4/tests/test_public_api.py +69 -0
- pinky_core-0.1.0.dev4/tests/test_sql.py +170 -0
- pinky_core-0.1.0.dev3/docs/explanation/2-features.md +0 -74
- pinky_core-0.1.0.dev3/tests/test_sql.py +0 -82
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/publish.yml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/release.yml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.gitignore +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.gitleaks.toml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.pre-commit-config.yaml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.secrets.baseline +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/LICENSE +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/adr/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/adr/0001-zero-dependency-static-html.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/explanation/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/explanation/1-design.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/how-to/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/how-to/index.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/index.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/mkdocs.yml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/fec.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/files.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/fmt.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/html.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/lint.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/schedule.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/security.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/sql.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/validate.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/warnings.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/xml.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/hooks_badge.svg +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/noqa_badge.svg +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/tutorials/.pages +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/tutorials/index.md +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/deploy_validate_udfs.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/badges.toml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_forbidden_keywords.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_snowpark_api.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_typed_api.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_zws.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/commit-msg +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/docs_identity.json +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/generate_ci_badges.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/render_docs.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/sql/deploy_udf_example.sql +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/files.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/finance/__init__.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/html.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/lint.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/schedule.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/validate.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/warnings.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/__init__.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/fixtures/cats_domination.xml +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_fec.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_files.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_html.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_lint.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_schedule.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_security.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_validate.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_warnings.py +0 -0
- {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_xml_xpath.py +0 -0
|
@@ -33,7 +33,10 @@ jobs:
|
|
|
33
33
|
uses: j178/prek-action@v2
|
|
34
34
|
|
|
35
35
|
- name: Install dependencies
|
|
36
|
-
|
|
36
|
+
# All optional extras, not just validate/address/dev — omitting xml/schedule/lint
|
|
37
|
+
# silently skips their test files at collection time (pytest.importorskip on the
|
|
38
|
+
# missing package), deflating the coverage number without ever failing CI.
|
|
39
|
+
run: pip install -e ".[validate,address,schedule,xml,lint,dev]"
|
|
37
40
|
|
|
38
41
|
- name: Tests
|
|
39
42
|
run: pytest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pinky-core
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev4
|
|
4
4
|
Summary: Shared Python utilities for Snowflake development
|
|
5
5
|
Project-URL: Homepage, https://github.com/pinky-and-co/pinky-core
|
|
6
6
|
Project-URL: Repository, https://github.com/pinky-and-co/pinky-core
|
|
@@ -198,6 +198,7 @@ pinky-core/
|
|
|
198
198
|
│ ├── test_fmt.py
|
|
199
199
|
│ ├── test_html.py
|
|
200
200
|
│ ├── test_lint.py
|
|
201
|
+
│ ├── test_public_api.py
|
|
201
202
|
│ ├── test_schedule.py
|
|
202
203
|
│ ├── test_security.py
|
|
203
204
|
│ ├── test_sql.py
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Features
|
|
3
|
+
---
|
|
4
|
+
# Features — pinky-core
|
|
5
|
+
*Update date : 2026-07-12 10:37*
|
|
6
|
+
|
|
7
|
+
Feature list ordered by priority. `stable` = in production. `planned` = not yet built, order = priority.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Stable
|
|
12
|
+
|
|
13
|
+
| Module | Description | Status |
|
|
14
|
+
|--------|-------------|--------|
|
|
15
|
+
| `fmt.py` | Formatters: dates/periods, numbers (compact/duration/fraction/trend/stars/boolean), string case (UPPER_SNAKE/camelCase/slug), text/city normalization, ISO3→ISO2, header dedup, FR/AFNOR address parsing | stable |
|
|
16
|
+
| `validate.py` | Input validation: SIRET/SIREN, IBAN/BIC, TVA EU, NIR, email, phone | stable |
|
|
17
|
+
| `security.py` | Filepath and SQL-identifier sanitisation — guards against injection in dynamically built DDL | stable |
|
|
18
|
+
| `sql.py` | SQL parsing helpers powered by sqlglot: statement kind, table-ref extraction, query resolution, statement splitting | stable |
|
|
19
|
+
| `html.py` | HTML templating engine — emails, reports, mini-sites, Outlook-safe | stable |
|
|
20
|
+
| `xml.py` | XPath helpers, namespace-aware parsing, safe attribute extraction | stable |
|
|
21
|
+
| `files.py` | S3/MFT filename pattern normalisation — `detect_file_pattern`, `FileDetection` | stable |
|
|
22
|
+
| `schedule.py` | French public holidays, business-day helpers, cron human-readable formatting, Airflow-timetable predicates (month/week/fiscal-quarter, eve-of-holiday) | stable |
|
|
23
|
+
| `lint.py` | Optional lint/format delegation (sqlfluff/ruff/yamllint/detect-secrets) — consumed by pinky-provider for the secret-scan deploy gate and SQL pre-flight | stable |
|
|
24
|
+
| `warnings.py` | `PinkyDeprecationWarning` — deprecated pinky APIs and UDFs. No consumer wired yet (destined for pinky-provider's registry `status: deprecated`) | stable |
|
|
25
|
+
| `finance/fec.py` | FEC parser (Fichier des Écritures Comptables, arrêté A47 A-1) — `parse_fec`/`check_fec_balance`, positional column reading, dual format tolerance (delimiter/date/decimal/amount schema), mojibake repair | stable |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Planned
|
|
30
|
+
|
|
31
|
+
Ordered by priority — closest to implementation first.
|
|
32
|
+
|
|
33
|
+
### finance/ — extend beyond FEC
|
|
34
|
+
|
|
35
|
+
Same lineage as `finance/fec.py` (real FR accounting/payroll file formats) — no current consumer card, kept for the likely follow-on employer-migration need.
|
|
36
|
+
|
|
37
|
+
- `finance/dsn.py` — Déclaration Sociale Nominative parser (INSEE/Net-Entreprises)
|
|
38
|
+
- `finance/sepa.py` — SEPA pain.001 / pain.008 XML builders (credit transfers, direct debits)
|
|
39
|
+
- `finance/ofx.py` — bank statement parsers (OFX/QFX for reconciliation)
|
|
40
|
+
|
|
41
|
+
### finance/ — FEC validator as CLI
|
|
42
|
+
|
|
43
|
+
`python -m pinky_core.finance.fec validate file.txt` — thin CLI wrapper around the already-built
|
|
44
|
+
`parse_fec`/`check_fec_balance`. Gated on `pinky-tools` CLI integration (chantier 0002), not speculative.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage:
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 84.34%"><title>coverage: 84.34%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#67ac09"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">84.34%</text><text x="865" y="150" fill-opacity=".3" textLength="430">84.34%</text></g><text x="865" y="140" textLength="430">84.34%</text></g></g></svg>
|
{pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/interrogate_badge.svg
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<svg width="140" height="20" viewBox="0 0 140 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
2
|
-
<title>interrogate:
|
|
2
|
+
<title>interrogate: 100%</title>
|
|
3
3
|
<g transform="matrix(1,0,0,1,22,0)">
|
|
4
4
|
<g id="backgrounds" transform="matrix(1.32789,0,0,1,-22.3892,0)">
|
|
5
5
|
<rect x="0" y="0" width="71" height="20" style="fill:rgb(85,85,85);"/>
|
|
6
6
|
</g>
|
|
7
|
-
<rect x="71" y="0" width="47" height="20" data-interrogate="color" style="fill:#
|
|
7
|
+
<rect x="71" y="0" width="47" height="20" data-interrogate="color" style="fill:#4c1"/>
|
|
8
8
|
<g transform="matrix(1.19746,0,0,1,-22.3744,-4.85723e-16)">
|
|
9
9
|
<rect x="0" y="0" width="118" height="20" style="fill:url(#_Linear1);"/>
|
|
10
10
|
</g>
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
|
13
13
|
<text x="590" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="610">interrogate</text>
|
|
14
14
|
<text x="590" y="140" transform="scale(.1)" textLength="610">interrogate</text>
|
|
15
|
-
<text x="1160" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="
|
|
16
|
-
<text x="1160" y="140" transform="scale(.1)" textLength="
|
|
15
|
+
<text x="1160" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330" data-interrogate="result">100%</text>
|
|
16
|
+
<text x="1160" y="140" transform="scale(.1)" textLength="330" data-interrogate="result">100%</text>
|
|
17
17
|
</g>
|
|
18
18
|
<g id="logo-shadow" serif:id="logo shadow" transform="matrix(0.854876,0,0,0.854876,-6.73514,1.732)">
|
|
19
19
|
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pinky-core"
|
|
7
|
-
version = "0.1.0.
|
|
7
|
+
version = "0.1.0.dev4"
|
|
8
8
|
description = "Shared Python utilities for Snowflake development"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -105,13 +105,21 @@ follow_imports = "skip"
|
|
|
105
105
|
|
|
106
106
|
# ── Interrogate ───────────────────────────────────────────────────────────────
|
|
107
107
|
[tool.interrogate]
|
|
108
|
-
ignore-init-method
|
|
109
|
-
ignore-magic
|
|
110
|
-
ignore-module
|
|
111
|
-
ignore-private
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
ignore-init-method = true
|
|
109
|
+
ignore-magic = true
|
|
110
|
+
ignore-module = true
|
|
111
|
+
ignore-private = true
|
|
112
|
+
# --ignore-private only covers dunder-prefixed (__foo) names; this codebase's actual
|
|
113
|
+
# private-helper convention is single-underscore (_foo) — that's --ignore-semiprivate.
|
|
114
|
+
# Without it, every internal helper (xml.py's _parse_path, fmt.py's _parse_fr, …) counted
|
|
115
|
+
# against docstring coverage, deflating the real number from ~98% to ~66%.
|
|
116
|
+
ignore-semiprivate = true
|
|
117
|
+
# Real coverage is ~97.6% now that ignore-semiprivate is set (was capped at 60 to not
|
|
118
|
+
# block interrogate's initial rollout). Threshold left with headroom below current, not
|
|
119
|
+
# pinned to 97.6, so incidental drift doesn't fail CI on unrelated PRs.
|
|
120
|
+
fail-under = 95
|
|
121
|
+
paths = ["src/"]
|
|
122
|
+
generate-badge = "docs/reference/generated/"
|
|
115
123
|
|
|
116
124
|
# ── Darglint ─────────────────────────────────────────────────────────────────
|
|
117
125
|
[tool.darglint]
|
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
# manual `setup.py --all` — gating on staged src/ files missed exactly that edit.
|
|
15
15
|
# 2. SVG quality badges (interrogate/coverage/noqa/hooks) — unchanged from check-docs-up-to-date,
|
|
16
16
|
# still gated on staged src/ or .pre-commit-config.yaml (more expensive: coverage xml + genbadge).
|
|
17
|
+
#
|
|
18
|
+
# Coverage badge reliability fix (2026-07-12): the old version trusted a pre-existing
|
|
19
|
+
# .cache/coverage/.coverage file — silently stale for a month on pinky-core (nobody's commit
|
|
20
|
+
# happened to run a full, correctly-instrumented `pytest` right before hitting this hook) with
|
|
21
|
+
# no failure, no warning, nothing. It now runs the test suite itself, every time, and prints a
|
|
22
|
+
# visible warning instead of silently no-op'ing when pytest or genbadge aren't available. Uses
|
|
23
|
+
# `coverage run -m pytest --no-cov` (not pytest-cov's own instrumentation) — editable-install
|
|
24
|
+
# path setups (multiple repos' src/ on sys.path at once) can make pytest-cov silently measure
|
|
25
|
+
# nothing ("module-not-measured") with no visible error; wrapping externally sidesteps that.
|
|
17
26
|
# =============================================================================
|
|
18
27
|
|
|
19
28
|
# 1. Repo-local docs (README sections + docs/index.md) — always, idempotent.
|
|
@@ -35,10 +44,18 @@ fi
|
|
|
35
44
|
if [ "$SRC_STAGED" -gt 0 ]; then
|
|
36
45
|
git add docs/reference/generated/interrogate_badge.svg 2>/dev/null
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
mkdir -p .cache/coverage
|
|
48
|
+
COV_LOG=$(mktemp)
|
|
49
|
+
if python -m coverage run -m pytest -q --no-cov > "$COV_LOG" 2>&1; then
|
|
50
|
+
python -m coverage xml -o .cache/coverage/coverage.xml > /dev/null 2>&1
|
|
51
|
+
if command -v genbadge > /dev/null 2>&1; then
|
|
52
|
+
genbadge coverage -i .cache/coverage/coverage.xml -o docs/reference/generated/coverage_badge.svg > /dev/null 2>&1
|
|
53
|
+
git add docs/reference/generated/coverage_badge.svg 2>/dev/null
|
|
54
|
+
else
|
|
55
|
+
echo "⚠ coverage_badge.svg not regenerated: 'genbadge' not installed — pip install -e '.[dev]'" >&2
|
|
56
|
+
fi
|
|
57
|
+
else
|
|
58
|
+
echo "⚠ coverage_badge.svg not regenerated: test run failed, see $COV_LOG" >&2
|
|
42
59
|
fi
|
|
43
60
|
|
|
44
61
|
python scripts/hooks/generate_ci_badges.py --noqa > /dev/null 2>&1
|
|
@@ -9,6 +9,8 @@ files File pattern normalization for S3 and MFT path analysis.
|
|
|
9
9
|
finance French accounting file formats (FEC parser).
|
|
10
10
|
fmt Pure formatting helpers (text, dates, numbers, FR addresses).
|
|
11
11
|
html HTML templating engine (emails, reports, mini-sites) — Outlook-safe.
|
|
12
|
+
lint Delegated tooling for pinky-provider's SQL/secret pre-flight — accessed
|
|
13
|
+
via ``pinky_core.lint`` directly, never top-level.
|
|
12
14
|
schedule Business-day calendar (FR) and cron utilities for SPs and DAGs.
|
|
13
15
|
security Path and identifier sanitisation for safe dynamic SQL.
|
|
14
16
|
sql SQL parsing helpers powered by sqlglot (Snowflake dialect).
|
|
@@ -46,6 +48,7 @@ from pinky_core.finance.fec import (
|
|
|
46
48
|
from pinky_core.fmt import (
|
|
47
49
|
AddressComponents,
|
|
48
50
|
AfnorAddress,
|
|
51
|
+
NumType,
|
|
49
52
|
deduplicate_headers,
|
|
50
53
|
format_boolean,
|
|
51
54
|
format_date,
|
|
@@ -61,6 +64,7 @@ from pinky_core.fmt import (
|
|
|
61
64
|
normalize_text,
|
|
62
65
|
parse_address,
|
|
63
66
|
period_to_date,
|
|
67
|
+
slugify,
|
|
64
68
|
to_camel_case,
|
|
65
69
|
to_upper_snake_case,
|
|
66
70
|
)
|
|
@@ -79,10 +83,18 @@ from pinky_core.html import (
|
|
|
79
83
|
from pinky_core.schedule import (
|
|
80
84
|
business_days_between,
|
|
81
85
|
cron_to_human,
|
|
86
|
+
days_until_end_of_month,
|
|
87
|
+
first_business_day_of_month,
|
|
88
|
+
fiscal_quarter,
|
|
82
89
|
is_business_day,
|
|
90
|
+
is_eve_of_holiday,
|
|
91
|
+
is_even_week,
|
|
83
92
|
is_in_schedule,
|
|
93
|
+
is_odd_week,
|
|
94
|
+
last_business_day_of_month,
|
|
84
95
|
next_business_day,
|
|
85
96
|
next_schedule,
|
|
97
|
+
nth_business_day_of_month,
|
|
86
98
|
public_holidays_fr,
|
|
87
99
|
)
|
|
88
100
|
|
|
@@ -94,6 +106,7 @@ from pinky_core.security import (
|
|
|
94
106
|
|
|
95
107
|
# sql — pure SQL parsing, always available
|
|
96
108
|
from pinky_core.sql import (
|
|
109
|
+
extract_table_refs,
|
|
97
110
|
resolve_query,
|
|
98
111
|
split_sql_statements,
|
|
99
112
|
statement_kind,
|
|
@@ -137,6 +150,7 @@ __all__ = [
|
|
|
137
150
|
# fmt
|
|
138
151
|
"AddressComponents",
|
|
139
152
|
"AfnorAddress",
|
|
153
|
+
"NumType",
|
|
140
154
|
"deduplicate_headers",
|
|
141
155
|
"iso3_to_iso2",
|
|
142
156
|
"normalize_city_fr",
|
|
@@ -152,6 +166,7 @@ __all__ = [
|
|
|
152
166
|
"format_trend",
|
|
153
167
|
"parse_address",
|
|
154
168
|
"period_to_date",
|
|
169
|
+
"slugify",
|
|
155
170
|
"to_camel_case",
|
|
156
171
|
"to_upper_snake_case",
|
|
157
172
|
# html
|
|
@@ -166,6 +181,14 @@ __all__ = [
|
|
|
166
181
|
"is_business_day",
|
|
167
182
|
"next_business_day",
|
|
168
183
|
"business_days_between",
|
|
184
|
+
"is_eve_of_holiday",
|
|
185
|
+
"last_business_day_of_month",
|
|
186
|
+
"first_business_day_of_month",
|
|
187
|
+
"nth_business_day_of_month",
|
|
188
|
+
"is_even_week",
|
|
189
|
+
"is_odd_week",
|
|
190
|
+
"fiscal_quarter",
|
|
191
|
+
"days_until_end_of_month",
|
|
169
192
|
"cron_to_human",
|
|
170
193
|
"next_schedule",
|
|
171
194
|
"is_in_schedule",
|
|
@@ -173,6 +196,7 @@ __all__ = [
|
|
|
173
196
|
"safe_filepath",
|
|
174
197
|
"validate_identifier",
|
|
175
198
|
# sql
|
|
199
|
+
"extract_table_refs",
|
|
176
200
|
"resolve_query",
|
|
177
201
|
"split_sql_statements",
|
|
178
202
|
"statement_kind",
|
|
@@ -90,6 +90,8 @@ class FecAmountSchema(StrEnum):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class FecSens(StrEnum):
|
|
93
|
+
"""Debit/credit sign, as used by the ``Sens`` column of the ``Montant``/``Sens`` amount schema."""
|
|
94
|
+
|
|
93
95
|
DEBIT = "D"
|
|
94
96
|
CREDIT = "C"
|
|
95
97
|
|
|
@@ -186,6 +188,13 @@ class FecParseResult(BaseModel):
|
|
|
186
188
|
|
|
187
189
|
@property
|
|
188
190
|
def is_valid(self) -> bool:
|
|
191
|
+
"""Whether every row parsed without error.
|
|
192
|
+
|
|
193
|
+
Does not check debit/credit balance — see :func:`check_fec_balance` for that.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if ``errors`` is empty.
|
|
197
|
+
"""
|
|
189
198
|
return not self.errors
|
|
190
199
|
|
|
191
200
|
|
|
@@ -13,6 +13,7 @@ import math
|
|
|
13
13
|
import re
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from datetime import date, datetime
|
|
16
|
+
from typing import Literal
|
|
16
17
|
|
|
17
18
|
# =============================================================================
|
|
18
19
|
# Dates
|
|
@@ -99,7 +100,10 @@ def period_to_date(period: str) -> date:
|
|
|
99
100
|
# =============================================================================
|
|
100
101
|
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
NumType = Literal["count", "amount", "percent", "percent_decimal"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def format_number(num: float | int | None, num_type: NumType = "count") -> str:
|
|
103
107
|
"""Format a number using compact notation (K, M).
|
|
104
108
|
|
|
105
109
|
Args:
|
|
@@ -297,6 +301,29 @@ def to_upper_snake_case(name: str) -> str:
|
|
|
297
301
|
return name.strip("_").upper()
|
|
298
302
|
|
|
299
303
|
|
|
304
|
+
def slugify(text: str) -> str:
|
|
305
|
+
"""Convert a string to a URL-safe slug (lowercase, ASCII, hyphens).
|
|
306
|
+
|
|
307
|
+
Uses ``unidecode`` to transliterate accented and non-ASCII characters,
|
|
308
|
+
then collapses any run of non-alphanumeric characters to a single ``-``.
|
|
309
|
+
|
|
310
|
+
Examples:
|
|
311
|
+
"Résidence de l'Étoile" -> "residence-de-l-etoile"
|
|
312
|
+
"NAF 2025\\nsous-classes" -> "naf-2025-sous-classes"
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
text: String to convert.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Lowercase ASCII slug with hyphens, no leading/trailing hyphen.
|
|
319
|
+
"""
|
|
320
|
+
from unidecode import unidecode
|
|
321
|
+
|
|
322
|
+
text = unidecode(text)
|
|
323
|
+
text = re.sub(r"[^a-zA-Z0-9]+", "-", text)
|
|
324
|
+
return text.strip("-").lower()
|
|
325
|
+
|
|
326
|
+
|
|
300
327
|
def to_camel_case(name: str) -> str:
|
|
301
328
|
"""Convert UPPER_SNAKE_CASE or snake_case to camelCase.
|
|
302
329
|
|
|
@@ -13,16 +13,17 @@ import re
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def safe_filepath(filepath: str) -> str:
|
|
16
|
-
"""Validate
|
|
16
|
+
"""Validate a file path for use in a Snowflake REMOVE statement.
|
|
17
17
|
|
|
18
|
-
Rejects paths containing special characters
|
|
19
|
-
|
|
18
|
+
Rejects paths containing special characters (including single quotes) or
|
|
19
|
+
``..`` segments to prevent SQL injection and path traversal in dynamically
|
|
20
|
+
built DDL.
|
|
20
21
|
|
|
21
22
|
Args:
|
|
22
23
|
filepath: Relative path to validate (e.g. ``"folder/file.csv"``).
|
|
23
24
|
|
|
24
25
|
Returns:
|
|
25
|
-
|
|
26
|
+
The unchanged path if valid.
|
|
26
27
|
|
|
27
28
|
Raises:
|
|
28
29
|
ValueError: If the path contains disallowed characters or ``..``.
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import re
|
|
10
10
|
|
|
11
11
|
import sqlglot
|
|
12
|
-
from sqlglot import ErrorLevel
|
|
12
|
+
from sqlglot import ErrorLevel, exp
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def statement_kind(statement: str) -> str:
|
|
@@ -93,7 +93,53 @@ def resolve_query(template: str, refs: dict[str, str]) -> str:
|
|
|
93
93
|
for key, value in refs.items():
|
|
94
94
|
if not _SAFE_REF_PATTERN.match(value):
|
|
95
95
|
raise ValueError(f"Unsafe table reference for {{{key}}}: {value!r}")
|
|
96
|
-
|
|
96
|
+
try:
|
|
97
|
+
return template.format(**refs)
|
|
98
|
+
except KeyError as e:
|
|
99
|
+
raise ValueError(f"Template placeholder {e} has no matching key in refs") from e
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_table_refs(query: str) -> list[str]:
|
|
103
|
+
"""Extract fully-qualified table references from a SQL query.
|
|
104
|
+
|
|
105
|
+
Motivation: a View/Dynamic Table resource's body (``AS <query>`` or ``REFRESH USING (<dml>)``)
|
|
106
|
+
implicitly depends on every table it reads from — this walks the AST to recover that dependency
|
|
107
|
+
set instead of asking the manifest author to declare it by hand.
|
|
108
|
+
|
|
109
|
+
Uses sqlglot (Snowflake dialect). Excludes CTE aliases (``WITH x AS (...)`` — a local name, not a
|
|
110
|
+
real table) and any bare, unqualified identifier: a legitimate cross-resource reference is always
|
|
111
|
+
at least ``SCHEMA.TABLE`` (pinky-provider's FQN convention, ``DATABASE.SCHEMA.NAME``), so a bare
|
|
112
|
+
name is either a CTE already filtered out, or a DML self-reference placeholder (e.g. ``SELF`` in a
|
|
113
|
+
dynamic table's ``REFRESH USING (INSERT INTO SELF ...)``) — never a real dependency.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
query: A single SQL statement without trailing ``;``.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Sorted, deduplicated list of fully-qualified table references (``DB.SCHEMA.TABLE`` or
|
|
120
|
+
``SCHEMA.TABLE``) — a dependency set, not a trace of the query, so it sorts rather than
|
|
121
|
+
preserving AST-traversal order (which does not match source order). Unparseable or empty
|
|
122
|
+
input → ``[]``.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
tree = sqlglot.parse_one(
|
|
126
|
+
query.strip(),
|
|
127
|
+
dialect="snowflake",
|
|
128
|
+
error_level=ErrorLevel.IGNORE,
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
return []
|
|
132
|
+
if tree is None:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
cte_names = {cte.alias for cte in tree.find_all(exp.CTE)}
|
|
136
|
+
refs: set[str] = set()
|
|
137
|
+
for table in tree.find_all(exp.Table):
|
|
138
|
+
parts = [p for p in (table.catalog, table.db, table.name) if p]
|
|
139
|
+
if len(parts) < 2 or parts[-1] in cte_names:
|
|
140
|
+
continue
|
|
141
|
+
refs.add(".".join(parts))
|
|
142
|
+
return sorted(refs)
|
|
97
143
|
|
|
98
144
|
|
|
99
145
|
def split_sql_statements(sql: str) -> list[str]:
|
|
@@ -48,7 +48,7 @@ from typing import Any
|
|
|
48
48
|
# Snowflake VARIANT: dict node, text scalar, or None
|
|
49
49
|
Variant = Any
|
|
50
50
|
# XPath step: (kind, tag_or_attr, predicates)
|
|
51
|
-
|
|
51
|
+
_Step = tuple[str, str | None, list[str]]
|
|
52
52
|
|
|
53
53
|
# ---------------------------------------------------------------------------
|
|
54
54
|
# Public UDF handler
|
|
@@ -115,7 +115,7 @@ def xml_path_str(xml_string: str | None, path: str | None) -> Variant:
|
|
|
115
115
|
# namespace:: axis tuple, bytearray, memoryview, or other non-element
|
|
116
116
|
# XPath result — not a serialisable scalar
|
|
117
117
|
return None
|
|
118
|
-
except Exception:
|
|
118
|
+
except Exception: # UDF handler: malformed XML / invalid XPath → None, never raise
|
|
119
119
|
return None
|
|
120
120
|
|
|
121
121
|
|
|
@@ -134,8 +134,8 @@ def _evaluate(node: Variant, path: str) -> Variant:
|
|
|
134
134
|
return current
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
def _parse_path(path: str) -> list[
|
|
138
|
-
result: list[
|
|
137
|
+
def _parse_path(path: str) -> list[_Step]:
|
|
138
|
+
result: list[_Step] = []
|
|
139
139
|
i = 0
|
|
140
140
|
n = len(path)
|
|
141
141
|
while i < n:
|
|
@@ -183,7 +183,7 @@ def _read_bracket(path: str, start: int) -> tuple[str, int]:
|
|
|
183
183
|
return s.strip(), j + 1
|
|
184
184
|
|
|
185
185
|
|
|
186
|
-
def _read_step_token(path: str, start: int) -> tuple[
|
|
186
|
+
def _read_step_token(path: str, start: int) -> tuple[_Step | None, int]:
|
|
187
187
|
i = start
|
|
188
188
|
n = len(path)
|
|
189
189
|
token = ""
|
|
@@ -247,7 +247,7 @@ def _deep_find_all(node: Variant, tag: str) -> list[Variant]:
|
|
|
247
247
|
# ---------------------------------------------------------------------------
|
|
248
248
|
|
|
249
249
|
|
|
250
|
-
def _execute(node: Variant, step:
|
|
250
|
+
def _execute(node: Variant, step: _Step) -> Variant:
|
|
251
251
|
if not isinstance(node, dict):
|
|
252
252
|
return None
|
|
253
253
|
kind, tag, preds = step
|
|
@@ -11,6 +11,7 @@ from pinky_core.fmt import (
|
|
|
11
11
|
normalize_city_fr,
|
|
12
12
|
normalize_text,
|
|
13
13
|
parse_address,
|
|
14
|
+
slugify,
|
|
14
15
|
to_camel_case,
|
|
15
16
|
to_upper_snake_case,
|
|
16
17
|
)
|
|
@@ -162,6 +163,23 @@ def test_to_camel_case():
|
|
|
162
163
|
assert to_camel_case("phone") == "phone"
|
|
163
164
|
|
|
164
165
|
|
|
166
|
+
# ── slugify ───────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
"inp, expected",
|
|
171
|
+
[
|
|
172
|
+
("Résidence de l'Étoile", "residence-de-l-etoile"),
|
|
173
|
+
("NAF 2025\nsous-classes", "naf-2025-sous-classes"),
|
|
174
|
+
(" Multiple spaces ", "multiple-spaces"),
|
|
175
|
+
("Ñoño", "nono"),
|
|
176
|
+
("already-a-slug", "already-a-slug"),
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
def test_slugify(inp, expected):
|
|
180
|
+
assert slugify(inp) == expected
|
|
181
|
+
|
|
182
|
+
|
|
165
183
|
# ── deduplicate_headers ───────────────────────────────────────────────────────
|
|
166
184
|
|
|
167
185
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Regression test: every public name a module defines must be re-exported from
|
|
2
|
+
``pinky_core`` (the top-level package) and listed in ``pinky_core.__all__``.
|
|
3
|
+
|
|
4
|
+
Guards against the drift caught in the V1 stability pass: `schedule.py`'s 8
|
|
5
|
+
timetable predicates (PR #10) were defined and tested but never wired into the
|
|
6
|
+
package `__init__.py`, so `from pinky_core import is_even_week` raised
|
|
7
|
+
`ImportError` despite the module advertising them. Covers every submodule
|
|
8
|
+
re-exported from `pinky_core/__init__.py`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
import pinky_core
|
|
14
|
+
from pinky_core import (
|
|
15
|
+
files,
|
|
16
|
+
fmt,
|
|
17
|
+
html,
|
|
18
|
+
schedule,
|
|
19
|
+
security,
|
|
20
|
+
sql,
|
|
21
|
+
validate,
|
|
22
|
+
warnings,
|
|
23
|
+
xml,
|
|
24
|
+
)
|
|
25
|
+
from pinky_core.finance import fec
|
|
26
|
+
|
|
27
|
+
_PACKAGE_MODULES = [
|
|
28
|
+
fec,
|
|
29
|
+
files,
|
|
30
|
+
fmt,
|
|
31
|
+
html,
|
|
32
|
+
schedule,
|
|
33
|
+
security,
|
|
34
|
+
sql,
|
|
35
|
+
validate,
|
|
36
|
+
warnings,
|
|
37
|
+
xml,
|
|
38
|
+
]
|
|
39
|
+
# NOTE: pinky_core.lint is intentionally excluded — it is delegated tooling for
|
|
40
|
+
# pinky-provider's SQL/secret pre-flight (ADR-0024), not a consumer-facing API.
|
|
41
|
+
# Its symbols are never re-exported at the top level.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _public_names(module: object) -> set[str]:
|
|
45
|
+
return {
|
|
46
|
+
name
|
|
47
|
+
for name, member in vars(module).items()
|
|
48
|
+
if not name.startswith("_")
|
|
49
|
+
and inspect.getmodule(member) is module
|
|
50
|
+
and (inspect.isfunction(member) or inspect.isclass(member))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_every_module_public_name_is_reexported_at_package_level() -> None:
|
|
55
|
+
missing = {}
|
|
56
|
+
for module in _PACKAGE_MODULES:
|
|
57
|
+
gap = _public_names(module) - set(pinky_core.__all__)
|
|
58
|
+
if gap:
|
|
59
|
+
missing[module.__name__] = sorted(gap)
|
|
60
|
+
assert not missing, f"names defined but not re-exported from pinky_core: {missing}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_every_module_public_name_is_importable_from_package() -> None:
|
|
64
|
+
for module in _PACKAGE_MODULES:
|
|
65
|
+
for name in _public_names(module):
|
|
66
|
+
assert hasattr(pinky_core, name), (
|
|
67
|
+
f"{module.__name__}.{name} is public but `from pinky_core import "
|
|
68
|
+
f"{name}` would fail"
|
|
69
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for pinky_core.sql — pure Python, no external deps."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pinky_core.sql import (
|
|
6
|
+
extract_table_refs,
|
|
7
|
+
resolve_query,
|
|
8
|
+
split_sql_statements,
|
|
9
|
+
statement_kind,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# ── statement_kind ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
("stmt", "kind"),
|
|
17
|
+
[
|
|
18
|
+
("SELECT 1", "select"),
|
|
19
|
+
(
|
|
20
|
+
"WITH c AS (SELECT 1) SELECT * FROM c",
|
|
21
|
+
"select",
|
|
22
|
+
), # CTE → top node is the SELECT
|
|
23
|
+
("SELECT 1 UNION SELECT 2", "union"),
|
|
24
|
+
("INSERT INTO SELF SELECT 1", "insert"),
|
|
25
|
+
(
|
|
26
|
+
"MERGE INTO SELF USING s ON a = b WHEN MATCHED THEN UPDATE SET x = 1",
|
|
27
|
+
"merge",
|
|
28
|
+
),
|
|
29
|
+
("UPDATE t SET x = 1", "update"),
|
|
30
|
+
("DELETE FROM t", "delete"),
|
|
31
|
+
("CREATE TABLE t (id INT)", "create"),
|
|
32
|
+
("DROP TABLE t", "drop"),
|
|
33
|
+
("CALL my_proc()", "command"),
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
def test_statement_kind(stmt, kind):
|
|
37
|
+
assert statement_kind(stmt) == kind
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_statement_kind_unknown_on_empty():
|
|
41
|
+
assert statement_kind("") == "unknown"
|
|
42
|
+
assert statement_kind(" ") == "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_single_statement():
|
|
46
|
+
assert split_sql_statements("SELECT 1") == ["SELECT 1"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_trailing_semicolon():
|
|
50
|
+
assert split_sql_statements("SELECT 1;") == ["SELECT 1"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_multiple_statements():
|
|
54
|
+
sql = "SELECT 1; SELECT 2; SELECT 3"
|
|
55
|
+
assert split_sql_statements(sql) == ["SELECT 1", "SELECT 2", "SELECT 3"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_strips_inline_comments():
|
|
59
|
+
sql = "SELECT 1 -- this is a comment\n; SELECT 2"
|
|
60
|
+
stmts = split_sql_statements(sql)
|
|
61
|
+
assert len(stmts) == 2
|
|
62
|
+
assert "comment" not in stmts[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_strips_block_comments():
|
|
66
|
+
sql = "/* header */ SELECT 1; /* footer */ SELECT 2"
|
|
67
|
+
stmts = split_sql_statements(sql)
|
|
68
|
+
assert stmts == ["SELECT 1", "SELECT 2"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_semicolon_inside_string_ignored():
|
|
72
|
+
sql = "SELECT 'a;b' AS col; SELECT 2"
|
|
73
|
+
stmts = split_sql_statements(sql)
|
|
74
|
+
assert len(stmts) == 2
|
|
75
|
+
assert stmts[0] == "SELECT 'a;b' AS col"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_empty_input():
|
|
79
|
+
assert split_sql_statements("") == []
|
|
80
|
+
assert split_sql_statements(" ") == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_only_comments():
|
|
84
|
+
assert split_sql_statements("-- just a comment") == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── extract_table_refs ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_extract_table_refs_simple():
|
|
91
|
+
assert extract_table_refs("SELECT * FROM DB.SCH.COMPANIES") == ["DB.SCH.COMPANIES"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_extract_table_refs_joins():
|
|
95
|
+
sql = """
|
|
96
|
+
SELECT c.siren, e.name
|
|
97
|
+
FROM DB.SCH.COMPANIES c
|
|
98
|
+
JOIN DB.SCH.ESTABLISHMENTS e ON e.siren = c.siren
|
|
99
|
+
LEFT JOIN OTHER_SCH.CONTACTS ct ON ct.siren = c.siren
|
|
100
|
+
"""
|
|
101
|
+
assert extract_table_refs(sql) == [
|
|
102
|
+
"DB.SCH.COMPANIES",
|
|
103
|
+
"DB.SCH.ESTABLISHMENTS",
|
|
104
|
+
"OTHER_SCH.CONTACTS",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_extract_table_refs_excludes_ctes():
|
|
109
|
+
sql = """
|
|
110
|
+
WITH recent AS (SELECT * FROM DB.SCH.COMPANIES WHERE created_at > 1)
|
|
111
|
+
SELECT * FROM recent JOIN DB.SCH.ESTABLISHMENTS e ON 1 = 1
|
|
112
|
+
"""
|
|
113
|
+
assert extract_table_refs(sql) == ["DB.SCH.COMPANIES", "DB.SCH.ESTABLISHMENTS"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_extract_table_refs_excludes_bare_identifiers():
|
|
117
|
+
# unqualified names are never a real pinky FQN (SCHEMA.TABLE minimum) — e.g. the
|
|
118
|
+
# dynamic-table DML self-reference placeholder `INSERT INTO SELF ...`
|
|
119
|
+
sql = "INSERT INTO SELF SELECT * FROM DB.SCH.SRC"
|
|
120
|
+
assert extract_table_refs(sql) == ["DB.SCH.SRC"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_extract_table_refs_dedupes_and_sorts():
|
|
124
|
+
sql = "SELECT * FROM DB.SCH.B b JOIN DB.SCH.A a1 ON 1 = 1 JOIN DB.SCH.A a2 ON 1 = 1"
|
|
125
|
+
assert extract_table_refs(sql) == ["DB.SCH.A", "DB.SCH.B"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_extract_table_refs_no_tables():
|
|
129
|
+
assert extract_table_refs("SELECT 1") == []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_extract_table_refs_unparseable():
|
|
133
|
+
assert extract_table_refs("not valid sql (((") == []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_extract_table_refs_empty():
|
|
137
|
+
assert extract_table_refs("") == []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── resolve_query ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_resolve_query_simple_substitution():
|
|
144
|
+
tpl = "SELECT * FROM {companies} WHERE 1 = 1"
|
|
145
|
+
result = resolve_query(tpl, {"companies": "DB.SCH.COMPANIES"})
|
|
146
|
+
assert result == "SELECT * FROM DB.SCH.COMPANIES WHERE 1 = 1"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_resolve_query_multiple_refs():
|
|
150
|
+
tpl = "SELECT * FROM {a} JOIN {b} ON 1 = 1"
|
|
151
|
+
result = resolve_query(tpl, {"a": "DB.S.A", "b": "DB.S.B"})
|
|
152
|
+
assert result == "SELECT * FROM DB.S.A JOIN DB.S.B ON 1 = 1"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_resolve_query_iceberg_scan():
|
|
156
|
+
tpl = "SELECT * FROM {src}"
|
|
157
|
+
result = resolve_query(tpl, {"src": "iceberg_scan('s3://bucket/path/')"})
|
|
158
|
+
assert "iceberg_scan" in result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_resolve_query_rejects_unsafe_ref():
|
|
162
|
+
tpl = "SELECT * FROM {t}"
|
|
163
|
+
with pytest.raises(ValueError, match="Unsafe table reference"):
|
|
164
|
+
resolve_query(tpl, {"t": "DB.T; DROP TABLE X--"})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_resolve_query_missing_key():
|
|
168
|
+
tpl = "SELECT * FROM {companies} JOIN {establishments} ON 1 = 1"
|
|
169
|
+
with pytest.raises(ValueError, match="Template placeholder"):
|
|
170
|
+
resolve_query(tpl, {"companies": "DB.SCH.C"})
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Features
|
|
3
|
-
---
|
|
4
|
-
# Features — pinky-core
|
|
5
|
-
*Update date : 2026-07-12 05:44*
|
|
6
|
-
|
|
7
|
-
Feature list ordered by priority. `stable` = in production. `planned` = not yet built, order = priority.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Stable
|
|
12
|
-
|
|
13
|
-
| Module | Description | Status |
|
|
14
|
-
|--------|-------------|--------|
|
|
15
|
-
| `fmt.py` | String/value formatters: snake_case, truncate, normalize whitespace, FR/AFNOR address, ISO3→ISO2 | stable |
|
|
16
|
-
| `validate.py` | Input validation: SIRET/SIREN, IBAN/BIC, TVA EU, NIR, email, phone | stable |
|
|
17
|
-
| `security.py` | Secret masking, PII detection, zero-width space injection/detection | stable |
|
|
18
|
-
| `sql.py` | SQL parsing helpers powered by sqlglot: statement kind, query resolution, statement splitting | stable |
|
|
19
|
-
| `html.py` | HTML templating engine — emails, reports, mini-sites, Outlook-safe | stable |
|
|
20
|
-
| `xml.py` | XPath helpers, namespace-aware parsing, safe attribute extraction | stable |
|
|
21
|
-
| `files.py` | S3/MFT filename pattern normalisation — `detect_file_pattern`, `FileDetection` | stable |
|
|
22
|
-
| `schedule.py` | French public holidays, business-day helpers, cron human-readable formatting, Airflow-timetable predicates (month/week/fiscal-quarter, eve-of-holiday) | stable |
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## Planned
|
|
27
|
-
|
|
28
|
-
Ordered by priority — closest to implementation first.
|
|
29
|
-
|
|
30
|
-
### finance/fec.py — FEC parser
|
|
31
|
-
|
|
32
|
-
New submodule `pinky_core/finance/fec.py`. Pure Python, no Snowflake connection.
|
|
33
|
-
Parses FEC files (tab-separated, ISO-8859-1) into typed `FecRow` dataclass rows.
|
|
34
|
-
Validates DEBIT == CREDIT and mandatory column presence.
|
|
35
|
-
|
|
36
|
-
### schedule.py — additional calendar support
|
|
37
|
-
|
|
38
|
-
- `public_holidays_be(year)` / `public_holidays_uk(year)` — expand beyond FR
|
|
39
|
-
- `business_days_until_end_of_month(d)` — look-ahead for SLA calculations
|
|
40
|
-
- `nth_last_business_day_of_month(d, n)` — countdown from month end (e.g. J-2)
|
|
41
|
-
- Holiday calendar as injectable parameter — custom frozenset for non-standard calendars
|
|
42
|
-
|
|
43
|
-
### finance/ — extend beyond FEC
|
|
44
|
-
|
|
45
|
-
- `finance/dsn.py` — Déclaration Sociale Nominative parser (INSEE/Net-Entreprises)
|
|
46
|
-
- `finance/sepa.py` — SEPA pain.001 / pain.008 XML builders (credit transfers, direct debits)
|
|
47
|
-
- `finance/ofx.py` — bank statement parsers (OFX/QFX for reconciliation)
|
|
48
|
-
|
|
49
|
-
### sql_custom.py — query builder extensions
|
|
50
|
-
|
|
51
|
-
- `build_merge()` — MERGE INTO stubs from a key/value column dict
|
|
52
|
-
- `paginate_query(sql, page_size, offset)` — LIMIT/OFFSET wrapper for cursor-based pagination
|
|
53
|
-
|
|
54
|
-
### validate.py — additional validators
|
|
55
|
-
|
|
56
|
-
- `is_luhn(s)` — Luhn algorithm for generic checksums not already covered
|
|
57
|
-
|
|
58
|
-
### schedule.py — cron expression builder
|
|
59
|
-
|
|
60
|
-
Inverse of `cron_to_human`: build a 5-field cron string from structured parameters.
|
|
61
|
-
Council-validate before implementing — may already be covered by existing tools.
|
|
62
|
-
|
|
63
|
-
### fmt.py — slugify
|
|
64
|
-
|
|
65
|
-
`slugify(text) -> str` — converts any string to a URL-safe slug (lowercase, ASCII, hyphens).
|
|
66
|
-
Use cases: Snowflake object keys, URL segments, file/schema name generation.
|
|
67
|
-
|
|
68
|
-
### fmt.py — locale-aware formatters
|
|
69
|
-
|
|
70
|
-
French locale (espace insécable, virgule décimale). Low priority: rendering mostly happens at Streamlit level.
|
|
71
|
-
|
|
72
|
-
### finance/ — FEC validator as CLI
|
|
73
|
-
|
|
74
|
-
`python -m pinky_core.finance.fec validate file.txt` — depends on `snowflake-tools` CLI integration.
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
"""Tests for pinky_core.sql — pure Python, no external deps."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from pinky_core.sql import (
|
|
6
|
-
split_sql_statements,
|
|
7
|
-
statement_kind,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
# ── statement_kind ────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.mark.parametrize(
|
|
14
|
-
("stmt", "kind"),
|
|
15
|
-
[
|
|
16
|
-
("SELECT 1", "select"),
|
|
17
|
-
(
|
|
18
|
-
"WITH c AS (SELECT 1) SELECT * FROM c",
|
|
19
|
-
"select",
|
|
20
|
-
), # CTE → top node is the SELECT
|
|
21
|
-
("SELECT 1 UNION SELECT 2", "union"),
|
|
22
|
-
("INSERT INTO SELF SELECT 1", "insert"),
|
|
23
|
-
(
|
|
24
|
-
"MERGE INTO SELF USING s ON a = b WHEN MATCHED THEN UPDATE SET x = 1",
|
|
25
|
-
"merge",
|
|
26
|
-
),
|
|
27
|
-
("UPDATE t SET x = 1", "update"),
|
|
28
|
-
("DELETE FROM t", "delete"),
|
|
29
|
-
("CREATE TABLE t (id INT)", "create"),
|
|
30
|
-
("DROP TABLE t", "drop"),
|
|
31
|
-
("CALL my_proc()", "command"),
|
|
32
|
-
],
|
|
33
|
-
)
|
|
34
|
-
def test_statement_kind(stmt, kind):
|
|
35
|
-
assert statement_kind(stmt) == kind
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_statement_kind_unknown_on_empty():
|
|
39
|
-
assert statement_kind("") == "unknown"
|
|
40
|
-
assert statement_kind(" ") == "unknown"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_single_statement():
|
|
44
|
-
assert split_sql_statements("SELECT 1") == ["SELECT 1"]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_trailing_semicolon():
|
|
48
|
-
assert split_sql_statements("SELECT 1;") == ["SELECT 1"]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_multiple_statements():
|
|
52
|
-
sql = "SELECT 1; SELECT 2; SELECT 3"
|
|
53
|
-
assert split_sql_statements(sql) == ["SELECT 1", "SELECT 2", "SELECT 3"]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_strips_inline_comments():
|
|
57
|
-
sql = "SELECT 1 -- this is a comment\n; SELECT 2"
|
|
58
|
-
stmts = split_sql_statements(sql)
|
|
59
|
-
assert len(stmts) == 2
|
|
60
|
-
assert "comment" not in stmts[0]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_strips_block_comments():
|
|
64
|
-
sql = "/* header */ SELECT 1; /* footer */ SELECT 2"
|
|
65
|
-
stmts = split_sql_statements(sql)
|
|
66
|
-
assert stmts == ["SELECT 1", "SELECT 2"]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_semicolon_inside_string_ignored():
|
|
70
|
-
sql = "SELECT 'a;b' AS col; SELECT 2"
|
|
71
|
-
stmts = split_sql_statements(sql)
|
|
72
|
-
assert len(stmts) == 2
|
|
73
|
-
assert stmts[0] == "SELECT 'a;b' AS col"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_empty_input():
|
|
77
|
-
assert split_sql_statements("") == []
|
|
78
|
-
assert split_sql_statements(" ") == []
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_only_comments():
|
|
82
|
-
assert split_sql_statements("-- just a comment") == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/adr/0001-zero-dependency-static-html.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|