Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Modules, Environments & Packaging  ›  Lesson

Virtual Environments & Pinned Dependencies

Virtual Environments 24 minEvery project gets its own site-packages, and its versions are chosen once and written down
You're building a piece ofQuarry — the shipment
This piece — resolve_pins(): Turns loose requirements into exact, written-down versions, and names the ones nothing can satisfy.
Scenario Quarry ran perfectly on the laptop and crashed on the server, because the server's pip had quietly installed a newer chardet; from now on every version it runs with is chosen once and written down.
Your task
Build resolve_pins(requirements, index). requirements are requirements-file lines such as "chardet>=5,<6" (skip blanks and anything after #); index maps each normalised package name to its released versions. Normalise names (lower-case, runs of - _ . become -), let every line for a package apply, and pick the highest release that satisfies them all, comparing versions as numbers. Return {"pins": sorted "name==version", "unsatisfied": sorted names with no acceptable release}. Example: resolve_pins(["chardet>=5,<6", "rich"], index) → {"pins": ["chardet==5.2.0", "rich==13.9.4"], "unsatisfied": []}.

Virtual Environments & Pinned Dependencies

In short: pip install chardet unpacks a folder into site-packages, which sits on sys.path — but by default there's one site-packages per Python install, shared by every project, and two projects can need two different versions of the same package. A virtual environment gives each project its own site-packages: a small folder pointing back at the real interpreter, with an empty package folder of its own. Separately, a loose requirement like chardet>=5,<6 says what would work; it does not say what you tested — a pin (chardet==5.2.0) and a lock file (every transitive dependency, pinned) remove that bet. Comparing versions as text gets this wrong in a specific, nasty way: "2.10.0" > "2.9.1" is False.

Two virtual environments, each its own site-packages, pointing back at one shared base interpreter — so quarry's .venv can hold chardet 5.2.0 while another project's .venv holds chardet 6.0.0

Compare chardet 5.9.1 against 5.10.0 — and pick the newer one

Same code, same data, same Python — except the laptop had chardet 5.2.0 and the server's fresh install pulled 6.0.0, which changed a return value Quarry relied on. Before any tool can pick "the newest version in range", it has to compare two versions, and the obvious way is wrong:

What does this print?
print("2.10.0" > "2.9.1")

Turn a version into a tuple of ints and tuple comparison does it right:

python

One wrinkle: 5 and 5.0.0 are the same release, but their tuples disagree unless you drop trailing zeros first — (5, 0, 0) != (5,) even though the versions are equal:

What does this print?
print((5, 0, 0) == (5,), (1, 4) < (1, 4, 0))

The bug that bug like this causes is concrete: a naive picker returns the lexically largest version, not the newest one:

broken — fix it

The filter compares versions correctly, but the pin is still wrong — it picks 5.9.1. Make pick() return the newest release in range.

Install chardet into its own project folder

pip install chardet downloads a file and unpacks a chardet folder into site-packages — nothing magic, just a folder on sys.path. A virtual environment gives each project its own:

$ python -m venv .venv
$ source .venv/bin/activate          # Windows: .venv\Scripts\activate
(.venv) $ which python
/home/asha/quarry/.venv/bin/python
(.venv) $ python -m pip install "chardet>=5,<6"
Successfully installed chardet-5.2.0

"Activating" just puts .venv/bin first on PATH; scripts and servers usually skip it and call .venv/bin/python -m pip ... directly. Always install with python -m pip, never a bare pip, so there's no doubt which Python the package goes into. Python can tell whether it's running inside one — sys.prefix points at the environment, sys.base_prefix at the real install, and outside a venv they're the same:

python

In this browser the answer is always no — there's no venv and no pip here, so this lesson teaches the tools in prose and real terminal sessions, and grades you on the logic those tools apply: comparing versions, reading specifiers, resolving pins. That logic is exact; only the downloading is missing.

Pin tonight's exact chardet version

A project states what it needs as requirements — a name plus an optional specifier:

chardet>=5,<6          # 5.x only: 6.0 changed detect()'s return value
rich~=13.7              # 13.7 or newer, but still 13.x
requests>=2.28,!=2.30.0
Specifier Means
==2.31.0 exactly that release
!=2.30.0 anything but that release
>=5, <6 ordinary comparisons, as numbers
~=13.7 compatible release: >=13.7 and still 13.*

Commas mean and — >=5,<6 is one range, and names are normalised (lower-cased, runs of -_. collapsed to one -) so Typing_Extensions and typing-extensions are one package. Step through one version checked against three clauses at once:

step through it
1def vkey(v):
2 parts = [int(p) for p in v.split(".")]
3 while len(parts) > 1 and parts[-1] == 0:
4 parts.pop()
5 return tuple(parts)
6 
7version = "5.2.0"
8ok = True
9for clause in ">=5, <6, !=5.1.0".split(","):
10 clause = clause.strip()
11 op = clause[:2] if clause[:2] in (">=", "<=", "!=", "==") else clause[:1]
12 want = clause[len(op):]
13 a, b = vkey(version), vkey(want)
14 holds = {">=": a >= b, "<": a < b, "!=": a != b}[op]
15 ok = ok and holds
16print(ok)

A loose requirement is a bet that the future behaves. A pin removes it — chardet==5.2.0 — but pinning only what you import directly isn't enough, because rich depends on markdown-it-py, which depends on mdurl. A lock file pins the whole installed tree, produced by tools like pip-tools, uv or Poetry: loose requirements are the input, exact pins are the output, and the output gets committed.

Drawing diagram…

Traps worth knowing

Collisions need backtracking. Quarry needs chardet>=5; the newest quarry-report needs chardet<5. No single chardet fits both, but an older quarry-report that accepts chardet>=4 would — finding that means undoing a choice and trying the next one, which is what pip's resolver does when it reports a conflict. You build a small version of that resolver in this lesson's capstone exercise.

Drift compounds. If each dependency has a 5% chance of a new release in a month, an unpinned install stays identical with probability 0.95 ** k for k transitive dependencies — 51% at 13, under 8% at 50. Real projects have dozens, so without a lock file, "the same install" twice is rare.

Quick practice

What does this print?
def normalize(name):
    import re
    return re.sub(r"[-_.]+", "-", name.lower())

print(normalize("Typing_Extensions") == normalize("typing-extensions"))
What does this print?
def vkey(v):
    parts = [int(p) for p in v.split(".")]
    while len(parts) > 1 and parts[-1] == 0:
        parts.pop()
    return tuple(parts)

print(vkey("5.0.0") == vkey("5"), sorted(["5.10.0", "5.9.1", "5.2.0"], key=vkey))
broken — fix it

A comment on a requirements line isn't part of the specifier, but it's leaking into the output. Strip it before printing.


🎯 Your turn

Build resolve_pins(requirements, index), the piece of Quarry's shipment that writes the lock. requirements are lines from a requirements file; index maps each normalised package name to the versions that have been released.

  • Skip blank lines and everything after a #.
  • Normalise names; if a package appears on several lines, every spec applies.
  • Pin the highest release that satisfies all of them, comparing as numbers.
  • Report packages with no acceptable release (or none at all) as unsatisfied.
resolve_pins(["chardet>=5,<6", "rich"],
             {"chardet": ["4.0.0", "5.1.0", "5.2.0", "6.0.0"], "rich": ["13.9.4"]})
# → {"pins": ["chardet==5.2.0", "rich==13.9.4"], "unsatisfied": []}

normalize and vkey are in the starter. satisfies(version, spec) is yours to finish first — it is the rule every other step leans on.

Press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upOne package, many spellings

Package names are compared after normalising them: lower-case, with every run of -, _ and . turned into a single -. So Typing_Extensions, typing.extensions and typing--extensions are one package. Write normalize(name).

environmentsstrings
DrillThe newest release

Write newest(versions) returning the highest of a list of release versions like "2.9.1" and "2.10.0", compared as numbers part by part — so 2.10.0 beats 2.9.1. Missing trailing parts count as zero. An empty list gives "".

environmentssorted
BuildDoes this version fit?

Write satisfies(version, spec). spec is comma-separated clauses, each an operator (==, !=, <=, >=, <, >, ~=) and a version; every clause must hold, and an empty spec allows anything. Compare versions as numbers, with missing trailing parts counting as zero (5 == 5.0.0). ~=2.31 is the compatible release operator: >=2.31 and still 2.x; ~=1.4.5 means >=1.4.5 and still 1.4.x.

environmentsstrings
BossDoes the lock file still honour the requirements?

A lock file (the output of pip freeze) pins every installed package as name==version, one per line. Write check_lock(requirements, lock): requirements are loose lines like "chardet>=5,<6", lock is the lock file's lines. Ignore blank lines and comments in both, and compare names normalised. Return a sorted list of problems: "missing: <name>" for a requirement with no pin, "not pinned: <name>" for a lock line that isn't == an exact version, and "conflict: <name>==<version> vs <spec>" when a pin breaks a requirement (use the requirement's spec text exactly as written, stripped). Extra pins the requirements don't mention are fine — they are the dependencies' dependencies.

environmentsdictionaries
CapstoneA resolver that backtracks

Now the real problem: packages have dependencies of their own. index maps each package to its releases, and each release to the requirement lines it needs: {"quarry-report": {"2.0.0": ["rich>=13"]}}. Write resolve_tree(requirements, index). Work through a queue of requirements in order. For a package not yet pinned, try its releases from newest down; pin the first one that satisfies the requirement, add its own requirements to the end of the queue, and carry on. For a package already pinned, its pin must satisfy the new requirement. When nothing works, back up and try the previous package's next-newest release. Return {"pins": sorted "name==version"}, or {"error": "ResolutionImpossible"} when no combination works. Requirement names need normalising; index keys are already normal.

environmentsrecursionbacktracking
ExplainPin the app, range the library

Quarry ships two things: the quarry command people install to process their data, and quarry-report, a small library other programs import. A teammate wants both to declare exact pins like chardet==5.2.0. Explain why exact pins in a lock file are right for the running application, but a library should declare ranges such as chardet>=5,<6 — and what goes wrong if a library pins exactly.

resolve_pins.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
resolve_pins(requirements, index) → dictTurn loose requirements into exact pins, and say which ones can't be met.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.