Part 1 of the series sets the foundation. Before you write a single FastAPI route or call a language model, you want a project that installs in seconds, pins exact versions, and fails fast when types drift. This part walks through a modern Python 3.14 setup using uv, a clean project layout, and the typing and linting baseline the rest of the series relies on.
What you will set up
- A reproducible Python 3.14 project managed by uv
- A src layout that keeps imports honest
- Ruff for linting and formatting, plus a type-checking baseline
- A first runnable script you can extend in later parts
Info
Who this is for
You know basic Python and the terminal. You do not need prior FastAPI or LLM experience. Later parts build directly on this layout.
1. Why uv
uv is a fast Python package and project manager. It resolves and installs dependencies far quicker than pip plus venv, and it writes a lockfile so your environment is reproducible across machines. For an LLM project, where you will pull in an HTTP client, a model SDK, and a vector library, that speed and reproducibility matter.
# Install uv (macOS / Linux)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Start a project that targets Python 3.14
uv init llm-app --python 3.14
cd llm-app
# Add dependencies; uv creates and manages the virtualenv for you
uv add httpx pydantic
uv add --dev ruff mypy pytest
# Run anything inside the managed environment
uv run python -V
2. A src layout that keeps imports honest
A flat layout lets you import your package even when it is not installed, which hides packaging bugs until deploy. A src layout forces you to install the package, so local runs and production behave the same. Here is the shape this series uses.
llm-app/
pyproject.toml
uv.lock
src/
llm_app/
__init__.py
config.py
main.py
tests/
test_smoke.py
Point your build at the src directory in pyproject.toml so the package resolves correctly.
[project]
name = "llm-app"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = ["httpx>=0.28", "pydantic>=2.9"]
[tool.uv]
dev-dependencies = ["ruff>=0.6", "mypy>=1.11", "pytest>=8.3"]
[tool.hatch.build.targets.wheel]
packages = ["src/llm_app"]
3. Typing and linting baseline
Ruff handles both linting and formatting, so you do not need a separate formatter. Keep the config small and strict enough to catch real problems without fighting you.
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # errors, pyflakes, imports, pyupgrade, bugbear
[tool.mypy]
python_version = "3.14"
strict = true
Pro tip
Run uv run ruff check . and uv run mypy src in a pre-commit hook. Catching a type error before it reaches a request handler saves far more time than it costs.
4. Your first runnable code
Type hints are not decoration. They document intent and let your editor and mypy catch mistakes. Run the snippet below to see a typed function in action. This same playground appears throughout the series, so you can try ideas without leaving the page.
Checkpoint
Why does this series use a src layout instead of a flat layout?
5. Lock the environment for reproducibility
Installing the newest matching versions on your laptop and slightly different versions on the server is how the classic works on my machine bug is born. uv writes a lockfile, uv.lock, that pins the exact version and hash of every package, including the transitive ones you never named directly. Commit it. On any machine, uv sync rebuilds the same environment to the byte, and uv run executes inside it without you activating anything by hand.
# Resolve and lock every dependency, then install exactly what is locked
uv lock
uv sync # creates .venv matching uv.lock exactly
# In CI and production, fail if the lockfile is stale instead of resolving silently
uv sync --frozen
uv run pytest
The split that matters is simple. Developers run uv add, which updates both pyproject.toml and uv.lock together. CI and production run uv sync with the frozen flag, which refuses to install anything the lockfile does not already pin. That turns an accidental upgrade into a failed CI run you can see, rather than a difference between environments you discover at the worst possible moment.
6. Configuration and secrets from day one
An LLM project has secrets immediately: a model API key, often a database URL, sometimes a vector store token. Do not scatter os.environ reads across the codebase, and never hardcode a value. Keep a .env file for local settings, add it to .gitignore, and commit a .env.example with blank placeholders so a teammate can see exactly what they must provide. Part 5 promotes this into a typed settings object that fails on startup when a variable is missing; for now, set the habit.
# .env (gitignored, never committed)
ANTHROPIC_API_KEY=sk-ant-your-real-key
APP_ENV=local
# .env.example (committed, blank placeholders so teammates know what to set)
ANTHROPIC_API_KEY=
APP_ENV=local
Warning
A leaked key is a billed key
If a key ever lands in git history, rotate it right away. Removing the line in a later commit is not enough, because every clone and fork keeps the old history. Treat any exposed key as compromised and revoke it.
7. Make the common tasks one command
You will run the same handful of commands hundreds of times: format, lint, type check, test, run. Hide the exact flags behind short task names so nobody has to remember them and so CI runs precisely what you run locally. A tiny Makefile is enough, and it removes the most common source of green local runs and red CI runs, which is the two using different commands.
# Makefile
.PHONY: fmt check test
fmt:
uv run ruff format .
check:
uv run ruff check .
uv run mypy src
test:
uv run pytest -q
Now make check is the single gate before every commit, and the CI workflow calls the same targets. The value is not the tool, it is that the question how do I run the checks has exactly one answer that cannot drift from what runs in CI.
8. Choosing dependencies you will not regret
It is tempting to add every interesting library on the first day. Resist it. Each dependency is code you do not control, a possible future security advisory, and one more thing to keep current. For this series the core set is deliberately small: httpx for HTTP, pydantic for validation, the model SDK for generation, and a vector store later for retrieval. Add a dependency when a real need appears, not in anticipation, and prefer well maintained libraries with readable release notes.
✓ Pros
- A small, intentional dependency set is easy to audit and upgrade
- uv.lock makes every install reproducible across machines
- Dev tools live in a separate group, kept out of production installs
✕ Cons
- Every dependency is attack surface and maintenance you inherit
- Unpinned versions drift between machines and over time
- Pulling a heavy framework for one small helper rarely pays off
9. The project skeleton later parts extend
To make the layout concrete, here is the skeleton the rest of the series fills in. The point of looking at it now is that each later part drops into an obvious place. Settings live in config.py and are read everywhere through one cached function. The application is assembled by a small factory in main.py so tests can build a fresh app without side effects. Routers, the model client, and retrieval each get their own module as they arrive.
# src/llm_app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "LLM App"
anthropic_api_key: str = "" # set in .env; validated for real in Part 5
@lru_cache
def get_settings() -> Settings:
return Settings()
The application factory keeps construction in one function. Nothing runs at import time, which means a test can call create_app and get a clean instance, and you can register routers and middleware in a single readable place. By the end of the series this same function wires in authentication, a model client dependency, and the chat and retrieval routers, but the shape never changes.
# src/llm_app/main.py
from fastapi import FastAPI
from llm_app.config import get_settings
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name)
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "app": settings.app_name}
return app
app = create_app() # uvicorn / fastapi dev import this
Run it with uv run fastapi dev src/llm_app/main.py and hit /health to confirm the wiring. It is a tiny endpoint, but it proves the whole chain works: uv resolved the environment, the src layout resolved the import, settings loaded, and the app started. Every later part adds to this skeleton rather than starting over, which is the real payoff of spending Part 1 on setup instead of rushing ahead.
The bottom line
A fast, reproducible toolchain pays off in every later part. With uv managing dependencies, a src layout keeping imports honest, and Ruff plus mypy guarding quality, you have the base this series builds on. Next we make data modeling rigorous with Pydantic v2, which is also the backbone of FastAPI request validation and reliable model output later on.
? Frequently asked questions
Do I have to use uv? +
No. Poetry or pip with venv also work. The series uses uv for speed and a clean lockfile, but the code runs the same under any tool.
Is Python 3.14 required? +
The examples target 3.14 features where it helps, but most code runs on 3.11 and later. Pin whatever your deploy target supports.
Comments
0No comments yet. Be the first to share your thoughts.