Skip to main content

Python pip and Requirements Practice Problems & Exercises

Practice: pip and Requirements

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Parse Version SpecifiersEasy
version-specifiersPEP-440parsing

Parse requirement strings into their name and version specifier components. Understanding this structure is fundamental to writing tools that manipulate requirements.txt files programmatically.

Python
import re

def parse_requirement(line):
    line = line.strip()
    # Strip extras like requests[security]
    line = re.sub(r'\[.*?\]', '', line)
    # Split on version specifier operators
    match = re.split(r'(>=|<=|==|!=|~=|>|<)', line, maxsplit=1)
    name = match[0].strip()
    specifiers = []
    if len(match) > 1:
        rest = ''.join(match[1:])
        # Split multiple specifiers by comma
        for part in rest.split(','):
            part = part.strip()
            if part:
                specifiers.append(part)
    return {'name': name, 'specifiers': specifiers}

tests = [
    'requests>=2.28.0',
    'numpy==1.24.3',
    'flask>=2.0,<3.0',
    'pytest',
]
for t in tests:
    print(parse_requirement(t))
Solution
import re

def parse_requirement(line):
line = re.sub(r'\[.*?\]', '', line.strip())
match = re.split(r'(>=|<=|==|!=|~=|>|<)', line, maxsplit=1)
name = match[0].strip()
specifiers = []
if len(match) > 1:
rest = ''.join(match[1:])
for part in rest.split(','):
part = part.strip()
if part:
specifiers.append(part)
return {'name': name, 'specifiers': specifiers}

PEP 440 defines six comparison operators for version specifiers. The compatible release operator ~=2.1.3 means >=2.1.3, ==2.1.* — it allows patch upgrades but not minor version changes. The exclusion operator !=1.0.3 is commonly used to skip a known-broken release. Real parsers use the packaging library's Requirement class which handles all PEP 440 edge cases including pre-release markers and environment markers.

def parse_requirement(line):
    """Parse a pip requirement line into its components.
    
    Input examples:
        'requests>=2.28.0'
        'numpy==1.24.3'
        'flask>=2.0,<3.0'
        'pytest'
    
    Return a dict:
    - 'name': package name (str)
    - 'specifiers': list of specifier strings like ['>=2.28.0']
                    empty list if no specifiers
    
    Ignore extras like requests[security] — strip [...].
    """
    # TODO: implement
    pass
Expected Output
{'name': 'requests', 'specifiers': ['>=2.28.0']}
Hints

Hint 1: Split on operators: >=, <=, ==, !=, ~=, >, <. The package name comes before the first operator.

Hint 2: Use re.split or find the first occurrence of any operator character.

#2Version Satisfies SpecifierEasy
version-comparisonPEP-440specifier-matching

Implement a simple version satisfaction checker. This is the core logic inside pip's dependency resolver — every candidate version is tested against every specifier for the package.

Python
def version_satisfies(version_str, specifier_str):
    ops = {'==': lambda a, b: a == b,
           '!=': lambda a, b: a != b,
           '>=': lambda a, b: a >= b,
           '<=': lambda a, b: a <= b,
           '>':  lambda a, b: a > b,
           '<':  lambda a, b: a < b}

    for op in ('==', '!=', '>=', '<=', '>', '<'):
        if specifier_str.startswith(op):
            spec_version = specifier_str[len(op):]
            v1 = tuple(int(x) for x in version_str.split('.'))
            v2 = tuple(int(x) for x in spec_version.split('.'))
            # Pad to same length
            maxlen = max(len(v1), len(v2))
            v1 = v1 + (0,) * (maxlen - len(v1))
            v2 = v2 + (0,) * (maxlen - len(v2))
            return ops[op](v1, v2)
    raise ValueError('Unsupported specifier: ' + specifier_str)

print(version_satisfies('2.1.0', '>=2.0.0'))
print(version_satisfies('1.9.0', '>=2.0.0'))
print(version_satisfies('2.0.0', '==2.0.0'))
print(version_satisfies('2.0.1', '==2.0.0'))
Solution
def version_satisfies(version_str, specifier_str):
ops = {
'==': lambda a, b: a == b, '!=': lambda a, b: a != b,
'>=': lambda a, b: a >= b, '<=': lambda a, b: a <= b,
'>': lambda a, b: a > b, '<': lambda a, b: a < b,
}
for op in ('==', '!=', '>=', '<=', '>', '<'):
if specifier_str.startswith(op):
spec_version = specifier_str[len(op):]
v1 = tuple(int(x) for x in version_str.split('.'))
v2 = tuple(int(x) for x in spec_version.split('.'))
maxlen = max(len(v1), len(v2))
v1 += (0,) * (maxlen - len(v1))
v2 += (0,) * (maxlen - len(v2))
return ops[op](v1, v2)
raise ValueError('Unsupported: ' + specifier_str)

Padding to the same length before comparison is essential. Without it, (2, 0) and (2, 0, 0) would be unequal because Python tuple comparison is lexicographic by position. The real packaging.version.Version class handles more complex cases: pre-release ordering (alpha < beta < rc < final), post-releases, dev releases, and local version identifiers. For production code, always use packaging.version.Version rather than rolling your own.

def version_satisfies(version_str, specifier_str):
    """Check if version_str satisfies the specifier.
    
    Support only: ==, >=, <=, >, <, !=
    Versions are dot-separated integers (no pre-release).
    
    Examples:
        version_satisfies('2.1.0', '>=2.0.0') -> True
        version_satisfies('1.9.0', '>=2.0.0') -> False
        version_satisfies('2.0.0', '==2.0.0') -> True
    
    Return True or False.
    """
    # TODO: implement
    pass
Expected Output
True
False
True
False
Hints

Hint 1: Convert version strings to tuples of ints: "2.1.0" -> (2, 1, 0). Tuples compare element-by-element.

Hint 2: Parse the operator from the specifier string, then use the corresponding comparison.

#3Filter Requirements by EnvironmentEasy
environment-markersrequirements-parsingPEP-508

Filter a requirements list based on the current platform. Environment markers let you specify platform-specific dependencies in a single requirements.txtpywin32 for Windows, uvloop for Unix.

Python
import re

def filter_by_platform(requirements, current_platform):
    result = []
    for req in requirements:
        if ';' not in req:
            result.append(req.strip())
            continue
        pkg_part, marker_part = req.split(';', 1)
        marker_part = marker_part.strip()
        m = re.search(r'sys_platform\s*==\s*["\']([^"\']+)["\']', marker_part)
        if m:
            if m.group(1) == current_platform:
                result.append(pkg_part.strip())
        else:
            result.append(pkg_part.strip())
    return result

reqs = [
    'requests>=2.28',
    'pywin32>=306; sys_platform == "win32"',
    'uvloop>=0.17; sys_platform != "win32"',
    'pytest>=7.0',
]
print(filter_by_platform(reqs, 'linux'))
Solution
import re

def filter_by_platform(requirements, current_platform):
result = []
for req in requirements:
if ';' not in req:
result.append(req.strip())
continue
pkg_part, marker_part = req.split(';', 1)
m = re.search(r'sys_platform\s*==\s*["\']([^"\']+)["\']', marker_part)
if m:
if m.group(1) == current_platform:
result.append(pkg_part.strip())
else:
result.append(pkg_part.strip())
return result

PEP 508 defines environment markers as a mini-expression language evaluated against the current environment. Markers can check sys_platform, python_version, platform_machine (for ARM vs x86), implementation_name (cpython vs pypy), and more. The packaging.markers.Marker class evaluates these properly. This is how numpy ships different binary wheels for Linux/Mac/Windows and arm64/x86_64 — the marker selects the right one at install time.

def filter_by_platform(requirements, current_platform):
    """Filter a list of requirement strings, keeping only
    those applicable to current_platform.
    
    Requirements may have a simple platform marker:
        'pywin32>=306; sys_platform == "win32"'
        'requests>=2.28'  (no marker — always include)
    
    current_platform: 'win32', 'linux', 'darwin'
    Return list of requirement strings with markers stripped.
    """
    # TODO: implement
    pass
Expected Output
['requests>=2.28', 'uvloop>=0.17']
Hints

Hint 1: Split each line on ";" to separate the requirement from its marker.

Hint 2: If there is no marker, always include. If there is a marker, evaluate the sys_platform condition.

#4Generate Pinned RequirementsEasy
pip-freezelockfilepinning

Given a list of loose requirements and a resolver result, produce a fully pinned requirements.txt. This is what pip freeze does — converts your abstract dependencies into exact version pins for reproducible installs.

Python
import re

def pin_requirements(loose_reqs, resolved_versions):
    pinned = []
    for req in loose_reqs:
        req = req.strip()
        if not req or req.startswith('#'):
            continue
        name = re.split(r'[>=<!~\[]', req)[0].strip()
        if name in resolved_versions:
            pinned.append(name + '==' + resolved_versions[name])
    return sorted(pinned, key=lambda x: x.lower())

loose = ['requests>=2.28', 'flask>=2.0,<3.0']
resolved = {'requests': '2.31.0', 'flask': '2.3.3'}
print(pin_requirements(loose, resolved))
Solution
import re

def pin_requirements(loose_reqs, resolved_versions):
pinned = []
for req in loose_reqs:
req = req.strip()
if not req or req.startswith('#'):
continue
name = re.split(r'[>=<!~\[]', req)[0].strip()
if name in resolved_versions:
pinned.append(name + '==' + resolved_versions[name])
return sorted(pinned, key=lambda x: x.lower())

Pinning is the difference between "works today" and "works forever". A requirements.txt with requests>=2.28 will install whatever the latest version is each time — which breaks CI when a new major version introduces incompatibilities. A lockfile with requests==2.31.0 installs exactly that version every time. pip-compile (from pip-tools) automates this: it resolves the full dependency tree (including transitive deps) and writes a hash-verified lockfile. The loose requirements.in defines intent; the compiled requirements.txt defines the exact install.

def pin_requirements(loose_reqs, resolved_versions):
    """Given a list of loose requirement strings and a dict
    of resolved versions, produce a pinned requirements list.
    
    loose_reqs: ['requests>=2.28', 'flask>=2.0,<3.0']
    resolved_versions: {'requests': '2.31.0', 'flask': '2.3.3'}
    
    Return: ['flask==2.3.3', 'requests==2.31.0']
    (sorted alphabetically by package name)
    """
    # TODO: implement
    pass
Expected Output
['flask==2.3.3', 'requests==2.31.0']
Hints

Hint 1: Extract the package name from each requirement line (everything before the first operator or end of line).

Hint 2: Look up the resolved version in the dict, format as name==version, and sort.


Medium

#5Dependency Graph BuilderMedium
dependency-resolutiongraphtransitive-deps

Build a dependency graph from package metadata. Understanding graph structure is key to understanding why pip uninstall can break other packages and why dependency resolvers need topological sort.

Python
def build_dep_graph(packages):
    reverse = {pkg: [] for pkg in packages}
    for pkg, deps in packages.items():
        for dep in deps:
            if dep in reverse:
                reverse[dep].append(pkg)

    roots = sorted(pkg for pkg in packages if not reverse[pkg])
    leaves = sorted(pkg for pkg, deps in packages.items() if not deps)

    return {
        'direct': packages,
        'reverse': reverse,
        'roots': roots,
        'leaves': leaves,
    }

pkgs = {
    'flask': ['werkzeug', 'jinja2', 'click'],
    'werkzeug': [],
    'jinja2': ['markupsafe'],
    'click': [],
    'markupsafe': [],
}
result = build_dep_graph(pkgs)
print("roots=" + str(result['roots']))
print("leaves=" + str(result['leaves']))
Solution
def build_dep_graph(packages):
reverse = {pkg: [] for pkg in packages}
for pkg, deps in packages.items():
for dep in deps:
if dep in reverse:
reverse[dep].append(pkg)
roots = sorted(pkg for pkg in packages if not reverse[pkg])
leaves = sorted(pkg for pkg, deps in packages.items() if not deps)
return {'direct': packages, 'reverse': reverse, 'roots': roots, 'leaves': leaves}

This is the data structure that pipdeptree builds. The reverse graph is what pip show uses for "Required-by:" output. Roots are the packages you actually installed (your direct dependencies); leaves are packages with no further dependencies — they are safe to remove last. A dependency conflict arises when two packages require incompatible versions of the same leaf — the resolver must find a single version satisfying all specifiers, which is NP-hard in the general case (pip uses backtracking search).

def build_dep_graph(packages):
    """Build a dependency graph from a list of package specs.
    
    packages: dict mapping package_name -> list of dependency names
    Example:
        {'flask': ['werkzeug', 'jinja2', 'click'],
         'werkzeug': [],
         'jinja2': ['markupsafe'],
         'click': [],
         'markupsafe': []}
    
    Return a dict:
    - 'direct': same as input
    - 'reverse': dict mapping each package to packages that depend on it
    - 'roots': list of packages with no dependents (top-level packages)
    - 'leaves': list of packages with no dependencies
    """
    # TODO: implement
    pass
Expected Output
roots=['flask'], leaves=['werkzeug', 'click', 'markupsafe']
Hints

Hint 1: Build the reverse graph by iterating the direct graph and inverting edges.

Hint 2: Roots are packages with no entries in the reverse graph (no one depends on them). Leaves have empty dependency lists.

#6Layered Requirements MergerMedium
layered-requirementsrequirements-filesenvironments

Implement a requirements merger that handles the layered requirements pattern: a base file shared across environments, with environment-specific overrides (requirements-dev.txt overrides requirements.txt).

Python
import re

def extract_name(req):
    return re.split(r'[>=<!~\[;\s]', req.strip())[0].lower()

def merge_requirements(base_reqs, extra_reqs):
    merged = {}
    for req in base_reqs:
        req = req.strip()
        if req and not req.startswith('#'):
            merged[extract_name(req)] = req
    for req in extra_reqs:
        req = req.strip()
        if req and not req.startswith('#'):
            merged[extract_name(req)] = req
    return sorted(merged.values(), key=lambda r: extract_name(r))

base = ['requests>=2.28', 'flask>=2.0', 'pytest>=7.0']
extra = ['requests==2.31.0', 'coverage>=7.0']
print(merge_requirements(base, extra))
Solution
import re

def extract_name(req):
return re.split(r'[>=<!~\[;\s]', req.strip())[0].lower()

def merge_requirements(base_reqs, extra_reqs):
merged = {}
for req in base_reqs:
req = req.strip()
if req and not req.startswith('#'):
merged[extract_name(req)] = req
for req in extra_reqs:
req = req.strip()
if req and not req.startswith('#'):
merged[extract_name(req)] = req
return sorted(merged.values(), key=lambda r: extract_name(r))

The layered requirements pattern is the standard production approach. A typical project has: requirements/base.txt (shared runtime deps), requirements/dev.txt (-r base.txt + dev tools), requirements/prod.txt (-r base.txt + production-only deps). The -r other-file.txt directive in requirements files means "include the other file". Pip-compile compiles each layer separately, maintaining separate lockfiles that share the base but can override specific packages. This prevents dev tools from polluting production images.

def merge_requirements(base_reqs, extra_reqs):
    """Merge two requirements lists, with extra_reqs
    overriding base_reqs for packages that appear in both.
    
    base_reqs:  ['requests>=2.28', 'flask>=2.0', 'pytest>=7.0']
    extra_reqs: ['requests==2.31.0', 'coverage>=7.0']
    
    Result should have:
    - requests from extra_reqs (override)
    - flask and pytest from base_reqs
    - coverage from extra_reqs (new)
    
    Return a sorted list of requirement strings.
    Parse names case-insensitively.
    """
    # TODO: implement
    pass
Expected Output
['coverage>=7.0', 'flask>=2.0', 'pytest>=7.0', 'requests==2.31.0']
Hints

Hint 1: Build a dict keyed by lowercased package name. First populate from base_reqs, then update with extra_reqs.

Hint 2: Extract names using the same parsing logic as earlier problems (split on operators).

#7Hash Verification CheckerMedium
hash-verificationsupply-chain-securitypip-hashes

Implement the hash verification that pip performs when you use --require-hashes. This is the core of pip's supply chain security model — it ensures that what you download from PyPI is exactly what was pinned, preventing man-in-the-middle attacks and index poisoning.

Python
import hashlib

def verify_package_hash(content, expected_hash_str):
    algorithm, _, expected = expected_hash_str.partition(':')
    algorithm = algorithm.strip()
    expected = expected.strip()

    hasher = hashlib.new(algorithm)
    hasher.update(content)
    actual = hasher.hexdigest()

    return {
        'valid': actual == expected,
        'algorithm': algorithm,
        'expected': expected,
        'actual': actual,
    }

content = b'fake package content for testing'
h = hashlib.sha256(content).hexdigest()
result = verify_package_hash(content, 'sha256:' + h)
print(result['valid'])

bad_result = verify_package_hash(b'tampered content', 'sha256:' + h)
print(bad_result['valid'])
Solution
import hashlib

def verify_package_hash(content, expected_hash_str):
algorithm, _, expected = expected_hash_str.partition(':')
algorithm = algorithm.strip()
expected = expected.strip()
hasher = hashlib.new(algorithm)
hasher.update(content)
actual = hasher.hexdigest()
return {
'valid': actual == expected,
'algorithm': algorithm,
'expected': expected,
'actual': actual,
}

Hash pinning is the only reliable supply chain defense. PyPI packages can be modified after publishing in limited circumstances, and a compromised CDN or DNS can serve a different file than intended. When --require-hashes is set, pip computes SHA-256 of every downloaded file and refuses to install if it does not match. The hash is stored in requirements.txt in the format requests==2.31.0 --hash=sha256:abc123.... pip-compile generates these hashes automatically. The pip audit command then checks whether any pinned version has known CVEs.

import hashlib

def verify_package_hash(content, expected_hash_str):
    """Verify that the content (bytes) matches the expected hash.
    
    expected_hash_str format: 'sha256:abc123...'
    Supported algorithms: sha256, sha384, sha512
    
    Return a dict:
    - 'valid': bool
    - 'algorithm': str
    - 'expected': str (hex digest)
    - 'actual': str (hex digest)
    """
    # TODO: implement
    pass
Expected Output
{'valid': True, 'algorithm': 'sha256', 'expected': '...', 'actual': '...'}
Hints

Hint 1: Split the expected_hash_str on ":" to get algorithm and digest.

Hint 2: Use hashlib.new(algorithm, content).hexdigest() to compute the actual hash.

#8Conflict DetectorMedium
dependency-conflictresolutionversion-specifiers

Detect dependency version conflicts in a merged requirements list. This is a simplified version of what pip's resolver does before downloading anything.

Python
import re

def parse_name_and_spec(req):
    req = req.strip()
    for op in ('>=', '<=', '==', '!=', '>', '<'):
        idx = req.find(op)
        if idx != -1:
            return req[:idx].strip(), op, req[idx+len(op):].strip()
    return req, None, None

def to_tuple(ver_str):
    try:
        return tuple(int(x) for x in ver_str.split('.'))
    except ValueError:
        return (0,)

def find_conflicts(requirements):
    from collections import defaultdict
    specs = defaultdict(list)
    for req in requirements:
        name, op, ver = parse_name_and_spec(req)
        if op and ver:
            specs[name].append((op, ver))

    conflicts = []
    for pkg, spec_list in specs.items():
        lower_bounds = [to_tuple(v) for op, v in spec_list if op in ('>=', '>')]
        upper_bounds = [to_tuple(v) for op, v in spec_list if op in ('<=', '<')]
        if lower_bounds and upper_bounds:
            min_lower = min(lower_bounds)
            max_upper = max(upper_bounds)
            if min_lower >= max_upper:
                all_specs = [op + v for op, v in spec_list]
                conflicts.append({
                    'package': pkg,
                    'specifiers': all_specs,
                    'reason': 'lower bound ' + str(min_lower) + ' >= upper bound ' + str(max_upper),
                })
    return conflicts

reqs = ['requests>=3.0', 'requests<2.0', 'flask>=2.0']
print(find_conflicts(reqs))
Solution
import re
from collections import defaultdict

def find_conflicts(requirements):
specs = defaultdict(list)
for req in requirements:
req = req.strip()
for op in ('>=', '<=', '==', '!=', '>', '<'):
idx = req.find(op)
if idx != -1:
name = req[:idx].strip()
ver = req[idx+len(op):].strip()
specs[name].append((op, ver))
break

def to_t(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)

conflicts = []
for pkg, spec_list in specs.items():
lowers = [to_t(v) for op, v in spec_list if op in ('>=', '>')]
uppers = [to_t(v) for op, v in spec_list if op in ('<=', '<')]
if lowers and uppers and min(lowers) >= max(uppers):
conflicts.append({
'package': pkg,
'specifiers': [op + v for op, v in spec_list],
'reason': 'no version satisfies all bounds',
})
return conflicts

Real conflict detection is far more complex. Pip's resolver (pip 20.3+ uses the resolvelib backtracking resolver) must handle: multi-package constraint propagation, pre-release versions, extras, environment markers, and circular dependencies. A dependency conflict involving transitive dependencies (A requires B==1.0, C requires B==2.0) is the most common source of pip install failures in large projects. Tools like poetry and uv have faster, more sophisticated resolvers that give better error messages.

def find_conflicts(requirements):
    """Given a flat list of requirements (possibly from
    multiple packages' deps), find version conflicts.
    
    A conflict exists when the same package appears with
    specifiers that cannot all be satisfied simultaneously.
    
    Example conflict:
        ['requests>=3.0', 'requests<2.0']
    These cannot both be true for any version.
    
    Return a list of conflict dicts:
    - 'package': str
    - 'specifiers': list of all specifiers for that package
    - 'reason': descriptive string
    
    For simplicity, only detect obvious numeric conflicts:
    a >= X and < Y where X >= Y.
    """
    # TODO: implement
    pass
Expected Output
[{'package': 'requests', 'specifiers': ['>=3.0', '<2.0'], 'reason': '...'}]
Hints

Hint 1: Group specifiers by package name. For each package, collect all lower bounds (>=, >) and upper bounds (<=, <).

Hint 2: A conflict exists when the minimum lower bound is >= the maximum upper bound.


Hard

#9Topological Install OrderHard
topological-sortdependency-graphinstall-order

Implement topological sort for package installation ordering. Packages must be installed in dependency order — you cannot install Flask before its dependencies (Werkzeug, Jinja2, Click) are available.

This is the install ordering problem that pip solves before downloading anything.

Python
from collections import deque

def topological_install_order(packages):
    in_degree = {pkg: 0 for pkg in packages}
    for pkg, deps in packages.items():
        for dep in deps:
            if dep in in_degree:
                in_degree[dep]  # ensure it's in there
    # Recompute in-degree properly
    in_degree = {pkg: 0 for pkg in packages}
    for pkg, deps in packages.items():
        for dep in deps:
            if dep in packages:
                in_degree[pkg] += 0  # dep -> pkg means pkg depends on dep
    # Actually: in_degree[pkg] = number of packages that pkg depends on?
    # No: topological sort counts how many deps point INTO this node.
    # Edge: dep -> pkg means "install dep before pkg"
    # in_degree[pkg] = number of its dependencies (things it depends on)
    in_degree = {pkg: len([d for d in deps if d in packages])
                 for pkg, deps in packages.items()}
    reverse = {pkg: [] for pkg in packages}
    for pkg, deps in packages.items():
        for dep in deps:
            if dep in packages:
                reverse[dep].append(pkg)

    queue = deque(pkg for pkg, deg in in_degree.items() if deg == 0)
    result = []
    while queue:
        node = queue.popleft()
        result.append(node)
        for dependent in sorted(reverse[node]):
            in_degree[dependent] -= 1
            if in_degree[dependent] == 0:
                queue.append(dependent)

    if len(result) != len(packages):
        raise ValueError('Circular dependency detected')
    return result

pkgs = {
    'flask': ['werkzeug', 'jinja2', 'click'],
    'werkzeug': [],
    'jinja2': ['markupsafe'],
    'click': [],
    'markupsafe': [],
}
print(topological_install_order(pkgs))
Solution
from collections import deque

def topological_install_order(packages):
in_degree = {
pkg: len([d for d in deps if d in packages])
for pkg, deps in packages.items()
}
reverse = {pkg: [] for pkg in packages}
for pkg, deps in packages.items():
for dep in deps:
if dep in packages:
reverse[dep].append(pkg)

queue = deque(sorted(pkg for pkg, deg in in_degree.items() if deg == 0))
result = []
while queue:
node = queue.popleft()
result.append(node)
for dependent in sorted(reverse[node]):
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
queue.append(dependent)

if len(result) != len(packages):
raise ValueError('Circular dependency detected')
return result

Kahn's algorithm is O(V + E) where V is packages and E is dependency edges. The in-degree of a node is how many dependencies it has — nodes with in-degree 0 can be installed immediately. After installing a node, we "remove" its edges by decrementing the in-degree of everything that depended on it. If the final result does not contain all nodes, there was a cycle (impossible to install). In practice, pip parallelises independent installs (nodes processed at the same BFS level) to speed up installation of large dependency trees.

def topological_install_order(packages):
    """Compute the order packages must be installed
    so that each package's dependencies are installed first.
    
    packages: dict mapping name -> list of dependency names
    
    Return a list of package names in valid install order.
    Raise ValueError if there is a circular dependency.
    
    Use Kahn's algorithm (BFS-based topological sort).
    """
    # TODO: implement
    pass
Expected Output
['markupsafe', 'click', 'werkzeug', 'jinja2', 'flask']
Hints

Hint 1: Kahn's algorithm: compute in-degree for each node. Enqueue all nodes with in-degree 0. Process: dequeue, add to result, decrement neighbors' in-degrees, enqueue any that reach 0.

Hint 2: If the result has fewer packages than the input, there is a cycle — raise ValueError.

#10Requirements Diff GeneratorHard
diffrequirements-trackingversion-comparison

Generate a human-readable diff between two pinned requirements files. This is what CI pipelines use to comment on pull requests — "these 3 packages were upgraded, 1 was added, 2 were removed."

Python
def parse_pinned(req):
    name, _, version = req.strip().partition('==')
    return name.strip().lower(), version.strip()

def ver_tuple(v):
    try:
        return tuple(int(x) for x in v.split('.'))
    except ValueError:
        return (0,)

def requirements_diff(old_reqs, new_reqs):
    old_map = dict(parse_pinned(r) for r in old_reqs if '==' in r)
    new_map = dict(parse_pinned(r) for r in new_reqs if '==' in r)

    added, removed, upgraded, downgraded, unchanged = [], [], [], [], []

    for name in sorted(set(old_map) | set(new_map)):
        if name not in old_map:
            added.append(name + '==' + new_map[name])
        elif name not in new_map:
            removed.append(name + '==' + old_map[name])
        elif old_map[name] == new_map[name]:
            unchanged.append(name + '==' + new_map[name])
        else:
            entry = {'name': name, 'old_version': old_map[name], 'new_version': new_map[name]}
            if ver_tuple(new_map[name]) > ver_tuple(old_map[name]):
                upgraded.append(entry)
            else:
                downgraded.append(entry)

    return {'added': added, 'removed': removed, 'upgraded': upgraded,
            'downgraded': downgraded, 'unchanged': unchanged}

old = ['requests==2.28.0', 'flask==2.0.0', 'redis==4.6.0']
new = ['requests==2.31.0', 'flask==2.0.0', 'boto3==1.34.0']
diff = requirements_diff(old, new)
print("added:", diff['added'])
print("removed:", diff['removed'])
print("upgraded:", diff['upgraded'])
Solution
def parse_pinned(req):
name, _, version = req.strip().partition('==')
return name.strip().lower(), version.strip()

def ver_tuple(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)

def requirements_diff(old_reqs, new_reqs):
old_map = dict(parse_pinned(r) for r in old_reqs if '==' in r)
new_map = dict(parse_pinned(r) for r in new_reqs if '==' in r)
added, removed, upgraded, downgraded, unchanged = [], [], [], [], []
for name in sorted(set(old_map) | set(new_map)):
if name not in old_map:
added.append(name + '==' + new_map[name])
elif name not in new_map:
removed.append(name + '==' + old_map[name])
elif old_map[name] == new_map[name]:
unchanged.append(name + '==' + new_map[name])
else:
entry = {'name': name, 'old_version': old_map[name], 'new_version': new_map[name]}
if ver_tuple(new_map[name]) > ver_tuple(old_map[name]):
upgraded.append(entry)
else:
downgraded.append(entry)
return {'added': added, 'removed': removed, 'upgraded': upgraded,
'downgraded': downgraded, 'unchanged': unchanged}

Requirements diffs are essential for dependency review in PRs. Tools like pip-diff, Dependabot, and Renovate generate these automatically. The structured format allows CI to enforce policies: no major version upgrades without review, no downgrades without justification, automatic security alerts for removed packages with open CVEs. This is also how pip-audit --fix works — it generates the minimal upgrade diff needed to resolve known vulnerabilities.

def requirements_diff(old_reqs, new_reqs):
    """Compare two pinned requirements lists and return
    a structured diff.
    
    Both inputs are lists of 'name==version' strings.
    
    Return a dict:
    - 'added': list of req strings new in new_reqs
    - 'removed': list of req strings only in old_reqs
    - 'upgraded': list of dicts {name, old_version, new_version}
    - 'downgraded': list of dicts {name, old_version, new_version}
    - 'unchanged': list of req strings same in both
    
    All lists sorted alphabetically by package name.
    Parse names case-insensitively.
    """
    # TODO: implement
    pass
Expected Output
added=['boto3==1.34.0'], removed=['redis==4.6.0'], upgraded=[{name: requests, ...}]
Hints

Hint 1: Build dicts from both lists keyed by lowercased name. Then compare: in new but not old = added, in old but not new = removed, in both but different version = upgraded/downgraded.

Hint 2: Compare versions as tuples of ints to determine if it is an upgrade or downgrade.

#11Private Index AuthenticationHard
private-indexauthenticationpip-confignetrc

Build pip configuration for a multi-index setup with authentication. Private PyPI mirrors (Artifactory, AWS CodeArtifact, Google Artifact Registry) require credentials embedded in the index URL or stored in .netrc.

This is the configuration engineering for enterprise Python environments.

Python
from urllib.parse import urlparse, urlunparse

def embed_credentials(url, username, password):
    if not username:
        return url
    parsed = urlparse(url)
    netloc = username + ':' + password + '@' + parsed.hostname
    if parsed.port:
        netloc += ':' + str(parsed.port)
    return urlunparse((parsed.scheme, netloc, parsed.path,
                       parsed.params, parsed.query, parsed.fragment))

def build_pip_index_config(indexes):
    if not indexes:
        return {}

    def process(idx):
        url = idx['url']
        if idx.get('username'):
            url = embed_credentials(url, idx['username'], idx.get('password', ''))
        return url, idx.get('trusted', False), idx['url']

    primary_url, primary_trusted, primary_raw = process(indexes[0])
    extra_urls = []
    trusted_hosts = []

    if primary_trusted:
        trusted_hosts.append(urlparse(primary_raw).hostname)

    for idx in indexes[1:]:
        url, trusted, raw = process(idx)
        extra_urls.append(url)
        if trusted:
            trusted_hosts.append(urlparse(raw).hostname)

    return {
        'index-url': primary_url,
        'extra-index-url': ' '.join(extra_urls) if extra_urls else None,
        'trusted-host': ' '.join(trusted_hosts) if trusted_hosts else None,
    }

indexes = [
    {'url': 'https://private.example.com/simple', 'name': 'private',
     'username': 'user', 'password': 'pass', 'trusted': False},
    {'url': 'https://pypi.org/simple', 'name': 'public',
     'username': None, 'password': None, 'trusted': False},
]
print(build_pip_index_config(indexes))
Solution
from urllib.parse import urlparse, urlunparse

def embed_credentials(url, username, password):
if not username:
return url
parsed = urlparse(url)
netloc = username + ':' + password + '@' + parsed.hostname
if parsed.port:
netloc += ':' + str(parsed.port)
return urlunparse((parsed.scheme, netloc, parsed.path,
parsed.params, parsed.query, parsed.fragment))

def build_pip_index_config(indexes):
if not indexes:
return {}
def process(idx):
url = idx['url']
if idx.get('username'):
url = embed_credentials(url, idx['username'], idx.get('password', ''))
return url, idx.get('trusted', False), idx['url']
primary_url, primary_trusted, primary_raw = process(indexes[0])
extra_urls, trusted_hosts = [], []
if primary_trusted:
trusted_hosts.append(urlparse(primary_raw).hostname)
for idx in indexes[1:]:
url, trusted, raw = process(idx)
extra_urls.append(url)
if trusted:
trusted_hosts.append(urlparse(raw).hostname)
return {
'index-url': primary_url,
'extra-index-url': ' '.join(extra_urls) if extra_urls else None,
'trusted-host': ' '.join(trusted_hosts) if trusted_hosts else None,
}

Credential embedding in URLs is convenient but dangerous — they can leak into logs, process lists, and CI output. The production-grade alternative is storing credentials in ~/.netrc or using keyring (pip checks keyring automatically if installed). AWS CodeArtifact uses short-lived tokens (12-hour TTL) that must be refreshed; a common pattern is a CI step that runs aws codeartifact get-authorization-token and writes the result to pip.conf. Google Artifact Registry uses OAuth tokens via google-auth. Never commit pip.conf with embedded credentials to version control.

def build_pip_index_config(indexes):
    """Generate pip configuration for multiple package indexes.
    
    indexes: list of dicts with keys:
        - 'url': str
        - 'name': str
        - 'username': str or None
        - 'password': str or None
        - 'trusted': bool (trust without SSL verification)
    
    First index is the primary index-url.
    Remaining are extra-index-url entries.
    
    Return a dict representing the [global] section of pip.ini:
    - 'index-url': str
    - 'extra-index-url': str (space-separated) or None
    - 'trusted-host': str (space-separated hostnames) or None
    
    Embed credentials in URLs as https://user:pass@host/...
    """
    # TODO: implement
    pass
Expected Output
{'index-url': 'https://user:[email protected]/simple', 'extra-index-url': 'https://pypi.org/simple', 'trusted-host': None}
Hints

Hint 1: Parse the URL to find where to insert credentials: after the scheme (https://) insert user:pass@.

Hint 2: Trusted hosts should list only the hostname (no scheme, no path). Extract with urllib.parse.urlparse.

© 2026 EngineersOfAI. All rights reserved.