Development
The repository as a map, what each module owns, where the version lives, how the tests are run and how this site is built.
The project structure:
py_maze/
├── py_maze/ # The package itself
│ └── algorithms/ # The ways a maze can be carved, one to a module
├── docs/ # This site, one Markdown page to a file
│ ├── _layouts/ # The one layout every page is drawn in
│ ├── assets/ # The stylesheet, the one script and the marks
│ └── save-format.md # The save file, for a tool that writes one
├── .github/
│ ├── instructions/ # Editor instructions for this repository
│ └── workflows/
│ ├── tests.yml # The suite, on every platform and version
│ └── workflow.yml # This site, built and deployed to Pages
├── py_maze.bat # Windows launcher, no install needed
├── py_maze.sh # POSIX launcher, no install needed
├── test_py_maze.py # Unit tests
├── pyproject.toml # Packaging and project metadata
├── .gitignore # Ignored build, cache and editor artifacts
├── CHANGELOG.md # Version history
├── CONTRIBUTING.md # The test command and the conventions
├── DESIGN_LANGUAGE.md # The site's palette, type and spacing
├── KNOWN_BUGS.md # Defects a review found, and their state
├── LICENSE # The MIT text the manifest declares
├── TODO.md # Planned work, and what has been done
└── README.md # The front door, and the way to this site
The Package Layout
Each module owns one job, and __init__.py re-exports every public name, so
import py_maze reaches all of them whichever module they live in:
py_maze/
├── __init__.py # Re-exports the public names
├── __main__.py # python -m py_maze
├── grid.py # The grid, and the helpers that build and read it
├── algorithms/ # The ways a maze can be carved, one to a module
│ ├── __init__.py # The registry --algorithm reads its names from
│ ├── backtracker.py # Recursive backtracking, the default
│ ├── prim.py # Randomized Prim's algorithm
│ └── division.py # Recursive division
├── generation.py # Carving a maze, braiding it, scattering its pickups
├── solving.py # Breadth-first search over a grid
├── analysis.py # Measuring a maze that already exists
├── rendering.py # Drawing a maze, and measuring the terminal
├── saves.py # Reading and writing save files
├── keys.py # Single keypresses, and the terminal imports
├── game.py # Playing a maze at the terminal
├── chase.py # The antagonist chase mode sets on the player
├── quest.py # The doors quest mode shuts, and what opens them
├── modes.py # The registry --mode reads its names from
├── cli.py # The options, the parser and main()
└── version.py # The version number, on its own
One type is passed between them all: a maze is a grid, a list of rows of
booleans with True for a wall and False for a cell the player can stand
on, addressed as grid[y][x]. A maze carved by generation is the same
object solving walks, rendering draws and saves writes out, so nothing
is converted along the way. Every module and public name carries a docstring,
so help(py_maze) and help(py_maze.solve_maze) describe the surface.
keys.py is the only module that imports terminal machinery, so msvcrt,
tty and termios stay out of the way of anything that only wants to
generate or solve a maze.
modes.py is the same idea as algorithms/ a level up: it maps the name
--mode takes to the function that builds that game, so a mode is an entry
there and a function beside it rather than a branch in cli.py. Every mode
is handed every mode setting and reads the ones that belong to it, which is
what lets a mode be added without touching the modes already listed.
algorithms/ is the one subpackage, and every module in it is the same
shape: one carving function taking a width, a height and a random number
generator and returning a carved grid. algorithms/__init__.py maps the name
--algorithm takes to the function that carves it, so a fourth algorithm is
a module there and an entry in that map, with nothing in generation.py to
change. pyproject.toml lists the subpackage beside the package, which is
what installs it.
The Version Number
The version is a PEP 440 pre-release
counter, 1.0.0aN, counting up to the first published release. py_maze is
published to no registry, so the number says where it is going rather than
where it has been.
The semantic-versioning scheme this one replaced reached 1.0.0 on
2025-12-25, so the entry for it is headed ## [1.0.0 under semver] in
CHANGELOG.md, and the plain ## [1.0.0] is left for the release the counter
is climbing to.
It lives in one place, __version__ in py_maze/version.py, which
py_maze/__init__.py re-exports as py_maze.__version__. The manifest reads
it from there, so a version change only ever touches that one string:
# pyproject.toml
dynamic = ["version"]
[tool.setuptools.dynamic]
version = { attr = "py_maze.__version__" }
The same value backs the --version flag, and the test suite checks that the
newest dated entry in the changelog is that version. Work that earns no
version at all - documentation, repository chores - is recorded above those
entries under a dateless ## [Unreleased] heading instead, which the suite
reads and never accepts as the version’s own entry.
Those two headings and the ## [1.0.0 under semver] above are the whole
vocabulary the suite accepts, so a label it does not know fails by line
number rather than passing as a third shape. The dated entries descend by
date, which is the order the version check reads the newest entry from and
the order the suite holds the file to.
A pre-release segment holds exactly one number, so there is no patch, minor or
major to move: the counter advances by one for any change that earns a version,
whatever the size of it, and the size is recorded in that run’s changelog entry
instead. The 1.0.0 core stays where it is until the project releases.
Line Endings
Every text file in the repository is stored and checked out with LF endings.
.gitattributes says so in one line, and it applies to the whole tree:
* text=auto eol=lf
LF is the choice because the package is published to PyPI and its sources are read on every platform. On Windows, git checks the same bytes out that it stores, and every editor and tool in this project’s toolchain reads LF without complaint.
The rule exists because the repository once held both. A release that
rewrote 22 files from LF to CRLF, while the files it did not open stayed LF,
reported 25,113 insertions against a real 2,746 - a diff that could not be
read without --ignore-cr-at-eol, and a release that could not be reviewed.
The renormalization itself is in v1.0.0a6, alongside that release’s own
changes, because a run of this project’s automation records one commit rather
than two. So v1.0.0a5 and v1.0.0a6 are the two tags whose diffs want
--ignore-cr-at-eol, and every tag after them reads straight.
If you find a file with CRLF endings, normalise it rather than working around it:
# what git thinks, for one file
git check-attr text eol -- py_maze/rendering.py
Running the Tests
The tests use only the standard library, so no test dependencies are needed:
python -m unittest discover -v
The suite runs on any platform. Both the Windows and the POSIX keyboard branches are exercised through fake terminals, so neither is skipped for running on the other operating system.
That same command is what CI runs. .github/workflows/tests.yml runs it on
Windows, Linux and macOS across every supported Python version on each push
and pull request, so the cross-platform promise is checked rather than
asserted.
This Documentation Site
Every page of this site is a Markdown file in docs/, so the same file reads
on GitHub and here. There is no generator to install: GitHub Pages’ own
Markdown processing builds it, under the one layout in docs/_layouts/ and
the stylesheet in docs/assets/css/.
.github/workflows/workflow.yml builds and deploys it on every push to
main. Editing a page is editing its Markdown file, and adding one means
adding the file and a line in docs/_data/nav.yml, which is the side menu.
The palette, the type scale and the spacing are recorded in
DESIGN_LANGUAGE.md in the repository root, with the contrast ratio measured
for each text and background pair. That file and docs/assets/css/site.css
are meant to agree: a value changed in one is changed in the other.
The marks the header draws sit beside them in docs/assets/: a wordmark and
an icon in each of the two inks, and the icon again as the tab’s favicon. A
file added there and drawn by nothing, or drawn by the layout and missing
from there, fails the suite.
The suite reads these pages as well as the code. The worked example on
the library page is executed and its output compared character
for character, every command line the carving, braiding and scripting pages
show is run, the name tables are checked against __all__, and the project
tree above is resolved against the repository. Documentation that drifts from
the package fails the suite rather than a reader’s terminal.
The shape of those files is read too. No two blank lines in a row stand in
CHANGELOG.md, README.md, TODO.md, CONTRIBUTING.md,
DESIGN_LANGUAGE.md, KNOWN_BUGS.md or any page here - the pair
markdownlint MD012 names, which renders as one. That is every Markdown
document in the repository root, and the root is read back to hold it so - a
document added there and to nothing else fails the suite rather than being
left unread. What sits inside a fenced code block is left out of the count
the way MD012 leaves it out, so an example can be spaced the way PEP 8 asks.
A fence is read inside a blockquote as well as outside one, closing at the
depth it opened at, and every document is held to closing every fence it
opens: one that never closes reads to the end of its document and takes the
count with it.
Width is read on both sides of the repository: every line of every module
under py_maze/ stays under the 80 characters the package is written to,
and no line of test_py_maze.py runs past the 85 that file keeps to, a
wider number for a file whose test names are sentences. Both are things
only a reader had been catching: a collapsed line continuation arrives as
whitespace and leaves Python that still passes.