steamify is a published PyPI library that converts Markdown to Steam's BBCode-style markup and
back, exposing exactly two public functions, to_steam and to_markdown. README.md lists the
supported constructs; CONTRIBUTING.md covers commands, branches, commits, and CI.
- Zero runtime dependencies is a product constraint, not an accident. Everything is built on
reand the standard library. Do not add a runtime dependency without the maintainer asking for one requires-python = ">=3.10"is the supported floor, even though.python-versionpins 3.13 for local development and CI.just formatenforces it by runningpyupgrade --py310-plus- Run
just checkbefore proposing a change is done (just --listfor the rest). Type checking isty, not mypy - Never hand-edit
versioninpyproject.tomlorCHANGELOG.md. The Release workflow (workflow_dispatchonly) derives both from the commit history with git-cliff AGENTS.mdis a symlink to this file. EditCLAUDE.md; writingAGENTS.mddirectly replaces the symlink with a regular file.no-testsis an untracked sentinel that makesjust testa no-op. Do not create it unless deliberately silencing the suite- Ruff runs
select = ["ALL"]- assume a new rule will fire and runjust formatbeforejust lint just formatandjust lintreach ruff and ty throughuvx(ambient, latest) whilejust testruns throughuv run(project venv). A ruff version skew between the two is therefore possible and is not a bug to chase- Coverage is reported, not gated - there is no
fail_under, soterm-missingoutput blocks nothing __init__.pysets__version__fromimportlib.metadata.version("steamify"), so the package must be installed (editable is fine) for import to work
steam.py (Markdown -> Steam) and markdown.py (Steam -> Markdown) are deliberate mirror images of
each other. When editing one, check whether the other needs the symmetric change.
- The
_try_convert_*contract - each returnsTrueif it consumed the line andFalseif it did not apply, and_process_linerelies on that to short-circuit. Order matters: inmarkdown.pythe chain is a singleorexpression, and code-block detection must come first so markup inside a[code]block is never interpreted - The
@@CODE{n}@@sandwich - inside_convert_inline_elements,_convert_inline_code_spanspulls every code span out and leaves a sentinel, the other inline conversions run on the sentinel-bearing text, then_render_inline_code_spanssubstitutes the originals back wrapped in the target syntax. This is what stops**bold**inside`code`from being mangled, so any new inline conversion must be inserted between the extract and render steps or code spans stop being protected list_stackdiverges between the modules - it is alist[tuple[str, int]]in both, but the second element is the source indent width in spaces insteam.py, used to decide open/close/dedent, and the running item counter inmarkdown.py, used to number[olist]entries
These look like bugs but are intended; tests lock them in:
to_steamclamps headings to h1-h3 because Steam only supports three heading levels, whileto_markdownmaps[h1]-[h6]to#-######- Steam-only tags with no Markdown equivalent pass through verbatim rather than being stripped - see
test_unmappable_tags_pass_through - The round-trip guarantee is convergence, not identity:
to_steam(to_markdown(steam)) == steamafter the first pass, enforced bytest_round_trip_is_stable. Markdown -> Steam -> Markdown is not required to return the original string
- Add the compiled pattern to the
_PATTERN_*block at the top of the module, not inline in a function - Block-level: write another
_try_convert_*and insert it at the right position in the_process_linechain, never ahead of code-block detection - Inline: add the conversion between the extract and render halves of
_convert_inline_elements - Make the symmetric change in the sibling module, or establish why it does not apply
- Add cases to the existing
test_complex_scenarios/test_edge_casesparametrize blocks
The suite is white-box: private functions are imported directly by name and tested individually,
alongside end-to-end to_steam / to_markdown cases. Renaming a private helper breaks tests, which
is intentional. Heavy use of @pytest.mark.parametrize with (input, expected) tuples - add cases
to an existing parametrize list rather than writing a new test function when the shape fits.