Python projects done right: structure, virtual environments and packages
Turn a loose script into a small installable Python project with a standard layout, its own virtual environment and a lock file anyone can rebuild exactly.
A Python script that works on one laptop is not yet something a team can rely on. It quietly depends on which Python is installed, which libraries are present and which versions of them happened to be downloaded that day.
A Python project done right removes that luck with three habits:
A standard folder layout, so anyone can find the code, the tests and the instructions.
A private environment per project, so one project's libraries can't break another's.
A written record of exact versions, a lock file, so anyone can rebuild the same thing next week, on another computer or in the cloud.
It is the difference between a recipe that says "some flour" and one that names the brand, the weight and the oven temperature.
AI prototypes are built fast, often by one forward deployed engineer (FDE) on one laptop. Then they have to survive a handover, a security review and a deployment. Most "it worked in the demo" failures happen at exactly those moments.
Repeatable results. An AI service depends on dozens of open-source libraries. If one of them releases a new version, an unpinned project silently picks it up on the next install. Behavior can change with no line of your code changing.
Security. Libraries are a supply chain. SAP's own documentation for SAP AI Core lists the open-source LiteLLM library with a note to avoid two specific versions because of vulnerabilities. A team can only act on a warning like that if it knows exactly which versions it runs.
Faster handover. A project with a standard layout, a README and one install command can be picked up by the customer's IT team in an hour, not a week.
Audit and support. When an incident happens, "which version of what was running?" has a precise answer.
Example. An FDE builds a small tool that decides which blocked sales orders a credit analyst must look at. It runs on her laptop. Three weeks later, customer IT installs it on a server and gets different library versions; one of them changed how numbers are read from a file. With a lock file, IT installs exactly the versions she tested, and the tool behaves the same.
You won't find a "Python project" product in SAP's portfolio. You will find SAP using these habits in the places AI code meets SAP, as of October 2026:
SAP's Python SDKs are standard packages. The SAP Cloud SDK for AI for Python is published on PyPI, the public Python package index, as three packages: sap-ai-sdk-base, sap-ai-sdk-core and sap-ai-sdk-gen. You install and pin them like any other library. SAP's own ai-sdk-python repository keeps a pyproject.toml and a uv.lock file at its root, the same kind of files this topic teaches.
SAP BTP, Cloud Foundry runtime. When you deploy a Python app, the Cloud Foundry Python buildpack (the platform tool that prepares your app) recognizes it by its requirements.txt and installs the libraries listed there. The Python version comes from a runtime.txt file. Unit 6's Deploying AI apps on SAP BTP uses exactly this.
SAP AI Core. Custom training or serving code runs in container images that your team builds. SAP's beginner tutorial installs the libraries from a requirements.txt inside the image. What goes into that file decides what runs in production.
The lesson for leaders: SAP gives you places to run Python. Keeping the Python itself reproducible is your team's job, or your partner's.
Every well-run Python project has the same three pieces. Ask to see them.
Piece
What it says
Who reads it
Shared in Git?
pyproject.toml
What the project is, which Python it needs, which libraries it needs (in ranges, such as "version 1.0 or newer")
People and install tools
Yes
Lock file (requirements-lock.txt, uv.lock)
The exact version of every library, including the libraries those libraries need
Install tools
Yes
Virtual environment (.venv)
The installed result on one computer
Nobody; it is rebuilt from the two files above
Never
Two tools build these pieces in this course. pip with Python's built-in venv comes with every Python installation and is what most SAP tutorials show. uv is a newer, single tool that creates the environment, writes the lock file and installs in one step. SAP's own Python SDK repository uses uv. Both produce projects the other can work with; what matters is that the team picks one per project and writes it down in the README.
"Pinning is only for big systems." A two-file prototype breaks just as easily when a library updates. Pinning costs one command.
"requirements.txt and pyproject.toml are the same thing." The first is usually an exact list for one environment; the second describes what the project needs in ranges. Healthy projects have both kinds of information.
"We can just copy the environment folder to the server." Virtual environments are tied to the computer and folder they were made in. Python's documentation says to recreate them, not move them.
"A lock file freezes us on old versions forever." It freezes versions until someone updates them on purpose, tests, and commits the new lock file. That is the point.
"The newest tool is always the right one." pip and uv both work. Consistency inside a project matters more than the choice.
Pick one answer for each question. The explanation appears after you choose.
1An AI tool behaves differently after customer IT reinstalls it, though nobody changed its code. What is the most likely cause?
Answer: B. Without a lock file, each install downloads whatever versions are newest that day, so behavior can change with no code change. A lock file makes every install use the versions that were tested.
2What is the job of a lock file?
Answer: C. A lock file pins every library, including the ones your libraries need. Anyone installing from it gets the same versions you tested. It has nothing to do with access control or secrets.
3A developer suggests copying the virtual environment folder to the production server. What do you say?
Answer: D. Virtual environments are tied to the computer and folder where they were made; Python's documentation says to recreate them rather than move them. The repeatable path is the project file plus the lock file.
4Why does it matter that SAP's documentation tells teams to avoid two specific LiteLLM versions?
Answer: A. Security warnings name versions. With pinned versions in a lock file, the team can check in minutes whether it is affected and move to a safe version through a reviewed change.
5Your team's AI service will run on SAP BTP's Cloud Foundry runtime. Which question protects you best before go-live?
Answer: C. The Cloud Foundry Python buildpack installs from requirements.txt and takes the Python version from runtime.txt. If those match what was tested, production behaves like the test. Uploading the environment folder is the wrong approach.
6Your team uses pip and the partner team prefers uv. What matters most?
Answer: D. Both tools produce working projects, and SAP's own Python SDK repository uses uv. Mixing tools inside one project leads to two lock files that disagree. One documented choice per project keeps installs predictable.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Keep three things apart, and Python packaging stops being confusing.
The recipe: pyproject.toml. It says what the project is and what it needs, in ranges: "python-dotenv 1.0 or newer", "Python 3.11 or newer". The Python Packaging User Guide calls these "abstract" requirements: names and version limits.
The receipt: the lock file. It records what was actually installed when it worked, as exact versions of every package, including transitive ones. These are "concrete" requirements.
The kitchen: the virtual environment. The installed result on one computer. Disposable. You never share it; you rebuild it from the recipe and the receipt.
flowchart LR
P[pyproject.toml<br/>ranges] -->|resolve + install| V[.venv<br/>installed packages]
V -->|pip freeze / uv lock| L[lock file<br/>exact versions]
L -->|install anywhere| V2[new .venv<br/>same versions]
Every rule in this topic follows from that picture. Edit the recipe by hand. Generate the receipt with a tool. Rebuild the kitchen whenever in doubt.
The layout the Python Packaging User Guide recommends, applied to this topic's project:
blocked-triage/
├── pyproject.toml what the project is and needs
├── README.md how to install, run and test it
├── requirements-lock.txt exact versions (generated)
├── check_project.py this topic's check script
├── src/
│ └── blocked_triage/ the importable package
│ ├── __init__.py
│ ├── __main__.py lets "python -m blocked_triage" work
│ ├── cli.py command line
│ ├── orders.py where orders come from
│ └── rules.py the business rule
└── tests/
└── test_rules.py
Two names appear: the distribution nameblocked-triage (what you install, with a hyphen) and the import nameblocked_triage (what you import, with an underscore, because Python names can't contain hyphens).
Why src/? Python looks for imports in the current folder first. In a "flat" layout, with the package folder next to pyproject.toml, your tests may import the loose copy in the folder instead of the installed one. Everything passes on your laptop and fails after a real install. The src layout makes that impossible: code under src/ can only be imported once it is installed. The cost is one step, an install, which you need anyway.
python -m venv .venv creates a folder with a link to a Python interpreter and an empty place for packages. Activating it puts .venv's Scripts (Windows) or bin (macOS, Linux) folder first on your PATH, so python, pip and any commands installed into the environment are found there first. Inside Python, sys.prefix points at the environment; the check script uses that to tell which environment is active.
Python's documentation is clear on three rules: an environment is not checked into source control, it is disposable, and it is recreated rather than moved.
This course so far used one .venv for the whole course folder. That is fine for learning scripts. A project that will be handed over, tested or deployed gets its own environment, so its lock file lists only what it needs, not every library from every unit.
sequenceDiagram
participant You
participant pip
participant B as Build backend (setuptools)
participant V as .venv
You->>pip: pip install -e ".[dev]"
pip->>pip: read pyproject.toml
pip->>B: build in a temporary, isolated environment
B-->>pip: metadata + editable hook
pip->>V: install python-dotenv, pytest and their dependencies
pip->>V: link src/ onto the import path
pip->>V: create the blocked-triage command
Editable (-e) means pip doesn't copy your code; it points the environment at your src/ folder. Edit rules.py and the change applies immediately. pip's documentation notes the exception: changes to metadata, such as the version or the scripts table, need a reinstall. You will see that in the exercise.
pip freeze lists every package installed in the active environment with == versions, transitive dependencies included. Saved to a file, that list is a lock file you can install from with pip install -r. --exclude-editable leaves out your own project, which is installed from the folder, not from a version.
Two limits to know:
A freeze is per platform. It records what this computer installed. pytest needs a helper package called colorama on Windows only, so a lock frozen on Windows has one more line than one frozen on macOS. That is usually harmless, but it is not a cross-platform record.
Versions are not content. pip's repeatable-install guidance adds hashes (--hash=sha256:...) to prove the downloaded file is exactly the one you tested. Production pipelines should use them; this topic keeps the beginner path simple.
uv writes uv.lock, which uv's documentation describes as a universal, cross-platform lockfile. It records the Windows-only colorama with a marker, sys_platform == 'win32', plus hashes for each file. It is meant to be committed and never edited by hand. uv lock updates it; uv sync makes .venv match it; uv run does both, then runs your command. uv can also export to requirements.txt for platforms that only read that format.
pip also has an experimentalpip lock command that writes the standard pylock.toml format. Because it is experimental, this course doesn't depend on it yet.
venv + pip + pip freeze
uv
Comes with Python
Yes
No, one extra install
Lock file
requirements-lock.txt, per platform
uv.lock, universal, with hashes
Commands to rebuild
Create venv, activate, two installs
uv sync
Read by the Cloud Foundry buildpack
Yes, as requirements.txt
Export to requirements.txt (see The SAP way)
Best for
Learning, minimal tooling, existing SAP tutorials
Teams that want one fast, consistent tool
#Build it yourself: turn the triage script into an installable project
In the Git topic, triage.py was one file with the rule, the data and the printing mixed together. You will turn it into a project called blocked-triage: a src/ package with the rule in its own module, tests, a pyproject.toml, its own virtual environment, a real command you can type, and a lock file. Then you'll delete the environment and prove you can rebuild it exactly.
flowchart LR
S[triage.py<br/>one script] --> P[project folder<br/>src/ + tests/]
P --> I[pip install -e<br/>own .venv]
I --> C[blocked-triage<br/>command + tests]
C --> L[lock file]
L --> R[rebuild from scratch<br/>same result]
On macOS or Linux it looks like /Users/you/orchestrate-course/unit01/blocked-triage/.venv. The path must end in blocked-triage/.venv (or blocked-triage\.venv). If it ends in orchestrate-course/.venv, the course environment is still active: run deactivate and activate again.
Your course .gitignore already contains .venv/, which also covers this new .venv folder inside the project. Git will never track it.
In VS Code, create a new file, paste the text below and save it as pyproject.toml in unit01/blocked-triage:
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "blocked-triage"
version = "0.1.0"
description = "Decide which blocked SAP sales orders a person should look at today."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"python-dotenv>=1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
]
[project.scripts]
blocked-triage = "blocked_triage.cli:main"
Create README.md in the same folder with this text. It tells the next person, or you in three months, how to rebuild and run the project:
# blocked-triage
Decides which blocked SAP sales orders a person should look at today.
Part of my Orchestrate course portfolio (Unit 1).
## Run it
python -m venv .venv
# Windows: .venv\Scripts\Activate.ps1 macOS/Linux: source .venv/bin/activate
pip install -r requirements-lock.txt
pip install -e . --no-deps
blocked-triage --sample
## Test it
pytest
## Rules
- No keys and no real customer data in this folder.
- The escalation threshold is a business rule: change it through a pull request.
Read pyproject.toml from the top. [build-system] says setuptools builds the package. [project] names it, gives it a version and says it needs Python 3.11 or newer and python-dotenv 1.0 or newer. dev is an extra with pytest. [project.scripts] asks the installer to create a command blocked-triage that calls the main function in blocked_triage/cli.py.
Create each file below, in the folder shown, with the exact name.
src/blocked_triage/__init__.py marks the folder as a package and exposes the version:
"""blocked_triage: decide which blocked sales orders go to a person."""
from importlib.metadata import version
__version__ = version("blocked-triage") # read from pyproject.toml via the installed metadata
src/blocked_triage/rules.py holds the business rule and nothing else. No files, no printing: that makes it easy to test.
"""The business rule. No files, no printing, no network: easy to test."""
from decimal import Decimal
DEFAULT_THRESHOLD = Decimal("50000") # net value above which a blocked order goes to a person
def blocks(order: dict) -> list[str]:
"""Which header blocks an order has, as plain words."""
found = []
if order.get("delivery_block_reason"):
found.append("delivery")
if order.get("billing_block_reason"):
found.append("billing")
return found
def decide(order: dict, threshold: Decimal = DEFAULT_THRESHOLD) -> str:
"""ESCALATE a blocked order above the threshold, queue the rest, skip unblocked ones."""
if not blocks(order):
return "skip"
return "ESCALATE" if Decimal(order["net_amount"]) > threshold else "queue"
def triage(orders: list[dict], threshold: Decimal = DEFAULT_THRESHOLD) -> list[dict]:
"""Apply the rule to every order and return one result row per order."""
return [
{
"sales_order": o["sales_order"],
"sold_to_party": o.get("sold_to_party"),
"net_amount": o["net_amount"],
"currency": o.get("currency"),
"blocks": "+".join(blocks(o)) or "-",
"action": decide(o, threshold),
}
for o in orders
]
src/blocked_triage/orders.py knows where orders come from. The sample uses the same field names as the blocked_orders.json file from Calling your first SAP API, so the real file works too:
"""Where orders come from: built-in sample data or a blocked_orders.json file."""
import json
from pathlib import Path
# Made up, in the same shape as blocked_orders.json from "Calling your first SAP API".
SAMPLE_ORDERS = [
{"sales_order": "5000001", "sold_to_party": "17100001", "net_amount": "72000.00",
"currency": "EUR", "delivery_block_reason": "01", "billing_block_reason": None},
{"sales_order": "5000002", "sold_to_party": "17100002", "net_amount": "8400.00",
"currency": "EUR", "delivery_block_reason": None, "billing_block_reason": "02"},
{"sales_order": "5000003", "sold_to_party": "17100003", "net_amount": "51500.00",
"currency": "EUR", "delivery_block_reason": "01", "billing_block_reason": None},
{"sales_order": "5000004", "sold_to_party": "17100001", "net_amount": "23900.00",
"currency": "EUR", "delivery_block_reason": None, "billing_block_reason": None},
{"sales_order": "5000005", "sold_to_party": "17100004", "net_amount": "64000.00",
"currency": "EUR", "delivery_block_reason": "01", "billing_block_reason": "02"},
]
def load_orders(path: Path) -> list[dict]:
"""Read the orders list from a blocked_orders.json file."""
data = json.loads(path.read_text(encoding="utf-8"))
return data["orders"]
src/blocked_triage/cli.py is the command line. It reads options, loads orders, applies the rule and prints:
"""The command line: read options, load orders, apply the rule, print the result."""
import argparse
import json
import os
import sys
from decimal import Decimal, InvalidOperation
from pathlib import Path
from dotenv import find_dotenv, load_dotenv
from . import __version__
from .orders import SAMPLE_ORDERS, load_orders
from .rules import DEFAULT_THRESHOLD, triage
def pick_threshold(cli_value: str | None) -> Decimal:
"""Command line first, then TRIAGE_THRESHOLD from .env, then the default."""
load_dotenv(find_dotenv(usecwd=True)) # looks for .env here, then in parent folders
raw = cli_value or os.environ.get("TRIAGE_THRESHOLD")
if raw is None:
return DEFAULT_THRESHOLD
try:
return Decimal(raw)
except InvalidOperation:
sys.exit(f"Threshold must be a number, got {raw!r}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="blocked-triage", description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--sample", action="store_true", help="use built-in made-up orders")
source.add_argument("--file", type=Path, help="path to a blocked_orders.json file")
parser.add_argument("--threshold", help="net value above which to escalate (default 50000)")
parser.add_argument("--json", action="store_true", help="print JSON instead of a table")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args(argv)
if args.file and not args.file.exists():
sys.exit(f"File not found: {args.file}")
orders = SAMPLE_ORDERS if args.sample else load_orders(args.file)
threshold = pick_threshold(args.threshold)
rows = triage(orders, threshold)
if args.json:
print(json.dumps({"threshold": str(threshold), "rows": rows}, indent=2))
return 0
print(f"Escalate blocked orders above {threshold:,.2f}\n")
for r in rows:
print(f" {r['sales_order']:<8} {Decimal(r['net_amount']):>12,.2f} {r['currency'] or '':<4}"
f" {r['blocks']:<17} {r['action']}")
counts = {a: sum(r["action"] == a for r in rows) for a in ("ESCALATE", "queue", "skip")}
print(f"\n{counts['ESCALATE']} to escalate, {counts['queue']} queued, {counts['skip']} not blocked")
return 0
src/blocked_triage/__main__.py lets you run the package with python -m blocked_triage:
"""Lets you run the package with: python -m blocked_triage"""
import sys
from .cli import main
sys.exit(main())
tests/test_rules.py holds seven small tests. Each function starting with test_ checks one thing with assert:
"""Tests for the business rule and the command line. Run with: pytest"""
import json
from decimal import Decimal
from blocked_triage.cli import main
from blocked_triage.orders import SAMPLE_ORDERS, load_orders
from blocked_triage.rules import decide, triage
def order(amount, delivery=None, billing=None):
return {"sales_order": "1", "net_amount": amount,
"delivery_block_reason": delivery, "billing_block_reason": billing}
def test_blocked_order_above_threshold_is_escalated():
assert decide(order("60000.00", delivery="01")) == "ESCALATE"
def test_order_exactly_at_threshold_is_queued():
assert decide(order("50000.00", billing="02")) == "queue"
def test_unblocked_order_is_skipped_however_large():
assert decide(order("999999.00")) == "skip"
def test_threshold_can_be_changed():
assert decide(order("8400.00", billing="02"), Decimal("5000")) == "ESCALATE"
def test_sample_data_gives_three_escalations():
actions = [row["action"] for row in triage(SAMPLE_ORDERS)]
assert actions == ["ESCALATE", "queue", "ESCALATE", "skip", "ESCALATE"]
def test_load_orders_reads_the_file_shape(tmp_path):
path = tmp_path / "blocked_orders.json"
path.write_text(json.dumps({"source": "sample", "orders": [order("10.00", delivery="01")]}))
assert load_orders(path)[0]["net_amount"] == "10.00"
def test_cli_prints_json(capsys):
assert main(["--sample", "--json", "--threshold", "60000"]) == 0
result = json.loads(capsys.readouterr().out)
assert result["threshold"] == "60000"
assert [r["action"] for r in result["rows"]].count("ESCALATE") == 2
With this project's (.venv) active and the terminal in unit01/blocked-triage, run:
pip install -e ".[dev]"
Keep the quotes; some terminals treat square brackets specially. -e means editable, . means "the project in this folder", and [dev] adds the dev extra with pytest.
With the API topic's sample data, order 9000001 (18,250.00 USD) is escalated and the other two are queued. With the default threshold all three are queued: a valid answer, since none is above 50,000.
See editable mode at work. Open src/blocked_triage/rules.py, change DEFAULT_THRESHOLD = Decimal("50000") to Decimal("70000") and save. Run blocked-triage --sample again: only one order is escalated, with no reinstall. Run pytest: test_blocked_order_above_threshold_is_escalated and test_sample_data_gives_three_escalations now fail, because the business rule changed. Change it back to 50000, save, and run pytest until all seven pass.
You asked for two packages; the lock file pins six. The other four are what pytest needs. That is the point of a lock file: it pins what you didn't ask for too.
Now run pip freeze without the option and look at the first lines:
pip freeze
You'll see a line starting with -e and the full path to your folder. That path only exists on your computer, which is why the lock file leaves it out.
#Step 8: Prove it: delete the environment and rebuild it
A lock file is only worth something if a rebuild from it works. Throw the environment away.
Leave and delete the environment:
Windows (PowerShell):
deactivate
Remove-Item -Recurse -Force .venv
macOS / Linux:
deactivate
rm -rf .venv
Create it again and activate it, exactly as in Step 2:
Windows (PowerShell):python -m venv .venv then .venv\Scripts\Activate.ps1
macOS / Linux:python3 -m venv .venv then source .venv/bin/activate
Install the locked versions, then your project without letting pip choose anything else:
Then close the terminal completely and open a new one, so the uv command is found. Alternatives from uv's installation page: winget install --id=astral-sh.uv -e on Windows or brew install uv on macOS. Don't install uv with pip inside the project's .venv: it would end up in your lock file.
Go back to the project folder and check uv works:
Windows (PowerShell):cd ~\orchestrate-course\unit01\blocked-triage
Create the lock file and make .venv match it, including the dev extra:
uv lock
uv sync --extra dev
Resolved 8 packages in 403ms
Eight, not six: uv.lock also covers your project and the Windows-only colorama, whichever system you're on.
4. Run the tool and the tests through uv. uv run checks the lock and the environment first, then runs the command:
uv run blocked-triage --sample
uv run pytest -q
Open uv.lock in VS Code and look, but don't edit it. Search for colorama: it is listed with the marker sys_platform == 'win32'. Every file also has a hash = "sha256:...".
Check that the lock file still matches pyproject.toml. Pipelines run this to catch someone who changed one without the other:
uv lock --check
It prints Resolved 8 packages and exits without an error when they match.
Create check_project.py in unit01/blocked-triage, paste the script below and save. It uses only modules that come with Python.
"""Check that this folder is a well-formed, installed, reproducible Python project.
Run it from the project folder, with the project's .venv active: python check_project.py
It only reads; it changes nothing and sends nothing anywhere. Built-in modules only.
"""
import json
import re
import shutil
import sys
from importlib import metadata
from pathlib import Path
HERE = Path.cwd()
DIST = "blocked-triage" # the name in pyproject.toml
PACKAGE = "blocked_triage" # the folder under src/ you import
COMMAND = "blocked-triage" # the command from [project.scripts]
LOCK = HERE / "requirements-lock.txt"
problems = 0
def read_text_any(path: Path) -> str:
"""Read a text file saved as UTF-8 (with or without BOM) or UTF-16 (older PowerShell)."""
raw = path.read_bytes()
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
return raw.decode("utf-16")
return raw.decode("utf-8-sig")
def report(ok: bool, label: str, fix: str = "", optional: bool = False) -> None:
"""Print one line: OK, MISSING (must fix) or LATER (optional for now)."""
global problems
if ok:
print(f" OK {label}")
elif optional:
print(f" LATER {label} -> {fix}")
else:
problems += 1
print(f" MISSING {label} -> {fix}")
print("\n1. Python and environment")
version_ok = sys.version_info >= (3, 11)
report(version_ok, f"Python {sys.version.split()[0]} (3.11 or newer)", "install a newer Python")
if not version_ok:
sys.exit(1)
import tomllib # noqa: E402 (built in from Python 3.11)
in_venv = sys.prefix != sys.base_prefix
report(in_venv, "a virtual environment is active", "activate .venv (Step 2)")
report(Path(sys.prefix).resolve() == (HERE / ".venv").resolve(),
f"it is this project's .venv ({Path(sys.prefix).name})",
"deactivate, cd into the project folder, activate its .venv")
print("\n2. Layout")
for path, fix in [("pyproject.toml", "create it (Step 3)"), ("README.md", "create it (Step 3)"),
(f"src/{PACKAGE}/__init__.py", "create the package (Step 4)")]:
report((HERE / path).is_file(), path, fix)
tests = sorted((HERE / "tests").glob("test_*.py"))
report(bool(tests), f"tests/ holds {len(tests)} test file(s)", "add tests/test_rules.py (Step 4)")
report(not (HERE / PACKAGE).exists(), f"no {PACKAGE}/ next to src/ (src layout only)",
f"move {PACKAGE}/ into src/")
print("\n3. pyproject.toml")
try:
data = tomllib.loads((HERE / "pyproject.toml").read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as err:
data = {}
report(False, "pyproject.toml parses", f"fix the file: {err}")
project = data.get("project", {})
report("build-backend" in data.get("build-system", {}), "[build-system] names a build backend",
"add the [build-system] table (Step 3)")
report(project.get("name") == DIST, f"name = \"{DIST}\"", "set name in [project]")
report(bool(project.get("version")), f"version = \"{project.get('version', '')}\"", "set version")
report(bool(project.get("requires-python")), "requires-python is set", "add requires-python")
report(isinstance(project.get("dependencies"), list), "dependencies are listed",
"add a dependencies list, even if empty")
report(COMMAND in project.get("scripts", {}), f"[project.scripts] defines {COMMAND}",
"add the [project.scripts] table")
print("\n4. Installed")
try:
dist = metadata.distribution(DIST)
report(True, f"{DIST} {dist.version} is installed in this environment")
direct = json.loads(dist.read_text("direct_url.json") or "{}")
report(direct.get("dir_info", {}).get("editable", False), "installed in editable mode",
"run: pip install -e . --no-deps")
except metadata.PackageNotFoundError:
report(False, f"{DIST} is installed", "run: pip install -e \".[dev]\" (Step 5)")
report(shutil.which(COMMAND) is not None, f"the {COMMAND} command is on PATH",
"activate .venv, then reinstall with pip install -e .")
print("\n5. Reproducible")
pins = {}
if LOCK.is_file():
lines = [ln.strip() for ln in read_text_any(LOCK).splitlines()]
lines = [ln for ln in lines if ln and not ln.startswith("#")]
loose = [ln for ln in lines if not re.fullmatch(r"[A-Za-z0-9._-]+==[^=\s]+", ln)]
pins = dict(ln.split("==", 1) for ln in lines if ln not in loose)
report(not loose, f"{LOCK.name} pins {len(pins)} packages with ==",
"regenerate it with pip freeze (Step 7): " + ", ".join(loose[:3]))
names = {re.sub(r"[-_.]+", "-", n).lower() for n in pins}
report(DIST not in names, f"{DIST} itself is not in the lock file",
"regenerate with pip freeze --exclude-editable")
for needed in ("python-dotenv", "pytest"):
report(needed in names, f"{needed} is pinned", "install it, then regenerate the lock file")
drift = []
for name, wanted in pins.items():
try:
have = metadata.version(name)
except metadata.PackageNotFoundError:
have = "not installed"
if have != wanted:
drift.append(f"{name} {have} (lock says {wanted})")
report(not drift, "installed versions match the lock file",
"run: pip install -r requirements-lock.txt " + "; ".join(drift[:3]))
else:
report(False, f"{LOCK.name} exists", "create it with pip freeze (Step 7)")
report((HERE / "uv.lock").is_file(), "uv.lock exists (optional uv path)", "see Step 9", optional=True)
print()
if problems:
print(f"{problems} item(s) to fix. Fix them, then run this again.")
sys.exit(1)
print("Project OK: installable, tested and reproducible.")
Run it with this project's .venv active:
python check_project.py
1. Python and environment
OK Python 3.14.4 (3.11 or newer)
OK a virtual environment is active
OK it is this project's .venv (.venv)
2. Layout
OK pyproject.toml
OK README.md
OK src/blocked_triage/__init__.py
OK tests/ holds 1 test file(s)
OK no blocked_triage/ next to src/ (src layout only)
3. pyproject.toml
OK [build-system] names a build backend
OK name = "blocked-triage"
OK version = "0.1.0"
OK requires-python is set
OK dependencies are listed
OK [project.scripts] defines blocked-triage
4. Installed
OK blocked-triage 0.1.0 is installed in this environment
OK installed in editable mode
OK the blocked-triage command is on PATH
5. Reproducible
OK requirements-lock.txt pins 6 packages with ==
OK blocked-triage itself is not in the lock file
OK python-dotenv is pinned
OK pytest is pinned
OK installed versions match the lock file
LATER uv.lock exists (optional uv path) -> see Step 9
Project OK: installable, tested and reproducible.
Your Python version may differ, Windows shows 7 pinned packages, and the last line under 5 is OK if you did Step 9. Any MISSING line names the step to revisit.
The editable install created a folder src/blocked_triage.egg-info with generated metadata. It doesn't belong in Git. Open the .gitignore in your course folder (two levels up) and add these two lines below the existing ones:
*.egg-info/
build/
Commit on your branch, from the project folder:
git add pyproject.toml README.md requirements-lock.txt check_project.py src tests ../../.gitignore
git status
git commit -m "Turn triage script into installable blocked-triage project with lock file"
If you did Step 9, also git add uv.lock before committing. git status must not list .venv or blocked_triage.egg-info; if it does, fix .gitignore first.
Push and merge through a pull request, as in the Git topic:
SAP doesn't change how Python packaging works. It gives you SDKs to depend on and platforms that install your dependencies. As of October 2026, these are the touchpoints.
#SAP's Python SDKs are dependencies like any other
SAP documents the SAP Cloud SDK for AI for Python as three PyPI packages: sap-ai-sdk-base, sap-ai-sdk-core and sap-ai-sdk-gen. The sap-ai-sdk-gen page on PyPI shows it uses extras for providers: the default install covers OpenAI models, and sap-ai-sdk-gen[all] adds the others. Treat it exactly like pytest here: a range in pyproject.toml, an exact version in the lock file. Unit 5's SAP generative AI hub and orchestration names the SDK version it was tested with for this reason.
SAP's documentation for SAP AI Core also lists LiteLLM, an open-source library, with a warning to avoid versions 1.82.7 and 1.82.8 because of vulnerabilities. In pyproject.toml that becomes an exclusion such as "litellm!=1.82.7,!=1.82.8". Your lock file then shows, in one line, which version you actually run.
SAP's own ai-sdk-python repository has a pyproject.toml and a uv.lock at its root: the same structure you just built.
The Cloud Foundry documentation says the Python buildpack is used when it detects a requirements.txt or setup.py, and reads the Python version from runtime.txt. It also supports Pipenv (a Pipfile) and conda (environment.yml). For a pip project, deploy with a requirements.txt that holds your locked runtime dependencies, not your dev tools. Deploying AI apps on SAP BTP does this and checks every line is pinned.
--no-emit-project leaves out your own folder's -e . line and --no-hashes keeps the output short; drop it to keep hashes. Extras like dev are left out unless you ask for them with --extra. For this project the result pins only python-dotenv.
Broadcom's documentation for its commercial Cloud Foundry Python buildpack describes installing directly from uv.lock and pyproject.toml. The open-source Cloud Foundry page doesn't mention uv. Until SAP documents uv support for the buildpack in your BTP region, deploy with an exported requirements.txt, and run cf buildpacks to see the buildpack version you get.
On SAP AI Core, your code runs in a container image your team builds and pushes to a registry. SAP's beginner house-price tutorial shows the pattern: a Dockerfile copies the code and a requirements.txt and runs pip install inside the image. The tutorial lists its library without a version, which is fine for learning. For a customer, copy a locked file into the image, so each rebuild of the image installs the same versions.
Supply chain security. Every package is code you run with your credentials. Pin versions, add hashes in pipelines (pip's hash-checking mode or uv.lock), and install from your company's approved package mirror where one exists. Watch for advisories on your dependencies, like SAP's LiteLLM warning, and know how you'll roll out a fix: change the range or exclusion, regenerate the lock file, test, merge.
Updates as reviewed changes. A lock file update changes what runs in production. It goes through a pull request with the test results, like any code change. Update on a schedule, a few packages at a time, so a break is easy to trace.
Python version alignment. Keep three numbers in step: requires-python in pyproject.toml, the Python on developer laptops, and the platform's Python (runtime.txt on Cloud Foundry, the base image on SAP AI Core). A lock file made on 3.14 may not install on 3.12.
Secrets stay out of the package. Configuration such as the threshold can come from .env locally. Keys never go into pyproject.toml, the lock file or the code. The secrets topic later in Unit 1 covers production secret stores and BTP service bindings.
Licenses. Each dependency comes with a license. Customer legal teams often ask for the list; a lock file is the input for producing it. The packaging guide describes declaring your own project's license as an SPDX expression in pyproject.toml (this course project leaves it out because it isn't published).
Clean core. This project runs entirely outside SAP and only reads order data. Keeping AI logic in separate, versioned Python projects like this, and talking to S/4HANA only through released APIs, is what keeps the SAP core clean.
Installing into the wrong environment. Two terminals, two (.venv) prompts, two different Pythons. Check sys.prefix when anything looks odd.
Pinning in pyproject.toml.== there makes your package hard to combine with others. Ranges in pyproject.toml, exact versions in the lock file.
Freezing the course environment.pip freeze in a shared environment pins every library from every unit. One project, one environment.
Forgetting to re-freeze. You added a dependency to pyproject.toml and installed it, but didn't regenerate the lock file. The next rebuild fails or differs. check_project.py catches the drift.
Editing uv.lock by hand. It is generated. Change pyproject.toml, then run uv lock.
Expecting metadata changes to apply live. Editable installs pick up code edits, not a new version number or command. Reinstall after editing pyproject.toml.
Two version numbers. A version typed into both pyproject.toml and __init__.py drifts apart. Read it from the installed metadata, as this project does.
Committing generated folders..venv, *.egg-info and build/ are rebuilt from the project; keep them in .gitignore.
#Exercise: release version 0.2.0 with a currency filter
Add a feature the right way: code, test, version, lock file and pull request.
Open a terminal, cd into unit01/blocked-triage, activate its .venv and create a branch:
git switch -c currency-filter
In cli.py, add an option below the --threshold line:
parser.add_argument("--currency", help="only include orders in this currency, e.g. EUR")
In main(), right after the line orders = SAMPLE_ORDERS if args.sample else load_orders(args.file), add:
if args.currency:
orders = [o for o in orders if o.get("currency") == args.currency.upper()]
All sample orders are in EUR, so filtering on USD must return no rows. Run pytest until all eight tests pass.
In pyproject.toml, change version = "0.1.0" to version = "0.2.0". Run blocked-triage --version: it still says 0.1.0, because metadata changes need a reinstall. Run pip install -e . --no-deps and check again: 0.2.0.
Run python check_project.py. Every line in sections 1 to 5 should be OK (the uv line may be LATER). If you did Step 9, run uv lock too, since the project's version is recorded in uv.lock.
Done when:blocked-triage --version prints blocked-triage 0.2.0, blocked-triage --sample --currency EUR lists all five sample orders, pytest reports 8 passed, python check_project.py ends with Project OK, and the merged pull request is on GitHub. The topic on testing AI applications in Unit 6 starts from this project's tests, and the AI API you build in Unit 6 can import blocked_triage.rules as a package instead of copying the rule.
Pick one answer for each question. The explanation appears after you choose.
1In this project, what belongs in pyproject.toml and what belongs in requirements-lock.txt?
Answer: B. pyproject.toml holds abstract requirements, such as python-dotenv>=1.0, so the package combines well with others. The lock file, generated by pip freeze or uv lock, pins every installed package, transitive ones included, so rebuilds are exact.
2Why does the src layout protect you from tests that pass on your laptop but fail after a real install?
Answer: C. Python searches the current folder first, so in a flat layout tests can import the loose copy instead of the installed one. With src/, the only importable copy is the installed one, so tests exercise what users get. The cost is an install step, usually editable.
3You changed the threshold in rules.py and the version in pyproject.toml. Which change needs pip install -e . before it shows up?
Answer: D. An editable install points the environment at your src/ folder, so code edits apply at once. pip's documentation says metadata changes, such as the version or the scripts table, need a reinstall. That is why the exercise reinstalls after bumping to 0.2.0.
4Why does Step 7 use pip freeze --exclude-editable rather than plain pip freeze?
Answer: B. Your own project is installed from your folder, so pip freeze lists it as -e plus your local path. That line breaks installs elsewhere. The project is installed separately with pip install -e . --no-deps after the lock file.
5A teammate on macOS installs from a requirements-lock.txt you froze on Windows and sees an extra colorama. What explains it?
Answer: A. pip freeze records what this computer installed, and pytest needs colorama only on Windows. Installing it on macOS is harmless. uv.lock avoids the issue by recording it with the marker sys_platform == 'win32'.
6What would you do if check_project.py reports MISSING installed versions match the lock file with pytest 9.0.0 (lock says 9.1.1)?
Answer: C. The environment drifted from the recorded versions. Either bring the environment back with pip install -r requirements-lock.txt, or, if the upgrade was intended, regenerate the lock file and commit it through a pull request. Pinning in pyproject.toml or hand-editing a generated lock file are both pitfalls.
7You deploy the triage tool to SAP BTP's Cloud Foundry runtime, and the team uses uv. What do you give the buildpack?
Answer: D. The Cloud Foundry documentation describes detection by requirements.txt and the Python version from runtime.txt. uv export --format requirements.txt turns the lock into that format. uv.lock has its own format, and environments are never shipped.
8SAP's documentation warns against LiteLLM versions 1.82.7 and 1.82.8. How do you encode that in your project?
Answer: B. A specifier such as "litellm!=1.82.7,!=1.82.8" stops the resolver from choosing those versions. Regenerating the lock file records the version you actually run, and the pull request makes the change reviewable. A memory-based rule fails the first time someone reinstalls.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Sources
Packaging Python Projects (Python Packaging User Guide)— src/ layout with README, LICENSE, pyproject.toml and tests/; [build-system] examples for Hatchling, setuptools (>= 77.0.3), Flit and PDM; [project] fields including requires-python
venv: creation of virtual environments (Python documentation)— isolated environment on top of a base Python; not checked into source control; disposable and recreated rather than moved; activation commands; pip installs into the active environment
pip freeze (pip documentation)— --exclude-editable leaves the editable project out; freeze in one environment, install -r in another
Local project installs (pip documentation)— editable installs put the development folder on the import path; code edits apply at once; metadata changes such as version or scripts need a reinstall