Set up a professional repo
The capstone: a real professional repo AND a packaged, tested Python tool inside it — pathlib, argparse, a git changelog, resilient API use, pyproject packaging, PR, and CI — applying DF1–DF5.
Learning objectives
- Scaffold a professional, packaged repo from the CLI.
- Build a tested, pure-logic Python tool inside it.
- Ship a change through a branch + PR + CI.
- Hand the team a reproducible, documented template.
code/proj-df-setup/. Python runs offline; configs are ready to use.1 · Scaffold essential
scaffold.shmkdir my-tool && cd my-tool
mkdir -p src/my_tool tests
touch src/my_tool/__init__.py
printf ".venv/\n__pycache__/\n.env\n*.pyc\n" > .gitignore
python3 -m venv .venv && source .venv/bin/activate
pip install pytest requests && pip freeze > requirements.txt
This shell script builds an empty folder into the skeleton of a real Python project — the exact shape professional repos use. Run the lines top to bottom; each && means "only do the next thing if the previous one succeeded".
mkdir my-tool && cd my-tool— make a new project folder and step into it. Everything after this happens insidemy-tool.mkdir -p src/my_tool tests— create the two standard folders at once.-pmeans "make parent folders as needed and don't complain if they exist". Your code lives insrc/my_tool; your tests live intests.touch src/my_tool/__init__.py— create an empty file with that special name. Its presence tells Python "this folder is an importable package".printf "…" > .gitignore— write a.gitignorefile listing things git should never track (the virtual-env folder, caches, secrets in.env). The\npieces are line breaks, so each entry lands on its own line.python3 -m venv .venv && source .venv/bin/activate— create an isolated "virtual environment" (a private copy of Python for this project) and switch your shell into it. Now packages you install stay in this project, not your whole computer.pip install pytest requests && pip freeze > requirements.txt— install the test runner and an HTTP library, then record the exact versions intorequirements.txtso a teammate can reproduce your setup precisely.
What the output means: No visible result — this script just creates files and folders. Afterwards your prompt usually shows (.venv) at the front, confirming the virtual environment is active.
Try this: Run ls -R after this to see the tree you built, and cat .gitignore to confirm the four ignore lines landed correctly.
2 · The tool — pure logic (DF1) essential
core.pyfrom pathlib import Path
def summarize(paths):
"""Pure function: files -> {name: line_count}. Trivial to unit-test."""
return {Path(p).name: len(Path(p).read_text().splitlines())
for p in paths if Path(p).exists()}
def main(argv=None):
import argparse
ap = argparse.ArgumentParser(prog="my-tool")
ap.add_argument("paths", nargs="+")
for name, n in summarize(ap.parse_args(argv).paths).items():
print(f"{n:6} {name}")
return 0
Path("/tmp/cap.txt").write_text("a\nb\nc\n")
print(summarize(["/tmp/cap.txt"]))
{'cap.txt': 3}
This is the actual tool. It has two parts: a pure function that does the real work (counting lines in files) and a thin command-line wrapper around it. Keeping the logic separate from the command-line plumbing is what makes it easy to test later.
from pathlib import Path— bring in Python's modern file-path helper.Path(p)turns a plain string like"notes.txt"into an object that knows how to read the file, get its name, check if it exists, etc.summarize(paths)returns a dictionary mapping each file's short name to its line count. The{… for p in paths if Path(p).exists()}is a dict-comprehension: loop over every path, skip ones that don't exist, and for the rest storename → number of lines. It's "pure" because the same inputs always give the same output and it changes nothing else.main(argv=None)is the command-line entry point.argparsereads the arguments a user typed;nargs="+"means "accept one or more file paths". It then prints each count right-aligned in a 6-wide column ({n:6}) and returns0to signal success.- The last two lines are a quick live demo: write a 3-line file to
/tmp/cap.txt, then callsummarizeon it and print the result.
What the output means: {'cap.txt': 3} — a dictionary with one entry: the file cap.txt has 3 lines. That is the pure function working correctly.
Try this: Add a second file (e.g. write /tmp/two.txt with two lines) and pass both paths to summarize. You should see two entries in the dictionary.
3 · Tests (TQ preview) intermediate
test_core.pyfrom pathlib import Path
# (in the repo: `from my_tool.core import summarize`)
def summarize(paths):
return {Path(p).name: len(Path(p).read_text().splitlines())
for p in paths if Path(p).exists()}
# the assertions a pytest file would contain:
f = Path("/tmp/cap_test.txt"); f.write_text("one\ntwo\n")
assert summarize([str(f)]) == {"cap_test.txt": 2}
assert summarize(["/no/such/file"]) == {}
print("tests pass")
tests pass
This shows how you prove the tool works — automatically, not by eyeballing output. It re-defines summarize so the snippet runs on its own, but in the real repo you'd import it (see the commented line). The important part is the assert checks.
- An
assertsays "this must be true; if it isn't, stop and shout". A passing assert is silent; a failing one raises an error that pinpoints the bug. f = Path("/tmp/cap_test.txt"); f.write_text("one\ntwo\n")— set up a known input: a file with exactly two lines. Tests should create their own predictable data.assert summarize([str(f)]) == {"cap_test.txt": 2}— the happy path: a real two-line file must report a count of 2.assert summarize(["/no/such/file"]) == {}— the edge case: a file that doesn't exist must be skipped, giving an empty dictionary rather than crashing.- If both asserts pass, execution reaches
print("tests pass").
What the output means: tests pass — both assertions held, so the function behaves correctly on a normal file and on a missing one. If an assert failed you'd instead see an AssertionError and no "tests pass" line.
Try this: Break the code on purpose: change the expected 2 to 3 and re-run. The AssertionError you get is exactly how a test tells you something regressed.
4 · Advanced — package it (DF5) advanced
pyproject.toml[project]
name = "my-tool"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.30,<3"]
[project.scripts]
my-tool = "my_tool.core:main" # pip install -e . -> `my-tool` on PATH
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
pyproject.toml is the standard packaging manifest — the ID card that turns your loose folder into an installable Python package. [section] headers group related settings; key = value lines set them.
[project]— the basic identity: the packagename, itsversion, the minimum Python it needs (requires-python = ">=3.10"), and its runtimedependencies(hererequests, pinned to a safe version range).[project.scripts]— the magic that gives you a real command.my-tool = "my_tool.core:main"means "when installed, create a command calledmy-toolthat runs themainfunction inmy_tool/core.py". The comment shows the install step:pip install -e ..[build-system]— tells pip how to build the package. Here it useshatchling, a common, low-fuss build backend. You rarely change this.
Try this: After pip install -e . in the repo, type my-tool README.md in your terminal. Because of the [project.scripts] mapping, that command now exists and runs your tool.
5 · Professional — Git history + PR (DF2+DF3) professional
git.shgit init && git add . && git commit -m "chore: scaffold my-tool"
git branch -M main
git remote add origin git@github.com:you/my-tool.git
git push -u origin main
git switch -c feat/summarize
# ...commit the tool + tests...
git push -u origin feat/summarize # open the PR on GitHub
These git commands put the project under version control, publish it to GitHub, and start a feature branch so your change can go through a pull request (PR) — the professional way to get code reviewed before it joins the main line.
git init && git add . && git commit -m "chore: scaffold my-tool"— start tracking this folder, stage every file (add .), and record the first snapshot with a message.chore:is a conventional prefix meaning "setup, not a feature".git branch -M main— name the primary branchmain(the modern default).git remote add origin git@github.com:you/my-tool.git— tell git where the online copy lives, nicknaming that addressorigin.git push -u origin main— uploadmainto GitHub for the first time.-ulinks your local branch to the remote one so future pushes are justgit push.git switch -c feat/summarize— create and move onto a new branch for your feature, somainstays stable while you work. After committing the tool and tests, the finalgit push -u origin feat/summarizeuploads the branch — GitHub then offers to open the PR.
What the output means: Each command prints a short status line (files staged, branch created, push progress). The payoff is on GitHub: your branch appears with a "Compare & pull request" button.
Try this: Run git log --oneline to see your commit, and git branch to confirm you're on feat/summarize, not main.
6 · Tech-lead — CI + a reusable template tech-lead
Finish by making it the template the team starts from: CI runs tests on every push, protected main (DF3), reproducible env (DF5). Every new project inherits production shape.
ci.ymlname: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e . pytest
- run: pytest -q # blocks merge if red
This YAML file is a CI (Continuous Integration) pipeline: GitHub runs it automatically on a fresh machine every time you push or open a PR, so tests are checked for you — no one can forget. Indentation defines the nesting, so keep it exact.
name: CIandon: [push, pull_request]— name the workflow and set its triggers: run it on every push and on every pull request.jobs: test:defines one job calledtest;runs-on: ubuntu-latestgives it a clean Linux machine to run on.steps:is an ordered list. The twouses:lines pull in ready-made actions —checkoutgrabs your code,setup-pythoninstalls Python 3.12 on the runner.run: pip install -e . pytestinstalls your package and the test runner;run: pytest -qruns the tests quietly. If any test fails, the job goes red and, with branch protection on, that blocks the merge — bad code can't reachmain.
What the output means: On GitHub you'll see a green check next to the commit when tests pass, or a red X when they fail. The red X is what stops a broken change from being merged.
Try this: Push a commit with a deliberately failing test and watch the Actions tab turn red — then fix it and watch the same pipeline turn green.
| Deliverable | From |
|---|---|
| README + .gitignore | DF2 |
| src/ + tests/ layout | DF5 |
| venv + pinned requirements | DF5 |
| packaged, pip-installable CLI | DF1+DF5 |
| resilient API usage | DF4 |
| branch + PR + protected main | DF3 |
| CI running tests on push | DF3+TQ |
tests/; Containers (CD) add a Dockerfile + deploy to this exact repo. A lead who standardizes this template means every new project starts production-shaped — not from a bare folder.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A clean, packaged layout is the launchpad every later project reuses: a src/ package stops accidental imports of an uninstalled package, and a .gitignore keeps venvs and secrets out of git from commit one.
Your task: Model the scaffold as a check that the required files and directories are present.
Requirements:
- A
src/<package>/package layout with an__init__.pyand a module - A
tests/directory,.gitignore,requirements.txt, andpyproject.toml - The check reports exactly which required paths are missing
- Runs offline over a list of present paths
💡 Hint: Express 'set up correctly' as a passing check over a required-paths list rather than eyeballing a tree.
Show solution
A validator for the scaffold makes "did I set it up right?" a passing test, not a guess:
REQUIRED = ["src/my_tool/__init__.py", "src/my_tool/core.py",
"tests/test_core.py", ".gitignore", "requirements.txt",
"pyproject.toml"]
GITIGNORE = [".venv/", "__pycache__/", ".env", "*.pyc"]
def check_scaffold(present_paths):
missing = [p for p in REQUIRED if p not in present_paths]
return {"ok": not missing, "missing": missing}
have = ["src/my_tool/__init__.py", "src/my_tool/core.py",
"tests/test_core.py", ".gitignore", "requirements.txt"]
print(check_scaffold(have)) # missing: ['pyproject.toml']
The src/ layout stops accidental imports of an uninstalled package; the .gitignore keeps venvs and secrets out of git from commit one. Getting the skeleton right is the launchpad every later project reuses.
Context: Keeping logic testable and the CLI thin is what lets you unit-test without spawning a subprocess. The seam is a pure function plus an argparse wrapper.
Your task: Write summarize(paths) as a pure function and an argparse main(argv=None) wrapper that calls it.
Requirements:
summarizemaps each path to its line count and takes no CLI concerns- An injectable reader lets the logic run in-memory with no real files
main(argv=None)parses real args in production, a list in tests- The CLI prints per-file counts by delegating to the pure function
- Demonstrate the logic with an injected fake reader
💡 Hint: Dependency-inject the file reader so tests pass a dict's .get; argv=None is the standard testable-CLI trick.
Show solution
Separating pure logic from the CLI wrapper is what makes the tool unit-testable without a subprocess:
import argparse
def summarize(paths, read=None):
# pure: line count per file. `read` injected so tests need no real files.
read = read or (lambda p: open(p, encoding="utf-8").read())
return {p: len(read(p).splitlines()) for p in paths}
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+")
ns = ap.parse_args(argv) # argv=None -> real args; list -> test
for name, n in summarize(ns.paths).items():
print(f"{name}: {n}")
# offline test with an injected reader -- no filesystem needed:
fake = {"a.py": "x\ny\nz", "b.py": "one"}
print(summarize(["a.py", "b.py"], read=fake.get)) # {'a.py': 3, 'b.py': 1}
Dependency-injecting the reader means the logic tests run in-memory. main(argv=None) reads real args in production but accepts a list in tests — the standard pattern for a testable CLI.
Context: Real bugs live in the edges, not the happy path: the empty file, the missing key, multiple inputs. The empty-file case is the classic off-by-one — "".splitlines() is length 0, not 1.
Your task: Write pytest-style tests for the happy path, an empty file, a missing file, and multiple files.
Requirements:
- A happy-path test asserting the expected counts
- An empty-file test asserting 0, not 1
- A multiple-files test
- A missing-file test that pins the raised error behaviour
- All tests run in-memory via the injected reader
💡 Hint: Reuse the injectable reader to simulate an empty string and a reader that raises FileNotFoundError.
Show solution
Edge-case tests are where real bugs live — empty inputs and missing files, not the happy path:
def test_happy():
assert summarize(["a"], read=lambda p: "l1\nl2") == {"a": 2}
def test_empty_file():
assert summarize(["a"], read=lambda p: "") == {"a": 0} # not 1!
def test_multiple():
r = summarize(["a", "b"], read={"a":"x", "b":"y\nz"}.get)
assert r == {"a": 1, "b": 2}
def test_missing_raises():
try:
summarize(["nope"], read=lambda p: (_ for _ in ()).throw(FileNotFoundError))
assert False
except FileNotFoundError:
pass
for fn in [test_happy, test_empty_file, test_multiple, test_missing_raises]:
fn()
print("4 tests passed")
The empty-file case is the classic off-by-one ("".splitlines() is [], length 0, not 1). Testing missing files pins the error behavior. These are the cases a hurried tool gets wrong.
Context: Shipping means an installable command on PATH, not a script path. A typo in the module:function entry point installs fine but fails at runtime with 'no attribute' — so validate that the target actually resolves.
Your task: Write the pyproject.toml with a [project.scripts] entry point and validate the target resolves to the CLI.
Requirements:
- A
[project.scripts]mapping a command name tomodule:function - A validator that parses the
module:functionstring - It confirms the named callable actually exists in that module
- The validator runs offline against a symbol table
- Note the real install/run step (
pip install -e .) as labelled
💡 Hint: Split the entry point on : and check the function name against the module's available callables before you publish.
Show solution
The entry point is what turns python src/.../core.py into a my-tool command on PATH — and it must point at a real callable:
PYPROJECT = {
"project": {"name": "my-tool", "version": "0.1.0",
"scripts": {"my-tool": "my_tool.core:main"}},
"build-system": {"requires": ["hatchling"],
"build-backend": "hatchling.build"},
}
def validate_entry_point(pyproject, module_symbols):
# module_symbols: {"my_tool.core": ["main", ...]} available callables
ep = pyproject["project"]["scripts"]["my-tool"] # "my_tool.core:main"
mod, _, fn = ep.partition(":")
ok = fn in module_symbols.get(mod, [])
return {"entry_point": ep, "resolves": ok}
print(validate_entry_point(PYPROJECT, {"my_tool.core": ["main", "summarize"]}))
# resolves: True -- 'my_tool.core:main' exists
# --- needs: pip install -e . then run: my-tool a.py b.py ---
A broken entry point (typo in module:function) installs fine but fails at runtime with "no attribute". Validating that main actually exists in my_tool.core catches it before you publish.
Context: Professional work ships through review, not straight to main. A feature branch plus a PR is the unit of change, and branch protection makes the rule impossible to forget under deadline pressure.
Your task: Model the branch-and-PR flow and a check that blocks a direct push to main.
Requirements:
- A
can_pushcheck that refuses pushes to protected branches (main/master) - It allows pushes to feature branches
- It explains the refusal (open a PR from a feature branch)
- Model the git flow: branch, commit, push, open PR (git commands labelled)
- The check runs offline
💡 Hint: A protected-branch set plus a membership test models branch protection; the git commands themselves are reference, not runnable in tests.
Show solution
Branch protection as a checkable rule — the flow every professional repo enforces. Git commands labeled:
def can_push(branch, protected=("main", "master")):
if branch in protected:
return False, f"'{branch}' is protected; open a PR from a feature branch"
return True, "push ok"
print(can_push("main")) # (False, "...open a PR...")
print(can_push("feat/summarize-edge")) # (True, 'push ok')
# --- needs git + a GitHub remote ---
# git init
# git checkout -b feat/summarize-edge
# git add -A && git commit -m "add file summarizer + tests"
# git push -u origin feat/summarize-edge
# gh pr create --fill # open the PR for review
Pushing straight to main skips review and CI. A feature branch + PR is the unit of change; branch protection (modeled by can_push) makes the rule impossible to forget under deadline pressure.
Context: The payoff of the foundations project: a required check turns 'please run the tests' into an enforced merge gate, so a red pipeline blocks the merge button and nobody lands broken code by accident.
Your task: Write the GitHub Actions workflow and model the required-check logic that blocks merge unless tests pass.
Requirements:
- A
can_mergecheck that blocks when a required check is failing - It names which required checks are red
- It allows merge only when all required checks pass
- The Actions workflow runs the tests on push and pull_request
- The test job is the required check (workflow labelled, gate logic runnable)
💡 Hint: Model required checks as a set the merge gate insists on; the workflow is what produces the check's pass/fail.
Show solution
A required check turns "please run the tests" into an enforced merge gate. Workflow labeled; gate logic runnable:
def can_merge(checks, required=("test",)):
# checks: {"test": True/False, ...}
failing = [c for c in required if not checks.get(c)]
if failing:
return False, f"blocked: required checks failing {failing}"
return True, "merge allowed"
print(can_merge({"test": False})) # blocked: ['test']
print(can_merge({"test": True})) # merge allowed
# .github/workflows/ci.yml --- needs GitHub Actions ---
# on: [push, pull_request]
# jobs:
# test:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-python@v5
# with: {python-version: "3.12"}
# - run: pip install -e . pytest
# - run: pytest -q # this is the required check
Marking test a required check in branch protection means a red pipeline blocks the merge button — nobody can land broken code, even by accident. That gate is the whole payoff of the foundations project.
✓ Checkpoint — you can move on when you can…
- Scaffold a clean, packaged repo from the CLI.
- Build a pure-logic, tested Python tool.
- Ship via branch + PR with CI running tests.
- Turn it into a reusable, reproducible team template.
| Dimension | Meets the bar | Above the bar (staff) |
|---|---|---|
| Reproducibility | A fresh clone + documented setup (venv + pinned requirements) produces a working environment on another machine. | The environment is reproducible from scratch on a clean CI runner with no manual steps, and versions are pinned so builds are deterministic over time. |
| Tooling correctness | The tool is pure-logic and runs; argparse/pathlib are used correctly and API calls are resilient (timeouts/retries). | The CLI is packaged and pip install -e .-able, entry points work end to end, and the resilient API path is tested against failure (timeout, non-200). |
| Environment isolation | Work happens inside a venv; a .gitignore keeps the env, caches, and secrets out of the repo. | No secrets or state are committed, the package installs without polluting the system Python, and the isolation survives being handed to a new developer. |
| Repo structure & packaging | A clean src/ + tests/ layout with a valid pyproject.toml and a README. | Packaging metadata is complete (deps, entry points, version), and the layout matches conventions a new hire would expect without explanation. |
| Git hygiene & workflow | Change ships via a branch + PR into a protected main; the history is meaningful. | Main is protected with a required check, PRs are the only path in, and the changelog/history is generated from real commits rather than hand-written. |
| CI gate | CI runs tests on every push/PR and goes red on failure. | The green check is required to merge, CI installs from the pinned environment (proving reproducibility), and the whole thing is a reusable template a new project inherits. |
Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–5: a folder with code in it. 6–9: a solid project repo. 10–12: staff-level — reproducible, packaged, isolated, and CI-gated as a template the team starts from. A 0 on Reproducibility or CI gate means it is not yet a template — fix first.
Knowledge check check yourself
Why is the tool split into a pure summarize() function and a thin argparse wrapper in main()?
Show answer
Why does the scaffold run pip freeze > requirements.txt after installing, and use a src/ layout with an __init__.py?
Show answer
src/my_tool/__init__.py makes the folder an importable package and the src-layout keeps package code separate from tests and config — the standard shape professional repos start from.