Skip to main content

Python Semantic Versioning Practice Problems & Exercises

Practice: Semantic Versioning

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

Easy

#1Parse Semantic VersionEasy
semverparsingMAJOR-MINOR-PATCH

Parse a semantic version string into its components. SemVer 2.0.0 specifies a precise format: MAJOR.MINOR.PATCH[-pre-release][+build-metadata].

Python
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
    pass
Expected 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.

#2Classify Version Bump TypeEasy
version-bumpbreaking-changessemver-rules

Classify the type of version bump between two versions. This is the first step in automated changelog generation and release validation.

Python
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
    pass
Expected Output
major
minor
patch
same
downgrade
Hints

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.

#3Compute Next VersionEasy
version-incrementsemver-bumprelease-automation

Compute the next version string after a bump. This is the core operation in release automation tools like bumpversion, commitizen, and semantic-release.

Python
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
    pass
Expected Output
2.0.0
1.3.0
1.2.4
2.0.0-rc.1
Hints

Hint 1: Parse the current version into major, minor, patch integers.

Hint 2: Reset downstream components to 0 when bumping a higher-order component.

#4Sort VersionsEasy
version-sortingpre-release-orderingPEP-440

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.

Python
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
    pass
Expected 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

#5Breaking Change ClassifierMedium
breaking-changesAPI-changessemver-rules

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.

Python
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
    pass
Expected Output
major
minor
patch
Hints

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.

#6Changelog GeneratorMedium
changelogconventional-commitsrelease-notes

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.

Python
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
    pass
Expected 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.

#7Version Range IntersectionMedium
version-rangeintersectiondependency-resolution

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?

Python
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
    pass
Expected 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).

#8CalVer to SemVer BridgeMedium
calversemverversion-formats

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.

Python
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
    pass
Expected Output
'2024.3.15' -> SemVer: 2024.3.15 year=2024 month=3 day=15
Hints

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

#9Automated Release ValidatorHard
release-automationvalidationsemver-enforcement

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.

Python
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))  # downgrade
Solution
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
    pass
Expected 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.

#10Deprecation Warning SystemHard
deprecationwarningsmigration-path

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.

Python
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 * 2
Expected 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.

#11Version Pin AuditorHard
security-auditversion-pinsvulnerability-detection

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.

Python
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
    pass
Expected 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.

© 2026 EngineersOfAI. All rights reserved.