A simple Python parser generator. You give it a grammar (symbols and rules), and it builds LR0, SLR or LR1 parsing tables and parsers that run user callbacks on each reduction. It's a library published on PyPI. Docs are at https://maximosnikiforakis.gr/syntactes/.
Use uv for everything. Dev tools (unittest-extensions, ruff, ty, and mkdocs for
the docs) are in the dev dependency group, locked in uv.lock. Bare python
doesn't work here, because there's no .python-version for pyenv. Always go
through uv run python ….
make test # whole unit suite (unittest discover)
make lint # ruff check
make lint-fix # ruff check --fix
make type-check # ty
make format # ruff format (src and examples)
uv run python -m unittest syntactes.tests.test_parser # one module
uv run python examples/parser.py # run an example
uv run mkdocs build --strict -d <scratch dir> # check the docs
make start-doc-server # serve the docs locallyBefore every commit, run make test, make lint and make type-check, and
make sure uv run ruff format --check src examples is clean. CI
(.github/workflows/test-package.yml) runs the same four checks on Python
3.13, for pushes to main and tester/* and for PRs.
Tooling notes:
- ty and ruff target Python 3.13, which they take from
requires-python. - The subpackage
__init__.pyfiles use# isort: skip_file. Their import order resolves the circular imports between package modules, so don't sort them. Modules import from the subpackages (syntactes.config,syntactes.generator), never from the rootsyntactes, so the root__init__.pyis sorted normally. - Table generation is deterministic: states are numbered breadth-first and
output doesn't depend on
PYTHONHASHSEED.test_determinism.pychecks this across seeds, and CI runs the suite under several.
All code lives in src/syntactes/:
config/: everything that describes a grammar. Its__init__.pyre-exportsSymbol,Rule,Grammar, the grammar errors and every primitive.symbol.py:Symbol(name, is_terminal), a grammar symbol, withSymbol.eof()($) andSymbol.null()(ε). It has no value: values live onTokens.rule.py:Rule(number, lhs, *rhs). Equality and hashing use only the symbols, not the number. A rule's primitive lives on the grammar, not on it.primitive.py: the primitives a rule can be tagged with. Value types (Integer,Float,String,NoneType,Boolean) convert one value when called. Binary operations (Addition, …,InequalityComparison) take two operands.string()is a primitive's name in the text format (int % expr -> NUMBER,add(1,3) % expr -> expr PLUS expr),primitives()lists them all, and the private_is_binary()tells the kinds apart.grammar.py:Grammar(starting_rule, rules, symbols, *, primitives=None, operands=None),Grammar.from_text,GrammarError(withproblems, a list of(line, message)) andGrammarWarning.grammar.primitivesmaps rules to primitives, andgrammar.operandsmaps each binary-operation rule to its 1-based operand positions, defaulting to the first and last symbols. Both are read-only properties over private dicts, so aGrammarstays picklable. Value types other thanNoneneed exactly one symbol, and binary operations at least two. The starting rule's$isn't counted._text.py: parses the text format forGrammar.from_text, includingprimitive %andname(i,j) %prefixes. It collects every error, and computes the warnings (unreachable and unproductive non-terminals).
generator/: builds parsing tables. Its__init__.pyre-exports the generators._item.py,_state.py,_action.py: private LR0/LR1 items, states and shift/reduce/accept actions.LR1ItemsubclassesLR0ItemandLR1StatesubclassesLR0State, so code typed with the LR0 classes accepts both.Stateis a protocol.generator.py:LR0Generator,SLRGeneratorandLR1Generator. They compute FIRST/FOLLOW sets (nullable symbols included), closures and gotos, and theirgenerate()returns a parsing table.Generatoris generic over its item and state types (Generator[LR1Item, LR1State]).
parsing_table/:Entry,Conflict/ConflictType, theParsingTableprotocol, and theLR0ParsingTable,SLRParsingTableandLR1ParsingTableclasses. These havefrom_entries(entries, grammar),pretty_str()andconflicts(). The subclasses only change the header.parser/:token.py:Token(symbol, location, value=None), a token of the parser's input: a terminalSymbol, theLocationit starts at, and the value it was read with. Equality uses the symbol and the value, and hashing only the symbol.Location(offset, line, column)is a frozen dataclass (0-based offset, 1-based line and column, as in lectes), andLocation.from_objectbuilds one from a mapping or an object with those attributes, raisingLocationObjectError.parser.py:LR0Parser,SLRParserandLR1Parser, built from a table or withfrom_grammar().@parser.execute_on(rule)registers a callback on that parser. On each reduction, the rule's value becomes thevalueof the pushed LHS token, and its location is the first RHS token's (the lookahead's for an empty rule). The value comes from the callback (called with one token per RHS symbol), else the rule's primitive, else the value of its only token, elseNone.parse(stream)consumes tokens and returns the starting rule's value, computed the same way without the trailing$.exception.py:ParserErrorand its subclasses, includingPrimitiveError, which wraps a failing primitive.
tests/:data.pyholds the shared test grammars, rules, states and parsing tables.test_generator.pyandtest_parser.pyuse them.c_grammar.py(ANSI C89) andpython_grammar.py(Python 3.8) hold real language grammars, written in the yacc-like BNF that_bnf.pyloads, with a lexer and a parser whose callbacks rebuild the source with every expression in parentheses. Their LR1 tables take a few seconds to generate and are cached per test run.
examples/ holds runnable scripts. They're not part of the package.
The docs site (mkdocs.yml, docs/) is MkDocs Material with mkdocstrings,
laid out like the lectes docs (../lectes). Its guide pages are
grammars.md, parsing-tables.md and parsing.md. It shares lectes's arcade
look, with its own green phosphor palette: docs/stylesheets/arcade.css maps
Material's dark (slate) palette onto the --sx-* colours. The fonts in
docs/assets/fonts/ are copied from lectes.
- No runtime dependencies (
dependencies = []). Use the stdlib only, and so notyping_extensionseither. Adding a runtime dependency needs explicit approval. Dev-only tools go in thedevdependency group (uv add --dev …). - Python 3.13+ (
requires-python = ">=3.13"). Modern syntax is fine, and ruff enforces it. Use PEP 695 generics (class Generator[ItemT: LR0Item]),typealiases, andtyping.Self. - The public API is everything exported from
syntactes/__init__.pyand the__init__.pyofconfig,generator,parserandparsing_table, plus the behaviour the README shows. Point out any breaking change and get agreement before making it. - New public names go in the relevant
__init__.py. Modules prefixed with_are private.
-
Test first, always. For every behaviour change or bug fix:
- Write the tests.
- Run them and watch them fail.
- Implement until they pass.
Tests and implementation go in the same commit.
-
Tests use
unittestwithunittest-extensions. A test class definessubject(...), test methods are decorated with@args(...), and they callself.result(),self.assertResult(...)orself.assertResultRaises(...). Shared setup and assert helpers go on a baseTestCase. New grammars, states and tables go intests/data.py, except full language grammars, which get a module of their own. -
Git:
- Always work on a branch, never directly on
main. - Make small atomic commits, each one passing the checks.
- Write commit subjects in the present tense, third person, e.g. "Adds …", "Defines …", "Fixes …", "Increments version to X.Y.Z". Add a body explaining why when it isn't obvious.
- Don't merge into
main, push, tag, publish or deploy docs unless asked. When asked to merge, usegit merge --no-ff <branch>(message:Merge branch '<branch>').
- Always work on a branch, never directly on
-
Docs go with every user-facing change, on the same branch:
- Update the relevant
docs/*.mdpage, andREADME.mdandexamples/if needed. Run every code sample you add, and paste its real output. Don't print anything whose order changes between runs, like a state's items. - Add a bullet under
## X.Y.Z - UnreleasedinCHANGELOG.md, in theBreaking changes/Added/Changed/Fixedsections. - New pages go in the
mkdocs.ymlnav. API pages are::: modulestubs rendered by mkdocstrings, which leaves out objects without a docstring, so give new public classes and methods one.
- Update the relevant
-
Code style:
- Formatting is ruff's (line length 88).
- Type hints everywhere, and ty must pass. Prefer real narrowing (e.g.
if x is None) overcast. Usecastonly where an invariant can't be expressed, like an action'sactionablebeing a state for shifts and a rule for reduces. - Prefix private helpers and modules with
_. - Docstrings are triple-quoted, with the text starting on the next line.
-
.envrcholds secrets and is git-ignored. Never print it or commit it.
- On the branch, set
versioninpyproject.toml, runuv lock, change## X.Y.Z - UnreleasedinCHANGELOG.mdto today's date, and commit as "Increments version to X.Y.Z". - Merge into
mainwith--no-ff, runmake test, thengit push origin main. - Run
git tag vX.Y.Z(with avprefix, like the earlier tags), thengit push origin vX.Y.Z. - Run
make clean build-package(uv build). Check thatdist/holds only X.Y.Z, and that the wheel has what you expect (e.g.unzip -l dist/*.whl). It should hold all subpackages and notests. - Run
make upload-package(uv publish). It readsUV_PUBLISH_TOKENfrom.envrc(via direnv). Never print it. - Run
make deploy-documentation(mkdocs gh-deploy). GitHub Pages takes a few minutes to rebuild. Check progress withgh api repos/Maxcode123/syntactes/pages/builds/latest. - Verify from outside the repo, with
PYTHONPATHunset:uvx --refresh --from syntactes==X.Y.Z python -c "import syntactes". Then run an example against it, and check the changed docs pages on the live site.
Pitfalls:
- Never build unreleased code into
dist/under an already-released version number. To try a local build, useuv build -o <scratch dir>. - Because of
PYTHONPATH, importingsyntactesfrom the repo always loadssrc/. To test the installed package, run from outside the repo withPYTHONPATHunset.