Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last time, you traced a document question from ingestion through retrieval, prompt construction, generation, and display. The generation step was deliberately a placeholder. Before replacing it with Groq, Mistral, or Ollama, we need a project that can be recreated reliably and configured without putting API keys in source control.
By the end of this lesson, you’ll have a runnable Python project, a dependency lockfile, a local configuration template, and a small program that selects a provider without making an API request. Allow about 40 minutes, including the setup and checks.
Create the project and lock its dependencies
We’ll use uv to manage the Python environment. If uv --version does not work yet, install uv using a method available on your system before continuing. Then, in a terminal, create a fresh project:
uv init --python 3.11 doc-qa
cd doc-qa
uv add python-dotenv
uv run python --version
python-dotenv is the only third-party package we need today. We’ll add provider SDKs and retrieval libraries when we implement those components, rather than installing the entire eventual stack now.
For a demonstration of what these commands do, watch these portions of Corey Schafer’s “Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv.” Follow the commands above for this project; the video’s example packages are different.
Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv
Corey Schafer demonstrates project initialization, dependency locking, and running or rebuilding a uv environment. Watch to connect the files appearing in your directory with the commands that create them.
Watch project setup for the application layout, then dependency locking for what uv add changes. Skip ahead to running and syncing to see how uv run uses the project environment and uv sync recreates it.
Inspect the files in doc-qa after uv add:
pyproject.tomldeclares the project’s Python requirement and its direct dependency onpython-dotenv..python-versionrecords the Python version selected for the project. Check that it says3.11, and that the Python requirement inpyproject.tomlis compatible with it.uv.lockrecords the resolved dependency versions, including dependencies of your direct dependencies. Commit this file..venv/is the installed environment on your machine. Do not commit this directory.uv runuses it without requiring you to activate it manually.
The distinction between a requirement and a lock matters: pyproject.toml expresses what versions are acceptable; uv.lock records the resolution the project will install. Neither file substitutes for the other.
Read the short “Project structure” portion of uv’s Working on projects guide to reinforce that distinction:
The uv documentation explains the purpose of each generated project file and why its lockfile belongs in version control.
Under “Project structure,” read the “pyproject.toml,” “.python-version,” and “.venv” subsections, from project files. Then read the “uv.lock” subsection, from lockfile explanation. Focus on which files specify the project and which merely hold a local installation.
A lockfile improves reproducibility across machines, but it does not make different operating systems or Python interpreters identical. Pinning a project Python version and using the lockfile together gives us a much clearer starting point for debugging differences.
Separate shareable configuration from local secrets
A provider choice and an API credential are both configuration, but only one is a secret. We want the program to read both from environment variables. For local development, a .env file can supply those variables; for another environment, they can be supplied directly without changing Python code.
Watch the relevant parts of Corey Schafer’s “Python Tutorial: Securely Manage Passwords and API Keys with DotEnv.” One caution: the demonstration prints a sample key to prove it loaded. Our application will never print a credential.
Python Tutorial: Securely Manage Passwords and API Keys with DotEnv
Corey Schafer shows why environment variables are preferable to hard-coded keys, why a local .env must be ignored by Git, and how Python loads its values.
Watch the purpose, Git protection, and loading values. Finally, environment precedence explains why an already-set environment variable can take priority over a value in .env.
Your project may already have a .gitignore from uv init. Keep its existing entries and ensure it also contains these lines:
.venv/
__pycache__/
*.pyc
.env
.env.*
!.env.example
The .env.* pattern protects files such as .env.local; the last line makes the deliberately shareable .env.example an exception. Ignoring a file only prevents new, untracked copies from being added. It does not remove a secret that was committed previously.
Next, create .env.example in the project root:
# Copy this file to .env and edit your local copy.
APP_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434
GROQ_API_KEY=
MISTRAL_API_KEY=
Copy it to a file named .env in the same directory. Keep the example free of real keys. You can leave the two key values empty while the provider is ollama; later, when you select groq or mistral, put only that provider’s actual key in your local .env, or supply it as a process environment variable. Never paste a real key into .env.example, a screenshot, or a committed script.
A .env file is still plain text, not an encrypted vault. .gitignore reduces accidental Git exposure; it does not protect the file from someone who can read your machine or from code that logs its contents. That is why the program below reports whether configuration is ready, not what a credential contains.
Make configuration a small Python boundary
The previous lesson’s generate_stub marked a boundary where any of three model providers could eventually be called. Let’s make provider selection explicit before writing those calls.
Replace the generated main.py after creating a new file named config.py in the project root:
# config.py
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
@dataclass(frozen=True)
class Settings:
provider: str
ollama_base_url: str
groq_api_key: str | None = field(repr=False)
mistral_api_key: str | None = field(repr=False)
def load_settings() -> Settings:
env_file = Path(__file__).resolve().parent / ".env"
load_dotenv(dotenv_path=env_file, override=False)
provider = os.getenv("APP_PROVIDER", "ollama").strip().lower()
if provider not in {"groq", "mistral", "ollama"}:
raise ValueError("APP_PROVIDER must be groq, mistral, or ollama")
groq_key = os.getenv("GROQ_API_KEY", "").strip() or None
mistral_key = os.getenv("MISTRAL_API_KEY", "").strip() or None
required_keys = {
"groq": groq_key,
"mistral": mistral_key,
}
if provider in required_keys and not required_keys[provider]:
raise ValueError(
f"{provider.upper()}_API_KEY is required when APP_PROVIDER={provider}"
)
ollama_url = (
os.getenv("OLLAMA_BASE_URL") or "http://localhost:11434"
).rstrip("/")
return Settings(
provider=provider,
ollama_base_url=ollama_url,
groq_api_key=groq_key,
mistral_api_key=mistral_key,
)
# main.py
from config import load_settings
def main() -> None:
settings = load_settings()
print(f"Selected provider: {settings.provider}")
if settings.provider == "ollama":
print(f"Local endpoint: {settings.ollama_base_url}")
print("No hosted API key required for this selection.")
else:
print("Selected hosted credential is configured; value not shown.")
print("Configuration ready. No model request was sent.")
if __name__ == "__main__":
main()
Run it from the project root:
uv run python main.py
With the example configuration, you should see ollama selected and a confirmation that no model request was sent. This does not mean Ollama is installed or running yet; it only verifies the configuration boundary.
There are a few intentional design choices here. load_dotenv(..., override=False) lets an environment variable already set by the process take priority over .env, which is useful outside local development. The explicit path makes the project’s .env location unambiguous. Only the selected hosted provider requires a key: local-only work should not be blocked because you have not configured both hosted accounts. Finally, repr=False makes accidental printing of a Settings instance less likely to reveal a key; it is a safeguard, not permission to log configuration objects indiscriminately.
For a quick failure check, temporarily change APP_PROVIDER in .env to groq while leaving GROQ_API_KEY empty, and rerun uv run python main.py. You should get a missing-credential error without the value of any key appearing. Restore APP_PROVIDER=ollama afterward. No hosted account or local model is needed for this check.
Verify what someone else could rebuild—and see
Before committing anything, check that Git ignores the private file and local environment:
git check-ignore -v .env
git status --short --ignored
git ls-files -- .env
If git status reports that this is not a Git repository, run git init in doc-qa, then repeat the checks. git check-ignore should identify a rule covering .env; git ls-files -- .env should print nothing. Confirm that pyproject.toml, uv.lock, .python-version, .env.example, and your Python files are not ignored.
You can also verify that the dependency resolution is usable without asking uv to update it:
uv sync --locked
uv run --locked python main.py
On a fresh checkout, uv sync --locked recreates the project environment from the committed project files. The fresh checkout would still need its own .env or supplied environment variables; copying someone else’s keys is neither necessary nor desirable.
If .env was already tracked, adding .gitignore is insufficient. git rm --cached .env removes it from Git’s index while leaving the local file, but a credential previously committed may remain in repository history: treat that key as exposed and rotate it. Inspect staged files before any commit rather than assuming an ignore rule catches secrets in other files.
What you have built
The project now has three deliberately different kinds of state: declared and locked dependencies that belong in version control, a recreatable local environment that does not, and runtime configuration whose example is shareable but whose credentials are not. The program can select Groq, Mistral, or Ollama and fail early when a selected hosted provider lacks its key.
Next, we’ll use this foundation to send a real Groq chat-completion request. That will replace the first piece of the generation placeholder while preserving the document-answering workflow you traced earlier.