Recommended resources
The freely available resources this lesson will direct you to.
Hi! Last lesson, you identified where an AI feature depends on a provider, handles secrets, and makes model calls. Before changing that feature, give it a dependable foundation: an environment another developer can recreate, and configuration that changes between local and hosted use without editing source code.
Today’s deliverable is a small runnable project with a committed dependency lockfile, an ignored local configuration file, and settings that fail early when misconfigured. Use a throwaway folder first; then apply the pattern to the application you audited. Allow about 45–50 minutes, including the selected videos and hands-on work.
Build the project skeleton first
We’ll use uv to manage the Python environment. Its project workflow gives us two different records: pyproject.toml declares what the application needs, while uv.lock records the resolved dependency versions. The virtual environment itself is disposable.
Watch Corey Schafer build and run a uv project before starting yours. The useful distinction is between declaring a dependency, locking its resolution, and installing it into the current environment.
Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv
“Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv,” by Corey Schafer, shows the project workflow we’ll use.
Watch project initialization for the generated files, dependency locking for uv add and uv.lock, and running and recreating for uv run and uv sync. Focus on what remains after the virtual environment is removed.
Check that uv --version works. If uv is absent, you can install its CLI with python -m pip install uv; make sure the resulting uv command is on your PATH. In a new, empty folder, run:
uv init
uv python pin 3.11
Open the generated pyproject.toml and set its Python requirement to match the version you pinned:
requires-python = ">=3.11,<3.12"
If your audited application requires another Python minor version, use that version consistently instead. Now add the two packages we’ll import explicitly:
uv add pydantic pydantic-settings
uv sync --locked
Your project should now have pyproject.toml, .python-version, uv.lock, and .venv/. Keep any other files uv init generated for now; we’ll add config.py and app.py shortly.
Know which files are the contract
The official uv guide is worth a short read here because a lockfile and a virtual environment can look like two versions of the same thing. They have different jobs.
Read uv’s “Working on projects” guide to understand which files a teammate needs to recreate your environment and what uv run checks before execution.
Under “Project structure,” read the explanatory paragraph after the pyproject.toml example, then the .python-version, .venv, and uv.lock subsections: project files. Under “Running commands,” read from the paragraph beginning “Prior to every” through the note about extraneous packages: run behavior.
Commit pyproject.toml, .python-version, and uv.lock. Do not commit .venv/: it is an installation on your machine, not the recipe for recreating one. uv sync --locked installs from the committed project files and rejects a lockfile that is out of date with the declared dependencies. uv run --locked applies that same lockfile check when you run a command.
A lockfile substantially improves reproducibility, but it does not freeze your operating system, external model service, or every Python patch release. That distinction will matter when you later containerize and deploy the application.
Move deployment choices out of source code
In the audited offer feature, a model identifier and a pasted-key placeholder sat inside business logic. We are not replacing the model call yet. Instead, we’ll make backend, model name, timeout, and optional credential settings that the application reads at startup.
BugBytes demonstrates how pydantic-settings reads typed fields from environment variables and, when configured, a local .env file. Watch the selected portions with your own config.py in mind.
pydantic-settings - Modern, Type-Safe Configuration for Python Apps
“pydantic-settings - Modern, Type-Safe Configuration for Python Apps,” by BugBytes, shows how to define a settings class and enable .env loading.
Watch typed settings for defaults and environment-variable overrides. Then watch dotenv loading for SettingsConfigDict and an invalid-value failure. The video uses FastAPI, but the same settings class works in an ordinary Python script.
Create config.py:
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
model_backend: Literal["local", "hosted"] = "local"
model_name: str = "local-placeholder"
model_timeout_seconds: float = Field(default=20.0, gt=0, le=120)
model_api_key: SecretStr | None = None
The field names map to environment variables such as MODEL_BACKEND and MODEL_TIMEOUT_SECONDS. Values from the process environment take precedence over .env; defaults apply when neither supplies a value. Literal rejects an unknown backend, and Field rejects a zero, negative, or excessive timeout. The model name here is a placeholder, not a working model ID—we are testing configuration, not running inference.
SecretStr masks its value in ordinary object representations. It does not encrypt the key or make it safe to print after calling get_secret_value(). Keep real credentials out of source, Git, prompts, and logs.
Create app.py as a configuration smoke check:
from config import Settings
def main() -> None:
settings = Settings()
if settings.model_backend == "hosted":
if (
settings.model_api_key is None
or not settings.model_api_key.get_secret_value().strip()
):
raise RuntimeError(
"MODEL_API_KEY is required when MODEL_BACKEND=hosted"
)
print(
f"backend={settings.model_backend} "
f"model={settings.model_name} "
f"timeout_seconds={settings.model_timeout_seconds}"
)
if __name__ == "__main__":
main()
This checks the credential without displaying it. It also makes hosted configuration fail before a request reaches a provider. A future lesson will pass the configured values to a provider-neutral model interface; this script deliberately makes no model call.
Make local configuration usable without committing secrets
Add these lines to your existing .gitignore, or create the file if necessary:
.venv/
__pycache__/
.env*
!.env.example
Create a committable .env.example containing only safe sample values:
MODEL_BACKEND=local
MODEL_NAME=local-placeholder
MODEL_TIMEOUT_SECONDS=20
# MODEL_API_KEY=
Copy it to .env using your editor or file manager. Your .env is for local overrides and must remain ignored. For a hosted deployment later, supply the real key through that environment’s secret mechanism rather than committing it in a file.
Run the smoke check:
uv run --locked python app.py
It should report the local backend, placeholder model name, and a timeout of 20.0. To confirm configuration is genuinely external, change MODEL_TIMEOUT_SECONDS in .env to 35 and rerun the same command; the reported value should change without a code edit. Then try an invalid value such as not-a-number. Settings creation should fail with a validation error rather than silently passing a bad timeout into the application. Restore a valid value afterward.
You can also confirm that a process environment variable overrides .env without changing your shell’s permanent settings:
uv run --locked python -c "import os; os.environ['MODEL_TIMEOUT_SECONDS']='45'; from config import Settings; print(Settings().model_timeout_seconds)"
Finally, inspect git status --short. The files you intend to share include the project metadata, lockfile, config.py, app.py, .gitignore, and .env.example; .env and .venv/ must not be staged. In this throwaway project, you can delete .venv/ and run uv sync --locked followed by the smoke check again. That is the practical proof that the environment can be recreated from the files you kept.
Carry the pattern into your application
Your audit note tells you where to apply this foundation. In your real repository, first check whether it already has a dependency manager and lockfile—do not run uv init over an established project without planning the migration. If you adopt this setup, declare the application’s direct imports as dependencies, commit the resulting lockfile, and move provider names, timeouts, and credentials out of business functions. Check existing deployment and CI commands too: they must install from the lock rather than independently resolving fresh versions.
You now have two separate controls: locked dependencies make the Python application environment recreatable, while externalized, typed settings let the same code run with different configuration and reject invalid values early. Neither control alone makes a model call safe, but both make the next change easier to reason about. Next, you’ll use this configuration behind a provider-neutral Python interface instead of embedding provider details in the offer feature.