OMEP-0008: Public API Stability & Versioning Policy¶
Context and Problem Statement¶
OxydeMark is consumed on two fronts: as a Rust rlib crate (via the rlib
crate type declared in Cargo.toml) and as a Python package (built with
maturin, native module oxydemark._core). Downstream projects -- notably
OxydePress -- will depend on both surfaces.
Today, neither surface is explicitly documented as the stable API. The crate
root (src/lib.rs) does not re-export a curated public surface, there is no
py.typed marker or type stubs shipped with the Python package, and there is
no written semver policy. OxydeMark is pre-1.0 (0.1.0 in both Cargo.toml
and pyproject.toml).
Without an explicit contract, downstream consumers cannot tell which items are supported versus incidental, and we cannot make internal refactors with confidence about what we are allowed to break. We need to (1) freeze the intended public Rust and Python surfaces, (2) decide a stub/typing strategy for Python, and (3) define a semantic-versioning policy appropriate for the 0.x phase together with the criteria for cutting 1.0.
Decision Drivers¶
- Explicit contract -- Consumers must know exactly which items are stable and which are internal implementation details.
- Two consistent surfaces -- The Rust and Python APIs should expose the same conceptual operations (parse, transform, render) so the two ecosystems stay aligned.
- Typed Python -- Consumers using type checkers (mypy, pyright) must get accurate types for the compiled native module, which cannot be introspected from source.
- Refactor freedom pre-1.0 -- We are still iterating on internals (extensions, arena conversion); the policy must allow breaking changes while the project is young, but signal them clearly.
- Predictable path to 1.0 -- Downstream projects need to know what stabilisation means and when to expect it.
- Alignment with existing conventions -- AGENTS.md already mandates Conventional Commits, Python 3.12+, and type hints on all public functions; this OMEP formalises the surface those rules apply to.
Considered Options¶
- Option A: No explicit surface (status quo) -- Treat every public item as incidental; document nothing; consumers depend at their own risk.
- Option B: Freeze surfaces +
py.typedinline types, semver from 1.0 onward -- Declare the public surfaces, ship inline type information, but only start honouring semver at 1.0. - Option C: Freeze surfaces +
py.typedinline types + explicit 0.x semver policy with 1.0 criteria -- Declare the frozen Rust and Python surfaces, ship apy.typedmarker with inline type hints (plus a.pyistub only for the compiled_coremodule), and define a 0.x versioning contract now.
Decision Outcome¶
Chosen option: Option C, because it gives downstream consumers an explicit, typed contract immediately while preserving the freedom to iterate that a pre-1.0 project needs. It documents both surfaces so the Rust and Python ecosystems stay in lockstep, and it states unambiguously what 1.0 will mean.
Public Rust surface¶
The following items constitute the supported Rust surface and are to be
re-exported from the crate root (oxydemark::*). Everything else in the crate
(the api internals, the extensions, html_render, and ast internal
helpers, the thread-local parser/renderer caches, and the arena-conversion
functions) is private and may change without notice.
| Item | Kind | Description |
|---|---|---|
parse(markdown: &str) -> AstNode |
function | Parse Markdown into an AstNode tree (infallible). |
render_ast(node: &AstNode) -> String |
function | Render an AstNode tree to HTML. |
markdown_to_html(markdown: &str) -> Result<String, OxydeError> |
function | Convert Markdown to HTML in one pass (fast path). |
AstNode |
struct | Tree-based AST node (kind, children, text, attributes, metadata, props) with new() and walk(). |
OxydeError |
enum | First-class, PyO3-independent error type for the Rust surface. |
Meta |
enum | Typed metadata value, re-exported from rushdown; the value type of ParseResult::frontmatter and AstNode::props. |
The metadata-aware surface (parse_document, ParseResult, Heading,
slugify, extract_summary) is defined additively in
OMEP-0010.
Notes and constraints on the Rust surface:
- The parser is configured with GFM, YAML frontmatter (
rushdown-meta), emoji (rushdown-emoji), and the Comark extensions (OMEP-0007). The set of enabled extensions is part of the observable behaviour but the concrete extension types (BlockComponent,InlineComponent,SpanAttributes, etc.) are not part of the public surface. AstNode.kindstring values (e.g."document","paragraph","heading","emphasis","strong","emoji","block_component","inline_component","span_attributes","softbreak","hardbreak") are part of the contract: existing kinds will not be renamed within a minor series once 1.0 is reached. New kinds may be added.- The Rust surface is PyO3-independent: PyO3 is an optional dependency
behind the non-default
pythoncargo feature and is never required by Rust consumers (see "Feature gating" below). Fallible operations return a first-class [OxydeError];parseis infallible. This supersedes the earlier decision to leakPyResulton the Rust surface, which was tracked as a 1.0 follow-up and has now been pulled forward. Metais the one item in the surface that originates outside the crate: it is defined inrushdownand re-exported so the surface is self-contained -- a downstream crate can name it in a struct field, a function signature and amatchwithout depending onrushdownitself (issue #34). The corollary, already stated inCargo.toml, is that arushdownmajor bump is a breaking change for OxydeMark too; therushdown*requirements are pinned exactly for that reason (issue #33). Replacing it with an owned,Serialize-able OxydeMark metadata type would be a breaking change and deserves its own OMEP.
Feature gating¶
The crate is cdylib + rlib. All PyO3 bindings (#[pyclass], #[pyfunction],
#[pymodule]) live behind cargo features so downstream Rust crates never pull
in PyO3:
python-- enables the optionalpyo3dependency and the binding layer (src/python.rs, theoxydemark._coremodule). Safe forcargo test --features pythonbecause it links libpython.extension-module-- addspyo3/extension-moduleon top ofpythonfor building the wheel. maturin activates this feature ([tool.maturin] features = ["extension-module"]); it must not be used forcargo test.- No default features: a plain
cargo buildand downstreamoxydemark = "..."dependency are 100% PyO3-free.tests/public_api.rsexercises the whole public surface without any feature to prove this.
Public Python surface¶
The public Python surface is everything re-exported from the oxydemark
package top level and listed in its __all__:
| Item | Source | Description |
|---|---|---|
parse(markdown: str) -> AstNode |
oxydemark._core |
Parse Markdown into an AstNode tree. |
render_ast(ast: AstNode) -> str |
oxydemark._core |
Render an AstNode tree to HTML. |
markdown_to_html(markdown: str) -> str |
oxydemark._core |
Convert Markdown to HTML in one pass. |
AstNode |
oxydemark._core |
AST node class: constructor AstNode(kind, children=None, text=None, attributes=None, metadata=None), attributes kind/children/text/attributes/metadata and the read-only props, method walk(). |
OxydeEngine |
oxydemark.api |
Pipeline engine: OxydeEngine(plugins=None), render(markdown: str) -> str. |
Plugin |
oxydemark.api |
Union of the Preprocessor/Transformer/Postprocessor protocols, i.e. any object implementing at least one hook. |
As on the Rust side, the metadata-aware surface (parse_document,
ParseResult, Heading, slugify, extract_summary) is defined additively in
OMEP-0010.
Notes and constraints on the Python surface:
oxydemark._coreis an internal module name (the compiled extension). It is not part of the public contract and must not be imported directly by consumers; import fromoxydemarkinstead.- The
Pluginprotocol is structural: plugins are duck-typed and need only implement the hooks they use. This is a public, stable contract. - Anything not listed in
oxydemark.__all__is private, with the single documented exception of the provisionaloxydemark.contribnamespace below.
Provisional surface: oxydemark.contrib¶
oxydemark.contrib is a namespace of example plugins shipped with the
package (admonitions, shortcodes, mentions, lazy images). It exists to
demonstrate the Plugin protocol against a real, tested implementation rather
than through prose alone.
It sits in a distinct, weaker stability tier:
- It is public and importable (
from oxydemark.contrib import AdmonitionPlugin), documented indocs/plugins.md, and covered by tests intests/test_contrib.py. - It is intentionally excluded from
oxydemark.__all__, so the frozen core surface stays small and thetests/test_public_api.pycontract is unaffected. - It carries no stability guarantee: contrib plugins may change behaviour, change signatures, or be removed in a MINOR bump without being treated as a breaking change to the public API.
- Contrib plugins must not be a dependency of the core pipeline:
OxydeEnginenever imports them, and removing the whole namespace must leave the frozen surface intact. - Consumers who need long-term stability should copy the plugin into their own
codebase rather than depend on
oxydemark.contrib.
Typing / stub strategy¶
- Ship a
py.typedmarker file inpython/oxydemark/so type checkers treat the package as typed (PEP 561). - The pure-Python module (
api.py) already carries inline type hints; those are the source of truth forOxydeEngineandPlugin. - The compiled
_coremodule cannot be introspected from source by static type checkers, so ship a hand-writtenpython/oxydemark/_core.pyistub declaring every name the extension exports --AstNode,Heading,ParseResult,parse,parse_document,render_ast,markdown_to_html,slugifyandextract_summary. This is the single stub file we maintain; the rest of the package relies on inline hints. - Both
py.typedand_core.pyiare packaged by maturin (they live underpython-source).
Semver policy (0.x and criteria for 1.0)¶
While OxydeMark is in the 0.x series, versioning follows the widely-used 0.x
interpretation of Semantic Versioning:
0.MINOR.PATCH.- A breaking change to any public surface (Rust or Python, as frozen above)
bumps the MINOR version (
0.1.z -> 0.2.0). - Additive, backward-compatible changes and bug fixes bump the PATCH
version (
0.1.0 -> 0.1.1). - Both the crate (
Cargo.toml) and the Python package (pyproject.toml) are released with the same version number, in lockstep. - Breaking changes must be flagged in commit messages per Conventional Commits
(
feat!:/fix!:or aBREAKING CHANGE:footer) sogit-cliffsurfaces them in the changelog (OMEP-0003). - Changes to private items (internal modules, extension types, caches) are not breaking changes and do not, on their own, trigger a MINOR bump.
Criteria for cutting 1.0 (all must hold):
- The Rust and Python surfaces above have been stable across at least two consecutive minor releases with no breaking changes.
- ~~A dedicated, first-class Rust error type replaces the leaked
PyResulton the Rust surface (the follow-up noted above).~~ Done (pulled forward): the Rust surface uses [OxydeError] and PyO3 is optional behind a feature. AstNode.kindvalues and node structure are documented and covered by tests asserting the contract.- ~~
py.typed+_core.pyipass a type-check gate in CI.~~ Done:mise run typecheckrunstyover the package and is a dependency ofmise run ciand of the CIpythonjob. - Downstream OxydePress integration has validated the surface in real use.
Once 1.0 is reached, standard SemVer applies: breaking changes bump MAJOR.
Consequences¶
- Good, because downstream consumers (OxydePress) get an explicit, typed, versioned contract for both the crate and the package.
- Good, because internal modules remain free to change without a breaking-change bump, preserving refactor velocity pre-1.0.
- Good, because the Rust and Python surfaces are documented as mirror images, keeping the two ecosystems consistent.
- Bad, because a hand-written
_core.pyistub must be kept in sync with the Rust#[pymethods]; drift is possible until an automated check exists. - Good, because the Rust surface is PyO3-independent (
OxydeError+ optionalpythonfeature), so downstream Rust crates (OxydePress) can depend on therlibwithout pulling in Python. - Neutral, because lockstep versioning couples crate and package releases; this is simpler to reason about but occasionally forces a no-op bump on one side.
Confirmation¶
- A Python test (
tests/test_public_api.py) asserts thatoxydemark.__all__exactly matches the frozen public surface listed here and that each name is importable from the top-level package. ls docs/specs/OMEP-0008-public-api.mdconfirms the OMEP exists.- CI continues to run the Rust and Python suites (
mise run ci). - The typing gate runs
tyover the package with its stub (mise run typecheck), andtests/test_typing.pyadditionally type-checks a snippet exercising the frozen surface.
Pros and Cons of the Options¶
Option A: No explicit surface (status quo)¶
- Good, because zero upfront work.
- Bad, because consumers cannot distinguish stable from incidental API.
- Bad, because it blocks a confident OxydePress integration.
- Bad, because no typing story for the compiled module.
Option B: Freeze surfaces + py.typed, semver only from 1.0¶
- Good, because it documents and types the surfaces now.
- Bad, because "no semver until 1.0" gives 0.x consumers no signal about breaking changes, which is exactly the phase OxydePress will consume.
Option C: Freeze surfaces + py.typed + explicit 0.x semver (Chosen)¶
- Good, because consumers get a contract and a breaking-change signal during the 0.x phase.
- Good, because it sets concrete, testable 1.0 criteria.
- Neutral, because it requires maintaining one hand-written stub file.
- Bad, because it commits us to lockstep versioning and a 1.0 follow-up for the Rust error type.
More Information¶
- Semantic Versioning 2.0.0 -- including the 0.x clause.
- PEP 561 -- Distributing and Packaging Type Information.
- PyO3 User Guide -- class and function exposure.
- Related: OMEP-0001 (architecture and the Rust/Python split), OMEP-0003 (Conventional Commits / changelog), OMEP-0007 (extended syntax whose node kinds appear on the AST surface).
- Follow-up actions:
- ~~Add
python/oxydemark/py.typedandpython/oxydemark/_core.pyi.~~ Done. - ~~Re-export the public items from the crate root in
src/lib.rs.~~ Done (issue #20). - ~~Introduce a first-class Rust error type before 1.0.~~ Done (
OxydeError, issue #20). - ~~Add a type-check gate to CI.~~ Done (
ty,mise run typecheck).