ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 5 of 10 25 min

Files, .env, virtual environments and project structure

Read and write text and JSON files with paths that work from anywhere, keep API keys in environment variables and .env files, and set up a reproducible project with uv and a virtual environment.

Words in this lessonFile path · Context manager · Environment variable · .env file · Virtual environment · uv · API key
File path
The address of a file or folder on your computer. Like a postal address: either the full address from the country down, or directions from where you are standing.
Context manager
Something used with `with` that sets up a resource and always cleans it up afterwards. Like a librarian who makes sure every borrowed book goes back on the shelf.
Environment variable
A named value, like ANTHROPIC_API_KEY, that programs started from your shell can read. Like notes pinned on the kitchen noticeboard that every cook on the shift can read.
.env file
A small private file that holds secrets like API keys as NAME=value lines, kept out of your code and out of git. Like a locked drawer for the shop keys, separate from the recipe binder everyone can read.
Virtual environment
A private folder of Python and libraries for one project, separate from every other project. Like giving each recipe its own labelled pantry shelf, so the Christmas cake's ingredients never get mixed into the croissants.
uv
A fast tool that installs Python, creates project environments and manages a project's libraries. Like a kitchen manager who stocks each station with exactly the ingredients its recipe needs, and writes the list down.
API key
A long secret string that identifies your account when your code calls an API. Like the key to the shop's back door: anyone holding it gets in, and whatever they take goes on your tab.
On this page
  1. Arjun's bot, as Priya found it
  2. Where does "menu.json" mean? Files and paths
  3. Reading and writing files
  4. JSON files
  5. Secrets: keep the keys in a locked drawer
  6. Virtual environments: a separate supply box for each stall
  7. The classic way: venv and pip
  8. The modern way: uv
  9. Following Priya's fresh clone, step by step
  10. Opening the hood: what a virtual environment really is
  11. A project layout that grows with you
  12. Where it breaks
  13. Arjun explains it to Meera
  14. Try it yourself
  15. Common mistakes
  16. Recap
  17. Go deeper

Arjun has spent his evenings building a prototype: a support bot that answers customer questions about Crumb’s menu using Claude. It works beautifully on his laptop. He asks Priya to review it before he shows Meera.

Priya clones the repository onto her laptop and types python bot.py. It fails on the first line:

ModuleNotFoundError: No module named 'anthropic'

She installs the package and tries again. Now it fails on line 4:

FileNotFoundError: [Errno 2] No such file or directory: 'menu.json'

The file is right there in the folder. She opens bot.py to investigate, and stops at line 3. Arjun’s Anthropic API key (the secret password that lets a program use Claude, and bills Crumb for every use) is typed straight into the code. And the code is on GitHub.

“Three problems, one lesson,” Priya says, turning her laptop around. “Where your code lives, where its secrets live, and where its packages live. Get these right once, and every project after this starts clean.”

Important

The idea in one sentence: A healthy Python project finds its files by paths that don’t depend on where you run it from, reads its secrets from the environment instead of the code, and installs its packages into its own virtual environment, described in a file so anyone can rebuild it exactly.

Arjun’s bot, as Priya found it

Here are the first lines of bot.py:

import json
import anthropic

client = anthropic.Anthropic(api_key="sk-ant-api03-Xk9...")   # the real key, in the code
menu = json.load(open("menu.json"))

Three lines, three problems:

  1. The package problem. Arjun installed anthropic months ago into his laptop’s main Python. Priya’s laptop has never heard of it. Nothing in the repository says which packages the bot needs, or which versions.
  2. The path problem. open("menu.json") looks for the file relative to wherever you’re standing when you run the command, not relative to bot.py. Arjun always runs it from inside the src folder, where menu.json lives. Priya ran python src/bot.py from the project root.
  3. The secret problem. Anyone who can read the repository can copy the key and run up Crumb’s bill. Even private repositories get shared, forked, cloned onto laptops that get stolen, and pasted into chat tools.

Let’s fix them in order of difficulty, starting with files.

Where does “menu.json” mean? Files and paths

“Imagine I send you to fetch the recipe binder,” Priya says, “and I say ‘it’s on the shelf to your left’. Does that work?”

“Depends where I’m standing.”

“That’s a relative path. And ‘the shelf by the back door of the FC Road kitchen’?”

“That works from anywhere.”

“An absolute path. Your bot used the first kind.”

A path is a file’s address. A relative path like menu.json is resolved from the current working directory (the folder your terminal is in when you run the command). Python can tell you what that is:

from pathlib import Path

print(Path.cwd())                      # the folder you're "standing in"
print(Path("menu.json").resolve())     # where a relative "menu.json" points

Run from Arjun’s usual spot, inside src, it prints something like:

/home/arjun/crumb-bot/src
/home/arjun/crumb-bot/src/menu.json

Run the same file from the project root, and the lines become /home/arjun/crumb-bot and /home/arjun/crumb-bot/menu.json. Same code, different file.

The fix is to build paths from the location of the code file itself. Every Python file knows its own path, in a variable called __file__:

from pathlib import Path

HERE = Path(__file__).parent           # the folder this .py file is in
menu_path = HERE / "menu.json"         # / joins path pieces, on every OS
print(menu_path.name)
print(menu_path.is_absolute())
menu.json
True

pathlib is Python’s modern way to work with paths. Path(__file__).parent is the folder containing this file, and the / operator joins pieces with the right separator for the operating system (/ on Mac and Linux, \ on Windows). Now the bot finds its menu whether you run it from the project root, from src, or from a scheduler that starts it in some other folder entirely.

Note

Pause and think: The bot lives at crumb-bot/src/bot.py and the menu at crumb-bot/data/menu.json. Using Path(__file__), how do you build the menu’s path? And which of these two commands would have worked with Arjun’s original open("menu.json"): running python bot.py from inside src, or python src/bot.py from crumb-bot?

Show the answer

Path(__file__).parent is crumb-bot/src, so go up one more level and into data: Path(__file__).parent.parent / "data" / "menu.json". (.parent.parent means “the folder above the folder this file is in”.)

Neither command would have worked with the new layout, because menu.json isn’t in either working directory: one is src, the other is crumb-bot. With the original layout (menu inside src), only running from inside src worked. That’s the trap: relative paths work by accident on the developer’s machine and fail everywhere else.

Reading and writing files

Opening a file is like borrowing a book from the library. You must return it, or other people can’t use it, and changes you scribbled in might not be saved. Python’s with statement is a librarian who guarantees the book goes back, even if something goes wrong while you’re reading:

from pathlib import Path

notes = Path("store_notes.txt")

# "w" = write: creates the file, or REPLACES everything in it
with open(notes, "w", encoding="utf-8") as f:
    f.write("Baner: oven 2 needs servicing\n")
    f.write("FC Road: low on kesar\n")

# "a" = append: adds to the end, keeps what's there
with open(notes, "a", encoding="utf-8") as f:
    f.write("Kothrud: Diwali hampers sold out\n")

# "r" = read (the default)
with open(notes, encoding="utf-8") as f:
    for line in f:                     # one line at a time, memory-friendly
        print(line.strip())

print(notes.read_text(encoding="utf-8").count("\n"), "lines")   # the one-line way
Baner: oven 2 needs servicing
FC Road: low on kesar
Kothrud: Diwali hampers sold out
3 lines

When the indented with block ends, the file is closed automatically, error or not. Objects that work with with are called context managers; you’ll meet others, like database connections and HTTP clients, that clean up after themselves the same way.

Two habits worth forming now:

  • Always pass encoding="utf-8". It tells Python how text is turned into bytes on disk. On Mac and Linux, UTF-8 is the default, but on many Windows machines it isn’t, and a menu containing ₹ or कप केक can come out garbled or crash. Saying it explicitly makes your code behave the same everywhere.
  • Remember that "w" erases. Opening an existing file with "w" wipes it before you write a single byte. Use "a" to add.

JSON files

You met JSON in the data handling lesson. json.dump writes Python data to an open file, and json.load reads it back:

import json
from pathlib import Path

menu = [
    {"name": "Butter croissant", "price": 85, "tags": ["bestseller"]},
    {"name": "कप केक (cupcake)", "price": 60, "tags": []},
]

path = Path("menu.json")
with open(path, "w", encoding="utf-8") as f:
    json.dump(menu, f, indent=2, ensure_ascii=False)

with open(path, encoding="utf-8") as f:
    loaded = json.load(f)

print(loaded[1]["name"], loaded[1]["price"])
print(json.dumps("₹85"))                       # the default escapes non-English text
print(json.dumps("₹85", ensure_ascii=False))   # keeps it readable
कप केक (cupcake) 60
"\u20b985"
"₹85"

By default, json writes any character outside basic English as a code like \u20b9. That’s still valid JSON, and loads back correctly, but nobody can read the file. ensure_ascii=False keeps ₹ and Hindi text readable, which matters when a human (or an LLM) will read the file. indent=2 adds line breaks and indentation for the same reason.

Secrets: keep the keys in a locked drawer

Now the serious one. Priya asks: “If the shop had a master key, would you tape it to the front window, next to the menu?”

“Of course not.” Arjun winces as he says it.

“Your code is the front window. Everyone who can see the code can see the key.”

The standard fix has two parts.

Part 1: environment variables. An environment variable is a named value that the operating system hands to every program when it starts, like the notes a shift manager gives each worker at the start of a shift. The code asks for ANTHROPIC_API_KEY by name, and whoever runs the code provides the value. The code itself never contains it.

Part 2: a .env file. Setting environment variables by hand every time is tedious, so during development you keep them in a file called .env in the project folder, which never gets committed to Git. The python-dotenv package reads it and loads the values into the environment. In production, the hosting service (a server, a container platform) sets the same variables from its own secure settings, and there’s no .env file at all.

Create a file called .env:

ANTHROPIC_API_KEY=sk-ant-api03-replace-me
CRUMB_STORE=Baner

Then read it from Python:

import os
from dotenv import load_dotenv

load_dotenv()                                  # copies .env values into os.environ

store = os.getenv("CRUMB_STORE", "FC Road")    # with a default if unset
key = os.getenv("ANTHROPIC_API_KEY")

if key is None:
    raise SystemExit("ANTHROPIC_API_KEY is not set. Copy .env.example to .env and add your key.")

print("Store:", store)
print("Key loaded:", key[:7] + "..." + key[-4:])   # never print the whole key
Store: Baner
Key loaded: sk-ant-...e-me

What each piece does:

  • load_dotenv() finds the .env file and copies each line into the program’s environment. It does not overwrite variables that are already set, so a real environment variable on a server always wins over a stray .env file.
  • os.environ is a dictionary-like view of the environment; os.getenv(name, default) reads from it safely, returning None (or your default) if the name isn’t set.
  • The if key is None check fails fast: a clear message at startup beats a confusing authentication error ten calls later.
  • Printing only the first and last few characters lets you confirm which key loaded without exposing it.

The Anthropic, OpenAI and Gemini SDKs (software development kits: the official Python packages for each service) all look for their key in an environment variable automatically. So once load_dotenv() has run, anthropic.Anthropic() needs no api_key= argument at all. The key never appears in your code.

Finally, so teammates know which variables to set, commit a .env.example file with the names and placeholder values, never real ones:

# .env.example — copy to .env and fill in real values
ANTHROPIC_API_KEY=
CRUMB_STORE=Baner

Caution

Deleting a key from your code does not make it safe if it was ever committed. Git keeps every version of every file, so the key is still in the history, and anyone with a copy of the repository can find it. Automated bots scan public GitHub for keys within minutes. If a key has ever been committed or shared, revoke it in the provider’s console (Anthropic, OpenAI and Google all let you), create a new one, and put only the new one in .env.

Virtual environments: a separate supply box for each stall

Now, the ModuleNotFoundError. When you import anthropic, Python searches a list of folders for a package with that name. Arjun installed it into his laptop’s main Python months ago. Priya’s laptop doesn’t have it, and nothing in the repository said it was needed.

“Picture the Diwali fair,” Priya says. “Crumb has a sweets stall, and next to it, a coffee stall. If they shared one supply box, what happens when the coffee stall swaps the sugar for a sugar-free brand?”

“The kaju katli tastes wrong.”

“So each stall gets its own box, with a shopping list taped to the lid: exact brands, exact sizes. Anyone can restock it from the list.”

That’s a virtual environment: a private folder of packages for one project, so two projects can use different versions of the same package without breaking each other. The shopping list is a file that records exactly what to install. Today, there are two common ways to set this up. You’ll see both in the wild.

The classic way: venv and pip

venv comes with Python, and pip is Python’s standard package installer (the tool that downloads packages from PyPI, the public index of Python packages):

python3 -m venv .venv                  # create the environment in a folder called .venv
source .venv/bin/activate              # Mac/Linux: use this environment in this terminal
# .venv\Scripts\activate               # Windows (PowerShell or cmd)
pip install anthropic python-dotenv    # install into .venv, not your main Python
pip freeze > requirements.txt          # write the shopping list, with exact versions

Anyone else rebuilds it with python3 -m venv .venv, activate, then pip install -r requirements.txt. It works, but there are several steps to remember, and forgetting to activate is the classic way to install a package into the wrong place.

The modern way: uv

uv is a newer tool that does the same jobs (and also installs Python itself) much faster, with fewer steps to forget. It’s what this course uses:

uv init crumb-bot            # create a new project folder with a pyproject.toml
cd crumb-bot
uv add anthropic python-dotenv   # install packages AND record them
uv run src/crumb_bot/bot.py      # run using the project's environment

uv add does three things at once. It creates .venv if there isn’t one, installs the packages into it, and records them in two files:

  • pyproject.toml: the project’s description, including which packages it needs. You can read and edit it:
[project]
name = "crumb-bot"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "anthropic>=2.4.0",
    "python-dotenv>=1.2.4",
]

(Your version numbers will be whatever is current when you run it.)

  • uv.lock: the exact shopping list. It records the precise version of every package, including the packages those packages depend on, so everyone gets an identical environment. You don’t edit it by hand; you commit it.

uv run makes sure the environment matches the lock file, then runs your command with it. There’s nothing to activate and nothing to forget.

Following Priya’s fresh clone, step by step

Here’s the same review again, after Arjun fixes everything. Priya deletes her copy and clones the repository from scratch. Follow every step:

Step Priya runs What happens State afterwards
1 git clone ... Downloads the code. .venv and .env were never committed, so neither exists pyproject.toml, uv.lock, .env.example, src/, data/
2 cp .env.example .env, then pastes her own key Creates her private settings file, which Git ignores .env with her key
3 uv sync Reads uv.lock, creates .venv, installs the exact versions listed .venv/ with anthropic, python-dotenv and their dependencies
4 uv run src/crumb_bot/bot.py Starts the Python inside .venv, whose package list includes anthropic import anthropic succeeds
5 (inside the bot) load_dotenv() Reads .env os.environ["ANTHROPIC_API_KEY"] is her key
6 (inside the bot) anthropic.Anthropic() The SDK reads the key from the environment A client, with no key in the code
7 (inside the bot) Path(__file__).parent.parent / "data" / "menu.json" Builds an absolute path from the code’s own location The menu loads, wherever she ran the command from

Seven steps, no surprises, and it works the same on her laptop, Arjun’s laptop and a server.

Opening the hood: what a virtual environment really is

There’s no magic in .venv. It’s an ordinary folder containing:

  • pyvenv.cfg: a small text file saying which Python it was made from.
  • bin/ (Scripts\ on Windows): a python command that points at that Python, plus commands installed by packages.
  • lib/python3.12/site-packages/: the installed packages themselves, one folder each.

When Python starts, it builds sys.path, the list of folders import searches, in order. Starting the Python inside .venv puts .venv’s site-packages on that list. That’s all “using an environment” means. “Activating” in the classic workflow only changes your terminal’s PATH (the list of folders the terminal searches for commands) so that typing python finds .venv/bin/python first.

You can always ask Python which environment it’s running in:

import sys

print(sys.prefix)          # the environment's folder
print(sys.executable)      # the exact python program running this code
/home/arjun/crumb-bot/.venv
/home/arjun/crumb-bot/.venv/bin/python3

When an import fails and you’re sure you installed the package, print these two lines. Nine times out of ten, you’re running a different Python from the one you installed into.

Note

Pause and think: Priya’s teammate has anthropic installed in .venv and still sees ModuleNotFoundError in their code editor, though uv run bot.py works in the terminal. What’s the most likely cause, and how would sys.executable help?

Show the answer

The editor is running a different Python, probably the system one, rather than the project’s .venv. Printing sys.executable inside the editor would show a path like /usr/bin/python3 instead of .../crumb-bot/.venv/bin/python3. The fix is to choose the .venv interpreter in the editor’s settings (VS Code calls this “Python: Select Interpreter”). The same thing happens with Jupyter notebooks, whose kernel (the Python process behind the notebook) has its own environment.

A project layout that grows with you

Here’s the layout Arjun ends up with. Recent versions of uv init create a src/ folder like this; older ones create a single main.py. Either is fine to start, but a structure like this scales from a prototype to the production apps later in the course:

crumb-bot/
├── pyproject.toml        # what the project is and which packages it needs
├── uv.lock               # exact versions (commit this)
├── .python-version       # which Python version to use
├── .env.example          # variable names, no real values (commit this)
├── .env                  # real secrets (NEVER commit)
├── .gitignore            # tells Git what to leave out
├── README.md             # how to set up and run it
├── data/
│   └── menu.json
├── src/
│   └── crumb_bot/
│       ├── __init__.py   # marks the folder as a package
│       ├── bot.py        # the entry point
│       ├── config.py     # loads settings from the environment, once
│       └── prompts.py    # prompt templates live here, not scattered in code
└── tests/
    └── test_bot.py

A package, in Python, is a folder of modules you can import from. __init__.py marks a folder as one, so code can say from crumb_bot.prompts import SUPPORT_PROMPT.

The .gitignore file lists what Git should never track. uv init creates one that already ignores .venv and Python’s cache files, but it does not ignore .env. Add it yourself, on day one:

# Python-generated files
__pycache__/
*.py[oc]

# Virtual environments
.venv

# Secrets
.env

The config.py idea deserves a sentence. Instead of calling os.getenv in twenty places, load every setting once, in one file, and import from there. When a variable is missing, you find out at startup, with one clear message. In the next lesson, you’ll see how Pydantic can do this loading and checking for you.

Where it breaks

Even with everything set up, these bite teams regularly:

  • The key that was committed once. Covered above, and worth repeating because it’s the costliest mistake in this lesson. Rotate any key that has touched Git, a chat message or a screenshot.
  • .env on the server. Copying .env to a production server works until someone checks the server’s files into a backup or a container image. In production, set real environment variables through the hosting platform. You’ll do this in the deployment lessons.
  • Mixing tools. Running pip install inside a uv project installs a package uv doesn’t know about; the next uv sync removes it. Pick one tool per project. In this course, that’s uv add.
  • Installing into the system Python. sudo pip install ... on Mac or Linux can break tools the operating system itself relies on. Always use a virtual environment.
  • Overwriting with "w". A script that “updates” a JSON file by opening it with "w" and then crashing halfway leaves you with an empty or half-written file. For important files, write to a temporary file first, then rename it over the original.

Arjun explains it to Meera

Arjun rotates the key, cleans up the repository, and runs Priya’s fresh-clone steps on a third laptop to be sure. Then Meera asks what took him all afternoon.

“Three things were tied to my laptop,” Arjun says. “The bot looked for its menu ‘nearby’, and nearby depended on which folder I happened to be in. Now it looks next to its own code. Its packages were installed on my laptop only. Now the project has its own private supply box, a virtual environment, and a shopping list, so anyone can rebuild it in one command. And the password for Claude was written in the code, where anyone who saw the code could use it. I’ve cancelled that one. The new one lives in a private file that never leaves the laptop, and on the server, in the server’s settings.”

“Could someone have used the old key?”

“It was only visible to people with access to our repository, and I cancelled it within the hour. But that’s why we cancel instead of hoping.”

Try it yourself

Build a small, correct project skeleton with uv.

  1. Run uv init crumb-orders, then cd crumb-orders and uv add python-dotenv.
  2. Create .env with CRUMB_STORE=Kothrud and DAILY_ORDER_LIMIT=150, add .env to .gitignore, and create a matching .env.example without values.
  3. Write report.py in the project folder that:
    • loads the .env file,
    • reads DAILY_ORDER_LIMIT as an int (environment variables are always strings),
    • writes {"store": ..., "limit": ...} to output/settings.json next to the script, creating the output folder if needed,
    • prints the saved JSON.
  4. Run it with uv run report.py from the project folder, then again from its parent folder (uv run crumb-orders/report.py). Both must work.

Expected output, both times:

{"store": "Kothrud", "limit": 150}
Show the answer
import json
import os
from pathlib import Path

from dotenv import load_dotenv

HERE = Path(__file__).parent
load_dotenv(HERE / ".env")                       # find .env next to this file

store = os.getenv("CRUMB_STORE", "FC Road")
limit = int(os.getenv("DAILY_ORDER_LIMIT", "100"))   # env values are text: convert

out_dir = HERE / "output"
out_dir.mkdir(exist_ok=True)                     # create the folder if it's missing
out_file = out_dir / "settings.json"

with open(out_file, "w", encoding="utf-8") as f:
    json.dump({"store": store, "limit": limit}, f, ensure_ascii=False)

print(out_file.read_text(encoding="utf-8"))

Two details make it work from any folder. load_dotenv(HERE / ".env") points at the .env next to the script instead of searching from wherever you’re standing. And every path is built from HERE. Notice too that DAILY_ORDER_LIMIT arrives as the string "150": environment variables are always text, so convert them, and decide what should happen if the conversion fails.

Common mistakes

  • Relative paths in scripts. open("menu.json") depends on the folder you run from. Build paths from Path(__file__).parent.
  • Forgetting encoding="utf-8". Fine on your Mac, garbled ₹ on a colleague’s Windows laptop.
  • Not adding .env to .gitignore before the first commit. Do it the moment you create the project.
  • Forgetting that environment variables are strings. os.getenv("LIMIT") > 100 compares text with a number and raises TypeError. Convert with int() or float().
  • Committing .venv. It’s large, specific to one operating system, and rebuilt in seconds from uv.lock. Ignore it.

Recap

  • Relative paths depend on where you run the command; build paths from Path(__file__).parent so code finds its files anywhere.
  • with open(path, mode, encoding="utf-8") reads and writes files and always closes them; "w" replaces, "a" appends, and json.dump/json.load handle JSON files.
  • Secrets live in environment variables, loaded from a .env file in development with python-dotenv; commit .env.example, never .env, and rotate any key that was ever exposed.
  • A virtual environment is a private folder of packages for one project; uv add installs and records packages, uv.lock pins exact versions, and uv run uses the right environment every time.
  • When imports fail mysteriously, print sys.executable to see which Python is running.

The bot now runs anywhere. So Arjun adds a feature: it reads a customer’s WhatsApp message, asks Claude to pull out the item and quantity as JSON, and creates a draft order. In testing, Claude returns the price as text, "price": "450", and the billing code multiplies it by 2. The draft invoice says ₹450450. “Python didn’t complain,” Arjun says, baffled. “I even wrote price: int.” Priya smiles. “Then it’s time you learned what type hints actually do, and what they don’t.”

Go deeper

Check your understanding

3 quick questions
1. open("menu.json") works when Arjun runs his script from the src folder but fails for Priya from the project root. Why?
2. An API key was committed to Git last week. Arjun deletes it from the code and adds .env to .gitignore. What else must he do?
3. What does uv.lock record?