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

*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:

```python
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

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/119e1fdf-bec8-4b12-b286-ea2fefa7c8f7.png align="center")

---

## 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:

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

Windows (PowerShell):

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

And read it in Python:

```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:

```plaintext
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:

```plaintext
# .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:

```plaintext
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.

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/b910dbd6-390e-40e3-bc41-80baf74464b2.png align="center")

### 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

```bash
pip install python-dotenv
```

### Use

```python
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:

```python
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:

```python
api_key = os.environ["API_KEY"]
```

### A clean pattern: [config.py](http://config.py)

Put all secret loading in one place:

```python
# 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:

```python
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:

```python
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

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/b2e8dd8f-3883-413a-a9d5-fea6ff408545.png align="center")

---

## 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

```bash
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:

```json
{
  "[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):

```toml
[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:

```python
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

```bash
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:

```plaintext
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:

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

Windows (PowerShell):

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

Or with pip, if you prefer:

```bash
pip install uv
```

Check:

```bash
uv --version
```

---

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

### Start a new project

```bash
uv init my-ai-app
cd my-ai-app
```

uv creates:

```plaintext
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:

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

### Add a package

```bash
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`

```toml
dependencies = [
    "requests>=2.32.3",
]
```

Add several at once:

```bash
uv add pandas matplotlib python-dotenv
```

Remove:

```bash
uv remove matplotlib
```

### Run code

You do **not** need to activate the environment:

```bash
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:

```bash
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:

```bash
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. |

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/cdd6c424-edb1-4754-992b-7970e3799bcd.png align="center")

### 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

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

### Step 2: Add packages

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

### Step 3: Set up folders

```bash
mkdir data src
```

Move `main.py` into `src/`.

### Step 4: Secrets

Create `.env`:

```plaintext
API_BASE_URL=https://jsonplaceholder.typicode.com
```

Create `.env.example`:

```plaintext
API_BASE_URL=
```

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

### Step 5: Config

`src/config.py`:

```python
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`:

```python
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`:

```python
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`:

```python
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

```bash
uv run src/main.py
```

Output:

```plaintext
100 posts from 10 users. Top user: 1
```

And `data/posts.csv` now exists.

### Step 10: Clean

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

### Final folder

```plaintext
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
```

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/94066dfe-58ba-40fb-ad33-421eff31ab11.png align="center")

### 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:

```bash
uv init my-first-ai-agent
```

The real work begins.
