Orchestrate

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.

Updated Oct 3, 2026Foundational 8 minDeep 40 min
Foundational layer · 8 min read

The 60-second version

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:

  1. A standard folder layout, so anyone can find the code, the tests and the instructions.
  2. A private environment per project, so one project's libraries can't break another's.
  3. 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.

Why it matters to the business

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.

How SAP does it

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.

Three files, three jobs

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.

Questions to ask

  • Can a new team member go from a fresh laptop to a running project with the README alone? How long does it take?
  • Is there a lock file in the repository, and is it used for every install, including the deployment?
  • Which Python version does the project require, and does the production platform (for example, runtime.txt on Cloud Foundry) use the same one?
  • Who reviews library updates, and how often? Do updates go through a pull request like any other change?
  • How would we find out that one of our libraries has a published vulnerability, and how fast could we move to a fixed version?
  • Do we install packages from the public index directly, or through a company-approved mirror?

Common misconceptions

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

Key terms

  • Package: a library you can install, such as requests or sap-ai-sdk-gen.
  • PyPI: the Python Package Index, the public website pip and uv download packages from.
  • Dependency: a package your project needs. Packages your dependencies need are transitive dependencies.
  • Virtual environment: a private folder with its own Python and packages for one project.
  • pyproject.toml: the standard file that describes a Python project and what it needs.
  • Pinning: naming one exact version, such as python-dotenv==1.2.4.
  • Lock file: a file that pins every package, transitive ones included, so installs are repeatable.
  • uv: a fast tool that manages Python projects, environments and lock files in one place.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Deep layer · 40 min read

Mental model: a recipe, a receipt and a kitchen

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.

How it works

The layout

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 name blocked-triage (what you install, with a hyphen) and the import name blocked_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.

pyproject.toml, table by table

Table What it holds In this project
[build-system] Which build backend turns your folder into an installable package setuptools, one of the four backends the packaging tutorial shows
[project] Name, version, description, README, requires-python, dependencies One runtime dependency: python-dotenv
[project.optional-dependencies] Extras: optional groups installed with name[extra] dev holds pytest, needed only by developers
[project.scripts] Commands created at install time, as command = "module:function" blocked-triage = "blocked_triage.cli:main"

Version specifiers in dependencies follow a standard syntax:

Specifier Means Use it for
>=1.0 1.0 or newer Most dependencies in pyproject.toml
>=1.0,<2 At least 1.0, below 2 When you know version 2 will break you
!=1.82.7 Anything except this version Skipping a known-bad release
==1.2.4 Exactly this version Lock files, not pyproject.toml

The packaging guide advises caution with upper limits on requires-python: a project that refuses newer Pythons ages badly.

What a virtual environment really is

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.

What pip install -e . does

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.

Lock files: pip freeze, hashes, and uv.lock

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 experimental pip 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

Before you start: complete Set up your computer for this course and Git and GitHub for AI engineers. You need the orchestrate-course folder with Git and the unit01/triage.py script from the Git topic. If you also did Calling your first SAP API, your unit01/blocked_orders.json becomes real input in Step 6.

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]

What you need

  • The course folder from the setup topic, Python 3.11 or newer (the setup topic installed 3.14), Git and VS Code.
  • An internet connection: pip downloads python-dotenv, pytest and setuptools from PyPI.
  • About 60 to 90 minutes. No SAP access, no model key, no account, no cost.
  • Optional, Step 9: uv, free.

Step 1: Open a terminal and leave the course environment

  1. Open VS Code, choose File > Open Folder, and open orchestrate-course.

  2. Open a terminal with Terminal > New Terminal.

  3. If the prompt starts with (.venv), the course environment is active. Turn it off; this project gets its own:

    deactivate

    If it says deactivate is not recognized, no environment was active. That's fine.

  4. Start on a new Git branch, as the Git topic taught:

    git switch -c blocked-triage-project

Step 2: Create the project folder and its own virtual environment

  1. Create the folders and move into the project:

    • Windows (PowerShell):

      New-Item -ItemType Directory -Force -Path unit01\blocked-triage\src\blocked_triage, unit01\blocked-triage\tests
      cd unit01\blocked-triage
    • macOS / Linux:

      mkdir -p unit01/blocked-triage/src/blocked_triage unit01/blocked-triage/tests
      cd unit01/blocked-triage
  2. Create the project's virtual environment and turn it on:

    • Windows (PowerShell):

      python -m venv .venv
      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      python3 -m venv .venv
      source .venv/bin/activate
  3. Check that python now means this project's Python:

    python -c "import sys; print(sys.prefix)"
C:\Users\you\orchestrate-course\unit01\blocked-triage\.venv

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.

Step 3: Write pyproject.toml and the README

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

Step 4: Write the package and its tests

Create each file below, in the folder shown, with the exact name.

  1. 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
  1. 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
    ]
  1. 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"]
  1. 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
  1. 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())
  1. 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

Step 5: Install the project in editable mode

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

  2. Wait for the last line:

Successfully installed blocked-triage-0.1.0 iniconfig-2.3.0 packaging-26.3 pluggy-1.6.0 pygments-2.21.0 pytest-9.1.1 python-dotenv-1.2.4

Your version numbers may be newer; that is expected today, and it is exactly why Step 7 records them. On Windows you'll also see colorama.

  1. See what's installed:

    pip list

    blocked-triage 0.1.0 appears with the path to your folder in the last column: that is the editable install.

Step 6: Run the command and the tests

  1. Run the new command on the built-in sample:

    blocked-triage --sample
Escalate blocked orders above 50,000.00

  5000001     72,000.00 EUR  delivery          ESCALATE
  5000002      8,400.00 EUR  billing           queue
  5000003     51,500.00 EUR  delivery          ESCALATE
  5000004     23,900.00 EUR  -                 skip
  5000005     64,000.00 EUR  delivery+billing  ESCALATE

3 to escalate, 1 queued, 1 not blocked

skip means the order has no delivery or billing block, so the rule ignores it.

  1. Try the options:

    blocked-triage --sample --threshold 60000
    python -m blocked_triage --sample --json
    blocked-triage --version

    The first escalates only two orders. The second prints the same result as JSON, ready for another program. The third prints blocked-triage 0.1.0.

  2. If you have unit01/blocked_orders.json from the SAP API topic, point the tool at it. It sits one folder up:

    • Windows (PowerShell): blocked-triage --file ..\blocked_orders.json --threshold 10000
    • macOS / Linux: blocked-triage --file ../blocked_orders.json --threshold 10000

    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.

  3. Run the tests:

    pytest -v
tests/test_rules.py::test_blocked_order_above_threshold_is_escalated PASSED [ 14%]
tests/test_rules.py::test_order_exactly_at_threshold_is_queued PASSED    [ 28%]
tests/test_rules.py::test_unblocked_order_is_skipped_however_large PASSED [ 42%]
tests/test_rules.py::test_threshold_can_be_changed PASSED                [ 57%]
tests/test_rules.py::test_sample_data_gives_three_escalations PASSED     [ 71%]
tests/test_rules.py::test_load_orders_reads_the_file_shape PASSED        [ 85%]
tests/test_rules.py::test_cli_prints_json PASSED                         [100%]

============================== 7 passed in 0.03s ===============================
  1. 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.

Step 7: Pin every version in a lock file

  1. Write the exact versions of everything installed, except your own project, to requirements-lock.txt:

    • Windows (PowerShell):

      pip freeze --exclude-editable | Out-File -Encoding utf8 requirements-lock.txt
    • macOS / Linux:

      pip freeze --exclude-editable > requirements-lock.txt

    On Windows, Out-File -Encoding utf8 stops older PowerShell versions from saving the file in a format (UTF-16) that Git shows as unreadable.

  2. Open requirements-lock.txt. It looks like this (your numbers may differ; on Windows there is also a colorama line):

iniconfig==2.3.0
packaging==26.3
pluggy==1.6.0
Pygments==2.21.0
pytest==9.1.1
python-dotenv==1.2.4

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.

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

  1. Leave and delete the environment:

    • Windows (PowerShell):

      deactivate
      Remove-Item -Recurse -Force .venv
    • macOS / Linux:

      deactivate
      rm -rf .venv
  2. 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
  3. Install the locked versions, then your project without letting pip choose anything else:

    pip install -r requirements-lock.txt
    pip install -e . --no-deps

    --no-deps tells pip not to fetch dependencies for the project; the lock file already installed them, at the exact versions.

  4. Prove it works:

    pytest -q
    blocked-triage --sample
.......                                                                  [100%]
7 passed in 0.02s

These are the same two install commands your README lists. Anyone with the repository can now rebuild your exact environment.

Step 9 (optional): The same project with uv

uv does Steps 2, 5, 7 and 8 with fewer commands and writes a cross-platform lock file. Try it on the same folder.

  1. Install uv with the official installer:

    • Windows (PowerShell):

      powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
    • macOS / Linux:

      curl -LsSf https://astral.sh/uv/install.sh | sh

    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.

  2. Go back to the project folder and check uv works:

    • Windows (PowerShell): cd ~\orchestrate-course\unit01\blocked-triage
    • macOS / Linux: cd ~/orchestrate-course/unit01/blocked-triage
    uv --version
  3. 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
  1. 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:...".

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

Step 10: Check the project and save it in Git

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

  1. 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/
  2. 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.

  3. Push and merge through a pull request, as in the Git topic:

    git push -u origin blocked-triage-project
    gh pr create --fill
    gh pr merge --squash --delete-branch
    git switch main
    git pull
  4. When you go back to other course topics, deactivate, cd back to the course folder and activate the course .venv again.

What each part of the code does

Part What it does
[build-system] Tells pip to build the package with setuptools, fetched into a temporary environment
dependencies / optional-dependencies.dev What users need (python-dotenv) vs. what only developers need (pytest)
[project.scripts] Creates the blocked-triage command, which calls blocked_triage.cli:main
__init__.py Marks the package and reads __version__ from the installed metadata, so the version lives in one place
rules.py The business rule: blocks(), decide() and triage(), pure functions with no input or output
orders.py Built-in sample orders and load_orders() for a blocked_orders.json file
cli.py Parses options with argparse, picks the threshold (command line, then .env, then default) and prints a table or JSON
__main__.py Makes python -m blocked_triage work, without the installed command
tests/test_rules.py Seven pytest tests: rule edge cases, file loading and the command line
check_project.py, sections 1–3 Python version, active environment, layout and pyproject.toml fields (parsed with the built-in tomllib)
check_project.py, section 4 Uses importlib.metadata to confirm the editable install and shutil.which to find the command
check_project.py, section 5 Checks every lock line is name==version and that installed versions match it

If something goes wrong

What you see What it means What to do
python or python3 is not recognized Python isn't installed or not on PATH Install it as in the setup topic, then open a new terminal
No module named venv (Linux) Your distribution ships venv separately Install your distribution's python3-venv package, then retry Step 2
PowerShell says running scripts is disabled The execution policy blocks Activate.ps1 Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser, answer Y, activate again
sys.prefix ends in orchestrate-course/.venv The course environment is still active deactivate, then activate the project's .venv (Step 2)
does not appear to be a Python project The terminal is not in the folder with pyproject.toml cd into unit01/blocked-triage and retry
ERROR: Package 'blocked-triage' requires a different Python Your Python is older than 3.11 Install a current Python (setup topic), delete .venv, recreate it
No module named 'dotenv' or 'blocked_triage' The wrong environment is active, or the install didn't run Check sys.prefix, then pip install -e ".[dev]"
blocked-triage is not recognized The command lives in the project's .venv, which isn't active Activate it; python -m blocked_triage --sample also works
Could not find a version that satisfies the requirement or a timeout No connection to PyPI, or a company proxy blocks it Try another network, or ask IT for the approved package mirror
pytest runs but finds 0 tests The terminal isn't in the project folder cd into unit01/blocked-triage, run pytest again
MISSING installed versions match the lock file Someone upgraded a package after the freeze pip install -r requirements-lock.txt, or re-freeze on purpose and commit
Git shows requirements-lock.txt as binary It was saved as UTF-16 by older PowerShell Regenerate it with the Out-File -Encoding utf8 command in Step 7
uv is not recognized after installing The terminal predates the install Close the terminal window completely and open a new one

The SAP way

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.

SAP BTP, Cloud Foundry runtime

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.

With uv, generate that file from uv.lock:

uv export --format requirements.txt --no-emit-project --no-hashes > requirements.txt

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

SAP AI Core

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.

Build vs. SAP: which tool, where

Situation Use Why
Learning scripts across a course One shared .venv and a short requirements.txt Simple; nothing is handed over
A tool or service someone else will run Its own folder, src/ layout, pyproject.toml, own .venv, a lock file Repeatable installs and a clean handover
Team project, mixed Windows and macOS laptops uv with a committed uv.lock One cross-platform lock file with hashes
Team standardized on pip pip freeze lock per target platform, hashes in the pipeline Works with tools every Python has
Deploying to SAP BTP, Cloud Foundry Pinned requirements.txt plus runtime.txt What the buildpack documentation describes
Custom code on SAP AI Core Locked requirements copied into the container image The image is the deployment unit
Calling SAP AI services from Python sap-ai-sdk-gen (and its siblings) as a pinned dependency SAP's documented Python SDK

Production concerns

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.

Pitfalls

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

  1. Open a terminal, cd into unit01/blocked-triage, activate its .venv and create a branch:

    git switch -c currency-filter
  2. In cli.py, add an option below the --threshold line:

        parser.add_argument("--currency", help="only include orders in this currency, e.g. EUR")
  3. 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()]
  4. Add a test to tests/test_rules.py:

    def test_cli_filters_by_currency(capsys):
        assert main(["--sample", "--json", "--currency", "usd"]) == 0
        assert json.loads(capsys.readouterr().out)["rows"] == []

    All sample orders are in EUR, so filtering on USD must return no rows. Run pytest until all eight tests pass.

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

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

  7. Commit, push and merge through a pull request:

    git add pyproject.toml src tests
    git commit -m "Add --currency filter; release 0.2.0"
    git push -u origin currency-filter
    gh pr create --fill
    gh pr merge --squash --delete-branch

    Add uv.lock to the git add line if it changed.

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.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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'.
  6. 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.
  7. 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.
  8. 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.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in