Zum Hauptinhalt springen
••
YouTube Security VideosAnonymous Official: Something Strange Is Happening Right Now in America(03.10.2026 um 21:30 Uhr)
•
Sichere ProgrammierungAzure ExpressRoute Gateway(03.10.2026 um 21:39 Uhr)
••
AI & KI NachrichtenThings I wish my Dot could do(03.10.2026 um 21:45 Uhr)
••
Sichere ProgrammierungI Lost Access to My Email. Here's How It Changed My Career Path(03.10.2026 um 21:45 Uhr)
•
Sichere ProgrammierungHow a race condition stopped UK air traffic control for six hours(03.10.2026 um 21:46 Uhr)
•
Sichere ProgrammierungBlender for Developers: 15 Concepts Explained Simply(03.10.2026 um 21:49 Uhr)
•••
YouTube Security VideosAnonymous Official: Something Strange Is Happening Right Now in America(03.10.2026 um 21:30 Uhr)
•
Sichere ProgrammierungAzure ExpressRoute Gateway(03.10.2026 um 21:39 Uhr)
••
AI & KI NachrichtenThings I wish my Dot could do(03.10.2026 um 21:45 Uhr)
••
Sichere ProgrammierungI Lost Access to My Email. Here's How It Changed My Career Path(03.10.2026 um 21:45 Uhr)
•
Sichere ProgrammierungHow a race condition stopped UK air traffic control for six hours(03.10.2026 um 21:46 Uhr)
•
Sichere ProgrammierungBlender for Developers: 15 Concepts Explained Simply(03.10.2026 um 21:49 Uhr)
•
Intelligence View
⚡ tsecurity.de Intelligence

Python Monorepo Magic: Organize, Build, and Ship Multi-Service Apps

What is a Monorepo? A monorepo is a single Git repository that contains multiple distinct packages or applications. The alternative is a polyrepo: one repo per…

Beitrag
0
Seite
0
↗ Quelle (dev.to)
Social ReaktionenReagiere als Erste:r — dein Feedback zählt!




What is a Monorepo?



A monorepo is a single Git repository that contains multiple distinct packages or applications. The alternative is a polyrepo: one repo per app or package.



This repo is nest-monorepo. It contains:




nest-monorepo/
├── apps/
│ ├── api/ ← FastAPI backend
│ ├── worker/ ← Celery async worker
│ └── cli/ ← Internal tooling CLI
├── packages/
│ ├── shared/ ← Pydantic schemas + SQLAlchemy models
│ ├── events/ ← Event type definitions
│ └── proto/ ← Protobuf definitions + generated stubs






The core idea: code that is needed by multiple apps lives in packages/, and all apps can import it directly — no PyPI publishing required.









Tool 1: uv Workspaces — The Foundation



uv is the package manager. Workspaces are its monorepo feature.






How it's declared



Root pyproject.toml:




[tool.uv.workspace]
members = ["apps/*", "packages/*"]






That's it. Three lines define the entire workspace. uv reads every pyproject.toml under those globs and treats each directory as a workspace member.






What this enables



When you run uv sync at the repo root, uv:




  1. Reads every pyproject.toml in every workspace directory

  2. Resolves all external dependencies into a single uv.lock file at the root

  3. Installs internal packages as editable installs so they can import each other directly






The { workspace = true } source



Look at apps/api/pyproject.toml:




[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["shared", "events", "fastapi>=0.115"]

[tool.uv.sources]
shared = { workspace = true }
events = { workspace = true }






{ workspace = true } means: don't fetch this from PyPI — link it from this workspace instead. When api imports shared, Python resolves it to packages/shared/src/ on disk via an editable install. No publishing, no version pinning against PyPI, no symlink juggling.



This is the mechanism that makes internal sharing work.






Why uv over pip/poetry?



uv uses a global content-addressable cache at ~/.cache/uv. Every version of every package is stored once globally across all projects on your machine. Workspaces get hard links to the cache, not copies. This means:




  • 10–100x faster installs than pip

  • A single uv.lock committed at the repo root — one lockfile, all packages

  • Strict resolution — packages can't accidentally import things they didn't declare



Note: uv.lock is committed to version control. Unlike go.work.sum, it is not gitignored. It is the canonical record of every resolved dependency across the entire workspace.






One virtualenv to rule them all



uv sync creates a single .venv at the repo root. All workspace members are installed into it as editable installs. There is no per-package virtualenv.



This has two important consequences:





  • No version skew. api and worker cannot depend on different versions of shared — they share the same installed environment.


  • IDE setup is trivial. Point your editor's Python interpreter at .venv/bin/python once, at the repo root. Every package is immediately importable — no per-project interpreter switching.




uv sync                  # creates .venv, installs everything
source .venv/bin/activate # or let your IDE detect it
python -c "import shared" # works from anywhere in the repo






Add .venv/ to .gitignore.









Tool 2: Task (Taskfile.yml) — Task Orchestration and Caching



uv workspaces handle dependencies. Task handles tasks (build, test, lint, etc.).






Installing Task



Task is a language-agnostic binary — it has nothing to do with Go and requires no Go installation.




# macOS / Linux via Homebrew (recommended)
brew install go-task

# Linux: direct install script (no Go needed)
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin

# Or grab a binary directly from https://github.com/go-task/task/releases






Verify with task --version.






The problem Task solves



If you run uv run pytest across 6 packages, you have to figure out the order yourself. shared must be importable before events, which must be importable before api and worker. proto must run buf generate before anything imports the generated stubs. Do it wrong and you get import errors or stale generated code.



Also, if nothing in packages/shared changed, you shouldn't rebuild it.



Task solves both: explicit task ordering + file-based caching via sources/generates.






Taskfile.yml — the pipeline






version: '3'

includes:
shared:
taskfile: ./packages/shared/Taskfile.yml
dir: ./packages/shared # without dir:, commands run from repo root
events:
taskfile: ./packages/events/Taskfile.yml
dir: ./packages/events
proto:
taskfile: ./packages/proto/Taskfile.yml
dir: ./packages/proto
api:
taskfile: ./apps/api/Taskfile.yml
dir: ./apps/api
worker:
taskfile: ./apps/worker/Taskfile.yml
dir: ./apps/worker
cli:
taskfile: ./apps/cli/Taskfile.yml
dir: ./apps/cli

tasks:
build:all:
desc: Build all packages in dependency order
cmds:
- task: shared:build
- task: events:build
- task: proto:generate
- task: api:build
- task: worker:build
- task: cli:build

test:all:
desc: Run all tests
deps: [shared:build, events:build, proto:generate]
cmds:
- task: api:test
- task: worker:test
- task: cli:test

lint:all:
cmds:
- task: shared:lint
- task: events:lint
- task: proto:lint
- task: api:lint
- task: worker:lint
- task: cli:lint






packages/proto/Taskfile.yml:




version: '3'

tasks:
generate:
desc: Generate Python stubs from .proto files
sources:
- '**/*.proto'
generates:
- 'generated/**/*.py'
cmds:
- buf generate






Key concepts:





  • sources — glob patterns of input files. Task fingerprints these.


  • generates — files produced by the task. Task checks if they still exist and match the fingerprint.

  • If inputs haven't changed and outputs still exist, Task skips the task entirely (cache hit). Results are stored in .task/.


  • deps: runs dependencies in parallel before the task executes.


  • cmds: runs commands sequentially, in order.






How the build graph flows






packages/shared (uv sync)
↓
packages/events (uv sync) packages/proto (buf generate)
↓ ↓
apps/api (generates stubs)
apps/worker ↓
apps/cli apps/api, apps/worker (import proto stubs)









Running individual apps






# Run just the API
task api:dev

# Run just the worker
task worker:dev

# Build a single package
task shared:build









Affected-only in CI



Task has no --affected flag. The naive approach — looping over changed package names — is wrong: if shared changes, api and worker (which depend on it) must also be tested. You need reverse-dependency propagation.




#!/usr/bin/env bash
# ci/affected.sh — tests changed packages AND their dependents
CHANGED=$(git diff --name-only origin/main...HEAD)

# Reverse dependency map: if X changes, also test these
declare -A RDEPS=(
[shared]="api worker cli"
[events]="api worker"
[proto]="api worker"
)

PACKAGES=()
mark() {
local pkg=$1
[[ " ${PACKAGES[*]} " =~ " ${pkg} " ]] && return # skip if already queued
PACKAGES+=("$pkg")
for dep in ${RDEPS[$pkg]:-}; do mark "$dep"; done # propagate to dependents
}

echo "$CHANGED" | grep -q "^packages/shared/" && mark "shared"
echo "$CHANGED" | grep -q "^packages/events/" && mark "events"
echo "$CHANGED" | grep -q "^packages/proto/" && mark "proto"
echo "$CHANGED" | grep -q "^apps/api/" && mark "api"
echo "$CHANGED" | grep -q "^apps/worker/" && mark "worker"
echo "$CHANGED" | grep -q "^apps/cli/" && mark "cli"

for pkg in "${PACKAGES[@]}"; do task "${pkg}:test"; done






A change to packages/shared marks shared → then propagates to api, worker, cli. A change to apps/api only marks api.



The honest alternative: skip this complexity and run task test:all every time. Task's sources/generates caching skips packages whose inputs haven't changed. You get the same outcome with far less script maintenance.



The key insight: Task knows nothing about Python — it just tracks file fingerprints and runs shell commands in the order you declare. That's exactly the level of abstraction you want.









Tool 3: Shared Packages — The Internal Packages Pattern






Source-level packages (no build step)



Most packages in this repo point directly at Python source:



packages/shared/pyproject.toml:




[project]
name = "shared"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
"sqlalchemy>=2.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"






packages/shared/src/shared/__init__.py exports the Pydantic models and SQLAlchemy base classes directly. No compilation, no dist/ folder. When apps/api imports shared, it reaches straight into packages/shared/src/ via the editable install.



This is the internal packages pattern — source is the distribution artifact.






Packages that do need a build step



packages/proto generates Python stubs from .proto files:



packages/proto/pyproject.toml:




[project]
name = "proto"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["grpcio>=1.60", "protobuf>=4.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"






packages/proto/Taskfile.yml:




tasks:
generate:
sources: ['**/*.proto']
generates: ['generated/**/*.py']
cmds:
- buf generate # buf handles all codegen — don't mix with raw protoc






Task's sources/generates fingerprinting ensures proto:generate runs before anything that imports the stubs — and skips if the .proto files haven't changed.






Consuming internal packages in apps



apps/api/pyproject.toml:




[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"shared",
"events",
"proto",
"fastapi>=0.115",
"uvicorn>=0.30",
]

[tool.uv.sources]
shared = { workspace = true }
events = { workspace = true }
proto = { workspace = true }






apps/worker/pyproject.toml declares the same [tool.uv.sources] block. Because uv resolves everything into a single uv.lock, both api and worker are guaranteed to use the exact same version of shared — no version skew between apps in the same repo.



Why this matters: you can refactor a Pydantic model in packages/shared and immediately see type errors in both apps/api and apps/worker without publishing anything. The workspace is the registry.









Tool 4: Root pyproject.toml — Shared Config



Rather than duplicating [tool.mypy] and [tool.pytest.ini_options] across 6 packages, this repo centralizes all tool configuration in the root pyproject.toml.



Root pyproject.toml:




[project]
name = "nest-monorepo"
version = "0.0.0"
requires-python = ">=3.12"

[tool.uv.workspace]
members = ["apps/*", "packages/*"]

# Dev tools live here — not in subpackage pyproject.toml files
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.25", # asyncio_mode = "auto" requires this
"pytest-cov>=5",
"mypy>=1.15",
"ruff>=0.9",
"pydantic[mypy]>=2.0", # provides the pydantic.mypy plugin
"types-requests",
]

[tool.mypy]
python_version = "3.12"
strict = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
plugins = ["pydantic.mypy"]
# Paths where mypy finds internal package source
mypy_path = "packages/shared/src:packages/events/src:packages/proto/src"

[tool.pytest.ini_options]
testpaths = ["apps", "packages"] # no top-level tests/ dir — each package has its own
asyncio_mode = "auto"
addopts = "-v --tb=short"

[tool.coverage.run]
branch = true
source = ["apps", "packages"] # not "src" — there's no top-level src/

[tool.coverage.report]
fail_under = 80
show_missing = true






Install dev tools with:




uv sync --group dev









How mypy finds this config



mypy does not walk up the directory tree. Unlike Ruff, mypy reads config from the directory where it's invoked — not from the file being type-checked. If you run uv run mypy src/ from inside apps/api/, mypy finds no [tool.mypy] there, silently falls back to non-strict defaults, and your strict = true is ignored.



The correct pattern: always run mypy from the repo root, passing the directories to check:




uv run mypy apps/ packages/






This is different from Ruff, which discovers config per-file by walking upward. With mypy, the invocation location is what matters — not the file location.



There is no [tool.mypy] in subpackages. Do not add one. mypy does not merge configs — a subpackage [tool.mypy] completely replaces the root config rather than extending it.



Why this matters: change strict = true in one place and it applies to all 6 packages — but only when mypy is invoked from the root. Enforce this in your Taskfile and CI.









Tool 5: Ruff — Unified Linting and Formatting



Ruff replaces Flake8 + Black + isort with a single fast tool written in Rust. A single [tool.ruff] section in the root pyproject.toml applies to the entire repo.



Root pyproject.toml (continued):




[tool.ruff]
target-version = "py312"
line-length = 100
src = ["apps", "packages"] # not "src" — tells Ruff where source roots are

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
]
ignore = ["E501"] # line length handled by formatter

[tool.ruff.lint.per-file-ignores]
"**/tests/**" = ["S101"] # allow assert in tests

[tool.ruff.lint.isort]
known-first-party = ["shared", "events", "proto"] # without this, internal packages
# sort as third-party — wrong grouping

[tool.ruff.format]
quote-style = "double"
indent-style = "space"









Config discovery: how subpackages inherit



Ruff discovers config by walking up the directory tree from the file being linted until it finds a pyproject.toml, ruff.toml, or .ruff.toml. In nest-monorepo, every file in apps/ and packages/ walks up and hits the root pyproject.toml. One config, all packages.






Per-package overrides



If apps/cli has looser requirements (it's internal tooling), add an extend in apps/cli/pyproject.toml:




[tool.ruff]
extend = "../../pyproject.toml"

[tool.ruff.lint]
ignore = ["E501", "T201"] # allow print() in CLI






extend inherits all parent settings and overrides only what you specify. This is the right pattern — not a full [tool.ruff] block that silently drops all parent rules.



Each package runs:




uv run ruff check src/
uv run ruff format src/






But the rules are defined once. This is the same pattern as the mypy config: one source of truth, many consumers.



The key insight: Ruff running at 10–100x the speed of Flake8 is not just a nice-to-have in a monorepo. When pre-commit runs on every commit across 6 packages, linting speed is the difference between a 2-second hook and a 30-second one.









Tool 6: pre-commit + Git Hooks — Enforcing Quality at Commit Time



Install pre-commit as a uv tool (not a project dependency):




uv tool install pre-commit
pre-commit install






.pre-commit-config.yaml at the repo root:




repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

# mypy CANNOT use the standard pre-commit mirror hook here.
# pre-commit/mirrors-mypy runs mypy in an isolated virtualenv that has no
# access to your uv workspace's editable installs — `from shared import ...`
# produces false ImportError failures. Run mypy via the local repo instead:
- repo: local
hooks:
- id: mypy
name: mypy
language: system # uses the active .venv, not an isolated env
entry: uv run mypy
args: [apps/, packages/, --config-file, pyproject.toml]
types: [python]
pass_filenames: false # mypy needs whole-package context, not individual files






The key insight: pre-commit automatically passes only staged files to Ruff — no custom script needed. ruff check --fix runs only against the files you're about to commit. mypy, however, needs the full package context to resolve imports correctly, so pass_filenames: false is set and it receives the full apps/ packages/ instead.



The flow on git commit:




  1. pre-commit intercepts the commit


  2. ruff checks and auto-fixes staged .py files


  3. ruff-format formats staged .py files


  4. mypy type-checks all of apps/ and packages/ (fast due to incremental cache)

  5. If any hook fails — commit is aborted, fixed files are left staged for review

  6. If all hooks pass — commit proceeds






Installing consistently across the team



Add to root Taskfile.yml:




tasks:
setup:
desc: Bootstrap the repo for local development
cmds:
- uv sync
- uv tool install pre-commit
- pre-commit install






Every developer runs task setup once after cloning. No tribal knowledge required.



Why this matters: a linting problem caught at commit time costs 3 seconds to fix. The same problem caught in CI costs 5 minutes of context switching. The same problem caught in code review costs someone else's time too.









CI: GitHub Actions






# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true # caches ~/.cache/uv between runs

- run: uv sync --frozen --group dev
# --frozen: fail if uv.lock is out of date (never auto-update in CI)
# --group dev: includes pytest, mypy, ruff

- run: uv run ruff check apps/ packages/
- run: uv run ruff format --check apps/ packages/

- run: uv run mypy apps/ packages/
# always run from repo root — mypy does not walk up per-file

- run: uv run pytest apps/ packages/ -x --cov
env:
PYTHONDONTWRITEBYTECODE: "1"

- run: uv cache prune --ci
# trims downloaded wheels, keeps source-built cache for next run






The --frozen flag is the CI safety net. If a developer forgot to commit an updated uv.lock after adding a dependency, the CI job fails immediately rather than silently installing a different package version than the rest of the team has.









Deploying One App from a Monorepo



The hardest Python monorepo question isn't the build — it's Docker. You need an image for apps/api that includes its internal deps (packages/shared, packages/events) without shipping the entire repo or the dev toolchain.



uv makes this clean with --package:




# apps/api/Dockerfile
FROM python:3.12-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Copy the workspace manifests first (cache layer for dep installation)
COPY pyproject.toml uv.lock ./
COPY packages/shared/pyproject.toml packages/shared/pyproject.toml
COPY packages/events/pyproject.toml packages/events/pyproject.toml
COPY apps/api/pyproject.toml apps/api/pyproject.toml

# Install only api's deps — no dev tools, no other apps
RUN uv sync \
--package api \
--no-dev \
--frozen \
--no-editable # copies source into site-packages instead of editable links

# Now copy the actual source (separate layer so code changes don't bust dep cache)
COPY packages/shared/src packages/shared/src
COPY packages/events/src packages/events/src
COPY apps/api/src apps/api/src

FROM python:3.12-slim
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/apps/api/src /app/src

ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]






Key flags:





  • --package api — installs only api and its transitive deps (including shared and events)


  • --no-dev — excludes pytest, mypy, ruff


  • --frozen — fails if uv.lock is stale


  • --no-editable — copies package source into .venv/lib/ so the final image doesn't need the source tree



Why this matters: without --no-editable, the editable installs point back at the source paths in the build context. The final image would need all of packages/shared/src/ mapped at the same absolute path. --no-editable severs that dependency — the .venv is self-contained.









The Full Picture: How These Tools Interact






Developer commits
↓
pre-commit hook
↓ ruff-check (staged files only, --fix)
↓ ruff-format (staged files only)
↓ (fails → abort commit; passes → continue)
↓
uv workspace resolves internal deps via { workspace = true } editable installs
↓
task build:all
↓ reads Taskfile.yml includes
↓ builds packages in order: shared → events → api/worker/cli
↓ caches outputs in .task/
↓
CI: git diff → affected packages → test only those + dependents
↓ diffs against main branch
↓ runs only impacted packages
↓ Ruff config inherited from root pyproject.toml
↓ mypy config inherited from root pyproject.toml












Summary: Why This Stack


























































Problem Tool Mechanism
Share code without publishing to PyPI uv workspaces
{ workspace = true } + editable installs
Run tasks in dependency order Task explicit cmds: ordering + deps: for parallel
Don't rebuild unchanged packages Task cache
sources/generates fingerprinting in .task/
Run tasks on only changed code in CI git diff script git diff --name-only origin/main...HEAD
Consistent mypy/pytest config root pyproject.toml
single [tool.mypy]; always invoke from repo root
One linter/formatter config Ruff root [tool.ruff]
config discovery walks up per-file
Per-package lint overrides Ruff extend
inherits parent rules, adds/removes specific ones
Enforce quality before commits pre-commit hooks staged-file Ruff; mypy via language: system
Deploy one app without full repo uv sync --package api --no-editable installs app + internal deps, no dev tools


The monorepo isn't one tool — it's these tools composing together. uv handles what exists and how it's linked, Task handles when things run and in what order, and the tooling layer in pyproject.toml handles how everything is configured.

🔍 CTI & Forensik

Cyber Threat Intelligence & Forensik

ATT&CK-Navigator · IoC-Radar · Exploit-Belege
CTI Threat Relationship Graph
Akteure · Techniken · Beziehungen
3 Knoten · 2 Relationen
CVE / Incident Threat Actor Software MITRE ATT&CK CWE Weakness IoC
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Python Monorepo Magic: Organize, Build, and Ship Multi-Service Apps

Thematisch verwandte Begriffe: Python, Monorepo, Magic, Organize · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

💬 Kommentare werden geladen…
Zum Aktualisieren ziehen
Nächster Beitrag