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.
Files changed (81) hide show
  1. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/ci.yml +4 -1
  2. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/PKG-INFO +2 -1
  3. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/README.md +1 -0
  4. pinky_core-0.1.0.dev4/docs/explanation/2-features.md +44 -0
  5. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/coverage_badge.svg +1 -1
  6. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/interrogate_badge.svg +4 -4
  7. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/pyproject.toml +16 -8
  8. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/generate-docs +21 -4
  9. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/__init__.py +24 -0
  10. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/finance/fec.py +9 -0
  11. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/fmt.py +28 -1
  12. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/security.py +5 -4
  13. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/sql.py +48 -2
  14. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/xml.py +6 -6
  15. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_fmt.py +18 -0
  16. pinky_core-0.1.0.dev4/tests/test_public_api.py +69 -0
  17. pinky_core-0.1.0.dev4/tests/test_sql.py +170 -0
  18. pinky_core-0.1.0.dev3/docs/explanation/2-features.md +0 -74
  19. pinky_core-0.1.0.dev3/tests/test_sql.py +0 -82
  20. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/publish.yml +0 -0
  21. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.github/workflows/release.yml +0 -0
  22. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.gitignore +0 -0
  23. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.gitleaks.toml +0 -0
  24. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.pre-commit-config.yaml +0 -0
  25. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/.secrets.baseline +0 -0
  26. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/LICENSE +0 -0
  27. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/.pages +0 -0
  28. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/adr/.pages +0 -0
  29. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/adr/0001-zero-dependency-static-html.md +0 -0
  30. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/explanation/.pages +0 -0
  31. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/explanation/1-design.md +0 -0
  32. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/how-to/.pages +0 -0
  33. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/how-to/index.md +0 -0
  34. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/index.md +0 -0
  35. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/mkdocs.yml +0 -0
  36. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/.pages +0 -0
  37. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/.pages +0 -0
  38. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/fec.md +0 -0
  39. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/files.md +0 -0
  40. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/fmt.md +0 -0
  41. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/html.md +0 -0
  42. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/lint.md +0 -0
  43. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/schedule.md +0 -0
  44. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/security.md +0 -0
  45. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/sql.md +0 -0
  46. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/validate.md +0 -0
  47. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/warnings.md +0 -0
  48. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/api/xml.md +0 -0
  49. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/hooks_badge.svg +0 -0
  50. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/reference/generated/noqa_badge.svg +0 -0
  51. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/tutorials/.pages +0 -0
  52. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/docs/tutorials/index.md +0 -0
  53. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/deploy_validate_udfs.py +0 -0
  54. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/badges.toml +0 -0
  55. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_forbidden_keywords.py +0 -0
  56. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_snowpark_api.py +0 -0
  57. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_typed_api.py +0 -0
  58. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/check_zws.py +0 -0
  59. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/commit-msg +0 -0
  60. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/docs_identity.json +0 -0
  61. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/generate_ci_badges.py +0 -0
  62. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/scripts/hooks/render_docs.py +0 -0
  63. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/sql/deploy_udf_example.sql +0 -0
  64. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/files.py +0 -0
  65. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/finance/__init__.py +0 -0
  66. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/html.py +0 -0
  67. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/lint.py +0 -0
  68. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/schedule.py +0 -0
  69. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/validate.py +0 -0
  70. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/src/pinky_core/warnings.py +0 -0
  71. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/__init__.py +0 -0
  72. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/fixtures/cats_domination.xml +0 -0
  73. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_fec.py +0 -0
  74. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_files.py +0 -0
  75. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_html.py +0 -0
  76. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_lint.py +0 -0
  77. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_schedule.py +0 -0
  78. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_security.py +0 -0
  79. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_validate.py +0 -0
  80. {pinky_core-0.1.0.dev3 → pinky_core-0.1.0.dev4}/tests/test_warnings.py +0 -0
  81. {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
- run: pip install -e ".[validate,address,dev]"
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.dev3
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
@@ -125,6 +125,7 @@ pinky-core/
125
125
  │ ├── test_fmt.py
126
126
  │ ├── test_html.py
127
127
  │ ├── test_lint.py
128
+ │ ├── test_public_api.py
128
129
  │ ├── test_schedule.py
129
130
  │ ├── test_security.py
130
131
  │ ├── 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: 50.56%"><title>coverage: 50.56%</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="#ea7233"/><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">50.56%</text><text x="865" y="150" fill-opacity=".3" textLength="430">50.56%</text></g><text x="865" y="140" textLength="430">50.56%</text></g></g></svg>
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>
@@ -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: 65.9%</title>
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:#dfb317"/>
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="370" data-interrogate="result">65.9%</text>
16
- <text x="1160" y="140" transform="scale(.1)" textLength="370" data-interrogate="result">65.9%</text>
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.dev3"
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 = true
109
- ignore-magic = true
110
- ignore-module = true
111
- ignore-private = true
112
- fail-under = 60
113
- paths = ["src/"]
114
- generate-badge = "docs/reference/generated/"
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
- if [ -f ".cache/coverage/.coverage" ] && command -v genbadge > /dev/null 2>&1; then
39
- coverage xml --data-file=.cache/coverage/.coverage -o .cache/coverage/coverage.xml > /dev/null 2>&1
40
- genbadge coverage -i .cache/coverage/coverage.xml -o docs/reference/generated/coverage_badge.svg > /dev/null 2>&1
41
- git add docs/reference/generated/coverage_badge.svg 2>/dev/null
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
- def format_number(num: float | int | None, num_type: str = "count") -> str:
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 and sanitize a file path for use in a Snowflake REMOVE statement.
16
+ """Validate a file path for use in a Snowflake REMOVE statement.
17
17
 
18
- Rejects paths containing special characters or ``..`` segments, and escapes
19
- single quotes to prevent SQL injection in dynamically built DDL.
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
- Sanitized path with single quotes doubled.
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
- return template.format(**refs)
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
- Step = tuple[str, str | None, list[str]]
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[Step]:
138
- result: list[Step] = []
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[Step | None, int]:
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: Step) -> Variant:
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