spintax-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spintax_core-0.1.0/.github/workflows/ci.yml +105 -0
- spintax_core-0.1.0/.github/workflows/release.yml +103 -0
- spintax_core-0.1.0/.gitignore +35 -0
- spintax_core-0.1.0/LICENSE +21 -0
- spintax_core-0.1.0/PKG-INFO +117 -0
- spintax_core-0.1.0/README.md +95 -0
- spintax_core-0.1.0/docs/plan-p1.md +142 -0
- spintax_core-0.1.0/docs/plan-p2.md +265 -0
- spintax_core-0.1.0/docs/plan-p3.md +92 -0
- spintax_core-0.1.0/docs/spec-python-port.md +370 -0
- spintax_core-0.1.0/pyproject.toml +51 -0
- spintax_core-0.1.0/src/spintax_core/__init__.py +282 -0
- spintax_core-0.1.0/src/spintax_core/_analyze.py +62 -0
- spintax_core-0.1.0/src/spintax_core/_ast.py +211 -0
- spintax_core-0.1.0/src/spintax_core/_charclasses.py +317 -0
- spintax_core-0.1.0/src/spintax_core/_directives.py +136 -0
- spintax_core-0.1.0/src/spintax_core/_errors.py +46 -0
- spintax_core-0.1.0/src/spintax_core/_extract.py +53 -0
- spintax_core-0.1.0/src/spintax_core/_neutralize.py +63 -0
- spintax_core-0.1.0/src/spintax_core/_parser.py +502 -0
- spintax_core-0.1.0/src/spintax_core/_pipeline.py +69 -0
- spintax_core-0.1.0/src/spintax_core/_plurals.py +123 -0
- spintax_core-0.1.0/src/spintax_core/_postprocess.py +221 -0
- spintax_core-0.1.0/src/spintax_core/_render.py +679 -0
- spintax_core-0.1.0/src/spintax_core/_rng.py +67 -0
- spintax_core-0.1.0/src/spintax_core/_source.py +124 -0
- spintax_core-0.1.0/src/spintax_core/_validator.py +536 -0
- spintax_core-0.1.0/src/spintax_core/py.typed +0 -0
- spintax_core-0.1.0/tests/conftest.py +59 -0
- spintax_core-0.1.0/tests/data/generate_parser_parity.cjs +43 -0
- spintax_core-0.1.0/tests/data/generate_postprocess_parity.cjs +53 -0
- spintax_core-0.1.0/tests/data/parser_parity.json +4843 -0
- spintax_core-0.1.0/tests/data/parser_parity_templates.json +1 -0
- spintax_core-0.1.0/tests/data/postprocess_parity.json +2578 -0
- spintax_core-0.1.0/tests/data/postprocess_parity_cases.json +1 -0
- spintax_core-0.1.0/tests/rng_strategy.py +37 -0
- spintax_core-0.1.0/tests/test_analyze.py +201 -0
- spintax_core-0.1.0/tests/test_ascii_parity.py +72 -0
- spintax_core-0.1.0/tests/test_ast_walk.py +132 -0
- spintax_core-0.1.0/tests/test_case_folding.py +121 -0
- spintax_core-0.1.0/tests/test_charclass_tables.py +145 -0
- spintax_core-0.1.0/tests/test_golden_corpus.py +184 -0
- spintax_core-0.1.0/tests/test_include_and_config.py +279 -0
- spintax_core-0.1.0/tests/test_make_rng.py +109 -0
- spintax_core-0.1.0/tests/test_neutralize.py +56 -0
- spintax_core-0.1.0/tests/test_packaging.py +59 -0
- spintax_core-0.1.0/tests/test_parser_depth.py +76 -0
- spintax_core-0.1.0/tests/test_parser_parity.py +134 -0
- spintax_core-0.1.0/tests/test_plan_covers_corpus.py +87 -0
- spintax_core-0.1.0/tests/test_postprocess_parity.py +57 -0
- spintax_core-0.1.0/tests/test_render_contract.py +217 -0
- spintax_core-0.1.0/tests/test_review_gaps.py +203 -0
- spintax_core-0.1.0/tests/test_rng_strategy.py +59 -0
- spintax_core-0.1.0/tests/test_source.py +75 -0
- spintax_core-0.1.0/tests/test_terminators_and_body.py +77 -0
- spintax_core-0.1.0/tests/test_validator_semantic.py +204 -0
- spintax_core-0.1.0/tests/test_validator_structural.py +153 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
|
|
18
|
+
# The golden corpus is the cross-engine contract, shared with @spintax/core
|
|
19
|
+
# (TypeScript) and the PHP engine. It is checked out rather than vendored: a
|
|
20
|
+
# copy would drift, and a drifting contract is not a contract. Same pattern
|
|
21
|
+
# spintax-php already runs in production.
|
|
22
|
+
- name: Check out the golden corpus
|
|
23
|
+
uses: actions/checkout@v7
|
|
24
|
+
with:
|
|
25
|
+
repository: investblog/spintax-js
|
|
26
|
+
path: .corpus
|
|
27
|
+
|
|
28
|
+
- uses: actions/setup-python@v7
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python }}
|
|
31
|
+
|
|
32
|
+
- name: Install
|
|
33
|
+
run: |
|
|
34
|
+
python -m pip install --upgrade pip
|
|
35
|
+
pip install -e . pytest ruff mypy
|
|
36
|
+
|
|
37
|
+
- name: Golden corpus
|
|
38
|
+
env:
|
|
39
|
+
SPINTAX_FIXTURES: ${{ github.workspace }}/.corpus/packages/conformance/fixtures
|
|
40
|
+
run: pytest -q
|
|
41
|
+
|
|
42
|
+
# Gated, not merely runnable: the plan's definition of done names both, and a
|
|
43
|
+
# check nobody runs is a check that does not exist.
|
|
44
|
+
- name: Lint
|
|
45
|
+
run: ruff check .
|
|
46
|
+
|
|
47
|
+
- name: Types
|
|
48
|
+
run: mypy
|
|
49
|
+
|
|
50
|
+
# Everything above aims at the code. Nothing looked at the DISTRIBUTION, and that is
|
|
51
|
+
# where the last two publication blockers were hiding: a `Typing :: Typed` classifier
|
|
52
|
+
# with no marker behind it, and a licence declared twice. Neither shows up in ruff,
|
|
53
|
+
# mypy or pytest, and both would have reached PyPI.
|
|
54
|
+
#
|
|
55
|
+
# Its own job rather than a matrix step: a distribution does not vary by interpreter,
|
|
56
|
+
# so building it four times would only multiply the noise.
|
|
57
|
+
package:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v7
|
|
62
|
+
|
|
63
|
+
- uses: actions/setup-python@v7
|
|
64
|
+
with:
|
|
65
|
+
python-version: "3.12"
|
|
66
|
+
|
|
67
|
+
- name: Install
|
|
68
|
+
run: |
|
|
69
|
+
python -m pip install --upgrade pip
|
|
70
|
+
pip install build twine pyroma
|
|
71
|
+
|
|
72
|
+
- name: Build
|
|
73
|
+
run: python -m build
|
|
74
|
+
|
|
75
|
+
# Guards the PyPI project page: a README that stops rendering is invisible here
|
|
76
|
+
# and permanent there.
|
|
77
|
+
- name: Distribution metadata
|
|
78
|
+
run: twine check dist/*
|
|
79
|
+
|
|
80
|
+
# Pinned at the rating this tree already earns, so the check catches a regression
|
|
81
|
+
# instead of asking for a round number. The two missing points are one complaint —
|
|
82
|
+
# no `author-email` — and that is deliberate: @spintax/core and spintax/core both
|
|
83
|
+
# publish name plus https://301.st and no address, routing contact through Issues.
|
|
84
|
+
# Do not "fix" it to 10 by adding an inbox the other two packages do not expose.
|
|
85
|
+
- name: Packaging quality
|
|
86
|
+
run: pyroma -n 8 .
|
|
87
|
+
|
|
88
|
+
# tests/test_packaging.py checks the marker beside the IMPORTED module, which under
|
|
89
|
+
# `pip install -e .` is the source tree — it would stay green if packaging config
|
|
90
|
+
# ever excluded the file. Only a built artifact can answer whether it ships.
|
|
91
|
+
- name: The typing marker must ship, not merely exist in the tree
|
|
92
|
+
run: |
|
|
93
|
+
python - <<'PY'
|
|
94
|
+
import glob, sys, tarfile, zipfile
|
|
95
|
+
wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
|
|
96
|
+
sdist = tarfile.open(glob.glob("dist/*.tar.gz")[0]).getnames()
|
|
97
|
+
missing = [
|
|
98
|
+
kind
|
|
99
|
+
for kind, names in (("wheel", wheel), ("sdist", sdist))
|
|
100
|
+
if not any(n.endswith("py.typed") for n in names)
|
|
101
|
+
]
|
|
102
|
+
if missing:
|
|
103
|
+
sys.exit(f"py.typed is absent from: {', '.join(missing)}")
|
|
104
|
+
print("py.typed ships in both the wheel and the sdist")
|
|
105
|
+
PY
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Tag-driven, and deliberately not automatic on push to main: a release is a decision, not
|
|
4
|
+
# a side effect of merging. Tag `v0.1.0` and this builds, re-verifies and uploads.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
# The CI workflow already runs on every push, but a tag can be placed on any commit —
|
|
14
|
+
# including one CI never saw green. Re-running the gate here means the artifact that
|
|
15
|
+
# reaches PyPI is one that passed, rather than one that probably did.
|
|
16
|
+
verify:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
|
|
21
|
+
- name: Check out the golden corpus
|
|
22
|
+
uses: actions/checkout@v7
|
|
23
|
+
with:
|
|
24
|
+
repository: investblog/spintax-js
|
|
25
|
+
path: .corpus
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-python@v7
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.12"
|
|
30
|
+
|
|
31
|
+
- name: Install
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade pip
|
|
34
|
+
pip install -e . pytest ruff mypy
|
|
35
|
+
|
|
36
|
+
- name: Test
|
|
37
|
+
env:
|
|
38
|
+
SPINTAX_FIXTURES: ${{ github.workspace }}/.corpus/packages/conformance/fixtures
|
|
39
|
+
run: pytest -q
|
|
40
|
+
|
|
41
|
+
- name: Lint
|
|
42
|
+
run: ruff check .
|
|
43
|
+
|
|
44
|
+
- name: Types
|
|
45
|
+
run: mypy
|
|
46
|
+
|
|
47
|
+
publish:
|
|
48
|
+
needs: verify
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
environment: pypi
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v7
|
|
53
|
+
|
|
54
|
+
- uses: actions/setup-python@v7
|
|
55
|
+
with:
|
|
56
|
+
python-version: "3.12"
|
|
57
|
+
|
|
58
|
+
- name: Install
|
|
59
|
+
run: |
|
|
60
|
+
python -m pip install --upgrade pip
|
|
61
|
+
pip install build twine
|
|
62
|
+
|
|
63
|
+
- name: Build
|
|
64
|
+
run: python -m build
|
|
65
|
+
|
|
66
|
+
# The same three checks the `package` job runs on every push. Repeated here because
|
|
67
|
+
# this is the last point at which a bad artifact can still be stopped.
|
|
68
|
+
- name: Distribution metadata
|
|
69
|
+
run: twine check dist/*
|
|
70
|
+
|
|
71
|
+
- name: The version being published matches the tag
|
|
72
|
+
run: |
|
|
73
|
+
python - <<'PY'
|
|
74
|
+
import glob, os, re, sys
|
|
75
|
+
tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
|
|
76
|
+
names = [os.path.basename(p) for p in glob.glob("dist/*.whl")]
|
|
77
|
+
built = re.match(r"spintax_core-([^-]+)-", names[0]).group(1)
|
|
78
|
+
if built != tag:
|
|
79
|
+
sys.exit(f"tag says {tag!r} but the wheel is {built!r} — bump pyproject or retag")
|
|
80
|
+
print(f"tag and wheel agree on {built}")
|
|
81
|
+
PY
|
|
82
|
+
|
|
83
|
+
- name: The typing marker ships
|
|
84
|
+
run: |
|
|
85
|
+
python - <<'PY'
|
|
86
|
+
import glob, sys, tarfile, zipfile
|
|
87
|
+
wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
|
|
88
|
+
sdist = tarfile.open(glob.glob("dist/*.tar.gz")[0]).getnames()
|
|
89
|
+
missing = [
|
|
90
|
+
kind
|
|
91
|
+
for kind, names in (("wheel", wheel), ("sdist", sdist))
|
|
92
|
+
if not any(n.endswith("py.typed") for n in names)
|
|
93
|
+
]
|
|
94
|
+
if missing:
|
|
95
|
+
sys.exit(f"py.typed is absent from: {', '.join(missing)}")
|
|
96
|
+
print("py.typed ships in both the wheel and the sdist")
|
|
97
|
+
PY
|
|
98
|
+
|
|
99
|
+
- name: Upload to PyPI
|
|
100
|
+
env:
|
|
101
|
+
TWINE_USERNAME: __token__
|
|
102
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
|
103
|
+
run: twine upload dist/*
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Agent charters live locally, not in the repo (mirrors the spintax-js convention).
|
|
2
|
+
CLAUDE.md
|
|
3
|
+
AGENTS.md
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.eggs/
|
|
10
|
+
build/
|
|
11
|
+
# `dist*` rather than `dist`: a wheel built into `dist-review/` for a one-off
|
|
12
|
+
# inspection slipped past a plain `dist/` and was committed (291ac13). The pattern
|
|
13
|
+
# has to cover the directory names people actually type, not just the canonical one.
|
|
14
|
+
dist*/
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
.python-version
|
|
18
|
+
|
|
19
|
+
# Tooling caches
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
.coverage
|
|
24
|
+
htmlcov/
|
|
25
|
+
|
|
26
|
+
# Local corpus checkout / fixture sync (see spec §6 Q4). `.corpus/` is where CI
|
|
27
|
+
# puts it and where the test fallback looks; never commit the fixtures here.
|
|
28
|
+
/fixtures-local/
|
|
29
|
+
/.corpus/
|
|
30
|
+
|
|
31
|
+
# Editors / OS
|
|
32
|
+
.vscode/
|
|
33
|
+
.idea/
|
|
34
|
+
.DS_Store
|
|
35
|
+
Thumbs.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 301st (https://301.st)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spintax-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spintax engine for Python — enumerations, permutations, variables, conditionals, plural agreement, includes. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://spintax.net
|
|
6
|
+
Project-URL: Source, https://github.com/investblog/spintax-py
|
|
7
|
+
Project-URL: Issues, https://github.com/investblog/spintax-py/issues
|
|
8
|
+
Author: 301st
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: content-spinning,spinner,spintax,template-variations,text-spinning
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# spintax-core (Python)
|
|
24
|
+
|
|
25
|
+
A framework-agnostic **[Spintax](https://spintax.net) engine** for Python — parse, render,
|
|
26
|
+
validate, extract, analyze, and neutralize spintax templates. MIT, zero runtime dependencies,
|
|
27
|
+
Python 3.10+.
|
|
28
|
+
|
|
29
|
+
This is the third engine in the Spintax family, and an **independent implementation** — not a
|
|
30
|
+
transcription of the others. It is held to the same behavior contract by a **shared golden corpus**
|
|
31
|
+
of language-neutral fixtures, which already gates the TypeScript engine and the PHP one. All 168
|
|
32
|
+
of them pass here, none skipped, none expected to fail.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install spintax-core
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from spintax_core import render, validate, parse
|
|
44
|
+
|
|
45
|
+
render("{Hello|Hi} there!") # "Hello there!" or "Hi there!"
|
|
46
|
+
render("{Hello|Hi} there!", seed=42) # same seed, same output, every time
|
|
47
|
+
render("%greeting%, world", context={"greeting": "Hello"}) # "Hello, world"
|
|
48
|
+
|
|
49
|
+
# Reuse a template: parse once, render many.
|
|
50
|
+
ast = parse('[<sep=", ">fast|cheap|good]')
|
|
51
|
+
[render(ast) for _ in range(3)]
|
|
52
|
+
# ['Cheap, fast, good', 'Good, cheap, fast', 'Cheap, good, fast']
|
|
53
|
+
|
|
54
|
+
# Check a template before you ship it.
|
|
55
|
+
[d.code for d in validate("{a|b")] # -> ['bracket.unclosed']
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Rendering is **lenient**: malformed markup degrades rather than raising, so a template a
|
|
59
|
+
non-programmer wrote cannot take a page down.
|
|
60
|
+
|
|
61
|
+
Syntax — enumerations `{a|b}`, permutations `[a|b]`, variables `%name%`, conditionals
|
|
62
|
+
`{?VAR?yes|no}`, plural agreement `{plural 3: one|few|many}`, `#set` / `#def` / `#include` — is
|
|
63
|
+
documented at **[spintax.net/docs](https://spintax.net/docs/)**.
|
|
64
|
+
|
|
65
|
+
- **Spec:** [`docs/spec-python-port.md`](docs/spec-python-port.md) — read it before writing any
|
|
66
|
+
code. It records the parity contract, the API surface, and the open questions. Corpus access
|
|
67
|
+
(Q4) is decided; Unicode in post-process (Q5) is a known trap with a verified stdlib answer,
|
|
68
|
+
and lands with P2.
|
|
69
|
+
- **Sibling engines:** [`@spintax/core`](https://www.npmjs.com/package/@spintax/core) (TypeScript,
|
|
70
|
+
MIT, published) · [Spintax for WordPress](https://wordpress.org/plugins/spintax/) (PHP, GPL, the
|
|
71
|
+
origin).
|
|
72
|
+
- **Tracking issue:** [investblog/spintax-js#43](https://github.com/investblog/spintax-js/issues/43).
|
|
73
|
+
|
|
74
|
+
## Why
|
|
75
|
+
|
|
76
|
+
The existing PyPI `spintax` package is **GPLv3** and has not shipped since **2018**. GPL blocks
|
|
77
|
+
commercial adoption; this one is MIT and maintained.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
[MIT](LICENSE). The WordPress plugin remains GPL; MIT/Expat is GPL-compatible.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
Part of the [301.st](https://301.st) toolset. Product home: [spintax.net](https://spintax.net).
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
The test suite **is** the shared golden corpus — the same JSON fixtures the TypeScript and PHP
|
|
90
|
+
engines are tested against, read from a checkout rather than vendored here. A copy would drift,
|
|
91
|
+
and a drifting contract is not a contract.
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
git clone https://github.com/investblog/spintax-js ../spintax-js # once
|
|
95
|
+
python -m venv .venv && .venv/bin/pip install -e . pytest
|
|
96
|
+
SPINTAX_FIXTURES=../spintax-js/packages/conformance/fixtures pytest
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Without the fixtures the suite **fails** rather than passing an empty run — a green suite that
|
|
100
|
+
tested nothing is the most expensive kind of green. The shape of the output is:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
N passed, M xfailed, 0 skipped
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The two numbers are the milestone tracker: `passed` grows as the engine does, `xfailed` is what
|
|
107
|
+
the corpus still expects and the engine cannot yet do. A **skip** should never appear — it would
|
|
108
|
+
mean a case is being neither asserted nor counted.
|
|
109
|
+
|
|
110
|
+
The xfails are the whole cross-engine contract, waiting on P1–P3. As milestones land, cases turn
|
|
111
|
+
into real passes with no change to the runner.
|
|
112
|
+
|
|
113
|
+
> **If you mutate a source file to check that a test catches it, delete `__pycache__` first.**
|
|
114
|
+
> Python invalidates bytecode on (mtime, size). A mutation that preserves both — swapping `.*?`
|
|
115
|
+
> for `.+?`, say — leaves the stale `.pyc` in place, so the *next* run still imports the mutated
|
|
116
|
+
> module after you have restored the file. That produced a failure pointing at correct code, and
|
|
117
|
+
> the obvious response to it would have been to break the code for real.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# spintax-core (Python)
|
|
2
|
+
|
|
3
|
+
A framework-agnostic **[Spintax](https://spintax.net) engine** for Python — parse, render,
|
|
4
|
+
validate, extract, analyze, and neutralize spintax templates. MIT, zero runtime dependencies,
|
|
5
|
+
Python 3.10+.
|
|
6
|
+
|
|
7
|
+
This is the third engine in the Spintax family, and an **independent implementation** — not a
|
|
8
|
+
transcription of the others. It is held to the same behavior contract by a **shared golden corpus**
|
|
9
|
+
of language-neutral fixtures, which already gates the TypeScript engine and the PHP one. All 168
|
|
10
|
+
of them pass here, none skipped, none expected to fail.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install spintax-core
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from spintax_core import render, validate, parse
|
|
22
|
+
|
|
23
|
+
render("{Hello|Hi} there!") # "Hello there!" or "Hi there!"
|
|
24
|
+
render("{Hello|Hi} there!", seed=42) # same seed, same output, every time
|
|
25
|
+
render("%greeting%, world", context={"greeting": "Hello"}) # "Hello, world"
|
|
26
|
+
|
|
27
|
+
# Reuse a template: parse once, render many.
|
|
28
|
+
ast = parse('[<sep=", ">fast|cheap|good]')
|
|
29
|
+
[render(ast) for _ in range(3)]
|
|
30
|
+
# ['Cheap, fast, good', 'Good, cheap, fast', 'Cheap, good, fast']
|
|
31
|
+
|
|
32
|
+
# Check a template before you ship it.
|
|
33
|
+
[d.code for d in validate("{a|b")] # -> ['bracket.unclosed']
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Rendering is **lenient**: malformed markup degrades rather than raising, so a template a
|
|
37
|
+
non-programmer wrote cannot take a page down.
|
|
38
|
+
|
|
39
|
+
Syntax — enumerations `{a|b}`, permutations `[a|b]`, variables `%name%`, conditionals
|
|
40
|
+
`{?VAR?yes|no}`, plural agreement `{plural 3: one|few|many}`, `#set` / `#def` / `#include` — is
|
|
41
|
+
documented at **[spintax.net/docs](https://spintax.net/docs/)**.
|
|
42
|
+
|
|
43
|
+
- **Spec:** [`docs/spec-python-port.md`](docs/spec-python-port.md) — read it before writing any
|
|
44
|
+
code. It records the parity contract, the API surface, and the open questions. Corpus access
|
|
45
|
+
(Q4) is decided; Unicode in post-process (Q5) is a known trap with a verified stdlib answer,
|
|
46
|
+
and lands with P2.
|
|
47
|
+
- **Sibling engines:** [`@spintax/core`](https://www.npmjs.com/package/@spintax/core) (TypeScript,
|
|
48
|
+
MIT, published) · [Spintax for WordPress](https://wordpress.org/plugins/spintax/) (PHP, GPL, the
|
|
49
|
+
origin).
|
|
50
|
+
- **Tracking issue:** [investblog/spintax-js#43](https://github.com/investblog/spintax-js/issues/43).
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
The existing PyPI `spintax` package is **GPLv3** and has not shipped since **2018**. GPL blocks
|
|
55
|
+
commercial adoption; this one is MIT and maintained.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
[MIT](LICENSE). The WordPress plugin remains GPL; MIT/Expat is GPL-compatible.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
Part of the [301.st](https://301.st) toolset. Product home: [spintax.net](https://spintax.net).
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
The test suite **is** the shared golden corpus — the same JSON fixtures the TypeScript and PHP
|
|
68
|
+
engines are tested against, read from a checkout rather than vendored here. A copy would drift,
|
|
69
|
+
and a drifting contract is not a contract.
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
git clone https://github.com/investblog/spintax-js ../spintax-js # once
|
|
73
|
+
python -m venv .venv && .venv/bin/pip install -e . pytest
|
|
74
|
+
SPINTAX_FIXTURES=../spintax-js/packages/conformance/fixtures pytest
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Without the fixtures the suite **fails** rather than passing an empty run — a green suite that
|
|
78
|
+
tested nothing is the most expensive kind of green. The shape of the output is:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
N passed, M xfailed, 0 skipped
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The two numbers are the milestone tracker: `passed` grows as the engine does, `xfailed` is what
|
|
85
|
+
the corpus still expects and the engine cannot yet do. A **skip** should never appear — it would
|
|
86
|
+
mean a case is being neither asserted nor counted.
|
|
87
|
+
|
|
88
|
+
The xfails are the whole cross-engine contract, waiting on P1–P3. As milestones land, cases turn
|
|
89
|
+
into real passes with no change to the runner.
|
|
90
|
+
|
|
91
|
+
> **If you mutate a source file to check that a test catches it, delete `__pycache__` first.**
|
|
92
|
+
> Python invalidates bytecode on (mtime, size). A mutation that preserves both — swapping `.*?`
|
|
93
|
+
> for `.+?`, say — leaves the stale `.pyc` in place, so the *next* run still imports the mutated
|
|
94
|
+
> module after you have restored the file. That produced a failure pointing at correct code, and
|
|
95
|
+
> the obvious response to it would have been to break the code for real.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# P1 — parser, validator, extract
|
|
2
|
+
|
|
3
|
+
Status: **complete**, with one step moved rather than done. `validate()` and `extract()` are
|
|
4
|
+
wired and every case in `validate.json` and `extract.json` passes. Governing contract:
|
|
5
|
+
[`spec-python-port.md`](spec-python-port.md). Next: P2 (parser + renderer).
|
|
6
|
+
|
|
7
|
+
P0 left the corpus running against an empty engine: **7 passed, 168 xfailed, 0 skipped**. P1's
|
|
8
|
+
progress metric is that number moving — every step below turns a named set of xfails into passes,
|
|
9
|
+
and no step is "done" until its cases pass for the reason intended.
|
|
10
|
+
|
|
11
|
+
## What P1 must close
|
|
12
|
+
|
|
13
|
+
`validate.json` — 40 cases (15 valid, 25 invalid) across **17 distinct diagnostic codes** — plus
|
|
14
|
+
`extract.json` (2 cases). That is 42 of the 168.
|
|
15
|
+
|
|
16
|
+
Two scope corrections against the spec's milestone list, both found by reading the fixtures rather
|
|
17
|
+
than the plan:
|
|
18
|
+
|
|
19
|
+
- **Plural *arity* belongs to P1, not P2.** 14 validate cases carry a `locale` and 5 assert
|
|
20
|
+
`plural.arity`, so `normalize_base_lang` and the arity table are P1 work. Rendering plurals
|
|
21
|
+
stays P2; deciding whether a template *could* render is P1.
|
|
22
|
+
- **`extract` is pulled forward from P3.** Once the parser exists, the names are already collected
|
|
23
|
+
for the diagnostics — `refs`, `sets`, `defs`, `includes` fall out of work P1 has to do anyway.
|
|
24
|
+
Leaving it in P3 would mean building the same index twice.
|
|
25
|
+
|
|
26
|
+
## Steps
|
|
27
|
+
|
|
28
|
+
Ordered so that each one moves the counter, and the hardest lands last against a suite that is
|
|
29
|
+
already mostly green.
|
|
30
|
+
|
|
31
|
+
### 1. Parser → `Ast` — **moved to P2, not done**
|
|
32
|
+
|
|
33
|
+
Reading the reference before writing any of it showed the premise was wrong: `validate` and
|
|
34
|
+
`extract` are raw-text scanners *by design*, because the AST is lenient — an unbalanced bracket
|
|
35
|
+
is not represented in it at all, and a `[…]` body stays a raw string. Neither needs a tree, so
|
|
36
|
+
building one first would have been a week with no case moving. It is P2's opening, where the
|
|
37
|
+
renderer actually requires it. `parse()` still raises.
|
|
38
|
+
|
|
39
|
+
What did survive from this step is the position work, and it moved into `_source.py`: comment
|
|
40
|
+
stripping keeps a map back to the original offsets, so a diagnostic points at the place the
|
|
41
|
+
author is looking at rather than at a place that existed before the comments were removed.
|
|
42
|
+
|
|
43
|
+
### 2. Structural diagnostics — 8 codes
|
|
44
|
+
|
|
45
|
+
`bracket.unclosed`, `bracket.mismatched`, `bracket.unexpected-closing`, `set.malformed`,
|
|
46
|
+
`def.malformed`, `permutation.minsize-not-integer`, `permutation.maxsize-not-integer`,
|
|
47
|
+
`permutation.unknown-key`.
|
|
48
|
+
|
|
49
|
+
Mechanical, and the first visible movement in the corpus.
|
|
50
|
+
|
|
51
|
+
### 3. Variable graph — 3 codes
|
|
52
|
+
|
|
53
|
+
`variable.self-reference`, `variable.circular-reference`, `variable.undefined` (a **warning**, and
|
|
54
|
+
the only one that must not flip a verdict to invalid).
|
|
55
|
+
|
|
56
|
+
`known_variables` suppression is implemented here and **is not gated by the corpus** — the fixture
|
|
57
|
+
schema has no such field. It needs local tests or it can break silently.
|
|
58
|
+
|
|
59
|
+
### 4. Plural diagnostics — 2 codes, 14 cases touched
|
|
60
|
+
|
|
61
|
+
`normalize_base_lang` (`sr-Latn` → `sr`, `pt-BR` → `pt`; three-letter tags are *not* mapped) plus
|
|
62
|
+
the arity table: 3 forms for `ru`/`uk`/`be`/`sr`/`hr`/`bs`, 2 for everything else — including
|
|
63
|
+
`pl`/`cs`/`sk`/`sl`/`bg`, which are wrong-but-accepted by design.
|
|
64
|
+
|
|
65
|
+
Then `plural.arity`. Note an empty or absent locale **skips** the arity check entirely.
|
|
66
|
+
|
|
67
|
+
`plural.nested-brackets` lands here too, and needs no locale: a form slot must be plain text, so
|
|
68
|
+
`{plural 1: {a|b}|c}` is rejected structurally. Keep it distinct from step 6 — this one is about
|
|
69
|
+
brackets *inside a form*, that one about a count that only becomes bracketed after expansion.
|
|
70
|
+
|
|
71
|
+
### 5. Definitions and includes — 3 codes
|
|
72
|
+
|
|
73
|
+
`definition.duplicate-name`, `def.include-in-value`, and `include.unknown-target`.
|
|
74
|
+
|
|
75
|
+
The include check is the only diagnostic that depends on caller-supplied data: it fires only when
|
|
76
|
+
`known_includes` is non-empty, so with no list every target is assumed to exist. Two fixtures
|
|
77
|
+
cover it, and one of them is a *valid* verdict — a circular include is a runtime outcome, not a
|
|
78
|
+
static error.
|
|
79
|
+
|
|
80
|
+
Read spec §5.3 before writing this: duplicate detection requires keeping directive **occurrences**
|
|
81
|
+
until after the diagnostic runs. Folding directives into a `dict[str, str]` first destroys the
|
|
82
|
+
evidence — the second assignment overwrites the first and there is nothing left to report. The PHP
|
|
83
|
+
pass lost duplicates exactly this way.
|
|
84
|
+
|
|
85
|
+
### 6. `plural.count-macro` — 1 code, 5 cases, and the real work
|
|
86
|
+
|
|
87
|
+
A taint analysis, not a node check. A count slot is poisoned when it resolves — possibly through a
|
|
88
|
+
chain of `#set` aliases — to a value that still holds unresolved spintax at the moment plurals run.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
#set %m% = {1|4|9} # chained alias: taint must reach %n%
|
|
92
|
+
#set %n% = %m%
|
|
93
|
+
{plural %n%: item|items} # error
|
|
94
|
+
|
|
95
|
+
#set %n% = {?flag?{1|4}|2} # a conditional is exempt (resolves before plurals)…
|
|
96
|
+
{plural %n%: item|items} # …but the enumeration inside it is not — still an error
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The rule is **stage order**, not bracket-spotting: conditionals resolve before the plural pass, so
|
|
100
|
+
they are the single exemption; a nested `{plural …}` is not. Propagate to a fixed point — a
|
|
101
|
+
one-pass walk gets `plural-count-macro` right and `plural-count-macro-chained` wrong.
|
|
102
|
+
|
|
103
|
+
There is a paired case where a conditional count **is valid**. If both pass, the rule is a rule; if
|
|
104
|
+
only the error cases pass, it is over-eager and the valid one will say so.
|
|
105
|
+
|
|
106
|
+
### 7. `extract`
|
|
107
|
+
|
|
108
|
+
`refs` / `sets` / `defs` / `includes` from the index step 3 already built. `sets` and `defs` are
|
|
109
|
+
separate buckets — the whole point of the fixture we added upstream.
|
|
110
|
+
|
|
111
|
+
## What the corpus will not catch
|
|
112
|
+
|
|
113
|
+
Local tests are mandatory for these; a green corpus says nothing about any of them:
|
|
114
|
+
|
|
115
|
+
| surface | why it is invisible |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `line` / `column` | **zero** fixtures assert positions |
|
|
118
|
+
| `known_variables` | no such field in the fixture schema |
|
|
119
|
+
| `max_depth` | only the circular-include outcome is pinned |
|
|
120
|
+
| `parse` itself | no fixtures; only observable through other ops |
|
|
121
|
+
|
|
122
|
+
## Why `validate()` was wired only at the end
|
|
123
|
+
|
|
124
|
+
The corpus reports by **op**. While `validate` raised `NotImplementedError` all 40 of its cases
|
|
125
|
+
were xfails, which reads as "not built". Wiring a half-finished validator would have run them for
|
|
126
|
+
real — the ones whose codes existed passing, the rest **failing** — and turned the suite red for
|
|
127
|
+
work nobody had claimed was done. Red would have stopped meaning "something broke".
|
|
128
|
+
|
|
129
|
+
So each check was proved by its own tests first and the public entry point flipped exactly once,
|
|
130
|
+
when all seventeen codes existed. Same reason the reference suite lights up by op, not by code.
|
|
131
|
+
Worth repeating at P2: `render` should stay unwired until it can pass its own cases.
|
|
132
|
+
|
|
133
|
+
## Definition of done
|
|
134
|
+
|
|
135
|
+
- `validate.json` and `extract.json` fully green — 42 cases moved from xfail to pass.
|
|
136
|
+
- The corpus's 42 P1 cases move from xfail to passed, and no case is ever skipped. The absolute
|
|
137
|
+
totals are deliberately not written down here: they change with every local test added, and a
|
|
138
|
+
number in a document is a number that goes stale.
|
|
139
|
+
- Local tests covering the ungated surfaces above, each verified by breaking the implementation
|
|
140
|
+
and watching the test fail — not by observing that it passes. `max_depth` is excluded: it is a
|
|
141
|
+
render budget, so it belongs to P2 with the code that enforces it.
|
|
142
|
+
- `mypy --strict` and `ruff` clean.
|