Python Poetry Practice Problems & Exercises
Practice: Poetry
← Back to lessonEasy
Parse a [[package]] block from poetry.lock. Understanding the lockfile format helps when debugging conflicts, writing migration tools, or auditing dependencies.
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
passExpected 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.
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."
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
passExpected 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.
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.
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
passExpected 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.
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.
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
passExpected Output
poetry add --group dev pytestHints
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
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."
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
passExpected 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.
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.
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
passExpected 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.
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).
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
passExpected Output
requests==2.31.0
click==8.1.7 --hash=sha256:abc123Hints
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.
Validate a CI job configuration for a Poetry project. This simulates the kind of linting that tools like actionlint and CI template validators perform.
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
passExpected 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
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.
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
passExpected 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.
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.
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
passExpected 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.
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.
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
passExpected 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.
