---
title: "0.2.4 Terminal, paths, environment variables and .env"
description: "Basic terminal commands, absolute and relative paths with pathlib, environment variables and a .env file reader that never shows the keys."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/terminal-paths-environment-variables/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/terminal-paths-environment-variables.png"
---

# 0.2.4 · Basic terminal, paths, environment variables and .env files

> A trading system is started from a terminal, reads data from a path and receives its keys through the environment. This lesson covers all three fronts and ends with a hand-written `.env` reader that never reveals a secret.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.2 · Modern working environment
- Duration: 40 min
- Prerequisites: [0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/)

## By the end of this lesson

- Move around folders, and list, create and view files from the terminal on Linux, macOS or Windows.
- Build paths with `pathlib` and explain why a relative path depends on the folder the program is run from.
- Read environment variables with `os.environ.get`, with default values and type conversion.
- Load configuration from a `.env` file with the right precedence (environment over `.env`) without ever showing a secret.

## The terminal in five minutes

The terminal is a program that reads typed commands and runs them. You have already used it to install uv (lesson
0.2.1) and create the project (lesson 0.2.2). In systematic trading it is the everyday tool: it starts the backtest,
launches the system on a server, reads its logs and stops it.

Inside the terminal runs a **command interpreter** (*shell*): `bash` or `zsh` on Linux and macOS, **PowerShell** on
Windows. Each has its own syntax, but the basic commands almost always match, because PowerShell defines aliases with
the Unix names:

| Action | Linux and macOS | Windows (PowerShell) |
|---|---|---|
| Which folder am I in? | `pwd` | `pwd` |
| List the contents | `ls`, `ls -la` (hidden ones too) | `ls`, `ls -Force` |
| Enter a folder / go up one | `cd datos`, `cd ..` | `cd datos`, `cd ..` |
| Create a folder | `mkdir -p datos/brutos` | `mkdir datos\brutos` |
| View a text file | `cat .gitignore`, `head -5 sint.csv` | `cat .gitignore`, `Get-Content sint.csv -Head 5` |
| Copy / delete | `cp a b`, `rm a` | `cp a b`, `rm a` |
| Stop the running program | Ctrl+C | Ctrl+C |

A typical session in the course project:

```bash
cd ~/ppt-curso                 # ~ is your home folder
ls                             # pyproject.toml, src, datos...
head -3 datos/brutos/sint_diario.csv
uv run python -m ppt.backtest  # runs a module of your package
```

Three habits save hours: the **Tab key** completes file and command names; the **up arrow** brings back previous
commands; and **Ctrl+C** interrupts the running program, which is the normal way to stop a backtest that takes too
long or a paper trading system.

<aside class="callout" data-kind="aviso">

`rm` has no recycle bin: what is deleted cannot be recovered. And `rm -r` deletes entire folders. Before deleting,
`pwd` and `ls` to confirm where you are. Never paste into your terminal a command from a forum that you do not
understand.

</aside>

## Paths: absolute and relative

A **path** says where a file is. It is **absolute** if it starts at the root of the disk (`/home/ana/ppt-curso/…` on
Linux, `C:\Users\ana\ppt-curso\…` on Windows) and **relative** if it is interpreted from the current folder
(`datos/sint_diario.csv`). Python has a module to handle them without fighting with slashes and separators,
`pathlib`:

```python
from pathlib import Path

ruta = Path("datos") / "brutos" / "sint_diario.csv"
print(ruta)
print(ruta.name, "|", ruta.stem, "|", ruta.suffix)
print(ruta.parent)
print(ruta.parts)
```
```output
datos/brutos/sint_diario.csv
sint_diario.csv | sint_diario | .csv
datos/brutos
('datos', 'brutos', 'sint_diario.csv')
```

The `/` operator joins pieces of a path with the system separator: `/` on Linux and macOS, `\` on Windows. That is
why it is better than writing paths by hand as text. On Windows, moreover, the backslash has a trap: inside a Python
string, `\n` is a newline and `\t` a tab.

```python
from pathlib import PureWindowsPath

mal = "C:\nuevos\ticks.csv"          # \n and \t are characters
bien = r"C:\nuevos\ticks.csv"        # r"" = literal backslashes
print(len(mal), len(bien))
print(PureWindowsPath(bien).name)
print(PureWindowsPath(bien).as_posix())
```
```output
17 19
ticks.csv
C:/nuevos/ticks.csv
```

The first string has two fewer characters: `\n` and `\t` have turned into a newline and a tab, and the file
`ticks.csv` will never show up. With the `r` prefix ("raw" string) or with `pathlib` the problem disappears.
`PureWindowsPath` lets you handle Windows paths from any system; in your code, `Path` is enough.

The most frequent problem with paths is not the slash, but the **current folder**. A relative path is resolved
against the folder the program was launched from, not against the folder of the code file. The course code runs in a
folder that contains the data files loose, so:

```python
print(Path("sint_diario.csv").exists())
print(Path("datos/sint_diario.csv").exists())
print(Path("sint_diario.csv").is_absolute())
print(Path("sint_diario.csv").resolve().is_absolute())
```
```output
True
False
False
True
```

The same script, launched from another folder, by cron or by a service on the server, would not find the file. The
solution is to anchor paths to something fixed. In a `.py` file, the `__file__` variable is the path of the file
itself; from it you go up to the project root:

```python skip title="src/ppt/rutas.py"
from pathlib import Path

# src/ppt/rutas.py -> up two levels = project root
RAIZ = Path(__file__).resolve().parents[2]
DATOS_BRUTOS = RAIZ / "datos" / "brutos"
DATOS_PROCESADOS = RAIZ / "datos" / "procesados"
```

The rest of the code imports `DATOS_BRUTOS` and never writes a path again. If tomorrow the data moves to another
disk, one line changes. CSV, Parquet and JSON files with `pathlib` are covered in lesson 0.4.4.

## Environment variables

Every program receives at startup a set of `NAME=value` pairs: its **environment variables**. Some are set by the
system (`PATH`, the list of folders where the terminal looks for programs; that is why after installing uv you had to
reopen the terminal), and others you set yourself. They are the standard way to pass configuration to a program
without touching its code: the operating mode, the capital, the broker key.

They are defined in the terminal before starting the program:

```bash
export PPT_MODO=paper                  # Linux and macOS, for the session
$env:PPT_MODO = "paper"                # PowerShell, for the session
PPT_MODO=live uv run python sistema.py # Linux and macOS, that run only
```

In Python they are read with `os.environ`, which works like a dictionary. Here the variable is created from Python so
that the cell is reproducible; in your system it would come from the terminal:

```python
import os

os.environ["PPT_MODO"] = "paper"      # in real life: export

print(os.environ.get("PPT_MODO"))
print(os.environ.get("PPT_CAPITAL"))             # does not exist
print(os.environ.get("PPT_CAPITAL", "25000"))    # default
```
```output
paper
None
25000
```

`os.environ["PPT_CAPITAL"]`, with square brackets, would raise a `KeyError` if the variable does not exist; `.get()`
returns `None` or the default value. Use square brackets for what is mandatory (so it fails early) and `.get()` with a
**safe** default value for what is optional. In trading, safe means conservative: if the mode is missing, paper, not
live.

Environment variables are **always text**. A number has to be converted, and a yes/no has a trap:

```python
os.environ["PPT_REAL"] = "false"
print(bool(os.environ["PPT_REAL"]))          # careful

def leer_bool(nombre: str, defecto: bool = False) -> bool:
    valor = os.environ.get(nombre)
    if valor is None:
        return defecto
    return valor.strip().lower() in {"1", "true", "si", "sí", "yes"}

print(leer_bool("PPT_REAL"))
capital = float(os.environ.get("PPT_CAPITAL", "25000"))
print(capital * 0.02)                        # maximum risk of 2%
```
```output
True
False
500.0
```

`bool("false")` is `True`, because any non-empty string is truthy. A system that decides whether to trade live with
`bool(os.environ[...])` would trade live with `PPT_REAL=false`. `leer_bool` only accepts an explicit "yes"; everything
else, including a typo, counts as no.

## .env files

Defining ten variables by hand every time you open the terminal is not practical. The convention is to keep them in a
`.env` file at the project root, one per line, which the program reads at startup. The `.env` belongs **to your
machine**: it has your keys and your configuration, it is in `.gitignore` from the first commit (lesson 0.1.3) and it
is not shared. What does get versioned is a `.env.example` with the names and without the values.

There are libraries to read it (`python-dotenv`, and Pydantic in lesson 0.4.2), but the format is so simple that it is
worth writing the reader by hand to understand what they do. First, an example `.env` with made-up keys:

```python
Path(".env").write_text("""\
# Broker (paper account)
BROKER_API_KEY=pk_demo_8f3a91c2e7b4
BROKER_API_SECRET="sk_demo_41d0c9e2a7f35b18"
export PPT_MODO=paper
PPT_CAPITAL=25000

PPT_RIESGO_MAX=0.02
""");
```

The reader: it ignores empty lines and comments, accepts the `export` prefix (so the same file works in `bash`),
splits on the first `=` and removes the quotes around the value:

```python
def leer_env(ruta: Path) -> dict[str, str]:
    valores = {}
    for linea in ruta.read_text().splitlines():
        linea = linea.strip()
        if not linea or linea.startswith("#"):
            continue
        linea = linea.removeprefix("export ")
        clave, igual, valor = linea.partition("=")
        if not igual:
            continue                    # line without '=': ignored
        valor = valor.strip()
        comillas = valor[:1] in ("'", '"')
        if comillas and len(valor) >= 2 and valor[-1] == valor[0]:
            valor = valor[1:-1]
        valores[clave.strip()] = valor
    return valores

env = leer_env(Path(".env"))
print(list(env))
```
```output
['BROKER_API_KEY', 'BROKER_API_SECRET', 'PPT_MODO', 'PPT_CAPITAL', 'PPT_RIESGO_MAX']
```

The cell prints only the names, not the values. It is deliberate: a printed value ends up in the notebook output, in a
log or in a screenshot. When the configuration needs to be shown (for example, in the system's startup log), secrets
are **masked**:

```python
SENSIBLES = ("KEY", "SECRET", "TOKEN", "PASSWORD")

def enmascarar(clave: str, valor: str) -> str:
    if any(s in clave.upper() for s in SENSIBLES):
        return valor[:3] + "***" if len(valor) > 12 else "***"
    return valor

for clave, valor in env.items():
    print(f"{clave:<18} {enmascarar(clave, valor)}")
```
```output
BROKER_API_KEY     pk_***
BROKER_API_SECRET  sk_***
PPT_MODO           paper
PPT_CAPITAL        25000
PPT_RIESGO_MAX     0.02
```

Three characters are enough to tell a paper key (`pk_`) from another one without revealing anything useful.

## Who wins: environment, .env and default values

The same program runs on your laptop, on a server and in the tests. Each place needs to adjust something without
touching the code or the others' `.env`. The convention, followed by `python-dotenv` and `uv run --env-file`, is a
precedence chain in which each layer overrides the previous one:

```mermaid
flowchart TB
  D[Defaults] --> E[.env file]
  E --> V[Environment]
  V --> C[Configuration]
```

Default values live in the code and are conservative; the `.env` adjusts them for your machine; an environment
variable changes them for a specific run. With the reader above, the full load looks like this, noting where each
value comes from:

```python
DEFECTO = {"PPT_MODO": "paper", "PPT_CAPITAL": "10000",
           "PPT_RIESGO_MAX": "0.01", "PPT_LOG": "INFO"}

def cargar_config(ruta: Path) -> dict[str, tuple[str, str]]:
    config = {k: (v, "default") for k, v in DEFECTO.items()}
    for k, v in leer_env(ruta).items():
        config[k] = (v, ".env")
    for k in config:
        if k in os.environ:
            config[k] = (os.environ[k], "environment")
    return config

os.environ["PPT_CAPITAL"] = "50000"   # export PPT_CAPITAL=50000
for clave, (valor, origen) in cargar_config(Path(".env")).items():
    print(f"{clave:<18} {enmascarar(clave, valor):<10} {origen}")
```
```output
PPT_MODO           paper      environment
PPT_CAPITAL        50000      environment
PPT_RIESGO_MAX     0.02       .env
PPT_LOG            INFO       default
BROKER_API_KEY     pk_***     .env
BROKER_API_SECRET  sk_***     .env
```

Each value carries its origin: when a system does something unexpected, knowing where the capital or the mode came
from is half the diagnosis. `PPT_MODO` comes from the environment because an earlier cell defined it with
`os.environ`; it is the same effect a forgotten `export` in the terminal would have.

In your scripts there is no need to call a reader of your own: uv loads the `.env` when it starts the program if you
ask it to.

```bash
uv run --env-file .env python -m ppt.backtest
uv run --env-file .env --env-file .env.servidor python -m ppt.sistema
```

The variables in the file reach `os.environ` and, as in `cargar_config`, a variable that already existed in the
environment is not overwritten. With several files, each one overrides the previous one: a common `.env` and another
with what is specific to the server. What uv does not do is validate the values or mask the secrets when showing
them; that is still up to your code, as in the second exercise.

<aside class="callout" data-kind="riesgo">

A broker API key is money. Rules with no exceptions: it goes in `.env` (or in the server's secrets manager), never in
the code; `.env` is in `.gitignore` before the first commit; it is not printed, not in logs, not in notebooks and not
in an AI prompt; while you are learning, only **paper account** keys and, if the broker allows it, without permission
to withdraw funds. If a key leaves your machine by mistake, it is revoked and another is created: deleting the file is
not enough (lesson 0.2.5).

</aside>

## Exercises

1. In the terminal, without using the file explorer: inside `ppt-curso`, create the folders `datos/brutos` and
   `datos/procesados`, copy the `sint_diario.csv` file from the resources into `datos/brutos` and show its first three
   lines.

**Solution.**

On Linux and macOS: `cd ~/ppt-curso`, `mkdir -p datos/brutos datos/procesados`, `cp ~/Downloads/sint_diario.csv
datos/brutos/` (the downloads folder depends on your system) and `head -3 datos/brutos/sint_diario.csv`. In
PowerShell: `mkdir datos\brutos`, `mkdir datos\procesados`, `cp $HOME\Downloads\sint_diario.csv datos\brutos\` and
`Get-Content datos\brutos\sint_diario.csv -Head 3`. The first line is the header:
`fecha,apertura,maximo,minimo,cierre,volumen`.

2. Extend `cargar_config` so that it fails with a clear message if a mandatory variable is missing (for example,
   `BROKER_API_KEY`) and so that it converts `PPT_CAPITAL` and `PPT_RIESGO_MAX` to `float`, checking that the risk is
   between 0 and 0.05.

**Solution.**

```python
OBLIGATORIAS = ("BROKER_API_KEY", "BROKER_API_SECRET")

def config_validada(ruta: Path) -> dict[str, object]:
    crudo = {k: v for k, (v, _) in cargar_config(ruta).items()}
    faltan = [k for k in OBLIGATORIAS if not crudo.get(k)]
    if faltan:
        raise RuntimeError(f"Missing variables: {', '.join(faltan)}")
    riesgo = float(crudo["PPT_RIESGO_MAX"])
    if not 0 < riesgo <= 0.05:
        raise ValueError(f"PPT_RIESGO_MAX out of range: {riesgo}")
    return crudo | {"PPT_CAPITAL": float(crudo["PPT_CAPITAL"]),
                    "PPT_RIESGO_MAX": riesgo}

cfg = config_validada(Path(".env"))
print(cfg["PPT_CAPITAL"], cfg["PPT_RIESGO_MAX"])
```
```output
50000.0 0.02
```

The message names the missing variables, but never shows values. Failing at startup is much better than finding out
in the middle of a session that the capital was a string or that the risk was 20%.

3. Write the `.env.example` that would accompany the lesson's `.env`. What is the difference between the two and which
   one goes to Git?

**Solution.**

```dotenv title=".env.example"
# Copy this file to .env and fill in the values
BROKER_API_KEY=
BROKER_API_SECRET=
PPT_MODO=paper
PPT_CAPITAL=25000
PPT_RIESGO_MAX=0.02
```

The `.env.example` has the same names, with the secrets empty and example values for what is not secret. It goes to
Git: it documents what the program needs. The `.env`, with the real keys, is never versioned.

## Self-assessment

1. Your script reads `datos/sint_diario.csv` and works when you launch it from the project folder. Scheduled with cron, it fails with `FileNotFoundError`. Why?

   - A. Because the path is relative and is resolved against the current folder, which under cron is not the project's
   - B. Because cron does not have permission to read CSV files
   - C. Because pandas does not work outside the terminal
   - D. Because the file was deleted

   *Answer A: Because the path is relative and is resolved against the current folder, which under cron is not the project's.* A relative path is interpreted from the process's working folder. Cron, a service or an editor may start the program from another folder. The solution is to build the path from the location of the code file itself or from a known root.

2. What does `bool(os.environ.get("PPT_REAL", "false"))` return if the variable does not exist?

   - A. `False`
   - B. `None`
   - C. `True`, because `"false"` is a non-empty string
   - D. An error

   *Answer C: `True`, because `"false"` is a non-empty string.* Environment variables are always text, and any non-empty string is truthy. To read a yes/no you have to compare the text with specific values, as `leer_bool` does in the lesson. A mistake like this can put a system into live trading.

3. The variable `PPT_CAPITAL` is `10000` in the environment and `25000` in the `.env`. With the precedence from the lesson, which capital does the system use?

   - A. 25000, because the `.env` belongs to the project
   - B. 10000, because the environment takes priority over the `.env`
   - C. The sum, 35000
   - D. Neither: the program fails because of the conflict

   *Answer B: 10000, because the environment takes priority over the `.env`.* The `.env` provides default values for your machine; the environment lets you change them for a specific run (a server, a test) without touching files. It is the same rule followed by uv with `--env-file` and by the `python-dotenv` library.

4. You accidentally pushed a `.env` with your broker key to a public repository and deleted it with another commit five minutes later. What do you do?

   - A. Nothing: it is already deleted
   - B. Make the repository private
   - C. Rename the file
   - D. Revoke the key at the broker and create another; the old one is still in the history and may already have been copied

   *Answer D: Revoke the key at the broker and create another; the old one is still in the history and may already have been copied.* There are programs that continuously crawl public repositories looking for keys, and the commit is still in the history. A key that has been in Git is considered compromised: it is revoked, another is created and the account activity is reviewed.

## Key points

- The terminal is the way to start, stop (Ctrl+C) and automate programs. A few commands are enough: `pwd`, `ls`, `cd`, `mkdir`, `cat`.
- A relative path depends on the current folder. Build paths with `pathlib` from a known root.
- Environment variables are always text: convert numbers and yes/no explicitly and give a safe default value.
- Precedence: default values, then `.env`, then the environment. The environment wins.
- Secrets go in `.env`, which is in `.gitignore`; they are never printed, not in logs and not in notebooks.

## Resources

### Documentation

- [Python documentation: pathlib](https://docs.python.org/3/library/pathlib.html) · docs.python.org
- [Python documentation: os.environ](https://docs.python.org/3/library/os.html#os.environ) · docs.python.org
- [The Twelve-Factor App: store config in the environment](https://12factor.net/config) · 12factor.net
- [PowerShell: environment variables (about_Environment_Variables)](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables) · learn.microsoft.com
- [uv: running commands in a project (includes --env-file)](https://docs.astral.sh/uv/concepts/projects/run/) · docs.astral.sh

Previous lesson: [0.2.3 · Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)](https://pythonparatrading.com/en/python-trading-course/level-0/code-editors-jupyter-marimo-notebooks/)

Next lesson: [0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore](https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/)
