Skip to main content

Python Poetry Practice Problems & Exercises

Practice: Poetry

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

Easy

#1Parse poetry.lock MetadataEasy
poetry.locklockfileparsing

Parse a [[package]] block from poetry.lock. Understanding the lockfile format helps when debugging conflicts, writing migration tools, or auditing dependencies.

Python
def parse_lock_package(package_block):
    result = {'name': None, 'version': None, 'description': None, 'python_versions': None}
    key_map = {
        'name': 'name',
        'version': 'version',
        'description': 'description',
        'python-versions': 'python_versions',
    }
    for line in package_block.splitlines():
        line = line.strip()
        if '=' in line and not line.startswith('['):
            key, _, value = line.partition('=')
            key = key.strip()
            value = value.strip().strip('"')
            if key in key_map:
                result[key_map[key]] = value
    return result

block = '''[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
python-versions = ">=3.7"'''

print(parse_lock_package(block))
Solution
def parse_lock_package(package_block):
result = {'name': None, 'version': None, 'description': None, 'python_versions': None}
key_map = {'name': 'name', 'version': 'version',
'description': 'description', 'python-versions': 'python_versions'}
for line in package_block.splitlines():
line = line.strip()
if '=' in line and not line.startswith('['):
key, _, value = line.partition('=')
key = key.strip()
value = value.strip().strip('"')
if key in key_map:
result[key_map[key]] = value
return result

poetry.lock is a TOML file where each package is an array-of-tables ([[package]]) entry. Unlike requirements.txt, it records the complete dependency graph including transitive dependencies, hashes for every download, and the markers under which each dependency applies. This is why poetry install is deterministic — it never re-resolves; it installs exactly what is in the lockfile. The poetry.lock file should always be committed to version control, but .gitignore-ing it is a common mistake that leads to environment drift between developers.

def parse_lock_package(package_block):
    """Parse a single [[package]] block from poetry.lock.
    
    The block is a string like:
        [[package]]
        name = "requests"
        version = "2.31.0"
        description = "Python HTTP for Humans."
        python-versions = ">=3.7"
        
    Return a dict with keys:
    - 'name': str
    - 'version': str
    - 'description': str or None
    - 'python_versions': str or None
    
    Strip surrounding quotes from values.
    """
    # TODO: implement
    pass
Expected Output
{'name': 'requests', 'version': '2.31.0', 'description': 'Python HTTP for Humans.', 'python_versions': '>=3.7'}
Hints

Hint 1: Split the block into lines. For each line, split on "=" to get key and value.

Hint 2: Strip the value of leading/trailing whitespace and surrounding quotes.

#2Interpret Version Constraint OperatorsEasy
version-constraintscarettildepoetry

Interpret Poetry's version constraint syntax. Poetry uses caret (^) and tilde (~) operators that are more intuitive than pip's specifiers for expressing "allow compatible upgrades."

Python
import re

def interpret_constraint(constraint):
    c = constraint.strip()

    if c == '*':
        return {'constraint': c, 'type': 'any', 'meaning': 'any version'}

    if c.startswith('^'):
        version = c[1:]
        parts = version.split('.')
        next_major = str(int(parts[0]) + 1)
        return {'constraint': c, 'type': 'caret',
                'meaning': 'compatible with ' + version + ', allows ' + parts[0] + '.x.x updates'}

    if c.startswith('~'):
        version = c[1:]
        parts = version.split('.')
        next_minor = str(int(parts[1]) + 1) if len(parts) >= 2 else '?'
        return {'constraint': c, 'type': 'tilde',
                'meaning': 'compatible with ' + version + ', allows ' + parts[0] + '.' + parts[1] + '.x updates'}

    if ',' in c:
        return {'constraint': c, 'type': 'range',
                'meaning': 'version must satisfy all of: ' + c}

    if '*' in c:
        return {'constraint': c, 'type': 'wildcard',
                'meaning': 'wildcard match: ' + c}

    if re.match(r'^[><=!]', c):
        return {'constraint': c, 'type': 'inequality',
                'meaning': 'version must satisfy: ' + c}

    if re.match(r'^\d+[\d.]*$', c):
        return {'constraint': c, 'type': 'exact',
                'meaning': 'exactly version ' + c}

    return {'constraint': c, 'type': 'unknown', 'meaning': 'unknown constraint type'}

for c in ['^1.2.3', '~1.2.3', '1.2.3', '>=1.2,<2.0', '1.*', '>=1.2', '*']:
    result = interpret_constraint(c)
    print(result['type'], '->', result['meaning'])
Solution
import re

def interpret_constraint(constraint):
c = constraint.strip()
if c == '*':
return {'constraint': c, 'type': 'any', 'meaning': 'any version'}
if c.startswith('^'):
v = c[1:]
p = v.split('.')
return {'constraint': c, 'type': 'caret',
'meaning': 'compatible with ' + v + ', allows ' + p[0] + '.x.x updates'}
if c.startswith('~'):
v = c[1:]
p = v.split('.')
return {'constraint': c, 'type': 'tilde',
'meaning': 'compatible with ' + v + ', allows ' + p[0] + '.' + (p[1] if len(p)>1 else '0') + '.x updates'}
if ',' in c:
return {'constraint': c, 'type': 'range', 'meaning': 'satisfies: ' + c}
if '*' in c:
return {'constraint': c, 'type': 'wildcard', 'meaning': 'wildcard: ' + c}
if re.match(r'^[><=!]', c):
return {'constraint': c, 'type': 'inequality', 'meaning': 'satisfies: ' + c}
if re.match(r'^\d+[\d.]*$', c):
return {'constraint': c, 'type': 'exact', 'meaning': 'exactly ' + c}
return {'constraint': c, 'type': 'unknown', 'meaning': 'unknown'}

The caret operator is Poetry's most useful feature. ^1.2.3 means >=1.2.3, <2.0.0 — it allows any update that does not change the leftmost non-zero digit. This aligns perfectly with semantic versioning: a major version bump signals breaking changes, so ^ safely allows minor and patch updates. ~1.2.3 is more conservative: >=1.2.3, <1.3.0 — only patch updates. Most packages should use ^ for libraries (to allow ecosystem-wide upgrades) and pin exact versions (==) only for applications that need maximum reproducibility.

def interpret_constraint(constraint):
    """Interpret a Poetry version constraint string.
    
    Return a dict:
    - 'constraint': the original string
    - 'type': one of 'caret', 'tilde', 'exact', 'range',
               'wildcard', 'inequality', 'any'
    - 'meaning': a human-readable explanation
    
    Constraints to handle:
    '^1.2.3'  -> caret: allows 1.x.x, not 2.x.x
    '~1.2.3'  -> tilde: allows 1.2.x, not 1.3.x
    '1.2.3'   -> exact: exactly 1.2.3
    '>=1.2,<2.0' -> range
    '1.*'     -> wildcard
    '>=1.2'   -> inequality
    '*'       -> any
    """
    # TODO: implement
    pass
Expected Output
{'constraint': '^1.2.3', 'type': 'caret', 'meaning': 'compatible with 1.2.3, allows 1.x.x updates'}
Hints

Hint 1: Check the first character or pattern: "^" for caret, "~" for tilde, "*" for any, "," for range.

Hint 2: A string with only digits and dots is exact; starting with >=, <=, >, < is inequality.

#3Classify Dependency GroupsEasy
dependency-groupsdev-depspoetry

Parse dependency groups from a Poetry pyproject.toml. Poetry's group system is more expressive than pip's layered requirements — each group is a named set that can be installed or excluded independently.

Python
def classify_dependencies(pyproject_dict):
    poetry = pyproject_dict.get('tool', {}).get('poetry', {})

    main = [
        name for name in poetry.get('dependencies', {})
        if name.lower() != 'python'
    ]

    groups = poetry.get('group', {})
    dev = list(groups.get('dev', {}).get('dependencies', {}).keys())
    test = list(groups.get('test', {}).get('dependencies', {}).keys())

    other_groups = {}
    for group_name, group_data in groups.items():
        if group_name not in ('dev', 'test'):
            other_groups[group_name] = list(group_data.get('dependencies', {}).keys())

    return {'main': sorted(main), 'dev': sorted(dev),
            'test': sorted(test), 'other_groups': other_groups}

data = {
    'tool': {
        'poetry': {
            'dependencies': {
                'python': '^3.9',
                'requests': '^2.28',
                'flask': '^2.0',
            },
            'group': {
                'dev': {'dependencies': {'black': '^23.0', 'mypy': '^1.0'}},
                'test': {'dependencies': {'pytest': '^7.0'}},
            }
        }
    }
}
print(classify_dependencies(data))
Solution
def classify_dependencies(pyproject_dict):
poetry = pyproject_dict.get('tool', {}).get('poetry', {})
main = [n for n in poetry.get('dependencies', {}) if n.lower() != 'python']
groups = poetry.get('group', {})
dev = list(groups.get('dev', {}).get('dependencies', {}).keys())
test = list(groups.get('test', {}).get('dependencies', {}).keys())
other = {g: list(d.get('dependencies', {}).keys())
for g, d in groups.items() if g not in ('dev', 'test')}
return {'main': sorted(main), 'dev': sorted(dev), 'test': sorted(test), 'other_groups': other}

Dependency groups were added in Poetry 1.2 as a replacement for dev-dependencies. The old [tool.poetry.dev-dependencies] section is deprecated. Groups allow finer-grained control: poetry install --without=docs,bench skips documentation and benchmark dependencies for a leaner CI install. poetry install --only=test installs only the test group. This is more explicit than pip's -r requirements-dev.txt pattern and the group metadata travels with the project rather than living in separate files.

def classify_dependencies(pyproject_dict):
    """Classify dependencies from a Poetry pyproject.toml dict
    into their groups.
    
    Poetry stores deps in:
    - [tool.poetry.dependencies]: main/runtime deps
    - [tool.poetry.group.dev.dependencies]: dev group
    - [tool.poetry.group.test.dependencies]: test group
    (and any other groups)
    
    Return a dict:
    - 'main': list of package names
    - 'dev': list of package names
    - 'test': list of package names
    - 'other_groups': dict of group_name -> list of names
    """
    # TODO: implement
    pass
Expected Output
{'main': ['requests', 'flask'], 'dev': ['black', 'mypy'], 'test': ['pytest'], 'other_groups': {}}
Hints

Hint 1: Access tool.poetry.dependencies for main deps. tool.poetry.group is a dict of group name -> {dependencies: {...}}.

Hint 2: Filter out "python" from the main deps — it is a Python version constraint, not a real package.

#4Generate poetry add CommandEasy
poetry-clicommand-generationgroups

Generate poetry add commands programmatically. This is useful in scaffolding tools, project initializers, and automation scripts that need to add dependencies to a Poetry project.

Python
def generate_poetry_add_command(package, version=None, group=None, extras=None, editable=False):
    parts = ['poetry', 'add']

    if group:
        parts.extend(['--group', group])

    if editable:
        parts.append('--editable')

    # Build package spec
    pkg_spec = package
    if extras:
        pkg_spec += '[' + ','.join(extras) + ']'
    if version:
        pkg_spec += version

    parts.append(pkg_spec)
    return ' '.join(parts)

print(generate_poetry_add_command('requests', '^2.28'))
print(generate_poetry_add_command('pytest', None, 'dev'))
print(generate_poetry_add_command('uvicorn', None, None, ['standard']))
print(generate_poetry_add_command('black', '^23.0', 'dev'))
Solution
def generate_poetry_add_command(package, version=None, group=None, extras=None, editable=False):
parts = ['poetry', 'add']
if group:
parts.extend(['--group', group])
if editable:
parts.append('--editable')
pkg_spec = package
if extras:
pkg_spec += '[' + ','.join(extras) + ']'
if version:
pkg_spec += version
parts.append(pkg_spec)
return ' '.join(parts)

poetry add does more than just update pyproject.toml — it resolves the full dependency tree to find a consistent set, updates poetry.lock, and installs into the active venv in one step. This is fundamentally different from pip install: pip installs first and records the result, while Poetry resolves the full tree first and then installs. The --group flag was the post-1.2 replacement for the old --dev flag. Always use poetry add --group dev for development tools — this keeps them out of the poetry install --no-dev production install.

def generate_poetry_add_command(package, version=None, group=None, extras=None, editable=False):
    """Generate the correct 'poetry add' command string.
    
    package: str
    version: str constraint like '^2.0' or None
    group: str like 'dev' or 'test' or None (main)
    extras: list of extra names or None
    editable: bool (local path installs only)
    
    Examples:
    ('requests', '^2.28') -> 'poetry add requests^2.28'
    ('pytest', None, 'dev') -> 'poetry add --group dev pytest'
    ('uvicorn', None, None, ['standard']) -> 'poetry add uvicorn[standard]'
    """
    # TODO: implement
    pass
Expected Output
poetry add --group dev pytest
Hints

Hint 1: Build the package spec first: name + optional [extras] + optional version constraint.

Hint 2: Prepend "--group GROUP" after "poetry add" if group is specified.


Medium

#5Lock File Integrity CheckerMedium
poetry.lockcontent-hashintegrity

Implement a lock file freshness checker. Poetry stores a hash of pyproject.toml inside poetry.lock and warns when they diverge — the basis of "poetry.lock is not consistent with pyproject.toml."

Python
import hashlib
import re

def check_lock_freshness(pyproject_content, lock_content):
    actual_hash = hashlib.sha256(pyproject_content.encode('utf-8')).hexdigest()

    stored_hash = None
    in_metadata = False
    for line in lock_content.splitlines():
        line = line.strip()
        if line == '[metadata]':
            in_metadata = True
        elif in_metadata and line.startswith('content-hash'):
            _, _, val = line.partition('=')
            stored_hash = val.strip().strip('"')
            break
        elif in_metadata and line.startswith('[') and line != '[metadata]':
            break

    if stored_hash is None:
        return {
            'fresh': False,
            'pyproject_hash': actual_hash[:16],
            'stored_hash': None,
            'recommendation': 'Run poetry lock to regenerate lock file',
        }

    fresh = actual_hash == stored_hash
    return {
        'fresh': fresh,
        'pyproject_hash': actual_hash[:16],
        'stored_hash': stored_hash[:16],
        'recommendation': 'Lock file is up to date' if fresh else 'Run poetry lock to update',
    }

pyproject = '[tool.poetry]\nname = "mypackage"\nversion = "1.0.0"\n'
fresh_hash = hashlib.sha256(pyproject.encode()).hexdigest()
lock_content = '[metadata]\ncontent-hash = "' + fresh_hash + '"\n'
print(check_lock_freshness(pyproject, lock_content))

stale_lock = '[metadata]\ncontent-hash = "0000000000000000"\n'
print(check_lock_freshness(pyproject, stale_lock))
Solution
import hashlib

def check_lock_freshness(pyproject_content, lock_content):
actual_hash = hashlib.sha256(pyproject_content.encode('utf-8')).hexdigest()
stored_hash = None
in_metadata = False
for line in lock_content.splitlines():
line = line.strip()
if line == '[metadata]':
in_metadata = True
elif in_metadata and line.startswith('content-hash'):
stored_hash = line.partition('=')[2].strip().strip('"')
break
if stored_hash is None:
return {'fresh': False, 'pyproject_hash': actual_hash[:16],
'stored_hash': None, 'recommendation': 'Run poetry lock'}
fresh = actual_hash == stored_hash
return {'fresh': fresh, 'pyproject_hash': actual_hash[:16],
'stored_hash': stored_hash[:16],
'recommendation': 'Up to date' if fresh else 'Run poetry lock to update'}

The content-hash mechanism is how poetry install and CI detect a stale lockfile. When you run poetry install in CI and the hash does not match, Poetry exits with an error (using --no-update mode) rather than silently re-resolving. This is intentional: re-resolving in CI could introduce unexpected upgrades. The correct CI workflow is to always run poetry lock --check as a pre-flight step and fail the pipeline if the lockfile is stale. This forces developers to run poetry lock locally and commit the updated lockfile before CI will pass.

import hashlib

def check_lock_freshness(pyproject_content, lock_content):
    """Check if poetry.lock is fresh (matches pyproject.toml).
    
    poetry.lock contains a [metadata] section with:
    content-hash = "SHA256 of relevant pyproject.toml content"
    
    Compute the SHA256 of the pyproject.toml content and
    compare to the stored hash in poetry.lock.
    
    Return a dict:
    - 'fresh': bool
    - 'pyproject_hash': str (first 16 chars)
    - 'stored_hash': str (first 16 chars)
    - 'recommendation': str
    """
    # TODO: implement
    pass
Expected Output
{'fresh': False, 'pyproject_hash': '...', 'stored_hash': '...', 'recommendation': 'Run poetry lock to update'}
Hints

Hint 1: Compute hashlib.sha256(pyproject_content.encode()).hexdigest() for the actual hash.

Hint 2: Parse the lock content to find the line starting with "content-hash = " in the [metadata] section.

#6Dependency Update PlannerMedium
poetry-updatesemverupdate-strategy

Plan dependency updates according to a strategy. poetry update --with=dev without arguments updates everything within constraints. This problem simulates planning which updates are available given a strategy.

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

def update_type(old, new):
    o, n = ver_tuple(old), ver_tuple(new)
    o = o + (0,) * (3 - len(o))
    n = n + (0,) * (3 - len(n))
    if n[0] != o[0]:
        return 'major'
    if n[1] != o[1]:
        return 'minor'
    return 'patch'

def allowed_by_strategy(old, new, strategy):
    utype = update_type(old, new)
    if strategy == 'patch':
        return utype == 'patch'
    if strategy == 'minor':
        return utype in ('patch', 'minor')
    return True  # major allows all

def plan_updates(current_deps, available_versions, strategy='minor'):
    updates = []
    no_update = []

    for name, current in current_deps.items():
        candidates = [
            v for v in available_versions.get(name, [])
            if ver_tuple(v) > ver_tuple(current)
            and allowed_by_strategy(current, v, strategy)
        ]
        if candidates:
            latest = max(candidates, key=ver_tuple)
            updates.append({
                'name': name,
                'current': current,
                'latest': latest,
                'update_type': update_type(current, latest),
            })
        else:
            no_update.append(name)

    return {'updates': updates, 'no_update': sorted(no_update)}

current = {'requests': '2.28.0', 'click': '8.1.7'}
available = {
    'requests': ['2.28.0', '2.29.0', '2.31.0', '3.0.0'],
    'click': ['8.1.7'],
}
print(plan_updates(current, available, 'minor'))
Solution
def ver_tuple(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)

def update_type(old, new):
o = ver_tuple(old) + (0,) * (3 - len(ver_tuple(old)))
n = ver_tuple(new) + (0,) * (3 - len(ver_tuple(new)))
if n[0] != o[0]:
return 'major'
if n[1] != o[1]:
return 'minor'
return 'patch'

def plan_updates(current_deps, available_versions, strategy='minor'):
updates, no_update = [], []
for name, current in current_deps.items():
ok = lambda old, new: (
(strategy == 'patch' and update_type(old, new) == 'patch') or
(strategy == 'minor' and update_type(old, new) in ('patch', 'minor')) or
strategy == 'major'
)
candidates = [v for v in available_versions.get(name, [])
if ver_tuple(v) > ver_tuple(current) and ok(current, v)]
if candidates:
latest = max(candidates, key=ver_tuple)
updates.append({'name': name, 'current': current, 'latest': latest,
'update_type': update_type(current, latest)})
else:
no_update.append(name)
return {'updates': updates, 'no_update': sorted(no_update)}

poetry update respects the constraints in pyproject.toml — it finds the newest version that still satisfies the caret/tilde constraint. poetry update requests updates only requests. poetry update with no arguments updates everything. The --dry-run flag previews changes without modifying poetry.lock. For production services, a common strategy is to automate patch updates via CI (low risk), manually review minor updates monthly, and treat major updates as planned migration work with dedicated testing.

def plan_updates(current_deps, available_versions, strategy='minor'):
    """Plan which packages can be updated given available versions.
    
    current_deps: dict of name -> current_version (str)
    available_versions: dict of name -> list of available version strs
    strategy: 'patch' (only patch), 'minor' (minor+patch), 'major' (all)
    
    Return a dict:
    - 'updates': list of {name, current, latest, update_type}
    - 'no_update': list of names already at latest
    
    update_type: 'patch', 'minor', or 'major'
    """
    # TODO: implement
    pass
Expected Output
{'updates': [{'name': 'requests', 'current': '2.28.0', 'latest': '2.31.0', 'update_type': 'minor'}], 'no_update': ['click']}
Hints

Hint 1: Convert versions to tuples for comparison. Find the highest version that satisfies the strategy constraint.

Hint 2: patch: only last segment changes. minor: second segment changes (major must match). major: any change.

#7Export to requirements.txtMedium
poetry-exportrequirements-txtcompatibility

Export a poetry.lock to requirements.txt format. This is what poetry export -f requirements.txt does — it bridges Poetry projects with tools that expect a requirements.txt (Docker builds, legacy CI systems, Heroku).

Python
def export_to_requirements(lock_packages, groups=None, include_hashes=False):
    if groups is None:
        groups = ['main']

    lines = []
    for pkg in sorted(lock_packages, key=lambda p: p['name'].lower()):
        if pkg.get('category', 'main') not in groups:
            continue

        line = pkg['name'] + '==' + pkg['version']

        if pkg.get('markers'):
            line += ' ; ' + pkg['markers']

        if include_hashes and pkg.get('hashes'):
            for h in pkg['hashes']:
                line += ' \\\n    --hash=' + h

        lines.append(line)

    return '\n'.join(lines)

packages = [
    {'name': 'requests', 'version': '2.31.0', 'category': 'main',
     'hashes': ['sha256:abc123', 'sha256:def456'], 'markers': None},
    {'name': 'click', 'version': '8.1.7', 'category': 'main',
     'hashes': ['sha256:111aaa'], 'markers': None},
    {'name': 'pytest', 'version': '7.4.0', 'category': 'dev',
     'hashes': [], 'markers': None},
]

print("--- main only ---")
print(export_to_requirements(packages))
print("\n--- with hashes ---")
print(export_to_requirements(packages, include_hashes=True))
Solution
def export_to_requirements(lock_packages, groups=None, include_hashes=False):
if groups is None:
groups = ['main']
lines = []
for pkg in sorted(lock_packages, key=lambda p: p['name'].lower()):
if pkg.get('category', 'main') not in groups:
continue
line = pkg['name'] + '==' + pkg['version']
if pkg.get('markers'):
line += ' ; ' + pkg['markers']
if include_hashes and pkg.get('hashes'):
for h in pkg['hashes']:
line += ' \\\n --hash=' + h
lines.append(line)
return '\n'.join(lines)

poetry export is the standard bridge to non-Poetry tooling. The most common pattern is: poetry export -f requirements.txt --without-hashes > requirements.txt for Docker COPY + RUN pip install, or poetry export --with=dev --format=requirements.txt > requirements-dev.txt for environments that need dev tools. The --without-hashes flag is needed when the requirements file will be used in environments where not all hashes are available (e.g., when adding local paths). Hash-verified exports are the most secure option for production containers.

def export_to_requirements(lock_packages, groups=None, include_hashes=False):
    """Convert poetry.lock package data to requirements.txt format.
    
    lock_packages: list of dicts with:
    - 'name': str
    - 'version': str
    - 'category': str ('main' or group name)
    - 'hashes': list of 'sha256:...' strings
    - 'markers': str or None (environment marker)
    
    groups: list of group names to include (default: ['main'])
    include_hashes: bool
    
    Return a string in requirements.txt format.
    """
    # TODO: implement
    pass
Expected Output
requests==2.31.0
click==8.1.7 --hash=sha256:abc123
Hints

Hint 1: Filter packages by their category matching one of the requested groups.

Hint 2: For each package, format as "name==version". Append markers with "; marker" if present.

#8Poetry CI Configuration ValidatorMedium
CIcachingpoetry-cigithub-actions

Validate a CI job configuration for a Poetry project. This simulates the kind of linting that tools like actionlint and CI template validators perform.

Python
def validate_poetry_ci_config(config):
    errors = []
    warnings = []

    install_cmd = config.get('install_command', '')
    if 'pip install' in install_cmd:
        errors.append('Use "poetry install" instead of "pip install" in Poetry projects')

    if 'poetry install' not in install_cmd:
        errors.append('install_command must include "poetry install"')

    if not config.get('cache_key'):
        warnings.append('No cache configured — CI will be slow; cache poetry venv directory')

    if not config.get('python_version'):
        warnings.append('Python version not pinned — builds may break on Python upgrades')

    if 'poetry install' in install_cmd and '--no-root' not in install_cmd:
        if config.get('role') == 'library-check':
            warnings.append('Consider --no-root for CI jobs that only run linters')

    return {
        'valid': len(errors) == 0,
        'warnings': warnings,
        'errors': errors,
    }

good_config = {
    'python_version': '3.11',
    'cache_key': 'poetry-3.11-${{ hashFiles("poetry.lock") }}',
    'install_command': 'poetry install --no-interaction',
    'run_tests': 'poetry run pytest',
}
print("good:", validate_poetry_ci_config(good_config))

bad_config = {
    'python_version': None,
    'cache_key': None,
    'install_command': 'pip install -r requirements.txt',
    'run_tests': 'pytest',
}
print("bad:", validate_poetry_ci_config(bad_config))
Solution
def validate_poetry_ci_config(config):
errors, warnings = [], []
install_cmd = config.get('install_command', '')
if 'pip install' in install_cmd:
errors.append('Use "poetry install" instead of "pip install"')
if 'poetry install' not in install_cmd:
errors.append('install_command must include "poetry install"')
if not config.get('cache_key'):
warnings.append('No cache configured — CI will be slow')
if not config.get('python_version'):
warnings.append('Python version not pinned')
return {'valid': len(errors) == 0, 'warnings': warnings, 'errors': errors}

The most impactful CI optimisation for Poetry projects is caching the virtualenv. Without caching, every CI run installs all packages from scratch — for a project with 100 dependencies, that is 2-3 minutes of pure install time. The cache key should include both the Python version and the hash of poetry.lock — if the lock file changes, the cache is invalidated and packages are reinstalled. The GitHub Actions actions/cache@v3 action with key poetry-${{ env.PYTHON_VERSION }}-${{ hashFiles('poetry.lock') }} is the standard pattern. After caching, the install step typically takes under 5 seconds for a warm cache.

def validate_poetry_ci_config(config):
    """Validate a simplified CI job configuration for Poetry.
    
    config: dict with keys:
    - 'cache_key': str or None
    - 'install_command': str
    - 'run_tests': str
    - 'python_version': str
    
    Check for common Poetry CI mistakes:
    1. Missing cache for poetry venv
    2. Using 'pip install' instead of 'poetry install'
    3. Not pinning Python version
    4. Missing 'poetry install --no-root' for library-only CI
    
    Return {'valid': bool, 'warnings': list, 'errors': list}
    """
    # TODO: implement
    pass
Expected Output
{'valid': False, 'warnings': [...], 'errors': [...]}
Hints

Hint 1: Check config["install_command"] for "pip install" — that is an error in a Poetry project.

Hint 2: Check config["cache_key"] is not None — missing cache means slow CI.


Hard

#9Transitive Dependency Tree PrinterHard
dependency-treerecursionpoetry-show

Build a recursive dependency tree starting from a root package. This is what poetry show --tree displays — a visual tree showing which packages pull in which others, helping identify why a package is installed.

Python
def build_dependency_tree(packages, root_package, _visited=None):
    if _visited is None:
        _visited = set()

    if root_package not in packages:
        return {'name': root_package, 'version': '?', 'deps': [], 'missing': True}

    if root_package in _visited:
        return {'name': root_package, 'version': packages[root_package]['version'],
                'deps': [], 'circular': True}

    _visited = _visited | {root_package}  # immutable update for each branch
    pkg = packages[root_package]

    return {
        'name': root_package,
        'version': pkg['version'],
        'deps': [
            build_dependency_tree(packages, dep, _visited)
            for dep in sorted(pkg.get('deps', []))
        ]
    }

def print_tree(tree, indent=0):
    prefix = '  ' * indent
    marker = ' [circular]' if tree.get('circular') else ''
    print(prefix + tree['name'] + ' ' + tree.get('version', '?') + marker)
    for dep in tree.get('deps', []):
        print_tree(dep, indent + 1)

pkgs = {
    'flask': {'version': '2.3.3', 'deps': ['werkzeug', 'jinja2', 'click']},
    'werkzeug': {'version': '2.3.7', 'deps': []},
    'jinja2': {'version': '3.1.2', 'deps': ['markupsafe']},
    'click': {'version': '8.1.7', 'deps': []},
    'markupsafe': {'version': '2.1.3', 'deps': []},
}

tree = build_dependency_tree(pkgs, 'flask')
print_tree(tree)
Solution
def build_dependency_tree(packages, root_package, _visited=None):
if _visited is None:
_visited = set()
if root_package not in packages:
return {'name': root_package, 'version': '?', 'deps': [], 'missing': True}
if root_package in _visited:
return {'name': root_package, 'version': packages[root_package]['version'],
'deps': [], 'circular': True}
_visited = _visited | {root_package}
pkg = packages[root_package]
return {
'name': root_package,
'version': pkg['version'],
'deps': [build_dependency_tree(packages, dep, _visited)
for dep in sorted(pkg.get('deps', []))]
}

Using immutable set updates (_visited | {root_package}) rather than mutating ensures that sibling branches of the tree get their own independent visited sets. Without this, the first sibling would mark its transitive deps as visited, and later siblings would incorrectly see them as circular. This is a subtle correctness issue in tree traversal. poetry show --tree is invaluable for understanding why a specific version of a transitive dependency is installed — trace up the tree to find which direct dependency constrains it.

def build_dependency_tree(packages, root_package):
    """Build a nested dependency tree starting from root_package.
    
    packages: dict of name -> {'version': str, 'deps': list of names}
    root_package: str
    
    Return a nested dict:
    {
      'name': 'flask',
      'version': '2.3.3',
      'deps': [
        {'name': 'werkzeug', 'version': '2.3.7', 'deps': [...]},
        ...
      ]
    }
    
    Handle circular dependencies by not recursing into
    already-visited packages (mark with 'circular': True).
    """
    # TODO: implement
    pass
Expected Output
{'name': 'flask', 'version': '2.3.3', 'deps': [...]}
Hints

Hint 1: Use a visited set to track packages already in the current recursion path.

Hint 2: If a dependency is in visited, return {"name": dep, "circular": True} instead of recursing.

#10Multi-Environment Dependency ResolverHard
environmentsmarkersresolutioncross-platform

Resolve which packages apply to a given target environment. This is what Poetry does when building a requirements file for a specific platform — some packages are Windows-only, some require specific Python versions.

Python
import re

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

def check_python_versions(constraint, python_version):
    if not constraint or constraint == '*':
        return True
    for spec in constraint.split(','):
        spec = spec.strip()
        for op in ('>=', '<=', '==', '!=', '>', '<'):
            if spec.startswith(op):
                req_v = spec[len(op):]
                v1 = ver_tuple(python_version)
                v2 = ver_tuple(req_v)
                n = max(len(v1), len(v2))
                v1 += (0,) * (n - len(v1))
                v2 += (0,) * (n - len(v2))
                ops = {'>=': v1>=v2, '<=': v1<=v2, '==': v1==v2,
                       '!=': v1!=v2, '>': v1>v2, '<': v1<v2}
                if not ops[op]:
                    return False
    return True

def check_marker(marker, environment):
    if not marker:
        return True
    platform_match = re.search(
        r'sys_platform\s*(==|!=)\s*["\']([^"\']+)["\']', marker
    )
    if platform_match:
        op, plat = platform_match.group(1), platform_match.group(2)
        if op == '==' and environment['sys_platform'] != plat:
            return False
        if op == '!=' and environment['sys_platform'] == plat:
            return False
    return True

def resolve_for_environment(packages, environment):
    result = []
    for pkg in packages:
        if not check_python_versions(pkg.get('python_versions'), environment['python_version']):
            continue
        if not check_marker(pkg.get('markers'), environment):
            continue
        result.append((pkg['name'], pkg['version']))
    return sorted(result)

packages = [
    {'name': 'requests', 'version': '2.31.0', 'markers': None, 'python_versions': '>=3.7'},
    {'name': 'uvloop', 'version': '0.19.0', 'markers': 'sys_platform != "win32"', 'python_versions': '>=3.8'},
    {'name': 'pywin32', 'version': '306', 'markers': 'sys_platform == "win32"', 'python_versions': None},
    {'name': 'click', 'version': '8.1.7', 'markers': None, 'python_versions': '>=3.7'},
]

linux_env = {'sys_platform': 'linux', 'python_version': '3.11'}
win_env = {'sys_platform': 'win32', 'python_version': '3.11'}

print("Linux:", resolve_for_environment(packages, linux_env))
print("Windows:", resolve_for_environment(packages, win_env))
Solution
import re

def resolve_for_environment(packages, environment):
def ver_t(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)

def check_python(constraint, pyver):
if not constraint or constraint == '*':
return True
for spec in constraint.split(','):
spec = spec.strip()
for op in ('>=','<=','==','!=','>','<'):
if spec.startswith(op):
v1, v2 = ver_t(pyver), ver_t(spec[len(op):])
n = max(len(v1), len(v2))
v1 += (0,)*(n-len(v1)); v2 += (0,)*(n-len(v2))
ops = {'>=':v1>=v2,'<=':v1<=v2,'==':v1==v2,'!=':v1!=v2,'>':v1>v2,'<':v1<v2}
if not ops[op]:
return False
return True

def check_marker(marker, env):
if not marker:
return True
m = re.search(r'sys_platform\s*(==|!=)\s*["\']([^"\']+)["\']', marker)
if m:
op, plat = m.group(1), m.group(2)
if op == '==' and env['sys_platform'] != plat:
return False
if op == '!=' and env['sys_platform'] == plat:
return False
return True

return sorted(
(p['name'], p['version']) for p in packages
if check_python(p.get('python_versions'), environment['python_version'])
and check_marker(p.get('markers'), environment)
)

Cross-platform packaging is one of the hardest parts of Python distribution. A package that works on Linux may not even install on Windows (missing C compiler for extensions). Poetry's solver considers environment markers when building the lockfile — it records which packages apply to which platforms rather than resolving separately. When you run poetry export targeting a specific platform, it applies this filtering. This is how you can develop on macOS and build a Docker container (Linux) from the same poetry.lock with the correct platform-specific packages in each.

def resolve_for_environment(packages, environment):
    """Filter packages for a specific environment.
    
    packages: list of dicts with:
    - 'name': str
    - 'version': str
    - 'markers': str or None, e.g. 'sys_platform == "win32"'
    - 'python_versions': str or None, e.g. '>=3.9'
    
    environment: dict with:
    - 'sys_platform': str ('linux', 'darwin', 'win32')
    - 'python_version': str (e.g. '3.11')
    
    Return list of (name, version) tuples for packages
    that apply to this environment.
    """
    # TODO: implement
    pass
Expected Output
[('click', '8.1.7'), ('requests', '2.31.0'), ('uvloop', '0.19.0')]
Hints

Hint 1: For python_versions, check if the environment python_version satisfies the constraint.

Hint 2: For markers with sys_platform, check if the platform matches. No marker means always include.

#11Poetry Plugin System SimulatorHard
pluginsentry-pointspoetry-plugins

Implement a simplified version of Poetry's plugin system. Real Poetry plugins hook into install, build, and publish events — this teaches you the plugin architecture pattern used by many Python frameworks.

Python
import inspect

class PoetryPluginSystem:
    def __init__(self):
        self._plugins = {}
        self._instances = {}

    def register(self, name, plugin_class):
        valid, errors = self.validate(plugin_class)
        if not valid:
            raise ValueError('Invalid plugin ' + name + ': ' + ', '.join(errors))
        self._plugins[name] = plugin_class
        self._instances[name] = plugin_class()
        return self

    def validate(self, plugin_class):
        errors = []
        if not hasattr(plugin_class, 'activate'):
            errors.append('missing activate() method')
        else:
            sig = inspect.signature(plugin_class.activate)
            params = list(sig.parameters.keys())
            if 'poetry' not in params or 'io' not in params:
                errors.append('activate() must have "poetry" and "io" parameters')
        return (len(errors) == 0, errors)

    def dispatch_event(self, event_name, **kwargs):
        method_name = 'on_' + event_name
        results = []
        for name, instance in self._instances.items():
            if hasattr(instance, method_name):
                method = getattr(instance, method_name)
                result = method(**kwargs)
                results.append((name, result))
        return results


class MyPlugin:
    def activate(self, poetry, io):
        io.write('MyPlugin activated')

    def on_install(self, package=None):
        return 'installing ' + str(package)

    def on_build(self, format=None):
        return 'building ' + str(format)


system = PoetryPluginSystem()
valid, errors = system.validate(MyPlugin)
print("validated:", valid)

system.register('my-plugin', MyPlugin)
results = system.dispatch_event('install', package='requests')
print("events dispatched to:", [r[0] for r in results])
print("result:", results[0][1])
Solution
import inspect

class PoetryPluginSystem:
def __init__(self):
self._plugins = {}
self._instances = {}

def register(self, name, plugin_class):
valid, errors = self.validate(plugin_class)
if not valid:
raise ValueError('Invalid plugin: ' + ', '.join(errors))
self._plugins[name] = plugin_class
self._instances[name] = plugin_class()
return self

def validate(self, plugin_class):
errors = []
if not hasattr(plugin_class, 'activate'):
errors.append('missing activate() method')
else:
sig = inspect.signature(plugin_class.activate)
params = list(sig.parameters.keys())
if 'poetry' not in params or 'io' not in params:
errors.append('activate() needs "poetry" and "io" params')
return (len(errors) == 0, errors)

def dispatch_event(self, event_name, **kwargs):
method_name = 'on_' + event_name
return [
(name, getattr(inst, method_name)(**kwargs))
for name, inst in self._instances.items()
if hasattr(inst, method_name)
]

Poetry's plugin system uses Python entry points for discovery. A plugin package declares itself in [tool.poetry.plugins."poetry.plugin"] — Poetry reads these at startup and calls activate(poetry, io) on each. This is how plugins like poetry-dynamic-versioning (injects version from git) and poetry-multiproject-plugin (monorepo support) work without modifying Poetry itself. The entry point pattern is the same mechanism used by pytest plugins (pytest11), Flask extensions, and SQLAlchemy dialects — it is Python's native plugin discovery mechanism that requires zero configuration from the host application.

class PoetryPluginSystem:
    """Simulate Poetry's plugin system.
    
    Plugins register via entry points under:
    'poetry.plugin' group in pyproject.toml.
    
    This simulator:
    1. Accepts plugin registrations
    2. Validates plugin interface compliance
    3. Dispatches events to registered plugins
    
    Plugin interface: a class with an 'activate(poetry, io)' method.
    
    Implement: register(), validate(), dispatch_event().
    """
    
    def __init__(self):
        # TODO: initialize
        pass
    
    def register(self, name, plugin_class):
        # TODO: register a plugin class by name
        pass
    
    def validate(self, plugin_class):
        # TODO: check plugin has activate(poetry, io) method
        # Return (valid: bool, errors: list)
        pass
    
    def dispatch_event(self, event_name, **kwargs):
        # TODO: call on_EVENT on all registered plugins that have it
        # Return list of (plugin_name, result) tuples
        pass
Expected Output
validated: True
events dispatched to: ['my-plugin']
Hints

Hint 1: Use inspect.signature or hasattr to check if a class has an "activate" method with the right parameters.

Hint 2: For dispatch_event, look for method "on_" + event_name on each registered plugin instance.

© 2026 EngineersOfAI. All rights reserved.