Developing Prettypretty
Since prettypretty integrates Rust and Python, it requires tooling for both programming languages as well as for integrating between the two. To keep development tasks manageable, the runner or r² script in the repository root automates the most common ones. Its only argument is the task to perform:
install
updates or installs necessary command line tools, including the Rust compiler and Python runtime, using either the APT or Homebrew package manager.build
compiles the Python extension module asprettypretty/color.pyd
(on Windows) orprettypretty/color.abi3.so
(on Unix).check
runs linters, type checkers, and tests for both languages. Tests can be found at the end of Rust modules, embedded in the Rust API documentation, embedded in the user guide, and thetest
directory.doc
builds the guide as well as the API documentation for both languages combining all three in thetarget/doc
directory.
r² only automates local tasks. Making a release requires manually tagging the sources and cutting a release on GitHub. A GitHub action then builds prettypretty’s extension modules for Linux, macOS, and Windows and uploads the source distribution and platform binaries to the Python package index. To validate that the repository’s main branch is, in fact, ready for release, that same action also runs the linters, type checkers, and tests for both languages.
In other words, even though r² and the repository’s GitHub actions have entirely different specifications and runtime environments, they nonetheless perform many of the same tasks. Hence, any substantial change to r² or prettypretty’s GitHub actions probably must be ported over as well.
The Python Extension Module
Prettypretty’s functionality is exposed to Python through a so-called extension module, i.e., a native code library. Python’s import machinery looks for extension modules in the same directories as for regular packages. Once loaded, Python’s runtime interacts with the library through its C API. That includes executing an initialization function to populate the module object with bindings.
In case of PyO3, that initialization function is the #[pymodule]
function,
which creates bindings for constants, #[pyfunction]
s, #[pyclass]
es, as well
as submodules. The latter are useful for structuring APIs that, like
prettypretty’s, comprise more than a handful of abstractions. However, PyO3’s
support for submodules is only rudimentary. Hence, prettypretty’s initialization
function explicitly sets submodules’ __package__
and __name__
attributes and
register them in sys.modules
.
That last step has the welcome side-effect of making submodules loadable with
Python’s import machinery without further customization. Let’s say, Python is
executing a script with an import statement for prettypretty.color.spectrum
.
As usual, Python’s import machinery first imports prettypretty
then
prettypretty.color
. Since the latter is the extension module, Python loads the
native code library and executes its initialization function. That function, in
turn, adds all submodules to sys.modules
. So, when Python’s import machinery
finally gets to importing prettypretty.color.spectrum
itself, it checks
sys.modules
for an entry with that name, which was just added by the extension
module initialization function. Et voilà !
As suggested by PEP 489, I also
experimented with symbolic links from the submodules to the actual native
library. But since all submodules implemented in Rust have prettypretty.color
as parent module, those symbolic links have no impact. Hence, I removed them
again.
Unfortunately, Pylance is confused about submodules of an extension module and currently generates a false warning. You’ll find comments that selectively disable this warning throughout prettypretty’s Python sources.