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
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.
src/ package, tests/, an ignore file, and an isolated .venv whose exact versions are pinned in requirements.txt.
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
mkdirturns 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_toolpackage, thetests/folder, and.gitignore. - The amber box is the isolated
.venvplus the pinnedrequirements.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.
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
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.
dict. Because the logic never touches argv, you can test it directly.
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 ofpaths. - The blue arrow crosses the seam into the green
summarize()pure function, which usespathlib(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 formainto print. Because logic never seesargv, 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.
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
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.
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.
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.
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
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.
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.
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_toolbox is your package code; the greypyproject.tomlis the manifest describing it. Both feed the amberpip install -e .step, which runs thehatchlingbuild backend. - The blue arrow labelled
scriptsis the[project.scripts]entry — it produces the greenmy-toolcommand on PATH, wired (dashed line) to the exact functionmy_tool.core:main. - The payoff: after this one install, typing
my-tool README.mdanywhere runs your tool — no script paths, nopython …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.
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
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.
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.
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 thenpush -ues to the green origin remote (GitHub). - The arrow labelled
switch -cbranches offmainonto the amberfeat/summarizebranch — somainstays stable while you work. - Pushing that branch produces the green
open PRbox; the grey arrow back up shows the PR targetingmain. The payoff is the bottom line: the PR is the reviewable unit of change, kept offmainuntil 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.
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.
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.
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 -qand 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.
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.