Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Building a Secure, Reproducible Python Project Setup

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.toml declares the project’s Python requirement and its direct dependency on python-dotenv.
  • .python-version records the Python version selected for the project. Check that it says 3.11, and that the Python requirement in pyproject.toml is compatible with it.
  • uv.lock records 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 run uses 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:

Working on projects | uv

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.

Can't find a good explanation? Sign up and we'll make it for you

Sign up