Week 1 · Ingest anything › Day 2
Day 2. Engineering setup that survives a real job
By the end of today you can
- Set up a reproducible Python project with uv, ruff and pytest
- Keep secrets out of git with .env and config objects
- Write structured logs a pipeline can be debugged from
- Use the bi-skills-kit CLAUDE.md rules on your own project
1. Why this matters
Most "pipeline broke" incidents aren't clever bugs. They're a laptop-only dependency, a password in code, a path that only exists on one machine, or a log that says Error and nothing else. Five-year engineers set these up on day one, so you will too.
2. A project layout you'll reuse every week
retail-ingest/
├── pyproject.toml # dependencies + tool config (one file)
├── uv.lock # exact versions, committed
├── .env.example # names of settings, no values
├── .gitignore # .env, data/, .venv/
├── README.md
├── src/retail_ingest/
│ ├── __init__.py
│ ├── config.py # reads settings once, typed
│ ├── log.py # structured logging
│ └── sources/ # one module per source
├── tests/
└── data/ # git-ignored: raw/, staging/
uv (from Astral) creates the venv, resolves dependencies and writes a lock file in seconds. pip + requirements.txt still works; the lock file is the part that matters.
pip install uv # or see docs.astral.sh/uv for the installer
uv init --package retail-ingest && cd retail-ingest
uv add pandas polars duckdb openpyxl pydantic-settings python-dotenv
uv add --dev pytest ruff
uv run pytest
3. Config and secrets
Rule: code reads settings; settings live outside code. Locally that's a git-ignored .env. In production it's environment variables injected by the orchestrator or a vault (Azure Key Vault, Week 4).
# src/retail_ingest/config.py
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="P2D_")
data_dir: Path = Path("./data")
landing_dir: Path = Path("./data/landing")
imap_host: str = "imap.example.com"
imap_user: str = ""
imap_password: str = "" # never logged, never committed
settings = Settings()
Watch out
If a secret is ever committed, deleting the line is not enough: it stays in git history. Rotate the secret first, then clean history. GitHub's push protection and gitleaks catch most leaks before they happen; turn both on.
4. Logs you can debug from
A good log line answers: what step, on which input, what happened, how many rows, how long. Use key=value or JSON so you can grep and parse it.
# src/retail_ingest/log.py
import json, logging, sys, time
class JsonFormatter(logging.Formatter):
def format(self, r):
base = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "level": r.levelname, "msg": r.getMessage()}
base.update(getattr(r, "ctx", {}))
return json.dumps(base)
def get_logger(name="p2d"):
log = logging.getLogger(name)
if not log.handlers:
h = logging.StreamHandler(sys.stdout); h.setFormatter(JsonFormatter())
log.addHandler(h); log.setLevel(logging.INFO)
return log
# usage
log = get_logger()
log.info("file landed", extra={"ctx": {"source": "email", "file": "sales_R104.csv", "rows": 812, "sha256": "ab12…"}})
Maths and principle
Principle: reproducibility means the output is a function of (code version, dependency versions, input data, config). Pin all four and you can reproduce any run.
Equation: \(\text{output} = f(\text{commit}, \text{lockfile}, \text{inputs}, \text{config})\). Remove any argument from your records and the function is no longer determined.
Analogy: a lab notebook records apparatus, calibration and conditions, not just the result. Where it breaks: external clocks and APIs ("now()", "yesterday's data") are hidden inputs; make them explicit parameters.
Try it: a job uses date.today() - 1 to choose which file to load. Name the hidden input and the fix. (The wall clock. Pass the business date as a parameter; Week 3 partitions do exactly this.)
5. Claude Code, safely, from day two
You'll use Claude Code throughout the course as a pair programmer. Copy CLAUDE.md from your bi-skills-kit/claude-code/ into each project and trim it. The rules that matter most on a personal project: never print secrets, propose before applying on anything shared, and save evidence (queries and outputs) for every claim. Ask it to explain code it writes, and reject anything you can't explain back.
The 5-year lens
On a team, "works on my machine" costs hours. The lock file, .env.example, a make/just file with setup, test, run, and a README "first run in 5 minutes" section are what make you the person others trust with production. Interviewers who've run teams notice these files in your repos before they read your code.
Lab
- Create the
retail-ingestproject with uv as above. Addruffconfig topyproject.toml(line-length = 100, rulesE,F,I,B,UP). - Write
config.pyandlog.py. Create.envand.env.example. Confirm.envis ignored:git check-ignore .env. - Write
tests/test_config.pythat setsP2D_DATA_DIRwithmonkeypatch.setenvand assertsSettings().data_dirchanges. - Add a
justfileorMakefilewithsetup,lint(ruff check . && ruff format --check .),test. - Install
pre-commitwith ruff and gitleaks hooks. Try committing a fake keyAKIA…and watch it get blocked. - Copy and adapt
CLAUDE.md. Ask Claude Code: "Read this repo and list anything that would fail on a fresh machine." Fix what it finds.
Ship
Push nothing public yet. Commit locally: chore: project skeleton with config, logging, tests, pre-commit.
Check yourself
1. Where should the IMAP password for production live?
Private repos get forked, cloned and leaked. Secrets come from the environment or a vault.
2. Why commit uv.lock?
A lock file pins the full dependency tree. That is half of reproducibility.
3. Which log line is most useful at 6 a.m.?
It names the step, the input, the count and the error class. You can act on it.