pinky-core 0.1.0.dev4__tar.gz → 0.1.0.dev5__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.dev4 → pinky_core-0.1.0.dev5}/.github/workflows/ci.yml +4 -6
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.gitignore +4 -1
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.pre-commit-config.yaml +25 -4
- pinky_core-0.1.0.dev5/.python-version +1 -0
- pinky_core-0.1.0.dev5/.vscode/settings.json +8 -0
- pinky_core-0.1.0.dev5/CONTEXT.md +24 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/PKG-INFO +15 -15
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/README.md +14 -1
- pinky_core-0.1.0.dev5/docs/adr/0001-zero-dependency-static-html.md +26 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/explanation/2-features.md +2 -2
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/generated/hooks_badge.svg +3 -3
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/pyproject.toml +21 -2
- pinky_core-0.1.0.dev5/scripts/hooks/check_weak_assertions.py +250 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/commit-msg +16 -17
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/generate-docs +31 -6
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/__init__.py +4 -4
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/html.py +9 -9
- pinky_core-0.1.0.dev5/tests/fixtures/golden/dl_card_basic.html +29 -0
- pinky_core-0.1.0.dev5/tests/fixtures/golden/dl_card_expiry.html +29 -0
- pinky_core-0.1.0.dev5/tests/fixtures/golden/generate_html_basic.html +67 -0
- pinky_core-0.1.0.dev5/tests/fixtures/golden/task_table_error.html +20 -0
- pinky_core-0.1.0.dev5/tests/fixtures/golden/task_table_no_snowsight.html +20 -0
- pinky_core-0.1.0.dev5/tests/fixtures/golden/task_table_snowsight.html +20 -0
- pinky_core-0.1.0.dev5/tests/test_html.py +102 -0
- pinky_core-0.1.0.dev5/uv.lock +1826 -0
- pinky_core-0.1.0.dev4/docs/adr/0001-zero-dependency-static-html.md +0 -26
- pinky_core-0.1.0.dev4/tests/test_html.py +0 -63
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.github/workflows/publish.yml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.github/workflows/release.yml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.gitleaks.toml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/.secrets.baseline +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/LICENSE +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/adr/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/explanation/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/explanation/1-design.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/how-to/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/how-to/index.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/index.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/mkdocs.yml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/fec.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/files.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/fmt.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/html.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/lint.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/schedule.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/security.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/sql.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/validate.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/warnings.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/api/xml.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/generated/coverage_badge.svg +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/generated/interrogate_badge.svg +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/reference/generated/noqa_badge.svg +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/tutorials/.pages +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/docs/tutorials/index.md +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/deploy_validate_udfs.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/badges.toml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/check_forbidden_keywords.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/check_snowpark_api.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/check_typed_api.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/check_zws.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/docs_identity.json +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/generate_ci_badges.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/scripts/hooks/render_docs.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/sql/deploy_udf_example.sql +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/files.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/finance/__init__.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/finance/fec.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/fmt.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/lint.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/schedule.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/security.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/sql.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/validate.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/warnings.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/src/pinky_core/xml.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/__init__.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/fixtures/cats_domination.xml +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_fec.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_files.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_fmt.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_lint.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_public_api.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_schedule.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_security.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_sql.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_validate.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_warnings.py +0 -0
- {pinky_core-0.1.0.dev4 → pinky_core-0.1.0.dev5}/tests/test_xml_xpath.py +0 -0
|
@@ -3,10 +3,8 @@ name: CI
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches: [main, develop]
|
|
6
|
-
paths: ['src/**', 'tests/**', 'pyproject.toml', 'scripts/**', '.pre-commit-config.yaml', '.github/workflows/ci.yml']
|
|
7
6
|
pull_request:
|
|
8
7
|
branches: [main, develop]
|
|
9
|
-
paths: ['src/**', 'tests/**', 'pyproject.toml', 'scripts/**', '.pre-commit-config.yaml', '.github/workflows/ci.yml']
|
|
10
8
|
|
|
11
9
|
jobs:
|
|
12
10
|
ci:
|
|
@@ -33,10 +31,10 @@ jobs:
|
|
|
33
31
|
uses: j178/prek-action@v2
|
|
34
32
|
|
|
35
33
|
- name: Install dependencies
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
run: pip install -e ".[validate,address,schedule,xml,lint
|
|
34
|
+
# --group dev, pas ".[dev]" : les deps de dev sont un dependency-group PEP 735 depuis
|
|
35
|
+
# 2026-07-22 (voir le commentaire dans pyproject.toml — `uv run` désinstallait les extras).
|
|
36
|
+
# Requiert pip >= 25.1 ; ensurepip en livre 26.1.2 avec Python 3.12, donc rien à épingler.
|
|
37
|
+
run: pip install -e ".[validate,address,schedule,xml,lint]" --group dev
|
|
40
38
|
|
|
41
39
|
- name: Tests
|
|
42
40
|
run: pytest
|
|
@@ -12,7 +12,10 @@ build/
|
|
|
12
12
|
|
|
13
13
|
# ── Environments ─────────────────────────────────────────────────────────────
|
|
14
14
|
.env
|
|
15
|
-
|
|
15
|
+
# No trailing slash: with `centralized-project-envs`, uv puts the env in ~/.cache/uv and drops a
|
|
16
|
+
# SYMLINK named .venv here. `.venv/` matches directories only, so the link stayed untracked-visible
|
|
17
|
+
# and committable — a machine-absolute path, useless on any other machine.
|
|
18
|
+
.venv
|
|
16
19
|
venv/
|
|
17
20
|
.conda/
|
|
18
21
|
|
|
@@ -15,6 +15,15 @@ default_install_hook_types: [pre-commit, commit-msg]
|
|
|
15
15
|
# Pin explicite : sans ça, les hooks `language: python` (mdformat, mypy, interrogate, darglint…)
|
|
16
16
|
# créent leur venv avec le python3 ambiant du PATH au moment de l'install, pas une version garantie
|
|
17
17
|
# par le repo — non-déterministe en local (CI est protégée par actions/setup-python en amont).
|
|
18
|
+
#
|
|
19
|
+
# Règle (2026-07-22) : tout hook `repo: local` dont l'`entry` commence par `python` DOIT être
|
|
20
|
+
# `language: python`, jamais `language: system`. `system` exécute la commande telle quelle dans le
|
|
21
|
+
# shell, et `python` nu n'existe ni sur macOS hors conda (homebrew ne pose que `python3`) ni sur une
|
|
22
|
+
# machine Windows vanilla — ça ne marchait que par l'auto-activation de conda base. Avec
|
|
23
|
+
# `language: python`, prek provisionne l'interpréteur (et le télécharge s'il manque) et met le bin/
|
|
24
|
+
# de son venv en tête du PATH : `python` y est garanti sur les trois OS. Les scripts de
|
|
25
|
+
# scripts/hooks/ étant 100 % stdlib, ils partagent tous un seul venv (dédupliqué par langage +
|
|
26
|
+
# version + additional_dependencies) — coût quasi nul.
|
|
18
27
|
default_language_version:
|
|
19
28
|
python: python3.12
|
|
20
29
|
|
|
@@ -80,7 +89,7 @@ repos:
|
|
|
80
89
|
- id: check-zws
|
|
81
90
|
name: "[ZWS] Invisible characters (zero-width)"
|
|
82
91
|
entry: python scripts/hooks/check_zws.py
|
|
83
|
-
language:
|
|
92
|
+
language: python
|
|
84
93
|
pass_filenames: true
|
|
85
94
|
|
|
86
95
|
# ── Employer reference guard ──────────────────────────────────────────────
|
|
@@ -89,7 +98,7 @@ repos:
|
|
|
89
98
|
- id: check-forbidden-keywords
|
|
90
99
|
name: "[FORBIDDEN] Employer references"
|
|
91
100
|
entry: python scripts/hooks/check_forbidden_keywords.py
|
|
92
|
-
language:
|
|
101
|
+
language: python
|
|
93
102
|
pass_filenames: true
|
|
94
103
|
|
|
95
104
|
# ── Commit message format ─────────────────────────────────────────────────
|
|
@@ -171,10 +180,22 @@ repos:
|
|
|
171
180
|
- id: check-typed-api
|
|
172
181
|
name: "[TYPED-API] No raw dict/tuple/set in public annotations"
|
|
173
182
|
entry: python scripts/hooks/check_typed_api.py
|
|
174
|
-
language:
|
|
183
|
+
language: python
|
|
175
184
|
types: [python]
|
|
176
185
|
files: ^src/
|
|
177
186
|
|
|
187
|
+
# Weak-assertion ratchet — first hook to target tests/, not src/ (tests.md § generator oracle).
|
|
188
|
+
# Blocks a fragment `assert "x" in <generator(...)>` that has no whole-output equality oracle in
|
|
189
|
+
# the same file. Narrow by design (name-matched generators only) so it stays at ~0 flags.
|
|
190
|
+
- repo: local
|
|
191
|
+
hooks:
|
|
192
|
+
- id: check-weak-assertions
|
|
193
|
+
name: "[WEAK-ASSERT] Generated-output assertion needs an equality oracle"
|
|
194
|
+
entry: python scripts/hooks/check_weak_assertions.py
|
|
195
|
+
language: python
|
|
196
|
+
types: [python]
|
|
197
|
+
files: ^tests/
|
|
198
|
+
|
|
178
199
|
# ── EXTRA snowflake — Snowpark / Core API repos ───────────────────────────
|
|
179
200
|
# Snowpark / Core API conventions (no session.sql(), canonical method names)
|
|
180
201
|
- repo: local
|
|
@@ -182,6 +203,6 @@ repos:
|
|
|
182
203
|
- id: check-snowpark-api
|
|
183
204
|
name: "[SNOWPARK-API] No session.sql(), canonical method names"
|
|
184
205
|
entry: python scripts/hooks/check_snowpark_api.py
|
|
185
|
-
language:
|
|
206
|
+
language: python
|
|
186
207
|
types: [python]
|
|
187
208
|
files: ^src/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
// uv keeps this repo's environment in its own cache, OUT of the synced iCloud drive (ADR-0023),
|
|
3
|
+
// and the `.venv` here is a SYMLINK to it. VSCode's environment auto-discovery does NOT follow that
|
|
4
|
+
// symlink, so the interpreter is declared explicitly rather than picked by hand each time.
|
|
5
|
+
// ${workspaceFolder} is THIS repo when the multi-root `pinky.code-workspace` is open.
|
|
6
|
+
// Env missing? `uv sync --all-extras` recreates it.
|
|
7
|
+
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
|
|
8
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# CONTEXT.md — pinky-core
|
|
2
|
+
*Update date : 2026-07-12 13:12*
|
|
3
|
+
|
|
4
|
+
Single source of truth for naming in this repo. Variables, functions, modules, and prose follow it.
|
|
5
|
+
**Agent rule: read this before naming anything.**
|
|
6
|
+
|
|
7
|
+
| Term (preferred) | Avoid | Definition |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `validate_*` (`validate_siret`, `validate_identifier`, …) | check, verify, sanitize | Reject-only — returns the input unchanged if valid, raises/returns invalid otherwise. Never transforms. |
|
|
10
|
+
| `standardize_*` (`standardize_email`, `standardize_phone`) | normalize, format | Validates **and** normalizes to a canonical form (e.g. phone → E.164). |
|
|
11
|
+
| `safe_*` (`safe_filepath`) | sanitize, escape | Same contract as `validate_*` — reject-only. Named `safe_` (not `sanitize_`) because the caller's safety comes from the *rejection*, not from any escaping/transform; a docstring once claimed it escaped quotes, it never did (coco review, 2026-07-12) — don't reintroduce that confusion. |
|
|
12
|
+
| `ref` / `refs` (`extract_table_refs`, `resolve_query`'s `refs` param) | reference(s) | A table reference string — see `FQN`. |
|
|
13
|
+
| `FQN` | fully-qualified name, qualified identifier | `DATABASE.SCHEMA.TABLE` (or `SCHEMA.TABLE`) — pinky-provider's identifier convention. A bare, unqualified name is never a legitimate FQN in this codebase (see `extract_table_refs`). |
|
|
14
|
+
| `predicate` (schedule.py's `is_business_day`, `is_eve_of_holiday`, …) | check, rule, condition | A pure `bool`-returning function taking a `date`, callable as a DAG root-task SKIP gate (ADR-0013 in pinky-provider). |
|
|
15
|
+
| `*Result` (`EmailResult`, `FecParseResult`, …) | `*Response`, `*Output` | A model wrapping the outcome of one validate/parse call — success or failure both representable, never raised. |
|
|
16
|
+
| `*Error` (`FecRowError`, `FecBalanceError`) | `*Failure`, `*Issue` | A record of one specific failure collected *during* processing (e.g. one bad row) — distinct from `*Result`, which wraps the whole call's outcome. |
|
|
17
|
+
| `lazy import` (`from unidecode import unidecode` inside a function body) | optional import, deferred import | The pattern every optional-dependency function uses so importing the *module* never requires the extra — only calling that specific function does. |
|
|
18
|
+
|
|
19
|
+
## Flagged ambiguities
|
|
20
|
+
|
|
21
|
+
- **`slugify` breaks the `to_*` prefix convention** (`to_upper_snake_case`, `to_camel_case`) — kept
|
|
22
|
+
bare because "slugify" is an established cross-language term (same name in Django, JS `slugify`
|
|
23
|
+
packages, etc.); renaming it `to_slug` would be more consistent internally but less recognizable.
|
|
24
|
+
Not revisited unless a second bare-verb name is added and the inconsistency compounds.
|
|
@@ -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.dev5
|
|
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
|
|
@@ -40,19 +40,6 @@ Requires-Dist: sqlglot
|
|
|
40
40
|
Requires-Dist: unidecode
|
|
41
41
|
Provides-Extra: address
|
|
42
42
|
Requires-Dist: usaddress; extra == 'address'
|
|
43
|
-
Provides-Extra: dev
|
|
44
|
-
Requires-Dist: bandit; extra == 'dev'
|
|
45
|
-
Requires-Dist: darglint; extra == 'dev'
|
|
46
|
-
Requires-Dist: genbadge[coverage]; extra == 'dev'
|
|
47
|
-
Requires-Dist: interrogate; extra == 'dev'
|
|
48
|
-
Requires-Dist: mkdocs-awesome-pages-plugin>=2.0; extra == 'dev'
|
|
49
|
-
Requires-Dist: mkdocs-material>=9.0; extra == 'dev'
|
|
50
|
-
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'dev'
|
|
51
|
-
Requires-Dist: mypy; extra == 'dev'
|
|
52
|
-
Requires-Dist: pre-commit; extra == 'dev'
|
|
53
|
-
Requires-Dist: pytest; extra == 'dev'
|
|
54
|
-
Requires-Dist: pytest-cov; extra == 'dev'
|
|
55
|
-
Requires-Dist: ruff; extra == 'dev'
|
|
56
43
|
Provides-Extra: fec
|
|
57
44
|
Requires-Dist: unidecode; extra == 'fec'
|
|
58
45
|
Provides-Extra: lint
|
|
@@ -158,12 +145,15 @@ pinky-core/
|
|
|
158
145
|
│ ├── ci.yml
|
|
159
146
|
│ ├── publish.yml
|
|
160
147
|
│ └── release.yml
|
|
148
|
+
├── .vscode/
|
|
149
|
+
│ └── settings.json
|
|
161
150
|
├── scripts/
|
|
162
151
|
│ ├── hooks/
|
|
163
152
|
│ │ ├── badges.toml
|
|
164
153
|
│ │ ├── check_forbidden_keywords.py
|
|
165
154
|
│ │ ├── check_snowpark_api.py
|
|
166
155
|
│ │ ├── check_typed_api.py
|
|
156
|
+
│ │ ├── check_weak_assertions.py
|
|
167
157
|
│ │ ├── check_zws.py
|
|
168
158
|
│ │ ├── commit-msg
|
|
169
159
|
│ │ ├── docs_identity.json
|
|
@@ -191,6 +181,13 @@ pinky-core/
|
|
|
191
181
|
│ └── xml.py
|
|
192
182
|
├── tests/
|
|
193
183
|
│ ├── fixtures/
|
|
184
|
+
│ │ ├── golden/
|
|
185
|
+
│ │ │ ├── dl_card_basic.html
|
|
186
|
+
│ │ │ ├── dl_card_expiry.html
|
|
187
|
+
│ │ │ ├── generate_html_basic.html
|
|
188
|
+
│ │ │ ├── task_table_error.html
|
|
189
|
+
│ │ │ ├── task_table_no_snowsight.html
|
|
190
|
+
│ │ │ └── task_table_snowsight.html
|
|
194
191
|
│ │ └── cats_domination.xml
|
|
195
192
|
│ ├── __init__.py
|
|
196
193
|
│ ├── test_fec.py
|
|
@@ -208,10 +205,13 @@ pinky-core/
|
|
|
208
205
|
├── .gitignore
|
|
209
206
|
├── .gitleaks.toml
|
|
210
207
|
├── .pre-commit-config.yaml
|
|
208
|
+
├── .python-version
|
|
211
209
|
├── .secrets.baseline
|
|
210
|
+
├── CONTEXT.md
|
|
212
211
|
├── LICENSE
|
|
213
212
|
├── pyproject.toml
|
|
214
|
-
|
|
213
|
+
├── README.md
|
|
214
|
+
└── uv.lock
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
</details>
|
|
@@ -85,12 +85,15 @@ pinky-core/
|
|
|
85
85
|
│ ├── ci.yml
|
|
86
86
|
│ ├── publish.yml
|
|
87
87
|
│ └── release.yml
|
|
88
|
+
├── .vscode/
|
|
89
|
+
│ └── settings.json
|
|
88
90
|
├── scripts/
|
|
89
91
|
│ ├── hooks/
|
|
90
92
|
│ │ ├── badges.toml
|
|
91
93
|
│ │ ├── check_forbidden_keywords.py
|
|
92
94
|
│ │ ├── check_snowpark_api.py
|
|
93
95
|
│ │ ├── check_typed_api.py
|
|
96
|
+
│ │ ├── check_weak_assertions.py
|
|
94
97
|
│ │ ├── check_zws.py
|
|
95
98
|
│ │ ├── commit-msg
|
|
96
99
|
│ │ ├── docs_identity.json
|
|
@@ -118,6 +121,13 @@ pinky-core/
|
|
|
118
121
|
│ └── xml.py
|
|
119
122
|
├── tests/
|
|
120
123
|
│ ├── fixtures/
|
|
124
|
+
│ │ ├── golden/
|
|
125
|
+
│ │ │ ├── dl_card_basic.html
|
|
126
|
+
│ │ │ ├── dl_card_expiry.html
|
|
127
|
+
│ │ │ ├── generate_html_basic.html
|
|
128
|
+
│ │ │ ├── task_table_error.html
|
|
129
|
+
│ │ │ ├── task_table_no_snowsight.html
|
|
130
|
+
│ │ │ └── task_table_snowsight.html
|
|
121
131
|
│ │ └── cats_domination.xml
|
|
122
132
|
│ ├── __init__.py
|
|
123
133
|
│ ├── test_fec.py
|
|
@@ -135,10 +145,13 @@ pinky-core/
|
|
|
135
145
|
├── .gitignore
|
|
136
146
|
├── .gitleaks.toml
|
|
137
147
|
├── .pre-commit-config.yaml
|
|
148
|
+
├── .python-version
|
|
138
149
|
├── .secrets.baseline
|
|
150
|
+
├── CONTEXT.md
|
|
139
151
|
├── LICENSE
|
|
140
152
|
├── pyproject.toml
|
|
141
|
-
|
|
153
|
+
├── README.md
|
|
154
|
+
└── uv.lock
|
|
142
155
|
```
|
|
143
156
|
|
|
144
157
|
</details>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "0001"
|
|
3
|
+
---
|
|
4
|
+
# ADR-0001 — Zero Snowflake dependency at import time
|
|
5
|
+
Status: accepted
|
|
6
|
+
Date: 2026-05-31
|
|
7
|
+
|
|
8
|
+
## Contexte
|
|
9
|
+
|
|
10
|
+
`pinky-core` doit être importable dans tous les contextes où Python s'exécute : pipelines CI,
|
|
11
|
+
scripts de développement local, gestionnaires UDF à l'intérieur de Snowflake, et Streamlit dans les applications Snowflake.
|
|
12
|
+
L'importation inconditionnelle de Snowpark lève une erreur ou ajoute un coût de démarrage dans les contextes
|
|
13
|
+
qui n'ont pas de session active.
|
|
14
|
+
|
|
15
|
+
## Décision
|
|
16
|
+
|
|
17
|
+
Aucun module dans `pinky-core` n'importe Snowflake ou Snowpark au niveau du module.
|
|
18
|
+
Les dépendances optionnelles (`email-validator`, `phonenumbers`, `python-stdnum`, `croniter`) sont
|
|
19
|
+
importées paresseusement dans les fonctions. Les importations réservées au type vérification utilisent la garde `TYPE_CHECKING`.
|
|
20
|
+
|
|
21
|
+
## Conséquences
|
|
22
|
+
|
|
23
|
+
- `pinky-core` est installable dans n'importe quel environnement Python 3.11+ sans client
|
|
24
|
+
Snowflake — `pip install pinky-core` fonctionne en CI pur.
|
|
25
|
+
- Tout module qui a besoin d'une session active appartient à `pinky-snowpark`, pas ici.
|
|
26
|
+
- Les erreurs d'exécution dues aux dépendances optionnelles manquantes apparaissent au moment de l'appel, non à l'importation.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
title: Features
|
|
3
3
|
---
|
|
4
4
|
# Features — pinky-core
|
|
5
|
-
*Update date : 2026-07-
|
|
5
|
+
*Update date : 2026-07-25 09:25*
|
|
6
6
|
|
|
7
7
|
Feature list ordered by priority. `stable` = in production. `planned` = not yet built, order = priority.
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ Feature list ordered by priority. `stable` = in production. `planned` = not yet
|
|
|
18
18
|
| `sql.py` | SQL parsing helpers powered by sqlglot: statement kind, table-ref extraction, query resolution, statement splitting | stable |
|
|
19
19
|
| `html.py` | HTML templating engine — emails, reports, mini-sites, Outlook-safe | stable |
|
|
20
20
|
| `xml.py` | XPath helpers, namespace-aware parsing, safe attribute extraction | stable |
|
|
21
|
-
| `files.py` | S3/MFT filename pattern normalisation — `detect_file_pattern`, `FileDetection
|
|
21
|
+
| `files.py` | S3/MFT filename pattern normalisation — `detect_file_pattern`, `FileDetection`; replaces dates, timestamps, UUIDs, ISO country codes, structured business identifiers (SIRET/SIREN/IBAN/VAT_FR), and numeric IDs with placeholders | stable |
|
|
22
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
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
24
|
| `warnings.py` | `PinkyDeprecationWarning` — deprecated pinky APIs and UDFs. No consumer wired yet (destined for pinky-provider's registry `status: deprecated`) | stable |
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" width="81" height="20">
|
|
2
|
-
<title>generate-docs · mdformat · check-zws · check-forbidden-keywords · commit-msg-format · mypy · interrogate · darglint · check-typed-api · check-snowpark-api</title>
|
|
2
|
+
<title>generate-docs · mdformat · check-zws · check-forbidden-keywords · commit-msg-format · mypy · interrogate · darglint · check-typed-api · check-weak-assertions · check-snowpark-api</title>
|
|
3
3
|
<rect width="81" height="20" rx="3" fill="#555"/>
|
|
4
4
|
<rect x="50" width="31" height="20" rx="3" fill="#007ec6"/>
|
|
5
5
|
<rect x="50" width="4" height="20" fill="#007ec6"/>
|
|
6
6
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
7
7
|
<text x="25" y="15" fill="#010101" fill-opacity=".3">hooks</text>
|
|
8
8
|
<text x="25" y="14">hooks</text>
|
|
9
|
-
<text x="65" y="15" fill="#010101" fill-opacity=".3">
|
|
10
|
-
<text x="65" y="14">
|
|
9
|
+
<text x="65" y="15" fill="#010101" fill-opacity=".3">11</text>
|
|
10
|
+
<text x="65" y="14">11</text>
|
|
11
11
|
</g>
|
|
12
12
|
</svg>
|
|
@@ -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.dev5"
|
|
8
8
|
description = "Shared Python utilities for Snowflake development"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -32,7 +32,18 @@ xml = ["lxml", "lxml-stubs"]
|
|
|
32
32
|
fec = ["unidecode"]
|
|
33
33
|
# Delegated lint/format/scan backends for pinky_core.lint (SQL / YAML / Python / secrets).
|
|
34
34
|
lint = ["sqlfluff>=3.0", "yamllint>=1.35", "ruff", "detect-secrets>=1.5"]
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
# Deps de dev en dependency-group (PEP 735), PAS en extra (2026-07-22, repo pilote).
|
|
37
|
+
# `uv run` resynchronise l'environnement vers l'état PAR DÉFAUT : les extras n'en font pas partie, il
|
|
38
|
+
# les DÉSINSTALLE donc silencieusement, puis retombe sur le premier pytest trouvé sur le PATH — c'est
|
|
39
|
+
# ce qui avait produit un « 1180 tests au vert » qui validait en réalité les envs conda. Le groupe
|
|
40
|
+
# `dev` de PEP 735, lui, est synchronisé par défaut : `uv run` redevient correct sans aucun flag à
|
|
41
|
+
# retenir. (`[tool.uv] default-extras` est accepté sans erreur mais reste sans effet — testé.)
|
|
42
|
+
# Côté CI, l'équivalent de `-e ".[dev]"` est `-e . --group dev` (pip >= 25.1 ; ensurepip livre 26.1.2
|
|
43
|
+
# avec Python 3.12, donc aucune contrainte à gérer). Un extra reste le bon outil pour une option
|
|
44
|
+
# destinée aux CONSOMMATEURS de la lib — validate/address/schedule/xml/fec/lint restent des extras.
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
36
47
|
"mkdocs-material>=9.0",
|
|
37
48
|
"mkdocs-awesome-pages-plugin>=2.0",
|
|
38
49
|
"mkdocstrings[python]>=0.25",
|
|
@@ -52,6 +63,14 @@ Homepage = "https://github.com/pinky-and-co/pinky-core"
|
|
|
52
63
|
Repository = "https://github.com/pinky-and-co/pinky-core"
|
|
53
64
|
Issues = "https://github.com/pinky-and-co/pinky-core/issues"
|
|
54
65
|
|
|
66
|
+
# ── uv ───────────────────────────────────────────────────────────────────────
|
|
67
|
+
# The project environment lives in uv's cache, NOT in a `.venv` at the repo root: this workspace sits
|
|
68
|
+
# on a synced iCloud drive, and ADR-0023 recorded confirmed corruption when a tool environment lives
|
|
69
|
+
# inside one. Preview feature, accepted knowingly — `uv sync` prints the resolved path, so re-check it
|
|
70
|
+
# still lands under ~/.cache/uv after a uv upgrade.
|
|
71
|
+
[tool.uv]
|
|
72
|
+
preview-features = ["centralized-project-envs"]
|
|
73
|
+
|
|
55
74
|
[tool.hatch.build.targets.wheel]
|
|
56
75
|
packages = ["src/pinky_core"]
|
|
57
76
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
check_weak_assertions.py — Ratchet against weak assertions on generated output.
|
|
4
|
+
|
|
5
|
+
Third and final stage of the "assertion faible sur sortie générée" pattern (tests.md §
|
|
6
|
+
generator oracle): a test that only asserts a *fragment* of a generator's output
|
|
7
|
+
(`assert "MY_DAG" in clause`) passes on the correct output AND on a wrong one — it freezes
|
|
8
|
+
the bug in place. The rule (tests.md) bites on new code; the purge cleared the stock; THIS
|
|
9
|
+
hook is the cliquet that keeps the stock at zero.
|
|
10
|
+
|
|
11
|
+
It is a RATCHET, not an exhaustiveness guarantee. It flags only the narrow, audit-validated
|
|
12
|
+
pattern, so it stays at ~0 flags on already-purged repos and never cries wolf (a hook one
|
|
13
|
+
learns to `--no-verify` is worse than no hook):
|
|
14
|
+
|
|
15
|
+
Flag a generator ONLY when ALL hold:
|
|
16
|
+
1. an assert of the form `assert "<str literal>" in <Y>` (operator `in`, NOT `not in` —
|
|
17
|
+
a negative assertion genuinely proves an absence);
|
|
18
|
+
2. `<Y>` traces back to a generator — a call to a function whose name (stripped of leading
|
|
19
|
+
underscores) matches the generator convention, directly or via a variable assigned from
|
|
20
|
+
such a call;
|
|
21
|
+
3. that generator has NO equality oracle anywhere in the repo's tests/ tree — neither an inline
|
|
22
|
+
`assert <gen(...)> == <literal>` nor an oracle routed through a local helper whose body
|
|
23
|
+
does the `==` (e.g. `_assert_golden("...", html)` where `html = generate_html(...)`).
|
|
24
|
+
|
|
25
|
+
The oracle need not sit in the SAME file as the fragment: tests.md asks for "at least one test"
|
|
26
|
+
with a whole-output oracle, **repo-wide** — a fragment in test_web_precommit.py is legitimately
|
|
27
|
+
covered by the oracle in test_precommit_config_oracle.py. pre-commit hands the hook only the
|
|
28
|
+
CHANGED files, so main() builds the covered set by scanning every `.py` under the tests/ root(s)
|
|
29
|
+
of the passed paths, not just argv. check_file() stays per-file (the testable unit of the AST
|
|
30
|
+
scan); main() is the layer that applies the repo-wide covered set.
|
|
31
|
+
|
|
32
|
+
Generators are matched BY NAME only (assumed hole, decided 2026-07-24): a badly-named generator
|
|
33
|
+
escapes the ratchet — it is not blocked, merely unguarded. Widening to "returns markup" was
|
|
34
|
+
rejected: it reopens the false positives the audit eliminated. (`html.py`'s `dl_card`/`task_table`
|
|
35
|
+
were the audit's found instances — renamed `render_dl_card`/`render_task_table` to close the hole.)
|
|
36
|
+
|
|
37
|
+
Usage (pre-commit local hook):
|
|
38
|
+
entry: python scripts/hooks/check_weak_assertions.py
|
|
39
|
+
language: system
|
|
40
|
+
types: [python]
|
|
41
|
+
files: ^tests/
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import ast
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
# Generator naming convention (tests.md + card hook-cliquet). Matched on name.lstrip("_") so
|
|
52
|
+
# private variants (`_build_ddl`, `_render_x`) are covered — bug #2 the purge audit already paid.
|
|
53
|
+
_SUFFIXES: tuple[str, ...] = ("_sql", "_ddl", "_clause", "_statement", "_query")
|
|
54
|
+
_PREFIXES: tuple[str, ...] = ("build_", "render_", "generate_")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_generator_name(name: str) -> bool:
|
|
58
|
+
bare = name.lstrip("_")
|
|
59
|
+
return bare.startswith(_PREFIXES) or bare.endswith(_SUFFIXES)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _call_name(func: ast.expr) -> str | None:
|
|
63
|
+
"""The callee's bare name: `build_x` for both `build_x(...)` and `obj.build_x(...)`."""
|
|
64
|
+
if isinstance(func, ast.Name):
|
|
65
|
+
return func.id
|
|
66
|
+
if isinstance(func, ast.Attribute):
|
|
67
|
+
return func.attr
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _gen_from_expr(expr: ast.expr, var_to_gen: dict[str, str]) -> str | None:
|
|
72
|
+
"""Generator name behind *expr*: a direct generator call, or a variable bound to one."""
|
|
73
|
+
if isinstance(expr, ast.Call):
|
|
74
|
+
name = _call_name(expr.func)
|
|
75
|
+
if name and _is_generator_name(name):
|
|
76
|
+
return name
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(expr, ast.Name):
|
|
79
|
+
return var_to_gen.get(expr.id)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_var_to_gen(scope: ast.AST) -> dict[str, str]:
|
|
84
|
+
"""Map every `var = <generator call>` in *scope* to the generator's name."""
|
|
85
|
+
mapping: dict[str, str] = {}
|
|
86
|
+
for node in ast.walk(scope):
|
|
87
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
88
|
+
name = _call_name(node.value.func)
|
|
89
|
+
if name and _is_generator_name(name):
|
|
90
|
+
for target in node.targets:
|
|
91
|
+
if isinstance(target, ast.Name):
|
|
92
|
+
mapping[target.id] = name
|
|
93
|
+
return mapping
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _has_eq(node: ast.AST) -> bool:
|
|
97
|
+
return any(
|
|
98
|
+
isinstance(n, ast.Compare) and any(isinstance(op, ast.Eq) for op in n.ops)
|
|
99
|
+
for n in ast.walk(node)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _collect_oracle_helpers(tree: ast.AST) -> set[str]:
|
|
104
|
+
"""Module-level functions whose body performs an equality check.
|
|
105
|
+
|
|
106
|
+
A call to one of these, fed a generator's output, stands in for an inline `==` oracle
|
|
107
|
+
(bug #3, the most dangerous: without it the hook cries wolf on correctly-covered code).
|
|
108
|
+
Deliberately permissive — a false *exemption* only misses a flag; a false *positive*
|
|
109
|
+
gets the ratchet disabled, the worse failure for this card.
|
|
110
|
+
"""
|
|
111
|
+
helpers: set[str] = set()
|
|
112
|
+
for node in ast.walk(tree):
|
|
113
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_eq(node):
|
|
114
|
+
helpers.add(node.name)
|
|
115
|
+
return helpers
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _iter_compares(test: ast.expr) -> list[ast.Compare]:
|
|
119
|
+
"""The Compare nodes of an assert test: the bare compare, or the members of a `and`/`or`."""
|
|
120
|
+
if isinstance(test, ast.Compare):
|
|
121
|
+
return [test]
|
|
122
|
+
if isinstance(test, ast.BoolOp):
|
|
123
|
+
return [v for v in test.values if isinstance(v, ast.Compare)]
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _scan_scope(
|
|
128
|
+
scope: ast.AST,
|
|
129
|
+
var_to_gen: dict[str, str],
|
|
130
|
+
oracle_helpers: set[str],
|
|
131
|
+
weak: dict[tuple[str, int], None],
|
|
132
|
+
covered: set[str],
|
|
133
|
+
) -> None:
|
|
134
|
+
for node in ast.walk(scope):
|
|
135
|
+
if isinstance(node, ast.Assert):
|
|
136
|
+
for cmp in _iter_compares(node.test):
|
|
137
|
+
if len(cmp.ops) != 1:
|
|
138
|
+
continue
|
|
139
|
+
op, right = cmp.ops[0], cmp.comparators[0]
|
|
140
|
+
if isinstance(op, ast.In) and _is_str_literal(cmp.left):
|
|
141
|
+
gen = _gen_from_expr(right, var_to_gen)
|
|
142
|
+
if gen:
|
|
143
|
+
weak[(gen, node.lineno)] = None
|
|
144
|
+
elif isinstance(op, ast.Eq):
|
|
145
|
+
gen = _gen_from_expr(cmp.left, var_to_gen) or _gen_from_expr(
|
|
146
|
+
right, var_to_gen
|
|
147
|
+
)
|
|
148
|
+
if gen:
|
|
149
|
+
covered.add(gen)
|
|
150
|
+
elif isinstance(node, ast.Call):
|
|
151
|
+
name = _call_name(node.func)
|
|
152
|
+
if name in oracle_helpers:
|
|
153
|
+
for arg in node.args:
|
|
154
|
+
gen = _gen_from_expr(arg, var_to_gen)
|
|
155
|
+
if gen:
|
|
156
|
+
covered.add(gen)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_str_literal(node: ast.expr) -> bool:
|
|
160
|
+
return isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scan(path: Path) -> tuple[dict[tuple[str, int], None], set[str]]:
|
|
164
|
+
"""Parse *path* into its (weak fragment sites, generators with an equality oracle).
|
|
165
|
+
|
|
166
|
+
The two halves stay separate so callers can compose them across files: check_file() subtracts
|
|
167
|
+
covered from weak within the one file; main() applies a repo-wide covered set built from many.
|
|
168
|
+
"""
|
|
169
|
+
# Distributed copies of this very hook live at scripts/hooks/ in every consumer repo — never
|
|
170
|
+
# under tests/, so `files: ^tests/` already excludes them; this guard keeps a manual run honest.
|
|
171
|
+
if "scripts/hooks" in path.as_posix():
|
|
172
|
+
return {}, set()
|
|
173
|
+
try:
|
|
174
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
175
|
+
except (OSError, SyntaxError):
|
|
176
|
+
return {}, set()
|
|
177
|
+
oracle_helpers = _collect_oracle_helpers(tree)
|
|
178
|
+
weak: dict[tuple[str, int], None] = {}
|
|
179
|
+
covered: set[str] = set()
|
|
180
|
+
for fn in ast.walk(tree):
|
|
181
|
+
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
182
|
+
_scan_scope(fn, _build_var_to_gen(fn), oracle_helpers, weak, covered)
|
|
183
|
+
return weak, covered
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_file(path: Path) -> list[tuple[int, str]]:
|
|
187
|
+
"""Per-file flags: weak fragment sites whose generator has no oracle IN THIS FILE.
|
|
188
|
+
|
|
189
|
+
The testable unit of the hook — a single file's behaviour in isolation. Repo-wide oracle
|
|
190
|
+
resolution (a fragment covered by an oracle in a sibling file) is main()'s job, not this one's.
|
|
191
|
+
"""
|
|
192
|
+
weak, covered = _scan(path)
|
|
193
|
+
return sorted((line, gen) for (gen, line) in weak if gen not in covered)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_disabled() -> bool:
|
|
197
|
+
disabled = os.environ.get("PINKY_DISABLED_HOOKS", "")
|
|
198
|
+
return Path(__file__).stem in {h.strip() for h in disabled.split(",") if h.strip()}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _tests_roots(paths: list[str]) -> set[Path]:
|
|
202
|
+
"""The `tests/` directory ancestor of each passed path (empty when a path has none)."""
|
|
203
|
+
roots: set[Path] = set()
|
|
204
|
+
for p in paths:
|
|
205
|
+
for parent in Path(p).resolve().parents:
|
|
206
|
+
if parent.name == "tests":
|
|
207
|
+
roots.add(parent)
|
|
208
|
+
break
|
|
209
|
+
return roots
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _repo_covered(paths: list[str]) -> set[str]:
|
|
213
|
+
"""Generators with an equality oracle anywhere under the tests/ root(s) of *paths*.
|
|
214
|
+
|
|
215
|
+
pre-commit hands main() only the CHANGED files, but an oracle for a fragment's generator may
|
|
216
|
+
sit in an unchanged sibling — so the covered set is built from the whole tests/ tree, not argv.
|
|
217
|
+
Empty when no path lives under a `tests/` dir (a manual run on a stray file → per-file fallback).
|
|
218
|
+
"""
|
|
219
|
+
covered: set[str] = set()
|
|
220
|
+
for root in _tests_roots(paths):
|
|
221
|
+
for f in root.rglob("*.py"):
|
|
222
|
+
covered |= _scan(f)[1]
|
|
223
|
+
return covered
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> int:
|
|
227
|
+
if _is_disabled():
|
|
228
|
+
return 0
|
|
229
|
+
paths = sys.argv[1:]
|
|
230
|
+
repo_covered = _repo_covered(paths)
|
|
231
|
+
failed = False
|
|
232
|
+
for filepath in paths:
|
|
233
|
+
for line, gen in check_file(Path(filepath)):
|
|
234
|
+
if gen in repo_covered:
|
|
235
|
+
continue # oracle exists elsewhere in tests/ — the fragment is documentation, not a hole
|
|
236
|
+
print(
|
|
237
|
+
f"❌ [WEAK-ASSERT] {filepath}:{line}: `{gen}` — fragment `in` assertion with no "
|
|
238
|
+
f"equality oracle for the whole output"
|
|
239
|
+
)
|
|
240
|
+
failed = True
|
|
241
|
+
if failed:
|
|
242
|
+
print(
|
|
243
|
+
" 💡 Add one `assert <generator(...)> == <full expected output>` in the same test "
|
|
244
|
+
"file (tests.md § generator oracle). Fragment asserts stay welcome alongside it."
|
|
245
|
+
)
|
|
246
|
+
return 1 if failed else 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
sys.exit(main())
|
|
@@ -30,24 +30,23 @@ if echo "$COMMIT_MSG" | grep -qE "^💾 save: .{3,} \[skip ci\]"; then
|
|
|
30
30
|
exit 0
|
|
31
31
|
fi
|
|
32
32
|
|
|
33
|
-
# Emoji → allowed type mapping
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
# Emoji → allowed type mapping.
|
|
34
|
+
#
|
|
35
|
+
# Bash pur, zéro subprocess (2026-07-22). Les deux `python3 -c` d'avant se justifiaient par « macOS
|
|
36
|
+
# BSD grep lacks PCRE (-P) », mais ils ne faisaient pas de PCRE : ils découpaient les deux premiers
|
|
37
|
+
# tokens. `read` le fait nativement, et bash porte l'ERE avec `[[ =~ ]]` — aucun `-P` en jeu.
|
|
38
|
+
# Ça enlève la dernière dépendance de ce hook à un interpréteur : `python3` existe sur macOS mais
|
|
39
|
+
# n'est PAS garanti sur une machine Windows vanilla (ADR-0045 : `py -3` y est le lanceur canonique),
|
|
40
|
+
# donc le frère n'aurait pas pu committer. Zéro interpréteur > choisir le bon lanceur.
|
|
41
|
+
# Équivalence vérifiée sur 10 messages, emoji multi-octets à sélecteur de variation (♿️, ⚡️) et cas
|
|
42
|
+
# limites (vide, sans emoji, mauvaise casse) inclus.
|
|
43
|
+
#
|
|
44
|
+
# Le découpage sur l'espace est sûr en UTF-8 : l'espace est ASCII et UTF-8 est auto-synchronisant,
|
|
45
|
+
# donc un emoji multi-octets n'est jamais coupé en deux.
|
|
46
|
+
read -r EMOJI REST <<< "$COMMIT_MSG"
|
|
41
47
|
|
|
42
|
-
TYPE
|
|
43
|
-
|
|
44
|
-
msg = sys.argv[1]
|
|
45
|
-
parts = msg.split(None, 2)
|
|
46
|
-
if len(parts) >= 2:
|
|
47
|
-
m = re.match(r'^([a-z_-]+)', parts[1])
|
|
48
|
-
if m:
|
|
49
|
-
print(m.group(1))
|
|
50
|
-
" "$COMMIT_MSG" 2>/dev/null)
|
|
48
|
+
TYPE=""
|
|
49
|
+
[[ "$REST" =~ ^([a-z_-]+) ]] && TYPE="${BASH_REMATCH[1]}"
|
|
51
50
|
|
|
52
51
|
if [ -z "$EMOJI" ] || [ -z "$TYPE" ]; then
|
|
53
52
|
echo "❌ Invalid format — missing emoji or type."
|