Skip to content

Phase 5.6: v1.0.0, C++20 modules, Apache-2.0, and a Typer CLI - #43

Merged
bbalouki merged 17 commits into
mainfrom
bbs-dev
Sep 23, 2026
Merged

bbalouki merged 17 commits into
mainfrom
bbs-dev

Conversation

@bbalouki

@bbalouki bbalouki commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

Completes Phase 5.6 of the roadmap and ships v1.0.0, plus the two
cross-cutting changes requested alongside it: the license moves to Apache-2.0
and the CLI moves to Typer.

  • C++20 module interfaces did not exist at all. api/modules.py is CMake
    include() reflection from Phase 5.3; a repo-wide search for CXX_MODULES,
    FILE_SET, .cppm and .ixx returned nothing. Now
    add_library(..., modules=[...]) emits
    target_sources(<t> <vis> FILE_SET CXX_MODULES FILES ...), PUBLIC for a
    library so consumers can import it and PRIVATE otherwise. A module-only
    library needs no sources= at all.
  • Nothing was ever deprecated during 0.x, so "deprecations from 0.x
    deleted" was a no-op. What was missing was the mechanism, now
    cmakeless.deprecated(...).
  • The API freeze is now a test rather than a reviewer's attention:
    tests/unittests/test_public_api.py snapshots every exported name, its
    inherited methods and full signatures.
  • Real-world validation is now mechanical: tests/integration/ builds
    every examples/ project from its real cmakelessfile.py.
  • Documentation: a new docs/stability.md states what the version number
    covers and what it does not.

The CMake floor moves per project, not globally

FILE_SET CXX_MODULES needs CMake 3.28, but a project that declares no
modules still emits cmake_minimum_required(VERSION 3.25), byte for byte as
before. Every pre-existing golden file is unchanged, which is the evidence
that non-module projects are unaffected.

Two real bugs the new integration suite found on its first run

  1. Custom-command arguments were emitted unquoted (82a9f61). Any argument
    with a backslash (every absolute Windows path, e.g. sys.executable), a
    space, or a quote reached CMake's parser raw, producing a configure-time
    syntax error or silently splitting into several arguments. VERBATIM
    governs the shell handoff and never protected the CMake-language parse, so
    the "argument lists, never shell strings" promise had a hole under it. This
    also closes an injection path.
  2. examples/09_build_language invoked a bare python (1ef1ab6), which
    is the Store stub on Windows and absent on Linux images shipping only
    python3.

Checklist

  • An issue exists for this change — n/a, this is the planned Phase 5.6
    release tracked in docs/ROADMAP.md.
  • Tests added or updated under tests/unittests/, mirroring src/.
    Emitter changes include golden-file tests with deterministic inputs
    (golden/cxx_modules.cmake, regenerated golden/custom_commands.cmake).
  • Every new/changed public module, class, function and method has a
    docstring (enforced by test_code_standards.py).
  • CHANGELOG.md updated — a full [1.0.0] entry, folding in the items
    that were sitting under [Unreleased].
  • Docs updated: FEATURES.md section 13, cookbook.md, migration.md,
    ARCHITECTURE.md, ROADMAP.md, README.md, new stability.md.
  • ruff check . and ruff format --check . pass locally.
  • mypy src passes locally (strict mode).
  • CI green on Windows, Linux, and macOS — watching this PR.

Locally: 912 passed, 13 skipped; mkdocs build --strict clean.

Apache-2.0 is the more common choice for developer tooling that teams
vendor into their own build, and its explicit patent grant removes a
question MPL leaves open for a tool that generates files into a
proprietary repository.

The per-file headers are gone rather than rewritten: one LICENSE and
NOTICE at the root state the terms once, and 155 files repeating them
added noise to the top of every module. Files that changed for other
reasons in this release drop their header in their own commit.

Also fixes the LICENSE file itself, which was a reflowed copy pasted
from a web page with curly quotes and an Exhibit B truncated
mid-sentence; the Apache text is the canonical one.

CONTRIBUTING gains an inbound=outbound clause (Apache-2.0 section 5),
so contributors know no separate CLA is required.
An argument carrying a backslash, a space, or a quote reached CMake's
parser raw. Any absolute Windows path did it: sys.executable emitted
COMMAND C:\Python\python.exe, where CMake reads \P and \p as escapes
and the configure step dies with a syntax error. An argument with a
space silently became two arguments instead.

VERBATIM never covered this. It governs how CMake hands a parsed
argument to the shell, not how the CMake language parses the argument
list in the first place, so the "argument lists, never shell strings"
promise had a hole underneath it.

Each argument is now escaped and wrapped in quotes, which also closes
the injection path where an argument containing a quote could end the
argument early and have the rest read as CMake code. Dollar signs are
left alone on purpose, so a caller passing ${CMAKE_COMMAND} still gets
it expanded.
The custom-command example invoked a bare "python", which resolves to
the Microsoft Store stub on a default Windows install and does not
exist at all on Linux images that ship only "python3". The example
that teaches code generation could therefore not run the code it
generated.

Using the interpreter that is already running the build description is
the portable answer, and the comment says so, because this is exactly
the kind of thing a reader is about to copy into their own project.

Found by the new integration suite on its first run.
The last feature Phase 5.6 owed. A library declares its module
interfaces with modules=[...] and consumers just write "import
geometry": no header, no include directory, no include guard, and for
a module-only library no sources= either.

Visibility is the part worth getting right. A library's interfaces are
PUBLIC because consumers need the built module interface to import
them at all; an executable's are PRIVATE, since nothing links against
one. That is why the block is emitted per target kind rather than from
the shared settings path.

The CMake floor is computed per project rather than raised globally.
FILE_SET CXX_MODULES needs 3.28, but making every user of every
feature pay for one they may never touch is not a trade worth making,
so a project declaring no modules still emits 3.25, byte for byte as
before. The existing golden files are the proof: none of them moved.

Four freeze-time checks, each saying what to try next: the target must
compile as C++20 or later, a file cannot be both a source and a module
interface, a header-only library cannot declare one because an
INTERFACE library compiles nothing, and installing a target that
exports one is refused while CMake's own support for shipping built
module interfaces is still behind an experimental flag. Refusing beats
emitting CMake that breaks on the next CMake release.

Modules also need a generator that scans for dependencies, so
"cmakeless doctor" gained a check that names whichever of the CMake
version or the generator falls short.
cmakeless.api's docstring claimed it "re-exports every public class and
function from its submodules" and quietly omitted three names. The
other four layer packages never made the claim but had the same gap,
between one and eleven names each, so reaching parts of a layer meant
importing from a sibling module that the architecture calls private.

Public names defined in underscore-prefixed modules now surface from
cmakeless itself, for the same reason: a user should never have to
write cmakeless._parallel to reach parallel_map. That covers the
version floors, BUILD_SCRIPT_NAME, and the free-threading helpers.

deps had two different constants both named MANIFEST_NAME, in conan
and vcpkg, which is precisely the collision a package-level re-export
surfaces. They are now CONAN_MANIFEST_NAME and VCPKG_MANIFEST_NAME;
both are private machinery, so renaming them costs nothing.

A new test enforces all of it, so the docstring cannot drift from the
truth again: every public name a submodule defines must appear in its
package's __all__, and every __all__ entry must resolve.
argparse had the CLI describing itself twice: a chain of helper
functions registering flags by verb-name membership tests, then a
_run_verb that pulled every possible flag back out of one flat
Namespace with getattr defaults, because no verb accepts them all.
Typer lets each verb declare exactly the options it takes, as typed
parameters mypy checks, and the getattr guessing disappears.

Nothing a user or a script can observe changes: same twelve verbs,
same flags and short flags, same output strings, same exit codes
(0, 1 on a CMakeless error, 2 on a usage error), and main(argv) keeps
its signature for the console script and python -m. The existing CLI
test suite passes untouched, which is the point of keeping it.

Help text now comes from each command's own docstring rather than a
parallel dict of help strings that could drift from it.

Rich markup and pretty tracebacks are switched off deliberately. This
CLI's output is plain text that scripts and CI logs read line by line,
and a terminal-width-sensitive renderer would break that.

Typer becomes the one runtime dependency, and only cli.py imports it,
so a cmakelessfile.py that does "import cmakeless" still pulls in
nothing but the standard library.
"Public API frozen and audited" was going to mean whatever a reviewer
happened to notice. It now means a test: every name in
cmakeless.__all__, every public method those classes inherit as well
as define, and every full signature, snapshotted to a golden file.
Inherited members are included deliberately, because dropping
add_sources from the shared target base is exactly as breaking as
dropping a method a class declares itself, and a vars()-only snapshot
would have missed it.

Regenerating the snapshot is a deliberate act with an obvious moment
to write the CHANGELOG entry, which is the behaviour we want from an
API freeze.

Nothing was ever deprecated during 0.x, so there was nothing to
delete; what was missing was the mechanism. cmakeless.deprecated and
warn_deprecated_argument give the first 1.x deprecation one house
style: a DeprecationWarning naming the replacement and the release
that removes it, with the note appended to the docstring so readers
of the API reference see it too.
The unit suite proves each feature against a synthetic model. Nothing
proved the thing users actually copy from: the examples tree. Each
example is now copied to a temp directory and its cmakelessfile.py is
executed exactly as a user would run it, so an API change that breaks
documented usage fails here instead of in somebody's first five
minutes.

It earned its place immediately, finding two real bugs on its first
run: unquoted custom-command arguments, and an example invoking a bare
"python" that does not exist on Windows.

Gated the way the existing end-to-end tests are, so a machine without
CMake still runs the rest, and examples that fetch dependencies need
CMAKELESS_NETWORK_TESTS=1.
A new stability page states what the version number actually covers,
because "1.0" on its own tells a reader nothing about whether their
import will still work next year. It names the three things that are
public (the __all__ surface, the generated file formats, the CLI),
says plainly that everything else changes freely, and points at the
three tests that enforce it rather than asking anyone to take it on
trust.

FEATURES gains the modules section, including the honest part: what
is deferred and why. The cookbook gets the three recipes someone
adopting modules will actually look for, and the migration guide
records module installation and import std as things that still have
no equivalent.

ARCHITECTURE's zero-dependency paragraph was no longer true once the
CLI moved to Typer. It now states the rule that replaced it, which is
more useful anyway: the dependency is confined to cli.py, so the four
layers stay standard library only.
Phase 5.6 is complete, so the version number says so: Development
Status moves to Production/Stable and the roadmap records the phase
as shipped along with what it deliberately deferred.

From here the version number is a contract rather than a description.
Breaking changes mean 2.0, and anything removed gets a deprecation
warning naming its replacement for at least one minor version first.
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…bject

A project with a Python module or a shared library failed to link on
Linux as soon as it depended on anything fetched from source: the
dependency's static archive was compiled without -fPIC, and ld refuses
to put that inside a shared object.

Our own targets already carry POSITION_INDEPENDENT_CODE, but a
FetchContent dependency is a subproject we do not own and cannot set
properties on, so the directory-level variable is the only lever that
reaches it. That is why this one global is deliberate rather than an
oversight, and the comment in the generated file says so.

Emitted only when the project actually builds something shared, so a
project of static libraries and executables produces the same bytes as
before.

Found by the new integration suite building examples/08_capstone on
Linux; macOS and Windows never showed it, since PIC is the default on
one and meaningless on the other.
The snapshot walked the whole MRO down to object, so every exception
class carried BaseException's add_note, with_traceback and args into
the golden file. Those are CPython's API, not ours, and CPython renders
them differently per version: add_note reads as "add_note(...)" on 3.12
and "add_note(self, object, /)" on 3.13, so the file could only ever
match the interpreter that generated it. Every 3.12 job failed on it.

Walking only the bases defined inside cmakeless keeps what the snapshot
is for, catching a method dropped from a shared target base, while
leaving the standard library to version itself.
Adding tests/integration to the default test path put every example
build, including the ones that fetch and compile fmt, GoogleTest and
pybind11, into all six matrix jobs. That is minutes of compiler time
repeated per Python version to test something that does not depend on
the Python version, and it made a plain local pytest slow enough that
contributors would start skipping it.

The two build tests now carry an "integration" marker and are
deselected by default. CI runs them in a dedicated job across the three
operating systems on one Python, which is the matrix that actually
matters for compiler and generator behaviour.

The two consistency checks stay in the default run: they only read
files, cost nothing, and catch an example that was added without a
catalogue entry.
Patch coverage flagged the paths a passing machine never takes, and
each one turned out to be worth pinning rather than waving through.

The CLI's exit-code mapping is the interesting one: scripts branch on
those numbers, so a normal return, a bare exit, an integer exit and a
message-style exit are now asserted against a stubbed command layer
instead of being whatever the command layer happens to do.

Also covers doctor reporting a failed required check as exit 1, the
scaffold deriving a valid project name from a directory name that is
not one, an unparseable cmake --version, and the generator helpers
that decide whether a generator can scan for modules.

cli.py, doctor.py and generators.py are now fully covered.
A .coverage database slipped into the previous commit. It is a local,
machine-specific binary that changes on every test run, so it has no
business in the history; the ignore rules now cover it and the report
formats alongside it.
Patch coverage was reporting 95.57% against a 97.56% target, and
splitting the number apart showed two unrelated things wearing one
label.

The real gap: a module interface produced by add_command() skips the
exists-on-disk check, because a code-generation step's output does not
exist until the step runs. That branch shipped untested, so generated
module interfaces are now covered both ways, one that is a declared
command output and one that is simply missing.

The rest was measurement. A bare --cov measures the test suite as well
as the package, so deselecting the example builds counted their own
bodies as untested code, and the patch number moved for a reason that
said nothing about whether cmakeless is tested. Coverage is now scoped
to the package, which is the thing the number is supposed to describe.
@bbalouki
bbalouki merged commit e902ca2 into main Sep 23, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant