AI EngineeringZero to ProductionHome·About·What’s new·Contact
Developer Foundations · Project DF

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.

⏱️ ~4 hours🏗️ Capstone project🎯 Beginner→Tech-lead
What you'll buildA real professional repository from an empty folder AND a small packaged Python tool inside it that ties DF1–DF5 together: pathlib + subprocess + argparse (DF1), a git-driven changelog (DF2), a resilient API call (DF4), packaged with pyproject (DF5), tested (TQ preview), and gated by CI (DF3). The launchpad every project should start from.

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.
▶ Runnable companionCode saved under code/proj-df-setup/. Python runs offline; configs are ready to use.

1 · Scaffold essential

Before any tool code, you lay down the shape of a professional repo — the folders, the package marker, the ignore file, and an isolated environment — in one pass from the command line. The diagram shows what an empty folder becomes.

one scaffold.sh run · empty folder → production-shaped repo empty folder mkdir my-tool/ src/my_tool/__init__.py tests/ .gitignore .venv +requirements.txt green = tracked source · amber = isolated env (ignored by git) Scaffolding is one pass, not many. A single script turns a bare directory into the standard layout — importable src/ package, tests/, an ignore file, and an isolated .venv whose exact versions are pinned in requirements.txt.
🗺️ How to read this diagram

Read it left-to-right: an empty folder becomes the named project, which fans out into the files and folders every professional repo starts with.

  • The left box is the empty folder; the arrow labelled mkdir turns it into the my-tool/ project root.
  • The blue arrows fan out to the pieces the scaffold creates. The green boxes are tracked source — the importable src/my_tool package, the tests/ folder, and .gitignore.
  • The amber box is the isolated .venv plus the pinned requirements.txt — the environment, deliberately kept out of git so teammates rebuild it from the pins.

In short: one script lays the whole skeleton — package, tests, ignore rules, and a reproducible environment — so every project starts the same production-shaped way.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · structure + venv (DF1+DF5)
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
▶ How this works

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".

  1. mkdir my-tool && cd my-tool — make a new project folder and step into it. Everything after this happens inside my-tool.
  2. mkdir -p src/my_tool tests — create the two standard folders at once. -p means "make parent folders as needed and don't complain if they exist". Your code lives in src/my_tool; your tests live in tests.
  3. touch src/my_tool/__init__.py — create an empty file with that special name. Its presence tells Python "this folder is an importable package".
  4. printf "…" > .gitignore — write a .gitignore file listing things git should never track (the virtual-env folder, caches, secrets in .env). The \n pieces are line breaks, so each entry lands on its own line.
  5. 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.
  6. pip install pytest requests && pip freeze > requirements.txt — install the test runner and an HTTP library, then record the exact versions into requirements.txt so 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

The tool is deliberately split into two layers with one seam between them: a thin CLI wrapper (main + argparse) that handles the messy outside world, and a pure function (summarize) that does the real work with no side effects. The diagram traces one request through those layers.

one run · args flow in, a dict flows back out CLI args main() + argparse thin CLI shell the seam: plumbing | pure logic paths summarize() pure fn pathlib read files {name: line_count} One seam, two responsibilities. The amber CLI shell only parses arguments and prints; the green pure function does the counting and returns a plain dict. Because the logic never touches argv, you can test it directly.
🗺️ How to read this diagram

Read left-to-right, watching for the dashed vertical seam in the middle — everything left of it is command-line plumbing, everything right of it is pure logic.

  • The blue CLI-args box feeds the amber main() + argparse shell, whose only job is to turn raw arguments into a clean list of paths.
  • The blue arrow crosses the seam into the green summarize() pure function, which uses pathlib (the grey box) to read each file and count its lines.
  • The payoff is the green label at the bottom — {name: line_count} — a plain dictionary handed back for main to print. Because logic never sees argv, section 3 can test it with no command line at all.

In short: the CLI shell handles the outside world; the pure function does the work and returns data — that split is exactly what makes the tool testable.

Python · src/my_tool/core.py (runs)
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}
▶ How this works

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.

  1. 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.
  2. 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 store name → number of lines. It's "pure" because the same inputs always give the same output and it changes nothing else.
  3. main(argv=None) is the command-line entry point. argparse reads 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 returns 0 to signal success.
  4. The last two lines are a quick live demo: write a 3-line file to /tmp/cap.txt, then call summarize on 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

A test feeds the pure function a known input and asserts the exact output — a green pass if it matches, a loud failure if it doesn't. The diagram shows that guard, including the two cases every tool must survive: a real file and a missing one.

each assertion is a gate: matches → pass, mismatch → fail 2-line file missing file assert == expected? match mismatch tests pass AssertionError the missing-file input must give {} — return empty, never crash Tests are the safety boundary. Known inputs go in; each assert compares the result to the expected value — a match is silent (green), a mismatch raises (red). The missing-file case proves the tool degrades gracefully instead of crashing.
🗺️ How to read this diagram

Read left-to-right: known inputs on the left flow into the assertion gate in the middle, which routes to one of two outcomes on the right.

  • The two blue input boxes are the fixtures the test builds itself — a real 2-line file (the happy path) and a path that doesn't exist (the edge case).
  • The amber assert == gate compares each result to the expected value. The green arrow (match) leads to tests pass; the red arrow (mismatch) leads to a raised AssertionError.
  • The payoff is the bottom line: the missing-file input must return an empty {} — degrade gracefully, never crash. That is the edge case a hurried tool gets wrong.

In short: a test is a gate that only lets correct output through — green when the result matches, a loud red error when it doesn't.

Python · tests/test_core.py logic (runs)
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
▶ How this works

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.

  1. An assert says "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.
  2. 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.
  3. assert summarize([str(f)]) == {"cap_test.txt": 2} — the happy path: a real two-line file must report a count of 2.
  4. assert summarize(["/no/such/file"]) == {} — the edge case: a file that doesn't exist must be skipped, giving an empty dictionary rather than crashing.
  5. 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

Packaging turns a loose folder into something installable: pyproject.toml declares the identity, dependencies, and — crucially — an entry point that maps a command name to a function. The diagram traces how that manifest becomes a real command on your PATH.

manifest + build backend → an installed command on PATH src/my_tool pyproject.toml pip install -e . hatchling build scripts my-tool on PATH → my_tool.core:main [project.scripts] wires the command name to the function it runs The entry point is the wire. pip install -e . hands your source and pyproject.toml to the build backend, which installs the package and registers a my-tool command that resolves to my_tool.core:main — a script path becomes a real command.
🗺️ How to read this diagram

Read left-to-right: the two inputs on the left are consumed by the install step in the middle, which produces the installed command on the right.

  • The green src/my_tool box is your package code; the grey pyproject.toml is the manifest describing it. Both feed the amber pip install -e . step, which runs the hatchling build backend.
  • The blue arrow labelled scripts is the [project.scripts] entry — it produces the green my-tool command on PATH, wired (dashed line) to the exact function my_tool.core:main.
  • The payoff: after this one install, typing my-tool README.md anywhere runs your tool — no script paths, no python … prefix.

In short: the manifest plus a build backend turn your folder into an installed package, and the entry point turns a function into a first-class command.

config · pyproject.toml
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"
▶ How this works

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.

  1. [project] — the basic identity: the package name, its version, the minimum Python it needs (requires-python = ">=3.10"), and its runtime dependencies (here requests, pinned to a safe version range).
  2. [project.scripts] — the magic that gives you a real command. my-tool = "my_tool.core:main" means "when installed, create a command called my-tool that runs the main function in my_tool/core.py". The comment shows the install step: pip install -e ..
  3. [build-system] — tells pip how to build the package. Here it uses hatchling, 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

Professional change doesn't go straight to the main line — it travels through version control and a review. The diagram traces the exact path these commands take: from an untracked folder to a first commit, up to a shared remote, then off onto a feature branch that becomes a pull request.

local history → shared remote → feature branch → PR folder init+commit main push -u origin switch -c feat/summarize push branch open PR the PR is the unit of change — review before it joins main Change ships through a branch, not straight to main. The first commit lands on main and pushes to origin; real work then happens on a feat/ branch that is pushed and opened as a pull request — the reviewable unit of change.
🗺️ How to read this diagram

Follow the arrows: the top row is getting your project under version control and online; the drop down to the bottom row is how a new change is actually made.

  • The blue folder becomes tracked history by git init + first commit, landing on the grey main box, which then push -ues to the green origin remote (GitHub).
  • The arrow labelled switch -c branches off main onto the amber feat/summarize branch — so main stays stable while you work.
  • Pushing that branch produces the green open PR box; the grey arrow back up shows the PR targeting main. The payoff is the bottom line: the PR is the reviewable unit of change, kept off main until it passes review.

In short: commit and push once to publish, then do every change on a feature branch that becomes a PR — never straight onto main.

shell · version it and open a PR
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
▶ How this works

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.

  1. 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".
  2. git branch -M main — name the primary branch main (the modern default).
  3. git remote add origin git@github.com:you/my-tool.git — tell git where the online copy lives, nicknaming that address origin.
  4. git push -u origin main — upload main to GitHub for the first time. -u links your local branch to the remote one so future pushes are just git push.
  5. git switch -c feat/summarize — create and move onto a new branch for your feature, so main stays stable while you work. After committing the tool and tests, the final git push -u origin feat/summarize uploads 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.

a push must pass the CI gate before it can reach protected main feat branch push / PR CI: pytest -q green red merge → main merge blocked protected main= the template new project A new project B new project C The CI gate is the one door into main. A pushed branch only merges when pytest is green; a red run blocks the merge. The hardened main then becomes the template every new project inherits.
🗺️ How to read this diagram

Read the top row left-to-right — a change travels from your branch through the test gate — then drop down to see what that protected result becomes.

  • The feat branch box on the left is your work; the blue arrow is the push / pull request that sends it to GitHub Actions.
  • The amber CI box runs pytest -q and splits into two outcomes: the green arrow to merge → main (tests passed) and the red arrow to merge blocked (tests failed) — only green gets through.
  • The blue box below is protected main — because every change had to pass the gate, it stays a clean, reproducible template. The grey arrows show new projects A, B, C all starting from that same production-shaped baseline.

In short: tests must be green to reach main; a hardened main then becomes the launchpad every future project copies.

config · .github/workflows/ci.yml
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
▶ How this works

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.

  1. name: CI and on: [push, pull_request] — name the workflow and set its triggers: run it on every push and on every pull request.
  2. jobs: test: defines one job called test; runs-on: ubuntu-latest gives it a clean Linux machine to run on.
  3. steps: is an ordered list. The two uses: lines pull in ready-made actions — checkout grabs your code, setup-python installs Python 3.12 on the runner.
  4. run: pip install -e . pytest installs your package and the test runner; run: pytest -q runs the tests quietly. If any test fails, the job goes red and, with branch protection on, that blocks the merge — bad code can't reach main.

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.

DeliverableFrom
README + .gitignoreDF2
src/ + tests/ layoutDF5
venv + pinned requirementsDF5
packaged, pip-installable CLIDF1+DF5
resilient API usageDF4
branch + PR + protected mainDF3
CI running tests on pushDF3+TQ
This is the launchpad for everything afterTesting (TQ) fills 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.

Exercise 1 · Scaffold a professional repo layoutBeginner

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__.py and a module
  • A tests/ directory, .gitignore, requirements.txt, and pyproject.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.

Exercise 2 · A pure-logic tool separated from its CLIIntermediate

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:

  • summarize maps 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.

Exercise 3 · Tests for the happy path and the edge casesAdvanced

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.

Exercise 4 · Package it with a console entry pointExpert

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 to module:function
  • A validator that parses the module:function string
  • 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.

Exercise 5 · Git history, a branch, and a PRProfessional

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_push check 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.

Exercise 6 · A CI gate that blocks merge on a failing checkIndustry scenario

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_merge check 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.
📋 Staff-level self-scoring — is this a repo the whole team should start from?
DimensionMeets the barAbove the bar (staff)
ReproducibilityA 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 correctnessThe 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 isolationWork 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 & packagingA 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 & workflowChange 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 gateCI 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

✓ Knowledge check

Why is the tool split into a pure summarize() function and a thin argparse wrapper in main()?

Show answer
Keeping the real logic pure (same inputs always give the same output, no side effects) makes it trivial to unit-test in isolation, while the CLI plumbing stays a thin shell around it — you test the logic without simulating command-line invocation.
✓ Knowledge check

Why does the scaffold run pip freeze > requirements.txt after installing, and use a src/ layout with an __init__.py?

Show answer
Freezing pins exact versions so a teammate reproduces the same environment precisely; the 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in