Declare standards-based project metadata, install editable source, create and inspect an sdist and wheel, and prove the wheel works in a clean environment outside the repository.
You will learn: Read the main pyproject.toml tables, connect source and distribution identities, install editable source, expose module and console entry points, build and inspect artifacts, and verify the exact wheel in a fresh environment.
Practice in: A local editor and terminal with two disposable virtual environments
The constellation source works in its project environment. That is not yet a release proof. Another person receives an installable distribution, not your working tree, editor settings, or accidental current-directory imports. This lesson writes one complete project description, builds the two common artifacts, and tests the wheel from somewhere that cannot see src/.
Keep these questions beside the terminal:
Which metadata is standardized, and which table belongs to a particular tool?
Which name is used by installers, imports, and console commands?
What changes between editable and regular installation?
What does a build frontend ask a build backend to do?
Which files actually entered the wheel?
Can the exact wheel run in a new environment outside the repository?
1. One pyproject.toml describes the installable project
At the root of constellation-report, replace the placeholder with:
[build-system]requires=["hatchling>=1.26"]build-backend="hatchling.build"[project]name="constellation-report"version="0.1.0"description="Build small constellation observation reports."readme="README.md"requires-python=">=3.10"authors=[{ name ="Course Learner" }]dependencies=[][project.scripts]constellation-report="constellation.cli:main"[tool.hatch.build.targets.wheel]packages=["src/constellation"]
TOML uses tables, keys, strings, arrays, and inline tables. The spelling and value type matter. This file has four responsibilities:
Table
Who defines it?
What it says here
[build-system]
Python packaging standard
backend interface and requirements needed to build
[project]
Python packaging standard
distribution identity, version, compatibility, description, and dependencies
[project.scripts]
Python packaging standard
installed command name mapped to an importable callable
[tool.hatch.build.targets.wheel]
Hatchling
which src-layout package the chosen backend should put in the wheel
The last table is tool-specific. Replacing Hatchling with another backend would require reading that backend’s configuration; do not assume all [tool.*] tables are portable standards.
The repository directory can be renamed without changing installed metadata. The distribution name is used by installers and importlib.metadata. The import-package name is used by Python. The console command is a generated launcher whose name happens to match the distribution here, but it need not.
State Python compatibility honestly
requires-python = ">=3.10" tells installers which Python versions the release claims to support. It does not install Python and does not test those versions. Choose the range from syntax used, dependencies, and actual verification. A mistyped range can make an otherwise correct wheel impossible to install.
Runtime dependencies stay empty when runtime uses only Python
The constellation package imports only standard-library modules, so:
dependencies=[]
is accurate. Hatchling and build are development/build tools. Users do not need to import them when running the installed report, so they do not belong in [project].dependencies.
Standard metadata connects the source tree to build tooling and the artifact a user installs.
Add a small command boundary. Unit 12 will teach full argument parsing, streams, and exit codes; this lesson only connects packaging entry points safely.
# src/constellation/cli.pyfrom .reports import observation_linedef main():"""Display one example observation report."""print(observation_line("moon gate", -15))
Make package-module execution delegate to the same callable:
The right side means: import the module constellation.cli, retrieve its main attribute, and call it. Do not add parentheses. This is metadata, not a Python call expression.
Keep cli.py import-safe
Do not call main() unconditionally in cli.py:
# Wrong design: importing constellation.cli would run the command.def main():print("Moon Gate: 345°")# main() # Do not place this unguarded call in the real file.
The generated launcher calls main. __main__.py calls main only when module mode executes it. An ordinary import merely defines it.
3. Install editable source for development
Activate .venv and verify its identity, or use its Python explicitly. From the project root:
.venv/bin/python-m pip install --editable .
PowerShell uses .venv\Scripts\python.exe. The dot means “install the project described by pyproject.toml in the current directory.”
An editable installation records a development link so imports use the working source. Editing src/constellation/reports.py can affect the next fresh Python process without rebuilding and reinstalling a wheel. Backend details vary, so treat editable installation as a development mode—not as the release artifact users receive.
Prove origin, metadata, API, and both entry routes:
Install the frontend as development tooling in the project environment:
.venv/bin/python-m pip install "build>=1"
Then remove old artifacts and build from the repository root:
rm-rf dist build.venv/bin/python-m build
By default, build creates an isolated build environment, installs the [build-system].requires there, asks the backend for a source distribution, and builds a wheel (normally from that source distribution). This can require access to a configured package index unless requirements are already available.
Do not put build into runtime dependencies merely because maintainers use it. The finished wheel records only [project].dependencies as runtime requirements.
5. An sdist and a wheel serve different installation paths
After a successful build, dist/ should contain files resembling:
Filename normalization can change hyphens to underscores. Read metadata rather than inferring every identity rule from the filename.
A source distribution (.tar.gz) contains source material and metadata from which a backend can build. A wheel (.whl) is a built distribution with a standard archive layout that installers can place without rebuilding project source in the usual pure-Python case.
Neither artifact is “the Git repository zipped up.” Backend selection rules decide included files. Inspect them.
Inspect the source distribution without extracting it
Use tarfile to list the .tar.gz safely. Listing avoids overwriting files in the current directory:
from pathlib import Pathfrom tarfile importopenas open_tarsource_archives =sorted(Path("dist").glob("*.tar.gz"))iflen(source_archives) !=1:raiseRuntimeError(f"expected one source distribution, found {len(source_archives)}" )source_archive = source_archives[0]with open_tar(source_archive, mode="r:gz") as archive: source_names =sorted(member.name for member in archive.getmembers())for name in source_names:print(name)
The archive should contain project metadata, README material, and package source needed for another build. It need not include .git, .venv, caches, or a previously generated dist/ directory. Treat any archive from an untrusted source as external input; listing is safer than extracting, and safe extraction still requires attention to member paths and links.
Why inspect both artifacts? The default frontend workflow can build the wheel from the sdist. A source file present in the repository but absent from the sdist may therefore disappear from the wheel even if a direct local wheel build would have found it. Checking both makes the handoff chain visible.
Inspect wheel contents with the standard library
from pathlib import Pathfrom zipfile import ZipFilewheels =sorted(Path("dist").glob("*.whl"))iflen(wheels) !=1:raiseRuntimeError(f"expected one wheel, found {len(wheels)}")wheel = wheels[0]with ZipFile(wheel) as archive: names =sorted(archive.namelist())for name in names:print(name)
Do not expect .venv/, .git/, checks/, caches, or dist/ inside the product wheel. The README may be represented in distribution metadata rather than as a top-level package file.
Read metadata from inside the wheel
from email.parser import BytesParserfrom email.policy import defaultfrom pathlib import Pathfrom zipfile import ZipFilewheel =next(Path("dist").glob("*.whl"))with ZipFile(wheel) as archive: metadata_name =next( name for name in archive.namelist() if name.endswith(".dist-info/METADATA") ) metadata = BytesParser(policy=default).parsebytes(archive.read(metadata_name))print(metadata["Name"])print(metadata["Version"])print(metadata["Requires-Python"])print(metadata.get_all("Requires-Dist", []))
Expected values are constellation-report, 0.1.0, >=3.10, and an empty runtime requirement list. This is artifact evidence, not merely the source TOML you hoped the backend would use.
If 0.1.0 and 0.2.0 wheels both remain, a successful install may verify the wrong release. Clean the output directory before building and assert the exact number of artifacts afterward:
from pathlib import Pathwheels =sorted(Path("dist").glob("*.whl"))sdists =sorted(Path("dist").glob("*.tar.gz"))assertlen(wheels) ==1, wheelsassertlen(sdists) ==1, sdistsprint(wheels[0].name)print(sdists[0].name)
For automation beyond this lesson, pass the exact expected artifact path from the build step rather than rediscovering it through a broad glob.
6. A clean installation must not see the repository source
Create a second environment in a temporary directory, install the exact wheel file, and run from another directory. This helper works on POSIX and Windows:
The installed origin should point inside the temporary clean environment, not the repository’s src. The version should be 0.1.0, normalization should print 359, and module execution should print Moon Gate: 345°.
To test the generated console launcher portably, locate its scripts directory:
Then run environment_command(clean_environment, "constellation-report") through subprocess.run. The launcher should produce the same output as module mode.
Compare installed metadata with source and wheel metadata
Ask the clean environment—not the development process—to report what it received:
The values should agree with the wheel inspection. This three-way comparison catches different mistakes:
source TOML wrong: repair project metadata before rebuilding;
source correct but wheel metadata stale: clean artifacts and rebuild;
wheel correct but clean environment reports another version: verify the exact installed path and environment interpreter;
metadata correct but import origin points to the repository: remove the source-tree shortcut and rerun outside it.
Do not import a __version__ constant and assume it matches distribution metadata unless the project deliberately has one version source and tests the relationship. This lesson uses installed distribution metadata as the release version record.
Preserve subprocess failures as release evidence
During a clean run, do not use check=True until you know which output you need to inspect. Capture the first failing attempt:
A missing module, missing callable, wrong output, or duplicated output identifies a different contract. After recording it, repair source/metadata, rebuild from a clean dist/, recreate or reinstall into the clean environment, and rerun. An old installed wheel cannot observe a source edit by itself.
ImportantWhy the outside directory matters
If the proof runs from the repository root, an accidental flat-layout package, path modification, or current-directory file might mask a broken wheel. A fresh environment plus unrelated working directory isolates the artifact contract.
Checkpoint: distinguish building from release proof
Starting from a clean project checkout, produce this evidence in order:
Tree:src/constellation, checks, README, .gitignore, and pyproject.toml are present; generated folders are absent.
Environment:.venv’s Python reports its executable and differing prefix/base_prefix.
Editable install: package origin points into src, public assertions pass, and module/console routes agree.
Build: one sdist and one wheel exist after cleaning dist/.
Inspection: the wheel contains every package module and entry-point metadata but no local environment or caches.
Clean install: a second environment installs the exact wheel path without seeing repository source.
Outside run: public API, version metadata, module command, and generated console launcher all produce expected results from another directory.
Git:git status --short contains only deliberate source changes; build artifacts remain ignored.
Add one changed requirement: update the version to 0.2.0 and change the demo label from moon gate to lunar archive. Clean dist/, rebuild, and verify the new wheel. Do not leave both versions in dist/, because a glob that silently chooses one would make the proof ambiguous.
Compare a compact release evidence checklist
[ ] Environment executable is inside .venv[ ] Editable origin is inside repository/src[ ] Distribution metadata version is 0.2.0[ ] Exactly one sdist and one wheel were built[ ] Wheel contains constellation package and entry_points.txt[ ] Wheel contains no .venv, .git, __pycache__, checks, or dist directory[ ] Clean origin is inside the second environment, not repository/src[ ] API returns 359 for -1 degrees[ ] python -m constellation prints Lunar Archive: 345°[ ] installed constellation-report prints the same line[ ] proof command ran outside the repository[ ] repository status contains no generated artifacts
If a check fails, retain the command, return code, standard output, standard error, interpreter, working directory, and relevant origin. Repair the earliest failed contract, rebuild from clean dist/, and repeat the clean install.
Diagnose the release layer before editing code
Use the first failed observation to choose the next inspection:
Failure
Inspect next
Avoid
TOML parse or validation error
named table, key, line, and value type
changing package functions
backend cannot load in isolated build
[build-system].requires, configured index, and connectivity
adding backend to runtime dependencies
editable import missing
environment Python, install result, package path, and origin
permanent sys.path mutation
wheel lacks one module
backend inclusion configuration and sdist contents
copying source into the clean environment
console launcher names a missing callable
[project.scripts] target and import-safe module
duplicating command logic in metadata
clean install refuses Python version
Requires-Python in source and wheel metadata
forcing installation without understanding compatibility
outside run imports repository source
working directory, environment, editable state, and module origin
treating passing output as release proof
One repair can require rebuilding and reinstalling. Source edits do not alter an already built wheel. Wheel edits do not alter an already installed copy. Keep a simple chain in the evidence log:
Write the filename and SHA-256 digest beside the installed wheel when several processes could handle artifacts:
from hashlib import sha256from pathlib import Pathwheels =sorted(Path("dist").glob("*.whl"))iflen(wheels) !=1:raiseRuntimeError(f"expected one wheel, found {len(wheels)}")wheel = wheels[0]digest = sha256(wheel.read_bytes()).hexdigest()print(wheel.name)print(digest)
The digest identifies exact bytes; it does not prove those bytes are safe or correct. Pair identity with metadata, content inspection, and behavior. Signing and full supply-chain policy belong beyond this Foundations unit.
Hand the project to a clean-room learner
Ask another learner—or simulate one with a new directory—to use only:
the source repository at one named commit;
a supported Python interpreter;
documented access to build requirements;
the release rehearsal commands; and
expected API, module, and console outputs.
They should not receive your .venv, editable-install metadata, caches, or dist/. If they build the same version, artifact bytes are not guaranteed to be identical across every backend, timestamp, or platform. The foundation claim is narrower: the declared workflow produces an artifact with the expected metadata and behavior. Fully reproducible builds require additional controls.
Have the learner report the base/environment Python versions, wheel filename, wheel digest, installed origin, and all three behavior results. Differences in absolute paths are expected. Differences in distribution version, public result, entry-point output, or artifact contents require investigation before a release is trusted.
8. Key points for installable projects
[build-system], [project], and [project.scripts] are standardized packaging tables; [tool.hatch...] is backend-specific configuration.
Repository, distribution, import-package, module, and console-command names are distinct identities even when some spellings match.
Runtime dependencies describe what installed product code needs. Build and verification tools belong in development configuration.
Editable installation supports source development. It is not proof of the wheel another environment receives.
Module mode and a generated console launcher should delegate to one import-safe callable.
A build frontend requests artifacts; a backend implements the build hooks.
Inspect both metadata and file contents. A successful build command alone does not establish artifact completeness.
The strongest release rehearsal installs the exact wheel into a fresh environment and runs it outside the repository.