Python Semantic Versioning Practice Problems & Exercises
Practice: Semantic Versioning
← Back to lessonEasy
Parse a semantic version string into its components. SemVer 2.0.0 specifies a precise format: MAJOR.MINOR.PATCH[-pre-release][+build-metadata].
import re
def parse_semver(version_str):
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?(?:\+([\w.]+))?$'
match = re.match(pattern, version_str.strip())
if not match:
return {'major': 0, 'minor': 0, 'patch': 0,
'pre_release': None, 'build_metadata': None, 'valid': False}
return {
'major': int(match.group(1)),
'minor': int(match.group(2)),
'patch': int(match.group(3)),
'pre_release': match.group(4),
'build_metadata': match.group(5),
'valid': True,
}
tests = ['1.2.3', '1.2.3-alpha.1', '1.2.3+build.001', '1.2.3-beta.2+sha.1a2b', 'not-semver']
for v in tests:
r = parse_semver(v)
print(v, '->', r['major'], r['minor'], r['patch'], r['pre_release'], r['valid'])Solution
import re
def parse_semver(version_str):
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?(?:\+([\w.]+))?$'
match = re.match(pattern, version_str.strip())
if not match:
return {'major': 0, 'minor': 0, 'patch': 0,
'pre_release': None, 'build_metadata': None, 'valid': False}
return {
'major': int(match.group(1)), 'minor': int(match.group(2)),
'patch': int(match.group(3)), 'pre_release': match.group(4),
'build_metadata': match.group(5), 'valid': True,
}
Build metadata must be ignored when determining version precedence. 1.2.3+build.001 and 1.2.3+build.002 are the same version for comparison purposes — the build metadata is just informational. Pre-release versions have lower precedence than the release: 1.2.3-alpha.1 less than 1.2.3. This is the most commonly misunderstood part of SemVer — developers often assume pre-release versions are higher because they were built "after" the release. The packaging.version.Version class handles all these edge cases correctly for Python packages.
import re
def parse_semver(version_str):
"""Parse a semantic version string.
Handles:
- '1.2.3' -> basic version
- '1.2.3-alpha.1' -> pre-release
- '1.2.3+build.001' -> build metadata
- '1.2.3-beta.2+sha.1a2b' -> both
Return a dict:
- 'major': int
- 'minor': int
- 'patch': int
- 'pre_release': str or None
- 'build_metadata': str or None
- 'valid': bool
"""
# TODO: implement
passExpected Output
{'major': 1, 'minor': 2, 'patch': 3, 'pre_release': 'alpha.1', 'build_metadata': None, 'valid': True}Hints
Hint 1: Use a regex: r"^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?(?:\+([\w.]+))?$"
Hint 2: Group 1=major, 2=minor, 3=patch, 4=pre_release, 5=build_metadata.
Classify the type of version bump between two versions. This is the first step in automated changelog generation and release validation.
import re
def parse_ver(v):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v.strip())
if m:
return int(m.group(1)), int(m.group(2)), int(m.group(3))
return 0, 0, 0
def classify_bump(old_version, new_version):
old = parse_ver(old_version)
new = parse_ver(new_version)
if old == new:
return 'same'
if new < old:
return 'downgrade'
if new[0] > old[0]:
return 'major'
if new[1] > old[1]:
return 'minor'
return 'patch'
pairs = [
('1.0.0', '2.0.0'),
('1.0.0', '1.1.0'),
('1.0.0', '1.0.1'),
('1.0.0', '1.0.0'),
('2.0.0', '1.9.9'),
]
for old, new in pairs:
print(classify_bump(old, new))Solution
import re
def parse_ver(v):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v.strip())
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
def classify_bump(old_version, new_version):
old, new = parse_ver(old_version), parse_ver(new_version)
if old == new:
return 'same'
if new < old:
return 'downgrade'
if new[0] > old[0]:
return 'major'
if new[1] > old[1]:
return 'minor'
return 'patch'
The version bump type communicates the risk of upgrading. Patch bumps are safe — they fix bugs without changing the API. Minor bumps add new functionality but do not break existing code. Major bumps are where you check the migration guide. This classification is what automated tools like semantic-release and Dependabot use to decide how urgently to merge a dependency update. A major bump PR gets reviewed by a human; a patch bump PR can often be auto-merged after tests pass.
def classify_bump(old_version, new_version):
"""Classify the type of version bump from old to new.
Return one of: 'major', 'minor', 'patch', 'pre-release',
'downgrade', or 'same'.
Rules:
- major: MAJOR increased
- minor: MAJOR same, MINOR increased
- patch: MAJOR and MINOR same, PATCH increased
- pre-release: same numeric parts but pre-release changed
- downgrade: new version is lower
- same: identical
"""
# TODO: implement
passExpected Output
major
minor
patch
same
downgradeHints
Hint 1: Parse both versions into (major, minor, patch) tuples. Compare the tuples to determine direction.
Hint 2: A version with a pre-release suffix is lower than the same version without it.
Compute the next version string after a bump. This is the core operation in release automation tools like bumpversion, commitizen, and semantic-release.
import re
def next_version(current, bump_type, pre_release=None):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', current.strip())
if not m:
raise ValueError('Invalid version: ' + current)
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
if bump_type == 'major':
major += 1
minor = 0
patch = 0
elif bump_type == 'minor':
minor += 1
patch = 0
elif bump_type == 'patch':
patch += 1
else:
raise ValueError('Unknown bump_type: ' + bump_type)
result = str(major) + '.' + str(minor) + '.' + str(patch)
if pre_release:
result += '-' + pre_release
return result
print(next_version('1.2.3', 'major'))
print(next_version('1.2.3', 'minor'))
print(next_version('1.2.3', 'patch'))
print(next_version('1.2.3', 'major', 'rc.1'))Solution
import re
def next_version(current, bump_type, pre_release=None):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', current.strip())
if not m:
raise ValueError('Invalid version: ' + current)
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
if bump_type == 'major':
major, minor, patch = major + 1, 0, 0
elif bump_type == 'minor':
minor, patch = minor + 1, 0
elif bump_type == 'patch':
patch += 1
else:
raise ValueError('Unknown bump_type: ' + bump_type)
result = str(major) + '.' + str(minor) + '.' + str(patch)
return result + '-' + pre_release if pre_release else result
Resetting downstream components is the most misunderstood SemVer rule. When you bump MINOR, PATCH must reset to 0 — 1.2.3 becomes 1.3.0, not 1.3.3. This makes sense semantically: the patch counter tracks fixes since the last feature release, so a new feature release starts a fresh patch cycle. Tools like commitizen automate this: they read commit messages following Conventional Commits format (feat:, fix:, BREAKING CHANGE:) and determine the appropriate bump type automatically, then compute and tag the next version.
def next_version(current, bump_type, pre_release=None):
"""Compute the next version given a bump type.
current: str like '1.2.3'
bump_type: 'major', 'minor', or 'patch'
pre_release: str or None (e.g. 'alpha.1')
Rules:
- major: increment MAJOR, reset MINOR=0, PATCH=0
- minor: MAJOR unchanged, increment MINOR, reset PATCH=0
- patch: MAJOR and MINOR unchanged, increment PATCH
If pre_release is given, append '-pre_release' to result.
Return the new version string.
"""
# TODO: implement
passExpected Output
2.0.0
1.3.0
1.2.4
2.0.0-rc.1Hints
Hint 1: Parse the current version into major, minor, patch integers.
Hint 2: Reset downstream components to 0 when bumping a higher-order component.
Sort version strings correctly. Lexicographic sorting fails for versions: "1.10.0" < "1.9.0" lexicographically because "1" < "9". Version sorting requires numeric comparison of each component.
import re
def sort_versions(versions):
pre_order = {'a': 1, 'alpha': 1, 'b': 2, 'beta': 2, 'rc': 3, 'c': 3}
def sort_key(v):
# Try PEP 440 style: 1.0.0a1, 1.0.0b2, 1.0.0rc1
m = re.match(r'^(\d+)\.(\d+)\.(\d+)(a|b|rc|alpha|beta)?(\d+)?', v.strip())
if not m:
return (0, 0, 0, 4, 0)
major = int(m.group(1))
minor = int(m.group(2))
patch = int(m.group(3))
pre = m.group(4)
pre_num = int(m.group(5)) if m.group(5) else 0
pre_weight = pre_order.get(pre, 4) if pre else 4
return (major, minor, patch, pre_weight, pre_num)
return sorted(versions, key=sort_key)
versions = ['1.0.0', '2.0.0', '1.10.0', '1.9.0', '1.0.0a1', '1.0.0b2', '1.0.0rc1', '0.9.0', '1.0.1']
print(sort_versions(versions))Solution
import re
def sort_versions(versions):
pre_order = {'a': 1, 'alpha': 1, 'b': 2, 'beta': 2, 'rc': 3, 'c': 3}
def key(v):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)(a|b|rc|alpha|beta)?(\d+)?', v.strip())
if not m:
return (0, 0, 0, 4, 0)
pre = m.group(4)
return (int(m.group(1)), int(m.group(2)), int(m.group(3)),
pre_order.get(pre, 4) if pre else 4,
int(m.group(5)) if m.group(5) else 0)
return sorted(versions, key=key)
Numeric component comparison is the key insight. Lexicographic sort treats strings character-by-character: "10" < "9" because "1" < "9". Version sort treats each component as an integer: 10 > 9. The production solution is from packaging.version import Version; sorted(versions, key=Version) — it handles every PEP 440 edge case including epochs (1!1.0.0 is higher than any 1.x.x), post-releases, dev releases, and local version identifiers. The sort key function approach shown here is a useful mental model for interviews.
def sort_versions(versions):
"""Sort a list of version strings in ascending order.
Must handle:
- Standard versions: '1.0.0', '2.0.0', '1.10.0'
- Pre-release: '1.0.0-alpha.1' < '1.0.0-beta.1' < '1.0.0'
- Python PEP 440: '1.0.0a1', '1.0.0b1', '1.0.0rc1', '1.0.0'
Use a sort key that correctly orders:
alpha < beta < rc < (no pre-release)
'1.10.0' > '1.9.0' (numeric, not lexicographic)
Return sorted list (ascending, oldest first).
"""
# TODO: implement
passExpected Output
['0.9.0', '1.0.0a1', '1.0.0b2', '1.0.0rc1', '1.0.0', '1.0.1', '1.10.0', '2.0.0']Hints
Hint 1: Use packaging.version.Version as the sort key — it handles all PEP 440 ordering correctly.
Hint 2: If packaging is not available, assign numeric weights: alpha=1, beta=2, rc=3, release=4.
Medium
Classify code changes according to SemVer rules. This is the logic that powers tools like commitizen — reading structured change descriptions and determining the appropriate version bump type.
def classify_change(change_description):
change_type = change_description.get('type')
scope = change_description.get('scope')
backward_compat = change_description.get('backward_compatible', True)
if not backward_compat:
return 'major'
if change_type in ('add',) and scope == 'public_api':
return 'minor'
if change_type == 'deprecate':
return 'minor'
if change_type in ('fix',) or scope == 'performance':
return 'patch'
if change_type == 'modify' and scope == 'internal':
return 'patch'
if change_type == 'modify' and scope == 'public_api':
return 'minor'
return 'patch'
changes = [
{'type': 'remove', 'scope': 'public_api', 'backward_compatible': False},
{'type': 'add', 'scope': 'public_api', 'backward_compatible': True},
{'type': 'fix', 'scope': 'behavior', 'backward_compatible': True},
]
for c in changes:
print(classify_change(c))Solution
def classify_change(change_description):
change_type = change_description.get('type')
scope = change_description.get('scope')
backward_compat = change_description.get('backward_compatible', True)
if not backward_compat:
return 'major'
if change_type in ('add', 'deprecate') and scope == 'public_api':
return 'minor'
if change_type in ('add',):
return 'minor'
return 'patch'
Deprecation is a MINOR change, not a MAJOR one. Deprecation warns users that a feature will be removed in a future major version, but it does not remove it yet — existing code still works. The removal of the deprecated feature is the MAJOR change. This two-phase process (deprecate in minor, remove in major) is the responsible way to evolve a public API. Libraries like requests and SQLAlchemy follow this strictly — they maintain deprecation warnings for at least one major version cycle before removal, giving users 6-12 months to migrate.
def classify_change(change_description):
"""Classify a code change as major, minor, or patch
according to SemVer rules.
change_description: dict with keys:
- 'type': 'add', 'remove', 'modify', 'fix', 'deprecate'
- 'scope': 'public_api', 'internal', 'behavior', 'performance'
- 'backward_compatible': bool
SemVer rules:
- PATCH: bug fix, backward compatible, no API change
- MINOR: new functionality, backward compatible
- MAJOR: breaking change (backward_compatible=False)
Return: 'major', 'minor', or 'patch'
"""
# TODO: implement
passExpected Output
major
minor
patchHints
Hint 1: If backward_compatible is False, it is always major regardless of other fields.
Hint 2: Adding to the public API (type="add", scope="public_api") is a minor change if backward-compatible.
Generate a CHANGELOG.md entry from a list of conventional commits. This is what semantic-release, commitizen, and git-cliff do automatically when you tag a release.
from datetime import date
def generate_changelog(commits, version):
today = date.today().isoformat()
breaking = [c for c in commits if c.get('breaking')]
features = [c for c in commits if c['type'] == 'feat' and not c.get('breaking')]
fixes = [c for c in commits if c['type'] == 'fix' and not c.get('breaking')]
other = [c for c in commits if c['type'] not in ('feat', 'fix') and not c.get('breaking')]
def fmt(c):
scope = c.get('scope')
if scope:
return '- **' + scope + '**: ' + c['description']
return '- ' + c['description']
lines = ['## [' + version + '] - ' + today]
if breaking:
lines.append('\n### BREAKING CHANGES')
lines.extend(fmt(c) for c in breaking)
if features:
lines.append('\n### Features')
lines.extend(fmt(c) for c in features)
if fixes:
lines.append('\n### Bug Fixes')
lines.extend(fmt(c) for c in fixes)
if other:
lines.append('\n### Other')
lines.extend(fmt(c) for c in other)
return '\n'.join(lines)
commits = [
{'type': 'feat', 'scope': 'auth', 'description': 'add OAuth2 support', 'breaking': False},
{'type': 'fix', 'scope': None, 'description': 'fix null pointer in parser', 'breaking': False},
{'type': 'feat', 'scope': 'api', 'description': 'remove deprecated endpoints', 'breaking': True},
{'type': 'docs', 'scope': None, 'description': 'update README', 'breaking': False},
]
print(generate_changelog(commits, '2.0.0'))Solution
from datetime import date
def generate_changelog(commits, version):
today = date.today().isoformat()
breaking = [c for c in commits if c.get('breaking')]
features = [c for c in commits if c['type'] == 'feat' and not c.get('breaking')]
fixes = [c for c in commits if c['type'] == 'fix' and not c.get('breaking')]
other = [c for c in commits if c['type'] not in ('feat', 'fix') and not c.get('breaking')]
def fmt(c):
scope = c.get('scope')
return ('- **' + scope + '**: ' if scope else '- ') + c['description']
lines = ['## [' + version + '] - ' + today]
for title, items in [('BREAKING CHANGES', breaking), ('Features', features),
('Bug Fixes', fixes), ('Other', other)]:
if items:
lines.append('\n### ' + title)
lines.extend(fmt(c) for c in items)
return '\n'.join(lines)
Conventional Commits is a specification for commit message structure that makes automated changelog generation possible. The format type(scope): description with types like feat, fix, docs, refactor, test, chore maps directly to SemVer: feat triggers MINOR, fix triggers PATCH, and BREAKING CHANGE in the footer triggers MAJOR. Projects like Angular, Vue, and many Google/Microsoft OSS projects mandate conventional commits. The tooling payoff is large: automated versioning, changelog generation, and PyPI releases from a single git push.
def generate_changelog(commits, version):
"""Generate a changelog from conventional commit messages.
commits: list of dicts with:
- 'type': 'feat', 'fix', 'docs', 'refactor', 'test', 'chore'
- 'scope': str or None
- 'description': str
- 'breaking': bool
version: str (the new version being released)
Return a formatted changelog string with sections:
## [version] - YYYY-MM-DD
### BREAKING CHANGES
### Features
### Bug Fixes
### Other
Only include sections that have entries.
Use today's date.
"""
# TODO: implement
passExpected Output
## [2.0.0] - 2026-03-21
### BREAKING CHANGES
...Hints
Hint 1: Group commits by their type. Breaking changes go first regardless of type.
Hint 2: Format each commit as "- [scope]: description" or "- description" if no scope.
Find the intersection of multiple version ranges. This is the core of dependency conflict detection — when two packages both require different versions of a shared dependency, can a single version satisfy both?
def ver_tuple(v):
if v is None:
return None
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)
def pad(a, b):
n = max(len(a), len(b))
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
def intersect_ranges(ranges):
result_min = None
result_min_inclusive = True
result_max = None
result_max_inclusive = False
for r in ranges:
rmin = ver_tuple(r.get('min'))
rmax = ver_tuple(r.get('max'))
rmin_inc = r.get('min_inclusive', True)
rmax_inc = r.get('max_inclusive', False)
if rmin is not None:
curr_min = ver_tuple(result_min)
if curr_min is None or rmin > pad(rmin, curr_min)[0]:
result_min = r['min']
result_min_inclusive = rmin_inc
elif rmin == pad(rmin, curr_min)[0] and not rmin_inc:
result_min_inclusive = False
if rmax is not None:
curr_max = ver_tuple(result_max)
if curr_max is None or rmax < pad(rmax, curr_max)[0]:
result_max = r['max']
result_max_inclusive = rmax_inc
elif rmax == pad(rmax, curr_max)[0] and not rmax_inc:
result_max_inclusive = False
empty = False
if result_min and result_max:
a, b = pad(ver_tuple(result_min), ver_tuple(result_max))
if a > b:
empty = True
elif a == b and (not result_min_inclusive or not result_max_inclusive):
empty = True
return {
'min': result_min,
'max': result_max,
'min_inclusive': result_min_inclusive,
'max_inclusive': result_max_inclusive,
'empty': empty,
}
ranges = [
{'min': '1.0.0', 'max': '3.0.0', 'min_inclusive': True, 'max_inclusive': False},
{'min': '2.0.0', 'max': '2.5.0', 'min_inclusive': True, 'max_inclusive': False},
]
print(intersect_ranges(ranges))
conflict_ranges = [
{'min': '3.0.0', 'max': None, 'min_inclusive': True, 'max_inclusive': False},
{'min': None, 'max': '2.0.0', 'min_inclusive': True, 'max_inclusive': False},
]
print(intersect_ranges(conflict_ranges))Solution
def ver_tuple(v):
if v is None:
return None
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)
def intersect_ranges(ranges):
rmin, rmin_inc, rmax, rmax_inc = None, True, None, False
for r in ranges:
lo, lo_inc = ver_tuple(r.get('min')), r.get('min_inclusive', True)
hi, hi_inc = ver_tuple(r.get('max')), r.get('max_inclusive', False)
curr_lo = ver_tuple(rmin)
if lo is not None and (curr_lo is None or lo > curr_lo):
rmin, rmin_inc = r['min'], lo_inc
curr_hi = ver_tuple(rmax)
if hi is not None and (curr_hi is None or hi < curr_hi):
rmax, rmax_inc = r['max'], hi_inc
empty = False
if rmin and rmax:
lo_t = ver_tuple(rmin) + (0,) * 3
hi_t = ver_tuple(rmax) + (0,) * 3
lo_t, hi_t = lo_t[:3], hi_t[:3]
if lo_t > hi_t or (lo_t == hi_t and (not rmin_inc or not rmax_inc)):
empty = True
return {'min': rmin, 'max': rmax, 'min_inclusive': rmin_inc,
'max_inclusive': rmax_inc, 'empty': empty}
Range intersection is why dependency conflicts are hard. Package A requires numpy>=1.24,<2.0. Package B requires numpy>=1.20,<1.23. Intersection: min=1.24, max=1.23 — empty! No single numpy version can satisfy both. The resolver must either fail with a conflict error or backtrack and try older versions of A or B. This is NP-hard in the general case because the search space grows exponentially with the number of packages. The uv resolver (written in Rust) solves this dramatically faster than pip by using PubGrub, a backtracking algorithm with better conflict messaging.
def intersect_ranges(ranges):
"""Find the intersection of multiple version ranges.
Each range is a dict:
- 'min': str or None (inclusive lower bound)
- 'max': str or None (exclusive upper bound)
- 'min_inclusive': bool
- 'max_inclusive': bool
Return a dict representing the intersection:
- 'min': str or None
- 'max': str or None
- 'min_inclusive': bool
- 'max_inclusive': bool
- 'empty': bool (True if no version can satisfy all ranges)
Compare versions as tuples of ints.
"""
# TODO: implement
passExpected Output
{'min': '2.0.0', 'max': '2.5.0', 'min_inclusive': True, 'max_inclusive': False, 'empty': False}Hints
Hint 1: The intersection minimum is the maximum of all lower bounds. The intersection maximum is the minimum of all upper bounds.
Hint 2: The range is empty if the intersection minimum >= intersection maximum (accounting for inclusivity).
Bridge between CalVer (Calendar Versioning) and SemVer. Tools like Ubuntu (22.04), pip (23.3.1), and Black (23.12.1) use CalVer. Understanding the relationship between the two systems helps when writing tools that need to handle both.
import re
def calver_to_semver_approximation(calver_str, patch=0):
parts = [int(p) for p in calver_str.strip().split('.')]
is_calver = len(parts) >= 2 and parts[0] >= 2000
if not is_calver:
return calver_str, {'year': None, 'month': None, 'day': None, 'is_calver': False}
year = parts[0]
month = parts[1] if len(parts) >= 2 else 0
day = parts[2] if len(parts) >= 3 else patch
semver = str(year) + '.' + str(month) + '.' + str(day)
return semver, {
'year': year, 'month': month, 'day': day, 'is_calver': True
}
tests = ['2024.3.15', '2024.03', '23.12.1', '1.2.3']
for v in tests:
semver, meta = calver_to_semver_approximation(v)
print(v, '-> SemVer:', semver, 'is_calver:', meta['is_calver'])Solution
def calver_to_semver_approximation(calver_str, patch=0):
parts = [int(p) for p in calver_str.strip().split('.')]
is_calver = len(parts) >= 2 and parts[0] >= 2000
if not is_calver:
return calver_str, {'year': None, 'month': None, 'day': None, 'is_calver': False}
year = parts[0]
month = parts[1] if len(parts) >= 2 else 0
day = parts[2] if len(parts) >= 3 else patch
return (str(year) + '.' + str(month) + '.' + str(day),
{'year': year, 'month': month, 'day': day, 'is_calver': True})
CalVer is appropriate when time-relatedness is the primary signal. Ubuntu 22.04 immediately tells you it was released in April 2022 and has an LTS support commitment. For packages whose versioning is driven by calendar (OS releases, data snapshots, protocol versions that track a spec year), CalVer communicates more information than SemVer. However, CalVer offers no semantic contract about compatibility — a 22.10 release could be completely incompatible with 22.04. Most packages are better served by SemVer's explicit compatibility promise.
from datetime import datetime
def calver_to_semver_approximation(calver_str, patch=0):
"""Convert a CalVer string to a SemVer approximation.
CalVer formats to handle:
- 'YYYY.MM.DD' -> MAJOR=YYYY, MINOR=MM, PATCH=DD
- 'YYYY.MM' -> MAJOR=YYYY, MINOR=MM, PATCH=patch
- 'YYYY.0M.DD' -> handle zero-padded months
- '2024.1' -> short year variant
Return SemVer string 'MAJOR.MINOR.PATCH'.
Also return a dict with 'year', 'month', 'day', 'is_calver': bool.
"""
# TODO: implement
passExpected Output
'2024.3.15' -> SemVer: 2024.3.15 year=2024 month=3 day=15Hints
Hint 1: Split on "." and parse each component as int (int() strips leading zeros).
Hint 2: CalVer uses calendar dates as version numbers — YYYY.MM.DD is the most common format.
Hard
Implement a release validator that enforces SemVer correctness. This is the gate that prevents a team from accidentally releasing a breaking change as a patch version — the kind of check that semantic-release and commitizen enforce in CI.
import re
def parse_ver(v):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v.strip())
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
def classify_single_change(change):
if change.get('breaking') or not change.get('backward_compatible', True):
return 'major'
if change.get('type') in ('feat', 'add', 'deprecate'):
return 'minor'
return 'patch'
def bump_type(old, new):
o, n = parse_ver(old), parse_ver(new)
if n[0] > o[0]:
return 'major'
if n[1] > o[1]:
return 'minor'
if n[2] > o[2]:
return 'patch'
return 'same'
def validate_release(current_version, proposed_version, changes):
errors = []
if parse_ver(proposed_version) <= parse_ver(current_version):
errors.append('Proposed version must be greater than current version ' + current_version)
bump_priorities = {'major': 3, 'minor': 2, 'patch': 1}
required = 'patch'
for change in changes:
ct = classify_single_change(change)
if bump_priorities.get(ct, 1) > bump_priorities.get(required, 1):
required = ct
actual = bump_type(current_version, proposed_version)
if actual == 'same':
errors.append('Proposed version is the same as current version')
elif bump_priorities.get(actual, 1) < bump_priorities.get(required, 1):
errors.append(
'Version bump too small: changes require at least ' +
required + ' bump, but got ' + actual
)
return {
'valid': len(errors) == 0,
'required_bump': required,
'actual_bump': actual,
'errors': errors,
}
changes = [
{'type': 'feat', 'scope': 'api', 'backward_compatible': True, 'breaking': False},
{'type': 'fix', 'scope': None, 'backward_compatible': True, 'breaking': False},
]
print(validate_release('1.2.3', '1.3.0', changes))
print(validate_release('1.2.3', '1.2.4', changes)) # too small
print(validate_release('1.2.3', '0.9.0', changes)) # downgradeSolution
import re
def validate_release(current_version, proposed_version, changes):
def parse_ver(v):
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', v.strip())
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0,0,0)
def classify(c):
if c.get('breaking') or not c.get('backward_compatible', True):
return 'major'
if c.get('type') in ('feat', 'add', 'deprecate'):
return 'minor'
return 'patch'
def actual_bump(old, new):
o, n = parse_ver(old), parse_ver(new)
if n[0] > o[0]: return 'major'
if n[1] > o[1]: return 'minor'
if n[2] > o[2]: return 'patch'
return 'same'
prio = {'major': 3, 'minor': 2, 'patch': 1, 'same': 0}
required = max(changes, key=lambda c: prio.get(classify(c), 1), default={'type':'fix'})
required = classify(required) if changes else 'patch'
actual = actual_bump(current_version, proposed_version)
errors = []
if parse_ver(proposed_version) <= parse_ver(current_version):
errors.append('Proposed version must be greater than current')
if actual != 'same' and prio.get(actual, 0) < prio.get(required, 0):
errors.append('Bump too small: need ' + required + ', got ' + actual)
return {'valid': not errors, 'required_bump': required, 'actual_bump': actual, 'errors': errors}
Automated release validation prevents the most common SemVer violations. The three most common mistakes: (1) releasing breaking changes as patch versions ("it's just a small fix"), (2) forgetting to bump MINOR when adding public API, (3) never releasing a major version because everyone is afraid of the psychological barrier. A CI gate that runs validate_release() on every PR description or commit set catches mistakes before they reach users. The python-semantic-release package implements this full pipeline — conventional commits in, versioned PyPI releases out.
def validate_release(current_version, proposed_version, changes):
"""Validate that a proposed version is correct for the given changes.
current_version: str (current released version)
proposed_version: str (version to be released)
changes: list of change dicts from Problem 5
{'type', 'scope', 'backward_compatible', 'breaking'}
Compute the required minimum bump from the changes.
Validate that proposed_version:
1. Is greater than current_version
2. Meets the minimum bump requirement
3. Does not exceed the minimum bump (e.g., no major bump for minor changes)
Return dict:
- 'valid': bool
- 'required_bump': str
- 'actual_bump': str
- 'errors': list of str
"""
# TODO: implement
passExpected Output
{'valid': True, 'required_bump': 'minor', 'actual_bump': 'minor', 'errors': []}Hints
Hint 1: Determine required_bump by finding the highest-priority change type in the list.
Hint 2: Priority order: major > minor > patch. If any change is major, required_bump is major.
Implement a @deprecated decorator. This is the professional way to signal API deprecation in Python — emitting a DeprecationWarning that tools, linters, and developers can act on rather than silently breaking on the next major version.
import warnings
from functools import wraps
def deprecated(since_version, removal_version, replacement=None, reason=None):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
msg = (
func.__name__ + ' is deprecated since version ' + since_version +
' and will be removed in version ' + removal_version + '.'
)
if replacement:
msg += ' Use ' + replacement + ' instead.'
if reason:
msg += ' Reason: ' + reason
warnings.warn(msg, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
wrapper.__deprecated__ = True
wrapper.__deprecated_since__ = since_version
wrapper.__deprecated_removal__ = removal_version
return wrapper
return decorator
@deprecated(
since_version='2.0.0',
removal_version='3.0.0',
replacement='new_calculate(x, y)',
reason='Performance improvements in new implementation',
)
def old_calculate(x, y):
return x + y
import warnings
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
result = old_calculate(3, 4)
print("Result:", result)
if w:
print("Warning:", str(w[0].message))
print("Has __deprecated__:", hasattr(old_calculate, '__deprecated__'))Solution
import warnings
from functools import wraps
def deprecated(since_version, removal_version, replacement=None, reason=None):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
msg = (func.__name__ + ' is deprecated since version ' + since_version +
' and will be removed in ' + removal_version + '.')
if replacement:
msg += ' Use ' + replacement + ' instead.'
if reason:
msg += ' Reason: ' + reason
warnings.warn(msg, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
wrapper.__deprecated__ = True
wrapper.__deprecated_since__ = since_version
wrapper.__deprecated_removal__ = removal_version
return wrapper
return decorator
stacklevel=2 is the critical detail. It makes the warning point to the line in the caller's code where the deprecated function was called, not to the line inside the decorator where warnings.warn is called. Without this, the warning shows a useless line inside your library's internals. @wraps(func) preserves __name__, __doc__, and __annotations__ so the deprecated function still appears correctly in help text and IDEs. Setting __deprecated__ as a custom attribute allows linters like mypy and pyright to flag usages of deprecated functions at type-check time.
import warnings
from functools import wraps
def deprecated(since_version, removal_version, replacement=None, reason=None):
"""Decorator that marks a function as deprecated.
Emits a DeprecationWarning when the function is called.
The warning message should include:
- The function name
- Since which version it is deprecated
- When it will be removed
- What to use instead (if replacement given)
- Why it is deprecated (if reason given)
The decorated function should still work normally.
"""
# TODO: implement
pass
# Usage:
# @deprecated(since_version='2.0.0', removal_version='3.0.0',
# replacement='new_function()')
# def old_function(x):
# return x * 2Expected Output
DeprecationWarning: old_function is deprecated since 2.0.0...Hints
Hint 1: Use @wraps(func) inside the decorator to preserve the original function metadata.
Hint 2: Call warnings.warn(message, DeprecationWarning, stacklevel=2) — stacklevel=2 points to the caller.
Audit pinned dependencies against a vulnerability database. This is the core of pip audit, safety check, and Dependabot security alerts — comparing installed versions against known CVEs.
import re
def ver_tuple(v):
try:
return tuple(int(x) for x in v.split('.'))
except (ValueError, AttributeError):
return (0,)
def check_affected(version, affected_range):
for spec in affected_range.split(','):
spec = spec.strip()
for op in ('>=', '<=', '>', '<', '==', '!='):
if spec.startswith(op):
bound = spec[len(op):]
v1, v2 = ver_tuple(version), ver_tuple(bound)
n = max(len(v1), len(v2))
v1 += (0,) * (n - len(v1))
v2 += (0,) * (n - len(v2))
checks = {
'>=': v1 >= v2, '<=': v1 <= v2, '>': v1 > v2,
'<': v1 < v2, '==': v1 == v2, '!=': v1 != v2,
}
if not checks[op]:
return False
break
return True
def audit_version_pins(requirements, vulnerability_db):
vulnerable = []
safe = []
risk_summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
for req in requirements:
req = req.strip()
if '==' not in req:
continue
name, _, version = req.partition('==')
name = name.strip().lower()
version = version.strip()
pkg_vulns = vulnerability_db.get(name, [])
pkg_vulnerable = False
for vuln in pkg_vulns:
if check_affected(version, vuln['affected_versions']):
vulnerable.append({
'package': name,
'version': version,
'cve': vuln['cve'],
'severity': vuln['severity'],
'fixed_in': vuln.get('fixed_in'),
})
severity = vuln['severity']
if severity in risk_summary:
risk_summary[severity] += 1
pkg_vulnerable = True
if not pkg_vulnerable:
safe.append(name)
return {
'vulnerable': vulnerable,
'safe': sorted(safe),
'risk_summary': risk_summary,
'critical_count': risk_summary['critical'],
}
requirements = ['requests==2.28.0', 'flask==2.0.0', 'click==8.1.7']
vuln_db = {
'requests': [{
'cve': 'CVE-2023-32681',
'severity': 'medium',
'affected_versions': '>=2.1.0,<2.31.0',
'fixed_in': '2.31.0',
}],
'flask': [{
'cve': 'CVE-2023-12345',
'severity': 'high',
'affected_versions': '>=1.0.0,<2.3.2',
'fixed_in': '2.3.2',
}],
}
result = audit_version_pins(requirements, vuln_db)
print("Vulnerable:", [(v['package'], v['cve']) for v in result['vulnerable']])
print("Safe:", result['safe'])
print("Risk:", result['risk_summary'])Solution
import re
def audit_version_pins(requirements, vulnerability_db):
def ver_t(v):
try:
return tuple(int(x) for x in v.split('.'))
except ValueError:
return (0,)
def check_affected(version, affected):
for spec in affected.split(','):
spec = spec.strip()
for op in ('>=','<=','>','<','==','!='):
if spec.startswith(op):
v1, v2 = ver_t(version), ver_t(spec[len(op):])
n = max(len(v1), len(v2))
v1 += (0,)*(n-len(v1)); v2 += (0,)*(n-len(v2))
if not {'>=':v1>=v2,'<=':v1<=v2,'>':v1>v2,'<':v1<v2,'==':v1==v2,'!=':v1!=v2}[op]:
return False
break
return True
vulnerable, safe = [], []
risk = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
for req in requirements:
if '==' not in req:
continue
name, _, ver = req.strip().partition('==')
name = name.strip().lower(); ver = ver.strip()
vulns = vulnerability_db.get(name, [])
hit = False
for v in vulns:
if check_affected(ver, v['affected_versions']):
vulnerable.append({'package': name, 'version': ver, 'cve': v['cve'],
'severity': v['severity'], 'fixed_in': v.get('fixed_in')})
if v['severity'] in risk:
risk[v['severity']] += 1
hit = True
if not hit:
safe.append(name)
return {'vulnerable': vulnerable, 'safe': sorted(safe),
'risk_summary': risk, 'critical_count': risk['critical']}
pip audit queries the OSV (Open Source Vulnerabilities) database — an open database maintained by Google that aggregates CVEs, GitHub Security Advisories, and other sources. It returns JSON that maps (package, version) pairs to known vulnerabilities. The safety tool queries PyUp.io's database. CI pipelines should run pip audit (or poetry run pip audit) on every build and fail on critical/high severity findings. The standard practice is to immediately pin to the fixed_in version when a vulnerability is found, then create a ticket for a proper test-driven upgrade.
def audit_version_pins(requirements, vulnerability_db):
"""Audit pinned requirements against a vulnerability database.
requirements: list of 'name==version' strings
vulnerability_db: dict of package_name -> list of dicts:
{
'cve': str,
'severity': 'critical'/'high'/'medium'/'low',
'affected_versions': '>=X.Y.Z,<A.B.C', (simplified)
'fixed_in': str or None
}
Return a dict:
- 'vulnerable': list of {package, version, cve, severity, fixed_in}
- 'safe': list of package names
- 'risk_summary': dict of severity -> count
- 'critical_count': int
"""
# TODO: implement
passExpected Output
{'vulnerable': [{'package': 'requests', 'version': '2.28.0', 'cve': 'CVE-2023-32681', ...}], ...}Hints
Hint 1: For each requirement, check its name in the vulnerability_db. For each CVE, parse affected_versions and check if the installed version falls in the range.
Hint 2: Parse ">=X,<Y" by splitting on "," and checking each bound separately.
