Skip to main content

Python 5 - Professional Python: Secrets, Clean Code, and uv

Updated 16 min readMarkdown
Part of seriespython

Keep API keys safe with .env files, let Ruff fix your code automatically, and manage projects the modern way with uv.

Part 1: Aman's Key Goes Public

Aman signs up for an AI API and gets a key. He pastes it into his code:

import requests

API_KEY = "sk-live-8f3a9c2b7e1d4f6a..."

response = requests.get(url, headers={"Authorization": f"Bearer {API_KEY}"})

It works. He is happy. He shares the file with a friend, uploads it to GitHub to show off, and goes to sleep.

Next morning, Neha sends him a message: "Your API key is on the internet. Anyone can use it. Your account will be billed for whatever they do. Revoke it now."

Aman learns the hard way: secrets never go in code.

This article covers:

  1. Environment variables and .env files
  2. The python-dotenv package
  3. Ruff: a linter and formatter
  4. Setting up Ruff in VS Code
  5. uv: the modern package manager
  6. uv init, uv add, uv sync
  7. A complete project workflow, start to finish

Part 2: Environment Variables and Secrets (.env)

What is a secret?

Anything that gives access to something valuable:

  • API keys (OpenAI, Anthropic, weather APIs)
  • Database passwords
  • Tokens for GitHub, Slack, email
  • Private URLs

If someone else gets it, they can act as you.

The rule

Code is public. Secrets are private. Keep them in different places.

What is an environment variable?

An environment variable is a value stored by the operating system, outside your code. Programs can read it, but it is not inside any file you share.

You can set one in the terminal:

Mac/Linux:

export API_KEY="sk-live-8f3a..."

Windows (PowerShell):

$env:API_KEY = "sk-live-8f3a..."

And read it in Python:

import os

api_key = os.getenv("API_KEY")
print(api_key)

Now the key is not in the code. But typing export every time you open a terminal is annoying. That is where .env files help.

The .env file

A .env file is a plain text file in your project root with KEY=value lines:

API_KEY=sk-live-8f3a9c2b7e1d4f6a
DATABASE_URL=postgres://user:pass@localhost/db
DEBUG=true

Rules:

  • No spaces around =
  • No quotes needed (but allowed)
  • One secret per line
  • Lines starting with # are comments

Never share .env

Add it to .gitignore so Git never uploads it:

# .gitignore
.env
.venv/
__pycache__/

If .gitignore already exists in your project, just add the line .env.

Share a template instead

Create .env.example with the names but no real values:

API_KEY=your_key_here
DATABASE_URL=your_database_url
DEBUG=false

This file is committed. Anyone who clones your project knows which secrets they need to fill in.

Practice

  1. Create a .env file with a fake key API_KEY=test123.
  2. Add .env to .gitignore.
  3. Why do we create a .env.example file?
  4. What is wrong with API_KEY = "sk-..." inside main.py?

Part 3: Using the python-dotenv Package

Python does not read .env files by itself. The python-dotenv package does it for you.

Install

pip install python-dotenv

Use

from dotenv import load_dotenv
import os

load_dotenv()   # reads .env and puts the values into environment variables

api_key = os.getenv("API_KEY")

That is all. load_dotenv() looks for a .env file, reads each line, and makes it available to os.getenv().

Fail loudly if a key is missing

os.getenv() returns None if the key is not found. That can cause confusing errors later. Better to check right away:

api_key = os.getenv("API_KEY")

if not api_key:
    raise ValueError("API_KEY is missing. Add it to your .env file.")

Or use os.environ[], which raises a KeyError automatically:

api_key = os.environ["API_KEY"]

A clean pattern: config.py

Put all secret loading in one place:

# src/config.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / "data"

load_dotenv(BASE_DIR / ".env")

API_KEY = os.environ["API_KEY"]
DEBUG = os.getenv("DEBUG", "false").lower() == "true"

Then everywhere else:

from config import API_KEY

Note the second argument of os.getenv("DEBUG", "false"): that is a default value used when the key is missing.

Where .env should live

load_dotenv() searches from the current working directory upwards. Passing the path explicitly (as above) is safer, for the same reason file paths were a problem in the last article.

Practice

  1. Install python-dotenv, create .env with NAME=Aman, and print it from Python.
  2. What does os.getenv("MISSING_KEY") return?
  3. What is the difference between os.getenv("X") and os.environ["X"]?
  4. Write a line that reads PORT from .env with a default of 8000, converted to an integer.

Part 4: Introduction to Ruff (Linter and Formatter)

Aman's code works, but it looks like this:

import os,sys
import requests
def   get_data( url ):
    x=requests.get(url)
    unused = 42
    return x.json( )

Neha says, "This runs. But nobody wants to read it. Including you, next month."

Two tools in one

Linter: reads your code and finds problems. Unused imports, unused variables, undefined names, risky patterns. Like a spell-checker for code.

Formatter: rewrites your code so the style is consistent. Spacing, line length, quotes, blank lines. Like auto-indent, but for everything.

Ruff does both. It is written in Rust, so it is extremely fast, and it replaces older tools like flake8, isort, and black.

Why it matters

  • Your code looks the same as everyone else's → easier to read
  • Mistakes are caught before you run → fewer bugs
  • Zero arguments about style → the tool decides

Part 5: Setting Up Ruff in VS Code

Step 1: Install the extension

In VS Code, open Extensions (Ctrl + Shift + X), search Ruff (by Astral), and install it.

Step 2: Install the package in your environment

pip install ruff

Step 3: Format on save

Open VS Code settings (Ctrl + ,), search "format on save", and tick it. Also set Ruff as the default formatter for Python. Or add this to .vscode/settings.json in your project:

{
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit",
      "source.organizeImports.ruff": "explicit"
    }
  }
}

Now every time Aman saves, Ruff formats the file and fixes what it can.

Optional: project settings

Ruff reads settings from pyproject.toml (more on this file soon):

[tool.ruff]
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "I"]   # errors, pyflakes, import sorting

Defaults are fine to start. Change them only when you need to.


Part 6: Ruff in Action

Aman saves his messy file. Ruff turns it into:

import requests


def get_data(url):
    x = requests.get(url)
    return x.json()

What happened:

  • import os,sys → removed (unused)
  • def get_data( url ) → spaces fixed
  • x=requests.get(url) → spaces around =
  • unused = 42 → flagged with a warning (Ruff does not delete code that might be intentional, but it tells you)
  • Two blank lines before the function (Python style standard, called PEP 8)

From the terminal

ruff check .          # find problems in all files
ruff check . --fix    # fix what can be fixed automatically
ruff format .         # format all files

Reading the warnings

A typical message:

src/main.py:5:5: F841 Local variable `unused` is assigned to but never used
  • src/main.py → file
  • 5:5 → line 5, column 5
  • F841 → rule code (you can search it)
  • the rest → plain explanation

Hover over the squiggle in VS Code to see the same message.

Neha's rule: "Turn on format-on-save on day one. Then you never think about style again."

Practice

  1. Install Ruff and enable format on save. Write some badly spaced code and save it. What changed?
  2. What is the difference between a linter and a formatter?
  3. Run ruff check . in your project. How many warnings do you get?

Part 7: Introduction to uv (Modern Package Manager)

So far, Aman uses three separate tools:

  1. python -m venv .venv → make an environment
  2. source .venv/bin/activate → activate it
  3. pip install ... and pip freeze > requirements.txt → manage packages

It works. But it is slow, and requirements.txt has problems: it does not separate "what I asked for" from "what got pulled in", and it is easy to forget to update.

uv replaces all three tools with one. It is made by the same team as Ruff, also written in Rust, and it is 10 to 100 times faster than pip.

What uv does

  • Creates the virtual environment
  • Installs packages
  • Tracks your dependencies in pyproject.toml
  • Locks exact versions in uv.lock
  • Can even install Python versions for you

Part 8: Installing uv

uv is installed once, on your computer, not inside a project.

Mac/Linux:

curl -LsSf https://astral.sh/uv/install.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Or with pip, if you prefer:

pip install uv

Check:

uv --version

Part 9: Using uv (init, add, sync)

Start a new project

uv init my-ai-app
cd my-ai-app

uv creates:

my-ai-app/
├── .gitignore
├── .python-version     ← which Python version to use
├── README.md
├── main.py
└── pyproject.toml      ← project settings and dependencies

Already have a folder? Run uv init inside it.

pyproject.toml

This file replaces requirements.txt. It describes the project:

[project]
name = "my-ai-app"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = []

Add a package

uv add requests

uv does four things at once:

  1. Creates .venv if it does not exist
  2. Installs requests
  3. Writes requests into pyproject.toml under dependencies
  4. Writes the exact version (and all sub-dependencies) into uv.lock
dependencies = [
    "requests>=2.32.3",
]

Add several at once:

uv add pandas matplotlib python-dotenv

Remove:

uv remove matplotlib

Run code

You do not need to activate the environment:

uv run main.py

uv runs the file using the project's .venv automatically. (You can still activate it the old way if you like.)

Sync (install from the lock file)

When someone clones the project, or when you pull changes:

uv sync

This reads uv.lock and installs exactly the same versions. Everyone on the team has an identical environment.

Add dev-only tools

Ruff is a tool for you, not for the app. Add it as a dev dependency:

uv add --dev ruff

Two files, two jobs

File What Who edits
pyproject.toml What you want: requests>=2.32 You (via uv add)
uv.lock Exact versions of everything uv only. Never edit by hand. Commit it.

Old way vs new way

Task pip + venv uv
Create env python -m venv .venv automatic
Activate source .venv/bin/activate not needed (uv run)
Install pip install requests uv add requests
Save list pip freeze > requirements.txt automatic
Install from list pip install -r requirements.txt uv sync
Speed slow very fast

Should I still learn pip?

Yes. You will see pip and requirements.txt in thousands of tutorials and older projects. Know both. Use uv for your own new projects.

Practice

  1. Install uv and run uv init test-project. List the files it created.
  2. Run uv add requests. Open pyproject.toml. What changed?
  3. What is the difference between pyproject.toml and uv.lock?
  4. Why do you not need to activate .venv with uv run?

Part 10: Complete Python Project Workflow

Neha says, "Let's do one project from zero, using everything from all five articles." Aman builds a small tool that fetches posts from an API, saves them, and prints a summary.

Step 1: Create the project

uv init post-fetcher
cd post-fetcher
code .          # open in VS Code

Step 2: Add packages

uv add requests pandas python-dotenv
uv add --dev ruff

Step 3: Set up folders

mkdir data src

Move main.py into src/.

Step 4: Secrets

Create .env:

API_BASE_URL=https://jsonplaceholder.typicode.com

Create .env.example:

API_BASE_URL=

Check that .gitignore contains .env (uv's default .gitignore usually does; add it if not).

Step 5: Config

src/config.py:

import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / "data"

load_dotenv(BASE_DIR / ".env")

API_BASE_URL = os.environ["API_BASE_URL"]

Step 6: API module

src/api.py:

import requests

from config import API_BASE_URL


def get_posts() -> list[dict]:
    try:
        response = requests.get(f"{API_BASE_URL}/posts", timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        print("API call failed:", e)
        return []

Step 7: Data module

src/data.py:

import pandas as pd

from config import DATA_DIR


def to_dataframe(records: list[dict]) -> pd.DataFrame:
    return pd.DataFrame(records)


def save(df: pd.DataFrame, name: str) -> None:
    DATA_DIR.mkdir(exist_ok=True)
    df.to_csv(DATA_DIR / name, index=False)


def summary(df: pd.DataFrame) -> str:
    counts = df["userId"].value_counts()
    return f"{len(df)} posts from {len(counts)} users. Top user: {counts.idxmax()}"

Step 8: Main

src/main.py:

from api import get_posts
from data import save, summary, to_dataframe


def main() -> None:
    posts = get_posts()
    if not posts:
        print("No data. Exiting.")
        return

    df = to_dataframe(posts)
    save(df, "posts.csv")
    print(summary(df))


if __name__ == "__main__":
    main()

Step 9: Run

uv run src/main.py

Output:

100 posts from 10 users. Top user: 1

And data/posts.csv now exists.

Step 10: Clean

uv run ruff check . --fix
uv run ruff format .

Final folder

post-fetcher/
├── .venv/
├── data/
│   └── posts.csv
├── src/
│   ├── main.py
│   ├── config.py
│   ├── api.py
│   └── data.py
├── .env               ← private
├── .env.example       ← shared
├── .gitignore
├── .python-version
├── pyproject.toml
├── uv.lock
└── README.md

The checklist for every new project

  1. uv init the project
  2. uv add the packages you need, --dev for tools
  3. data/ and src/ folders
  4. .env for secrets, .env.example for the template, .env in .gitignore
  5. config.py for paths and secrets
  6. One module per responsibility; short main.py with if __name__ == "__main__":
  7. try / except around anything that can fail
  8. Ruff on save
  9. uv run src/main.py

Practice (Workflow)

  1. Build the post-fetcher project above from scratch.
  2. Change it to fetch /users instead of /posts and print how many users have an email ending in .biz.
  3. Delete .venv and run uv sync. Does the project still work?
  4. Share the project with a friend (without .env). What do they need to do to run it?

Part 11: Final Quiz

Try without looking back. Answers at the end.

Q1. Why should an API key never be written directly in a .py file?

Q2. Which file holds secrets, and which file is a shared template?

Q3. What does load_dotenv() do?

Q4. What is the difference between a linter and a formatter?

Q5. What are the two Ruff terminal commands for finding problems and for formatting?

Q6. What does uv add requests change?

Q7. What does uv sync do?

Q8. Which file should you never edit by hand: pyproject.toml or uv.lock?

Q9. How do you run a script with uv without activating the environment?

Q10. Put these in the correct order for a new project: uv add, write .env, uv init, uv run, create src/.


Answers

Part 2:

  1. Done in your editor.
  2. Add a line .env to .gitignore.
  3. So others know which secrets they need, without seeing your real values.
  4. Anyone who sees the file (friend, GitHub, screenshot) can use your key and spend your money.

Part 3:

  1. load_dotenv() then print(os.getenv("NAME")) → Aman
  2. None
  3. os.getenv returns None if missing; os.environ[...] raises KeyError.
  4. port = int(os.getenv("PORT", "8000"))

Part 6:

  1. Spacing, blank lines, and import order were fixed; unused imports removed.
  2. A linter finds problems (unused variables, errors). A formatter fixes style (spacing, line length).
  3. Your own number.

Part 9:

  1. .gitignore, .python-version, README.md, main.py, pyproject.toml
  2. requests appears under dependencies, and uv.lock plus .venv were created.
  3. pyproject.toml lists what you asked for (loose versions). uv.lock lists exact versions of everything, for reproducibility.
  4. uv run finds the project's .venv automatically and uses it.

Part 10 (Workflow):

  1. Done in your editor.
  2. Change the URL to /users, then: df["email"].str.endswith(".biz").sum()
  3. Yes. uv sync rebuilds .venv from uv.lock.
  4. Run uv sync, copy .env.example to .env, fill in the values, then uv run src/main.py.

Final Quiz:

  1. Code gets shared and uploaded. A key in code becomes public and can be abused.
  2. .env holds secrets (private). .env.example is the template (shared).
  3. Reads the .env file and loads its values as environment variables so os.getenv() can read them.
  4. Linter finds problems; formatter fixes style.
  5. ruff check . and ruff format .
  6. Installs requests into .venv, adds it to pyproject.toml, and records exact versions in uv.lock.
  7. Installs everything from uv.lock so the environment matches exactly.
  8. uv.lock
  9. uv run script.py
  10. uv init → create src/ → uv add → write .env → uv run

Epilogue

Aman revokes the leaked key, creates a new one, and puts it in .env. He never types a secret into code again.

His projects now start with uv init, format themselves on save, and run with one command. When Neha clones his repository, uv sync gives her an identical environment in seconds.

"You started five articles ago with print("Hello, world!")," Neha says. "Now you have the workflow of a professional Python developer. Everything after this — AI agents, APIs, data pipelines — is built on exactly this foundation."

Aman opens a new terminal and types:

uv init my-first-ai-agent

The real work begins.

No comments yet. Be the first to comment.