The previous tutorial built a uv workspace with a single packages/ folder holding a library and a CLI. That layout mixes reusable libraries and runnable programs in one directory. A common convention is to split them: packages/ for importable libraries, apps/ for deployable programs. This tutorial builds a workspace with both, where a shared greetings library in packages/ is consumed by two apps in apps/: a simple command-line app and a FastAPI web app. A root Makefile runs, serves, and tests every member.
By the end, make run-cli runs the command-line app, make serve starts the FastAPI server, and make test runs the tests of all three members in one pass. Both apps import the same shared library through a workspace source, so editing the library updates both immediately.
Prerequisites
- Python 3.12 or later
- uv 0.11 or later (
curl -LsSf https://astral.sh/uv/install.sh | sh) - A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)- Familiarity with the uv workspace basics: the workspace root, members, and the
workspace = truesource
The target layout separates the two kinds of member:
uv-workspaces-apps/
├── packages/
│ └── greetings/ # importable library
└── apps/
├── cli/ # simple command-line app
└── api/ # FastAPI web app
Step 1: Initialize the workspace root
Create the file
uv init uv-workspaces-apps
cd uv-workspaces-apps
rm main.py README.md
Detailed breakdown
uv init uv-workspaces-appsscaffolds a project withpyproject.toml, a samplemain.py, aREADME.md, and a.python-version.rm main.py README.mdremoves the placeholders. The root coordinates members and ships no code of its own.- Step 5 rewrites this
pyproject.tomlinto a workspace coordinator once the members exist.
Step 2: Add the project .gitignore
Create the file
touch .gitignore
Add the code: .gitignore
__pycache__/
*.pyc
.venv/
.pytest_cache/
.DS_Store
*.log
tmp/
dist/
Detailed breakdown
.venv/excludes the single virtual environment uv creates at the root and shares across every member.__pycache__/,*.pyc, and.pytest_cache/exclude bytecode and test caches;dist/excludes build artifacts..DS_Store,*.log, andtmp/cover common OS and scratch files.- Add the ignore file before generating anything else so caches and the environment are never committed.
Step 3: Create the shared library in packages/
Create the file
uv init --lib packages/greetings
Detailed breakdown
uv init --libcreates a library member with asrc/greetings/layout and a[build-system], so it is built and imported like any package.- Running
uv initinside the existing project registerspackages/greetingsin the root’s[tool.uv.workspace]members list automatically. Step 5 replaces the explicit entries with globs. - The library holds logic both apps share. Putting it in
packages/signals that it is a dependency, not something you deploy on its own.
Step 4: Create the two apps in apps/
Create the file
uv init --package apps/cli
uv init --package apps/api
Detailed breakdown
uv init --packagecreates an application member with asrc/layout and a[project.scripts]console-script entry. The--packageform is meant for programs you run, which is what belongs underapps/.- Each command registers its directory as a workspace member. After both commands the root lists
packages/greetings,apps/cli, andapps/apiexplicitly. clibecomes a command-line program;apibecomes the FastAPI service. The next steps replace their generated code and wire each to the shared library.
Step 5: Turn the root into a workspace coordinator
Create the file
The root pyproject.toml already exists. Replace its contents with the coordinator configuration below.
Add the code: pyproject.toml
[project]
name = "uv-workspaces-apps"
version = "0.1.0"
description = "Workspace root coordinating a shared package and two apps"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = ["packages/*", "apps/*"]
[dependency-groups]
dev = ["pytest>=8.0", "httpx>=0.27"]
Detailed breakdown
members = ["packages/*", "apps/*"]uses two globs. This is the change from the previous tutorial’s singlepackages/*. uv unions both patterns, so every directory under either folder is a member. A new app added later underapps/is picked up without editing this list.- The root has a
[project]table but no[build-system], so uv treats it as a virtual root: its dev dependencies install into the shared environment, but the root itself is never built. - The dev group installs
pytestonce for every member’s tests, plushttpx. FastAPI’sTestClientrequireshttpxto drive the app in tests, and it is only needed during development, so it lives in the dev group rather than in theapiapp’s runtime dependencies.
Step 6: Write the shared library
Create the file
uv init --lib already created packages/greetings/src/greetings/__init__.py. Replace its contents.
Add the code: packages/greetings/src/greetings/__init__.py
"""Reusable greeting logic shared across every app in the workspace."""
def greet(name: str = "World") -> str:
"""Return a greeting for the given name.
Args:
name: The name to greet. Defaults to "World".
Returns:
A formatted greeting string.
"""
return f"Hello, {name}!"
Detailed breakdown
greetreturns a string instead of printing, so both the CLI and the API can format the result however they need.- The function is the single source of greeting logic. Because it lives in its own member, the two apps import it rather than duplicating it.
Step 7: Write the simple CLI app and wire the dependency
Create the file
Replace the generated entry point, then declare the dependency on the library.
Add the code: apps/cli/src/cli/__init__.py
"""A simple app: print a greeting from the shared greetings package."""
import argparse
from greetings import greet
def main(argv: list[str] | None = None) -> None:
"""Parse arguments and print a greeting.
Args:
argv: Optional argument list. Defaults to ``sys.argv[1:]`` when None.
Tests pass an explicit list so pytest's own flags are never parsed.
"""
parser = argparse.ArgumentParser(description="Print a greeting.")
parser.add_argument("--name", default="World", help="Name to greet (default: World)")
args = parser.parse_args(argv)
print(greet(args.name))
Now add the workspace dependency:
uv add greetings --package cli
Detailed breakdown
from greetings import greetimports across member boundaries.uv add greetings --package climakes it resolve by addinggreetingsto thecliapp’s dependencies and writing[tool.uv.sources]withgreetings = { workspace = true }, which points at the local member instead of PyPI.maintakes an optionalargv. At runtime the console script passes nothing, soparse_args(None)readssys.argv[1:]. Tests pass an explicit list; without this, a test callingmain()under pytest would parse pytest’s own flags (such as-q) and exit with an argparse error.
Step 8: Write the FastAPI app and wire its dependencies
Create the file
Replace the generated entry point, then add both the FastAPI dependency and the shared library.
Add the code: apps/api/src/api/__init__.py
"""A FastAPI app: serve greetings from the shared greetings package."""
from fastapi import FastAPI
from greetings import greet
app = FastAPI(title="Greetings API")
@app.get("/greet")
def greet_endpoint(name: str = "World") -> dict[str, str]:
"""Return a greeting as JSON for the given query parameter."""
return {"message": greet(name)}
Now add the dependencies:
uv add greetings --package api
uv add "fastapi[standard]" --package api
Then open apps/api/pyproject.toml and delete the [project.scripts] block that uv init --package generated:
[project.scripts]
api = "api:main"
Detailed breakdown
app = FastAPI(...)is the module-level application object. Theuvicorn api:appcommand in the Makefile imports it by theapi:apppath (moduleapi, attributeapp).- The
/greetroute reads anamequery parameter (defaulting toWorld), calls the sharedgreet, and returns JSON. Both apps produce identical greetings because they call the same library function. uv add greetings --package apiadds the sameworkspace = truesource as the CLI.uv add "fastapi[standard]"pulls in FastAPI plus thestandardextra, which includesuvicornand thefastapiCLI used to serve the app.- Delete the generated
[project.scripts]entry.uv init --packagewritesapi = "api:main", but this FastAPI app exposes anappobject, not amainfunction. Leaving the entry defines a broken console script that fails if anyone runs it. A web app is served with uvicorn, so it needs no console script.
After this step, apps/api/pyproject.toml contains:
dependencies = [
"fastapi[standard]>=0.139.0",
"greetings",
]
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[tool.uv.sources]
greetings = { workspace = true }
Step 9: Add tests to each member
Each member owns its tests. pytest discovers all three directories from the root in one run.
Create the file
mkdir -p packages/greetings/tests apps/cli/tests apps/api/tests
touch packages/greetings/tests/test_greet.py
touch apps/cli/tests/test_cli.py
touch apps/api/tests/test_api.py
Add the code: packages/greetings/tests/test_greet.py
"""Tests for the greetings package."""
from greetings import greet
def test_default_greeting() -> None:
assert greet() == "Hello, World!"
def test_custom_name() -> None:
assert greet("uv") == "Hello, uv!"
Add the code: apps/cli/tests/test_cli.py
"""Tests for the simple cli app."""
from cli import main
def test_cli_default_greeting(capsys) -> None:
main([])
assert capsys.readouterr().out.strip() == "Hello, World!"
def test_cli_custom_name(capsys) -> None:
main(["--name", "Workspace"])
assert capsys.readouterr().out.strip() == "Hello, Workspace!"
Add the code: apps/api/tests/test_api.py
"""Tests for the FastAPI app using Starlette's TestClient."""
from fastapi.testclient import TestClient
from api import app
client = TestClient(app)
def test_greet_default() -> None:
response = client.get("/greet")
assert response.status_code == 200
assert response.json() == {"message": "Hello, World!"}
def test_greet_custom_name() -> None:
response = client.get("/greet", params={"name": "FastAPI"})
assert response.status_code == 200
assert response.json() == {"message": "Hello, FastAPI!"}
Detailed breakdown
- The library test asserts on the return value directly.
- The CLI test calls
mainwith an explicit list and usescapsysto capture stdout. - The API test wraps the app in
TestClient, which issues in-process HTTP requests without starting a server. It asserts on the status code and the JSON body.TestClientis whyhttpxis a dev dependency. - All three run under one
pytestinvocation because every member installs into the shared environment.
Step 10: Create the Makefile
The Makefile gives each app its own run target and runs the whole test suite with one command. Plain make prints the help screen.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help sync run-cli serve test tree lock clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
sync: ## Install every workspace member and dev tools into one shared .venv
uv sync --all-packages
run-cli: ## Run the simple cli app (use NAME= to set a custom name)
uv run --package cli cli $(if $(NAME),--name "$(NAME)",)
serve: ## Serve the FastAPI app with autoreload on http://127.0.0.1:8000
uv run --package api uvicorn api:app --reload
test: ## Run the test suite across all workspace members
uv run --all-packages pytest -v
tree: ## Show the resolved dependency graph for the workspace
uv tree
lock: ## Update the single workspace lockfile (uv.lock)
uv lock
clean: ## Remove bytecode and cache files
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
find . -name '*.pyc' -delete 2>/dev/null || true
Detailed breakdown
.DEFAULT_GOAL := helpmakes plainmakeprint the help screen. Thehelprecipe lists every target annotated with a##comment.syncusesuv sync --all-packagesso every member (both apps and the library) installs into the shared environment. A plainuv syncwould install only the root and its dev group.run-cliruns the CLI app’s console script throughuv run --package cli. The optionalNAMEvariable maps to--name.serveruns the FastAPI app withuvicorn api:app --reloadinside theapimember’s environment.--reloadrestarts the server when you edit the app or the shared library. This target blocks until you stop it with Ctrl+C.testuses--all-packagesso pytest can import every member, then runs with-vfor per-test output.tree,lock, andcleaninspect the dependency graph, refresh the singleuv.lock, and remove caches.
Step 11: Run and validate
Run these commands from the uv-workspaces-apps/ project root.
Verify the help screen
make
Expected output:
help Show this help screen
sync Install every workspace member and dev tools into one shared .venv
run-cli Run the simple cli app (use NAME= to set a custom name)
serve Serve the FastAPI app with autoreload on http://127.0.0.1:8000
test Run the test suite across all workspace members
tree Show the resolved dependency graph for the workspace
lock Update the single workspace lockfile (uv.lock)
clean Remove bytecode and cache files
Sync the workspace
make sync
This creates one .venv at the root, installs the library and both apps plus their dependencies, and writes a single uv.lock.
Run the simple app
make run-cli NAME=Workspace
Expected output:
Hello, Workspace!
Serve and call the FastAPI app
In one terminal, start the server:
make serve
In another terminal, call the endpoint:
curl "http://127.0.0.1:8000/greet?name=FastAPI"
Expected output:
{"message":"Hello, FastAPI!"}
Stop the server with Ctrl+C when done.
Run every member’s tests
make test
Expected output:
collected 6 items
apps/api/tests/test_api.py::test_greet_default PASSED [ 16%]
apps/api/tests/test_api.py::test_greet_custom_name PASSED [ 33%]
apps/cli/tests/test_cli.py::test_cli_default_greeting PASSED [ 50%]
apps/cli/tests/test_cli.py::test_cli_custom_name PASSED [ 66%]
packages/greetings/tests/test_greet.py::test_default_greeting PASSED [ 83%]
packages/greetings/tests/test_greet.py::test_custom_name PASSED [100%]
========================= 6 passed, 1 warning in 0.11s =========================
The 1 warning is a StarletteDeprecationWarning about httpx that recent FastAPI and Starlette versions emit from TestClient. It is benign; all six tests pass. Exact counts of warnings and the timing vary with the installed versions.
Inspect the members
make tree
The graph is large because of FastAPI’s dependencies. Limit the depth to see just the members and their direct edges:
uv tree --depth 1
Expected output:
uv-workspaces-apps v0.1.0
├── httpx v0.28.1 (group: dev)
└── pytest v9.1.1 (group: dev)
greetings v0.1.0
cli v0.1.0
└── greetings v0.1.0
api v0.1.0
├── fastapi[standard] v0.139.0
└── greetings v0.1.0
Both cli and api show an edge to greetings, the shared workspace source. Exact dependency versions depend on when you run make sync.
Troubleshooting
ModuleNotFoundError: No module named 'greetings': The environment is missing a member. Runmake sync(which usesuv sync --all-packages); a plainuv syncdoes not install members the root does not depend on.- A member is not picked up: Confirm the app sits directly under
apps/(or the library underpackages/), so one of the["packages/*", "apps/*"]globs matches it, and that it has its ownpyproject.toml. RuntimeError: Cannot install ... api:main/ broken script: You left the generated[project.scripts]entry inapps/api/pyproject.toml. Delete it, as described in Step 8; the FastAPI app is served with uvicorn, not a console script.make serveexits immediately or the port is busy: Another process holds port 8000. Stop it, or append--port 8001to theserverecipe.- CLI tests fail with
SystemExit: 2: A test calledmain()with no argument, so argparse parsed pytest’s flags. Pass an explicit list such asmain([]). make: *** ... missing separator: Makefile recipes must be indented with tabs, not spaces.
Recap
This tutorial extended a uv workspace to separate libraries from programs:
- Two glob members,
packages/*andapps/*, so libraries and deployable apps live in distinct folders - A shared
greetingslibrary inpackages/consumed by both apps through aworkspace = truesource - A simple CLI app and a FastAPI app in
apps/, each with its own dependencies but one shared environment and lockfile httpxas a root dev dependency so FastAPI’sTestClientcan drive the API in tests- A Makefile with a default help screen, per-app
run-cliandservetargets, and a singletesttarget covering all three members
Next improvements could include adding a second FastAPI route backed by a new packages/ library, sharing a Pydantic settings model as its own member, or containerizing the api app with a Dockerfile that runs uv sync --package api for a lean production image.