npx skills add ...
npx skills add bm629/agent-skills --skill python-monorepo-architecture
Use when architecting or splitting a Python repository into a multi-package uv-workspace monorepo — a shared internal library plus one or more app or CLI members that depend on it — and you need the cross-package boundaries right: which code becomes a shared lib vs an app member, the acyclic depend-inward dependency direction (apps depend on the core lib, never on each other), the import-isolation discipline uv cannot enforce, each member's public API at the package boundary, cross-member test layout, and safely extracting shared code out of an existing package. Covers uv workspace wiring (members, tool.uv.sources, single lockfile, --package) and composes with the uv and python-project-structure skills. Keywords: monorepo, uv workspace, multi-package, shared library, package boundaries, dependency direction, circular import, import-linter.
npx skills add bm629/agent-skills --skill python-monorepo-architecture
python-monorepo-architecture — SKILL.mdVariant: standard · When to use: architecting the cross-package structure of a multi-package Python uv-workspace monorepo.
This skill is about the cross-package layer of a Python monorepo: a single repository holding several installable packages (members) managed by a uv workspace — typically one shared library plus one or more application or CLI members that build on it. It covers the decisions that live between packages: where the lib/app boundary goes, which way dependencies are allowed to point, how to keep the boundary honest when the tool can't, what each member exposes to the others, where shared tests live, and how to carve a shared library out of an existing package without breaking it. It does not re-teach intra-package module layout (__all__, layered structure — that is the python-project-structure skill) or general uv usage (uv add/uv run — the uv skill); it owns the uv-workspace wiring because those skills do not cover it, and it composes with both.
libs/<core> plus apps/<app> / tools/<cli> members, or keep it one package.Do NOT activate when:
__all__, package __init__ exports → use python-project-structure.uv skill.Split into multiple members only when there is genuinely shared code consumed by two or more deployables, or a deployable that benefits from an independently-testable core. Signals to split: two apps (e.g. a CLI and a web service) both need the same domain logic; one app is a thin shell over reusable logic. Signals not to split:
requires-python — a uv workspace enforces a single requires-python (the intersection of all members), so divergent Python floors don't fit one workspace.A typical shape, once you do split:
In the root pyproject.toml, declare members as globs:
Every directory matched by members (and not excluded) must contain its own pyproject.toml. A member depends on another member by naming it as a normal dependency and marking the source as the workspace:
Key mechanics:
uv.lock and one virtual environment for the whole workspace. uv lock operates on the entire workspace; uv run / uv sync default to the root and take --package <member> to target one member.core is immediately visible to api with no reinstall.[tool.uv.sources] at the root applies to all members; a member's own [tool.uv.sources] overrides it.core = { path = "../../libs/core" }) or separate them — not a workspace.(For uv add/uv run/lockfile basics see the uv skill; for a member's internal module layout see python-project-structure.)
The package-dependency graph must be a DAG, and it must point inward toward the shared core:
api → core, cli → core.api → cli, no cli → api.core has no app dependency. Shared code that "needs" something from an app is a sign that thing belongs in core (or in another lib), not that core should import upward.A cross-app or upward import is the anti-pattern: it creates a cycle (directly or transitively) and couples deployables that should ship independently. If two apps need to share something new, push it down into core (or a second lib), don't import sideways.
A uv workspace shares one environment, and Python has no dependency isolation: nothing at runtime stops a member from importing a package (or module) that a sibling member declared. uv itself states it "can't ensure that packages don't import dependencies declared by another workspace member." So the boundary is kept by convention + code review, not by the tool:
Treat a sideways/upward import (Step 3) as a review-blocking defect.
Declare every dependency a member actually uses in that member's pyproject.toml — never lean on a sibling's transitive install.
Optional automated enforcement — import-linter. It checks a .importlinter contract in CI / a pre-commit hook and fails the build on a forbidden import. The two contract types that express these rules:
api ↔ cli).core) or "core is forbidden from importing the apps" → enforces depend-inward.Keep it a thin safety net; the rules above stand with or without it.
A consumer imports a member through that member's public surface — its package __init__ / __all__ — and treats everything else as internal. So:
libs/core/src/core/__init__.py (with __all__); consumers do from core import X.core's _internal modules — reaching into another member's private modules re-couples you to its internals and defeats the split.__all__ / underscore-privacy mechanics are the python-project-structure skill's domain — this skill adds only the cross-member rule: consume the boundary, not the internals.)Use a src layout per member (libs/core/src/core/, apps/api/src/api/) so tests run against the installed package, not a stray top-level import — consistent across members.
tests/ with its own conftest.py; pytest auto-discovers fixtures per directory.tests/fixtures/ module) loaded via pytest_plugins, rather than copy-pasted conftests. Keep a fixture as close as possible to what it represents; only truly repo-wide fixtures belong at the top.tests/ directories directly; share through the support package.Carving a library out of an existing app is a behavior-preserving refactor — safety-net-first:
libs/<core> member (Step 2) and wire the app to depend on it.core (move, don't rewrite); repoint the app's imports to from core import ….libs/core/tests/.core too, or inverting the dependency (pass the app-specific piece in as a parameter / via an interface) — never satisfy it with an upward import.See references/worked-example.md for a full three-member walkthrough (root + lib + two apps), the .importlinter contract, and a step-by-step extraction.
Hard rules (never violate):
__init__/__all__), never its _internal modules.[tool.uv.sources] { <name> = { workspace = true } }; if members can't share one environment / requires-python, it is not a workspace (use a path dep or separate repos).Preferences (override-able):
import-linter contract (Independence + Layers/Forbidden) to enforce the boundary in CI once more than one app exists.pytest_plugins, not duplicated conftests.from cli import … inside api works at runtime even though it violates the architecture — uv can't catch it. Only review (or import-linter) will. Don't assume "it imports, so it's allowed."requires-python for the whole workspace. uv takes the intersection across members; a member that needs a higher Python floor silently raises the floor for everyone (or doesn't fit the workspace). Decide the Python floor at the workspace level.from core._internal.thing import x compiles and runs, but re-couples you to private structure that can change without notice. Import only the boundary.[tool.uv.sources]. Listing core in dependencies without the { workspace = true } source makes uv try to resolve it from PyPI — it'll fail or grab a stranger's package. The source line is what makes it a workspace dep.app → app import to reuse one function. Push the helper down into the shared lib instead; sideways imports cycle and couple deployables._internal to avoid widening the public API." If a consumer needs it, expose it on the boundary deliberately; importing privates is hidden coupling.The artifact is a decision + structure: a member topology (which packages exist and why), the root + per-member pyproject.toml wiring ([tool.uv.workspace] + [tool.uv.sources]), a stated acyclic depend-inward dependency direction, each member's public-API boundary, a test/fixture layout, and — when refactoring — a safety-net-first extraction sequence. The consumer is the engineer or coding agent building or restructuring the workspace; the structure it produces is what later feature work builds on.
uv — general uv usage (uv add, uv run, the lockfile). This skill owns the workspace wiring the uv skill doesn't cover; reach for uv for everything non-workspace.python-project-structure — intra-package module architecture, __all__, layered structure. This skill is the cross-package layer above it; use them together (this one for the member boundary, that one for what's inside a member).import-linter (external tool) — optional CI enforcement of the boundary via .importlinter contracts.references/worked-example.md — a full three-member workspace walkthrough (root + shared lib + two apps with the pyproject.toml edges), an example .importlinter contract (Independence + Layers), and a step-by-step safe-extraction. Load when building or restructuring a concrete workspace.references/sources.md — research provenance / citations.description ≤ 1,024 chars. Claude truncates combined description + when_to_use at 1,536 chars in the listing.# apps/api/pyproject.toml
[project]
dependencies = ["core"]
[tool.uv.sources]
core = { workspace = true } # resolve `core` from the workspace, not PyPI; editable