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)
  • make installed (pre-installed on macOS and most Linux distributions)
  • Familiarity with the uv workspace basics: the workspace root, members, and the workspace = true source

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-apps scaffolds a project with pyproject.toml, a sample main.py, a README.md, and a .python-version.
  • rm main.py README.md removes the placeholders. The root coordinates members and ships no code of its own.
  • Step 5 rewrites this pyproject.toml into 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, and tmp/ 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 --lib creates a library member with a src/greetings/ layout and a [build-system], so it is built and imported like any package.
  • Running uv init inside the existing project registers packages/greetings in 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 --package creates an application member with a src/ layout and a [project.scripts] console-script entry. The --package form is meant for programs you run, which is what belongs under apps/.
  • Each command registers its directory as a workspace member. After both commands the root lists packages/greetings, apps/cli, and apps/api explicitly.
  • cli becomes a command-line program; api becomes 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 single packages/*. uv unions both patterns, so every directory under either folder is a member. A new app added later under apps/ 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 pytest once for every member’s tests, plus httpx. FastAPI’s TestClient requires httpx to drive the app in tests, and it is only needed during development, so it lives in the dev group rather than in the api app’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

  • greet returns 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 greet imports across member boundaries. uv add greetings --package cli makes it resolve by adding greetings to the cli app’s dependencies and writing [tool.uv.sources] with greetings = { workspace = true }, which points at the local member instead of PyPI.
  • main takes an optional argv. At runtime the console script passes nothing, so parse_args(None) reads sys.argv[1:]. Tests pass an explicit list; without this, a test calling main() 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. The uvicorn api:app command in the Makefile imports it by the api:app path (module api, attribute app).
  • The /greet route reads a name query parameter (defaulting to World), calls the shared greet, and returns JSON. Both apps produce identical greetings because they call the same library function.
  • uv add greetings --package api adds the same workspace = true source as the CLI. uv add "fastapi[standard]" pulls in FastAPI plus the standard extra, which includes uvicorn and the fastapi CLI used to serve the app.
  • Delete the generated [project.scripts] entry. uv init --package writes api = "api:main", but this FastAPI app exposes an app object, not a main function. 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 main with an explicit list and uses capsys to 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. TestClient is why httpx is a dev dependency.
  • All three run under one pytest invocation 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 := help makes plain make print the help screen. The help recipe lists every target annotated with a ## comment.
  • sync uses uv sync --all-packages so every member (both apps and the library) installs into the shared environment. A plain uv sync would install only the root and its dev group.
  • run-cli runs the CLI app’s console script through uv run --package cli. The optional NAME variable maps to --name.
  • serve runs the FastAPI app with uvicorn api:app --reload inside the api member’s environment. --reload restarts the server when you edit the app or the shared library. This target blocks until you stop it with Ctrl+C.
  • test uses --all-packages so pytest can import every member, then runs with -v for per-test output.
  • tree, lock, and clean inspect the dependency graph, refresh the single uv.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. Run make sync (which uses uv sync --all-packages); a plain uv sync does 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 under packages/), so one of the ["packages/*", "apps/*"] globs matches it, and that it has its own pyproject.toml.
  • RuntimeError: Cannot install ... api:main / broken script: You left the generated [project.scripts] entry in apps/api/pyproject.toml. Delete it, as described in Step 8; the FastAPI app is served with uvicorn, not a console script.
  • make serve exits immediately or the port is busy: Another process holds port 8000. Stop it, or append --port 8001 to the serve recipe.
  • CLI tests fail with SystemExit: 2: A test called main() with no argument, so argparse parsed pytest’s flags. Pass an explicit list such as main([]).
  • make: *** ... missing separator: Makefile recipes must be indented with tabs, not spaces.

Recap

This tutorial extended a uv workspace to separate libraries from programs:

  1. Two glob members, packages/* and apps/*, so libraries and deployable apps live in distinct folders
  2. A shared greetings library in packages/ consumed by both apps through a workspace = true source
  3. A simple CLI app and a FastAPI app in apps/, each with its own dependencies but one shared environment and lockfile
  4. httpx as a root dev dependency so FastAPI’s TestClient can drive the API in tests
  5. A Makefile with a default help screen, per-app run-cli and serve targets, and a single test target 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.