Dependency management¶
This page documents the version specifier conventions and dependency audit procedures used across all repomatic-managed repositories. Downstream projects should follow these conventions in their pyproject.toml files.
Version specifier policy¶
Runtime dependencies ([project].dependencies)¶
Use
>=(not~=or==). Relaxed lower bounds give packagers freedom to release security hotfixes without waiting for an upstream bump. Upper bounds are forbidden.Every version bound needs a comment tying the floor to a concrete code dependency. The comment goes on the line above the dependency and states which feature, method, or API from that version the project actually uses:
# wcmatch 10.0 changed globbing semantics; sync_gitignore() relies on # the new symlink-aware matching behavior. "wcmatch>=10",
A good floor comment answers: “if someone installed an older version, what would break and where?”
Security fixes are a valid floor bump reason. A CVE or advisory in an older version justifies raising the floor even when the API is unchanged:
# requests 2.32.0 fixes CVE-2024-35195 (session credential leak on redirects). "requests>=2.32",
Python version support is not a valid reason to bump a floor. The dependency resolver already picks the right version via
requires-pythonmetadata. Ifboltons>=20works and boltons 25 merely adds Python 3.13 support, keep>=20. Exception: when a dependency drops a Python version your project still supports (or your project drops one, aligning minimumrequires-python), that alignment is a valid floor bump:# boltons 25.0.0 dropped Python 3.9, matching our requires-python >= 3.10. "boltons>=25",
Use conditional markers for Python-version-gated deps. Example:
"tomli>=2; python_version<'3.11'". When a dep has a version marker, the floor rationale must make sense for the Python versions where the dep is actually installed.Alphabetical order within the list.
Development dependencies ([dependency-groups])¶
Prefer
[dependency-groups](uv standard) over[project.optional-dependencies]for test, typing, and docs groups.>=is preferred for dev deps too, but~=is acceptable when stricter pinning reduces CI randomness. If a package also appears in runtime deps, the dev entry must use the same specifier style.Standard group names:
test,typing,docs(lowercase, alphabetical).Type stubs go in the
typinggroup with stub-specific versions:"types-boltons>=25.0.0.20250822".Alphabetical order within each group.
General rules¶
No upper bounds (
<,<=,!=,~=that implies an upper bound). The only exception is conditional markers likepython_version<'3.11'.Extras syntax is fine:
"coverage[toml]>=7.11".One dependency per line for readable diffs. Short groups that fit on one line are acceptable: the
format-jsonworkflow normalizes layout automatically.
Floor verification¶
Comments and changelogs can lie; the codebase is the source of truth. For each dependency with a weak or suspicious comment, verify the floor against actual usage:
Grep for imports. Search the source tree for all imports from the package. List the specific APIs used (functions, classes, constants).
Determine the oldest version providing those APIs. Check changelogs, release notes, or
pip index versions <pkg>to see what exists on PyPI.Lower the floor when it exceeds the oldest compatible version. Prefer conservative minimums (the major version that introduced the API) over aggressive ones. Update both the version specifier and the comment.
Run
uv lockafter any floor change to verify the lock still resolves.
Special cases¶
Backport packages (like
tomli,exceptiongroup) exist solely to provide a stdlib class to older Python versions. Their entire API is the backported class, available in all versions. The floor is typically>=1unless a specific bug fix is needed.Conditional deps with stale bug-fix floors. A dep gated by
python_version<'3.11'that has a floor set for a bug affecting Python<3.8.6: ifrequires-pythonis>=3.10, that bug is irrelevant and the floor can be lowered.pytest plugins with no special API beyond auto-registration have low effective floors. Set the floor at the major version introducing the current plugin interface, not at the latest release.
Red flag patterns in floor comments¶
These comment patterns typically signal a floor set at adoption or auto-bump time, not at an API boundary:
“First version we used” or “first version when we last changed the requirement”: the floor is an artifact of when the dep was added or last bumped by Renovate/Dependabot.
“First version to support Python 3.X”: unless it documents a
requires-pythondrop alignment or a concrete build failure, this is not a valid floor reason.The
~= -> >=conversion pipeline: a common inflation path where (a) dep is added as~=X.Y(latest at the time), (b) Renovate bumps to~=X.Z, © a bulk “relax requirements” commit converts all~=to>=. Each step inflates the floor without API validation.
exclude-newer-package cooldown overrides¶
The [tool.uv] section may contain exclude-newer-package entries that exempt specific packages from the global exclude-newer cooldown window. Each entry is one of two kinds:
A fixed UTC timestamp (like
"2026-06-16T00:00:00Z"): a freeze that holds the package at the version available just before that instant, used when a needed release is still inside the cooldown window.sync-uv-lockwrites these automatically (pinned to the day after the held version shipped) and prunes each one once its held version ages pastexclude-newer, returning the package to the normal cooldown. The full timestamp is deliberate: uv re-expands a bareYYYY-MM-DDdate in the locking machine’s local timezone, so a bare date serializes to a differentuv.lockvalue locally than in CI and churns the file on every run. These are self-expiring and rarely need manual attention.A
"0 day"span: a permanent exemption, for packages with no PyPI release to age against (git or path sources, like a project depending on itself). These never expire on their own.
To deliberately adopt a release that is still inside the cooldown (a feature migration to a just-published version, the counterpart to the security fix that audit --fix automates), add a freeze timestamp by hand: set it to the start of the UTC day after the target version shipped (the same convention the automatic freezes follow), so uv lock resolves up to and including that release. The hand-written entry is then managed like any other: sync-uv-lock prunes it automatically once the adopted version ages past exclude-newer, so it needs no later cleanup and will not linger as a stale pin.
For each exclude-newer-package entry, check:
Is the package still a dependency? If removed from
[project].dependenciesand all[dependency-groups], the entry is dead weight.Is a
"0 day"span still justified? A permanent span fits an in-repo (git or path) package. A"0 day"span on an external PyPI package is suspect: it tracks the latest release forever and never ages out, so it should be a freeze timestamp instead (whichsync-uv-lockmaterializes on its next run).Is a freeze timestamp stuck? A freeze timestamp older than the
exclude-newerwindow should already have been pruned. A lingering one usually means the package left the dependency tree or the sync job has not run since it aged.
Floor bumps to adopt new APIs¶
A floor bump is justified when a newer version of an existing dependency provides an API that replaces hand-rolled code in the project. A valid simplification bump must:
Replace existing code, not add new features. The goal is less code, not more capability.
Be a net reduction in complexity. Swapping a one-line comprehension for a library call is not a win.
Use the public API of the dependency. Private/undocumented attributes do not count.
Update the floor comment to reference the new API and the code it replaces.