Semantic Versioning - The Contract Behind Every Version Number
Reading time: ~25 minutes | Level: Intermediate → Engineering
Before reading further, study this scenario:
# Your service depends on: mylib>=1.2.0
# Your code in production:
from mylib import Client
client = Client(timeout=30)
# ─── mylib releases 1.3.0 ───────────────────────────────────────────────────
# Release notes: "Added AsyncClient for async frameworks"
# Breaking changes: None
# Your code: still works, Client(timeout=30) unchanged
# ─── mylib releases 2.0.0 ───────────────────────────────────────────────────
# Release notes: "Modernized API - Client renamed to SyncClient for clarity"
# Your code after upgrade:
from mylib import Client
# AttributeError: module 'mylib' has no attribute 'Client'
Which release broke your code? 2.0.0. And if the library authors followed Semantic Versioning correctly - that is the point. A MAJOR version bump is an explicit signal: breaking changes are intentional and expected. The version number is not metadata. It is a contract.
The problem is that many library authors do not follow SemVer correctly. A 1.3.0 release with a breaking change, or a 2.0.0 release with no breaking changes - both destroy user trust and make version constraints unreliable. Understanding SemVer in depth means understanding both what it requires and how to defend yourself when dependencies do not follow it.
What You Will Learn
- The formal MAJOR.MINOR.PATCH definitions and exactly what each increment means
- How to classify changes: what is breaking, what is not, and the gray areas
- Pre-release and build metadata versioning
- The
0.x.yexception: the wild west before 1.0.0 - Python version specifiers and how they map to SemVer concepts
- Using
packaging.version.Versionto parse and compare versions in code - CalVer as an alternative versioning scheme
- Changelog discipline and Git release tagging
Prerequisites
- Lesson 04 (Poetry) - version constraints in Poetry directly implement SemVer ranges
- Basic understanding of package dependencies - knowing what it means to depend on a library version
Part 1 - The MAJOR.MINOR.PATCH Contract
Semantic Versioning (SemVer, semver.org) defines exactly three rules:
PATCH (1.2.3 → 1.2.4): Backwards-compatible bug fixes. No new public API. No changed behavior that existing users depended on. Only fixes for behavior that was unambiguously wrong.
MINOR (1.2.3 → 1.3.0): New backwards-compatible functionality. New public API added. Existing API unchanged. Users who do not use the new API are unaffected.
MAJOR (1.2.3 → 2.0.0): Backwards-incompatible changes. Any change that breaks existing users. This is the explicit signal that upgrading may require code changes.
The engineering implications:
If you depend on: mylib>=1.0.0
You are saying: "Any 1.x.y version will work for me."
If the library follows SemVer:
1.0.0 → 1.0.1: safe (bug fix)
1.0.0 → 1.1.0: safe (new features, your old code still works)
1.0.0 → 2.0.0: NOT automatically safe (breaking changes expected)
Part 2 - Which Number to Bump: The Decision Tree
The decision is always about the public API contract, not about internal complexity or how much code changed.
Part 3 - What Counts as a Breaking Change
This is where engineering judgment matters. The SemVer spec requires MAJOR bumps for any backwards-incompatible change - but "backwards-incompatible" requires interpretation.
Unambiguous Breaking Changes (Always MAJOR)
# ── Removing a public name ────────────────────────────────────────────────────
# Version 1.x
from mylib import Client, AsyncClient, parse_url
# Version 2.0 - Client removed
from mylib import AsyncClient, parse_url
# → AttributeError for any user of Client
# ── Changing a function signature ────────────────────────────────────────────
# Version 1.x
def connect(host, port=5432, timeout=30):
...
# Version 2.0 - positional argument reordered
def connect(host, timeout=30, port=5432):
...
# → Any caller using connect(host, 5432) now passes port as timeout
# ── Changing a return type ────────────────────────────────────────────────────
# Version 1.x: returns dict
def get_config() -> dict:
return {"host": "localhost", "port": 5432}
# Version 2.0: returns Config object
def get_config() -> Config:
return Config(host="localhost", port=5432)
# → Any caller doing config["host"] will get TypeError
# ── Changing exception type ───────────────────────────────────────────────────
# Version 1.x
raise ConnectionError("timeout")
# Version 2.0
raise TimeoutError("timeout")
# → Any caller catching ConnectionError now misses the exception
# ── Narrowing accepted input ──────────────────────────────────────────────────
# Version 1.x: accepts str or int
def set_timeout(value: Union[str, int]) -> None: ...
# Version 2.0: accepts only int
def set_timeout(value: int) -> None: ...
# → Users passing str now get TypeError
What Does NOT Count as Breaking (Can be MINOR or PATCH)
# ── Adding a new optional parameter ──────────────────────────────────────────
# Version 1.x
def connect(host, port=5432):
...
# Version 1.5.0 - new optional parameter added at the end
def connect(host, port=5432, ssl=False):
...
# → All existing callers continue to work unchanged
# ── Adding a new public function or class ─────────────────────────────────────
# Version 1.5.0 - new AsyncClient added
# Existing Client users unaffected
# ── Improving performance ────────────────────────────────────────────────────
# Faster algorithm that produces the same result: PATCH (or MINOR if significant)
# ── Fixing a bug (unambiguously wrong behavior) ──────────────────────────────
# parse_date("2024-01-32") returned a date instead of raising ValueError: PATCH
# ── Adding type annotations ──────────────────────────────────────────────────
# Adding Py.typed marker and annotations to a previously untyped library: MINOR
# (technically cannot break runtime, but may affect mypy users - gray area)
# ── Updating documentation ───────────────────────────────────────────────────
# No version bump required (but often bundled with PATCH releases)
The Gray Areas
# ── Deprecating an API ────────────────────────────────────────────────────────
# Deprecation warnings: MINOR (not breaking - code still runs)
# Removing the deprecated API: MAJOR (breaking)
import warnings
def old_function():
warnings.warn("old_function is deprecated, use new_function", DeprecationWarning, stacklevel=2)
return new_function()
# ── Changing default values ──────────────────────────────────────────────────
# Version 1.x: default timeout=30
# Version 1.5.0: default timeout=60
# This changes behavior for users who relied on the default - technically breaking
# But many projects call this MINOR and document it prominently
# ── Removing a dependency ────────────────────────────────────────────────────
# If users import from your library's re-exported dependencies:
# from mylib import requests ← users doing this break when you stop re-exporting
# This is a breaking change even though it looks internal
Releasing a breaking change as a MINOR version destroys user trust permanently. If your library's 1.3.0 release breaks users' code, they will either pin to ==1.2.3 forever (preventing them from getting your future security fixes) or switch to a competing library. Breaking SemVer is not a technical mistake - it is an organizational credibility failure. The community's ability to use ^1.2.3 constraints safely depends on library authors following MAJOR version rules faithfully.
Part 4 - Pre-Release Versions
SemVer supports pre-release identifiers appended with a hyphen:
1.0.0-alpha.1 ← first alpha
1.0.0-alpha.2 ← second alpha
1.0.0-beta.1 ← first beta
1.0.0-beta.2 ← second beta
1.0.0-rc.1 ← first release candidate
1.0.0-rc.2 ← second release candidate
1.0.0 ← stable release
Precedence ordering (lower to higher):
1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
A stable release always has higher precedence than any pre-release with the same MAJOR.MINOR.PATCH. 1.0.0-rc.9 is still lower than 1.0.0.
In Python/PyPI, the convention uses . instead of -:
1.0.0a1 ← alpha 1 (PEP 440)
1.0.0b2 ← beta 2
1.0.0rc1 ← release candidate 1
1.0.0 ← stable
Pre-release versions are not installed by default:
pip install mylib # installs latest stable
pip install mylib==2.0.0a1 # explicitly request alpha
pip install mylib --pre # allow pre-releases
Build Metadata
SemVer also supports build metadata appended with +:
1.0.0+20240315 ← built on 2024-03-15
1.0.0+sha.a1b2c3d ← built from specific git SHA
1.0.0-beta.1+exp.sha.5114f85
Build metadata is ignored in version precedence comparisons. 1.0.0+build.1 and 1.0.0+build.2 are considered equal versions.
Part 5 - The 0.x.y Exception
SemVer has an important rule for versions before 1.0.0:
Major version zero (
0.y.z) is for initial development. Anything may change at any time. The public API should not be considered stable.
This is the "wild west" rule. Under 0.x.y:
- MINOR increments may include breaking changes
- There is no guarantee that
0.2.0is backwards-compatible with0.1.0 - The MAJOR version bump to
1.0.0is the commitment: "the API is now stable"
Stay at 0.x.y until your API is genuinely stable. Do not rush to 1.0.0 just because the library "feels done." Once you release 1.0.0, you are committing to backwards compatibility in all future 1.x.y versions. Many successful libraries (requests, click) spent years under 0.x.y while they refined their APIs. Bumping to 1.0.0 is a public commitment, not a milestone celebration.
Practical 0.x.y versioning for a library in development:
0.1.0 ← first functional release
0.1.1 ← bug fix
0.2.0 ← new feature + breaking change to alpha API (allowed before 1.0.0)
0.3.0 ← API stabilizing...
1.0.0 ← public commitment: this API is stable
Part 6 - Python Version Specifiers and How They Map to SemVer
Python's packaging ecosystem (PEP 440) defines its own version specifier syntax. Understanding how it relates to SemVer constraints prevents version constraint bugs.
The >=,< Explicit Range
>=1.2.0,<2.0.0 ← most explicit; exactly matches SemVer "^1.2.0" intent
>=2.0,<3.0 ← commonly used for major version pinning
>=1.2.0 ← open-ended: works until something breaks (avoid in libraries)
The Compatible Release Operator ~=
The ~= operator (PEP 440) is more subtle than it appears:
~=2.2.0 ← requires >=2.2.0 AND == 2.2.* → allows 2.2.0, 2.2.1, 2.2.9
(three components: pins to 2.2.x patch releases only)
~=2.2 ← requires >=2.2 AND == 2.* → allows 2.2, 2.3, 2.9
(two components: pins to 2.x minor + patch releases)
~=1.4.2 ← requires >=1.4.2 AND == 1.4.* → allows 1.4.2, 1.4.3, NOT 1.5.0
The number of version components in ~= changes its meaning entirely. ~=2.2 (two components) allows 2.3, 2.9, etc. - equivalent to Poetry's ^2.2 but only for the minor series. ~=2.2.0 (three components) only allows 2.2.x - equivalent to Poetry's ~2.2.0. Always count the dots when reading ~= constraints in requirements.txt or setup.cfg.
Poetry vs PEP 440 Constraint Mapping
| Poetry | PEP 440 equivalent | What it allows |
|---|---|---|
^1.2.3 | >=1.2.3,<2.0.0 | minor + patch updates |
^1.2 | >=1.2.0,<2.0.0 | minor + patch updates |
~1.2.3 | >=1.2.3,<1.3.0 | patch updates only |
~1.2 | >=1.2.0,<1.3.0 | patch updates only |
>=1.2,<2.0 | >=1.2,<2.0 | explicit range |
==1.2.3 | ==1.2.3 | exact pin |
Many Python packages do not follow SemVer strictly. boto3, botocore, google-cloud-*, and many AWS/GCP libraries use MINOR versions for breaking changes and release multiple times per week. numpy used MAJOR versions sparingly for years while making breaking changes in MINOR versions. Always read the release notes of your direct dependencies before running poetry update package. The version number is a signal, not a guarantee.
Part 7 - Using packaging.version.Version in Code
Sometimes you need to compare or parse versions programmatically. The packaging library (always available in environments with pip) provides PEP 440-compliant version parsing:
from packaging.version import Version, InvalidVersion
from packaging.specifiers import SpecifierSet
# Parse and compare versions
v1 = Version("1.2.3")
v2 = Version("1.10.0")
v3 = Version("2.0.0a1") # alpha
print(v1 < v2) # True (1.2.3 < 1.10.0 - note: NOT lexicographic comparison)
print(v2 < v3) # True (1.10.0 < 2.0.0a1)
print(v3.is_prerelease) # True
print(v3.major, v3.minor, v3.micro) # 2, 0, 0
# Check if a version satisfies a specifier
spec = SpecifierSet(">=1.2.0,<2.0.0")
print(Version("1.5.0") in spec) # True
print(Version("2.0.0") in spec) # False
print(Version("1.0.0") in spec) # False
# Handle invalid versions gracefully
def parse_version_safe(version_str: str) -> Version | None:
try:
return Version(version_str)
except InvalidVersion:
return None
# Sort a list of versions correctly
versions = [Version(v) for v in ["1.10.0", "1.2.0", "1.9.0", "2.0.0a1"]]
print(sorted(versions))
# [<Version('1.2.0')>, <Version('1.9.0')>, <Version('1.10.0')>, <Version('2.0.0a1')>]
# Note: 1.9.0 < 1.10.0 (numeric, not lexicographic - "10" > "9")
A common real-world use: a service that checks whether its own version is newer than the latest released version on PyPI:
import httpx
from packaging.version import Version
def get_latest_pypi_version(package: str) -> Version:
response = httpx.get(f"https://pypi.org/pypi/{package}/json", timeout=5)
response.raise_for_status()
data = response.json()
return Version(data["info"]["version"])
def check_for_updates(package: str, current_version: str) -> None:
current = Version(current_version)
latest = get_latest_pypi_version(package)
if latest > current:
print(f"Update available: {current} → {latest}")
elif latest < current:
print(f"Running pre-release or unreleased version: {current}")
else:
print(f"Up to date: {current}")
Part 8 - CalVer: Calendar Versioning
Not every project suits SemVer. Some projects use CalVer (Calendar Versioning): the version number encodes the release date rather than semantic information.
Common CalVer formats:
| Project | Format | Example | What the segments mean |
|---|---|---|---|
| Ubuntu | YY.0M | 24.04 | Year 2024, April (the 0M pads to 2 digits) |
| Django | MAJOR.MINOR | 5.2 | Not CalVer - Django uses custom SemVer-like scheme |
| Python | MAJOR.MINOR.PATCH | 3.12.3 | MAJOR=Python generation, MINOR=annual feature release |
| pip | YY.N | 24.1 | Year + release number within year |
| Black | YY.M.N | 24.3.0 | Year, month, release number |
When CalVer makes sense:
- Infrastructure tools that release on a schedule (Ubuntu LTS: every 2 years, non-LTS: every 6 months). The date IS the relevant information for support lifecycle decisions.
- Tools where "semantic" API stability is not the primary concern - a text formatter like
blackhas one job; the version encoding its release date tells you how current it is. - Projects with a regular release cadence where the date provides useful information to users.
When CalVer does NOT make sense:
- Libraries with a public API - users need to know whether upgrading is safe (SemVer's MAJOR signal)
- Libraries with many downstream dependents - CalVer gives no compatibility signal
- Any project where users need version constraints to protect against breaking changes
For the vast majority of Python libraries and packages, SemVer is the right choice.
Part 9 - Changelog Discipline
A version number without a changelog is incomplete communication. The Keep a Changelog format is the most widely adopted standard:
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Draft support for async context managers in `Client`
## [2.0.0] - 2024-06-15
### Breaking Changes
- `Client` renamed to `SyncClient` - update all imports
- `parse_url()` now raises `URLParseError` instead of `ValueError`
- Dropped support for Python 3.8 and 3.9
### Added
- `AsyncClient` for use with `asyncio` and `httpx`
- `URLParseError` exception with detailed parse information
### Removed
- `Client` class (was `SyncClient` alias since 1.4.0)
- `parse_url_legacy()` deprecated in 1.3.0
## [1.5.0] - 2024-03-01
### Added
- `Client.retry(max_attempts=3)` method for automatic retry logic
- Optional `ssl_verify` parameter to `Client.__init__`
### Fixed
- `Client.get()` no longer hangs on connection timeout; now raises `TimeoutError`
- Improved error messages for invalid host strings
## [1.4.3] - 2024-01-20
### Fixed
- Fixed memory leak when using `Client` as a context manager in long-running processes
[Unreleased]: https://github.com/org/mylib/compare/v2.0.0...HEAD
[2.0.0]: https://github.com/org/mylib/compare/v1.5.0...v2.0.0
[1.5.0]: https://github.com/org/mylib/compare/v1.4.3...v1.5.0
[1.4.3]: https://github.com/org/mylib/releases/tag/v1.4.3
The Keep a Changelog categories:
| Category | Use for |
|---|---|
Added | New features, new public API |
Changed | Changes to existing behavior (non-breaking or documented behavioral changes) |
Deprecated | Features marked for future removal |
Removed | Features removed (always MAJOR if public API) |
Fixed | Bug fixes |
Security | Security vulnerability fixes |
Breaking Changes | (not in spec, but widely used for MAJOR versions) - list explicitly |
Part 10 - Git Tagging for Releases
Releases must be tagged in Git. Tags create permanent, named references to specific commits. The standard convention is v prefix:
# Create a lightweight tag (just a pointer to a commit)
git tag v1.2.3
# Create an annotated tag (includes tagger, date, message - preferred for releases)
git tag -a v1.2.3 -m "Release 1.2.3: fix memory leak in Client context manager"
# List all tags
git tag -l
git tag -l "v1.*" # filter
# Push a tag to remote (tags are not pushed by default)
git push origin v1.2.3
# Push all tags
git push origin --tags
# Show what a tag points to
git show v1.2.3
Annotated vs Lightweight Tags
| Feature | Lightweight tag | Annotated tag |
|---|---|---|
| Stored as | Simple pointer to commit | Full Git object with metadata |
| Includes tagger + date | No | Yes |
| Includes message | No | Yes |
Used by git describe | Yes | Yes (preferred) |
| Best for | Temporary/personal use | Releases - always use annotated |
The Full Release Workflow
# 1. Finalize changelog
# Move [Unreleased] section to [1.2.3] - YYYY-MM-DD
# 2. Bump version in pyproject.toml
poetry version patch # 1.2.2 → 1.2.3
poetry version minor # 1.2.2 → 1.3.0
poetry version major # 1.2.2 → 2.0.0
# Or set explicitly:
poetry version 1.2.3
# 3. Commit the version bump
git add pyproject.toml CHANGELOG.md
git commit -m "chore: release 1.2.3"
# 4. Create annotated tag
git tag -a v1.2.3 -m "Release 1.2.3"
# 5. Push commit and tag
git push origin main
git push origin v1.2.3
# 6. Build and publish (see Lesson 06)
poetry build
poetry publish
CI pipelines (GitLab CI, GitHub Actions) can trigger automated publish jobs on tags matching v*, making steps 6 entirely automated.
Graded Practice
Level 1 - Classify the Change
For each change below, determine whether it requires a PATCH, MINOR, or MAJOR version bump:
- A bug where
parse_date("2024-02-29")returnedNoneis fixed to return the correctdatetimeobject. - A new optional parameter
encoding="utf-8"is added toread_file(path). Default behavior unchanged. read_file(path)previously returnedstr; now returnsbytesby default.- Internal refactoring: the
_parse_internal()private method is rewritten to be 3x faster. APIClient.connect()now raisesConnectionRefusedErrorinstead of returningFalseon failure.- A new
AsyncAPIClientclass is added alongside the existingAPIClient. APIClient.__init__now requirestimeoutas a keyword-only argument (timeout=30→*, timeout=30).- Docstrings are added to all public functions.
Show Answer
-
PATCH - Bug fix. The previous behavior was wrong (returning
Nonefor a valid date); fixing it is unambiguously a patch release. No API change. -
MINOR - New optional parameter with a default value that preserves existing behavior. All existing callers continue to work without modification. This adds new capability (callers can now pass
encoding) without breaking anything. -
MAJOR - Changing a return type from
strtobytesis a breaking change. Any user who doescontent.upper()orcontent.replace("old", "new")will getAttributeErrorat runtime (bytes has different methods than str). Even if the change seems logical, it breaks existing code. -
PATCH - Private methods (prefixed with
_) are not part of the public API contract. Internal refactoring that changes no observable behavior is a patch release. Performance improvements with identical results are patch-level changes. -
MAJOR - Changing how failure is signaled is a breaking change. Users doing
if not client.connect(): handle_failure()now get an unhandled exception instead. Even if raising is the "better" behavior, changing from return-based to exception-based failure handling breaks all existing callers. -
MINOR - Adding a new class (
AsyncAPIClient) is purely additive. No existing code is affected. Users who do not need async can ignore the new class entirely. -
MAJOR - Making
timeoutkeyword-only means any caller usingAPIClient(host, port, 30)(passing timeout positionally as the third argument) now getsTypeError: connect() takes 2 positional arguments but 3 were given. This is a breaking change even though most callers who usedtimeout=30explicitly are unaffected. -
PATCH (or no version bump) - Docstrings are not part of the runtime API. This is a documentation change. Many projects bundle documentation improvements into PATCH releases; some do it without a version bump. Either is acceptable.
Level 2 - Debug the Version Strategy
A library data-tools has this version history:
0.8.0 → 0.9.0: parse_csv() function added
0.9.0 → 0.9.1: bug fix in parse_csv()
0.9.1 → 1.0.0: API "stabilized", no changes
1.0.0 → 1.1.0: parse_json() added, parse_csv() signature changed (breaking)
1.1.0 → 1.1.1: performance improvement
1.1.1 → 1.2.0: parse_xml() added, parse_json() removed (breaking)
1.2.0 → 2.0.0: internal refactor, no public API changes
Identify every SemVer violation and explain the correct version increment for each.
Show Answer
Violation 1: 1.0.0 → 1.1.0 - breaking change released as MINOR
parse_csv() signature changed - this is backwards-incompatible. Under SemVer, this requires a MAJOR bump. Correct increment: 1.0.0 → 2.0.0.
This is the most damaging violation. Anyone using >=1.0.0 or ^1.0.0 constraints will automatically receive this "upgrade" and have their code broken. The caret constraint exists precisely to protect against this.
Violation 2: 1.1.0 → 1.2.0 - breaking change (removal) released as MINOR
parse_json() was removed. Removing public API is always a MAJOR bump. Correct increment: 1.1.0 → 2.0.0 (or 2.0.0 → 3.0.0 if the previous violation was correctly bumped to MAJOR).
Violation 3: 1.2.0 → 2.0.0 - no breaking changes, but MAJOR bumped
The MAJOR version was incremented for an internal refactor with no public API changes. This is not a SemVer violation per se (the spec does not prohibit MAJOR bumps without breaking changes - it only says MAJOR MUST be incremented for breaking changes). But it is misleading: users of ^1.x.x constraints now need to explicitly upgrade and review what changed, even though nothing they use changed. Correct increment: 1.2.0 → 1.2.1 (if the refactor was internal) or 1.3.0 (if it added any new capability).
Assessment:
A user who started with data-tools ^1.0.0 (meaning "safe to use any 1.x.y") was broken twice before reaching 1.2.0. This library destroyed its users' trust. The typical response is that users pin to ==1.0.0 and refuse to upgrade - which means they never receive future security patches or bug fixes either. The cost of SemVer violations is paid by users, indefinitely.
Level 3 - Design the Versioning Strategy
You are the maintainer of a Python library httpkit that has been in production use for 2 years. Current version: 1.8.3. It has 50+ known users (from GitHub stars and PyPI download stats). You have the following planned changes for the next 6 months:
- Fix a bug where
Client.get()does not properly close connections on timeout (fix is backwards-compatible) - Add
AsyncClientclass (new, additive) - Deprecate
Client.set_proxy(url)in favor ofClient(proxies={"https": url}) - Remove
Client.set_proxy()(it has been deprecated for 2 releases) - Rename
Client.cookiesproperty toClient.cookie_jarfor clarity - Improve JSON parsing performance by 40% (internal change)
- Add support for Python 3.13 (test and CI only)
Design the complete release plan:
- Which changes go into which release?
- What are the version numbers?
- What is in the
CHANGELOG.mdfor each release? - What constraints do you add to warn users?
- How do you communicate breaking changes to minimize disruption?
Show Answer
Release plan:
1.8.4 ← bug fix only
1.9.0 ← additive: AsyncClient + deprecation
2.0.0 ← breaking: removal + rename
Release 1.8.4:
Contains: Change 1 (bug fix) + Change 7 (Python 3.13 support - additive, non-breaking).
## [1.8.4] - 2024-07-01
### Fixed
- `Client.get()` now properly closes connections on timeout, preventing connection pool exhaustion
### Added
- Python 3.13 support (tested and verified in CI)
Release 1.9.0:
Contains: Change 2 (AsyncClient) + Change 3 (deprecation) + Change 6 (performance).
## [1.9.0] - 2024-09-15
### Added
- `AsyncClient` class for use with asyncio; see docs for migration guide
- 40% performance improvement in JSON response parsing (internal change)
### Deprecated
- `Client.set_proxy(url)` is deprecated and will be removed in 2.0.0.
Use `Client(proxies={"https": url})` instead.
A `DeprecationWarning` is now raised when `set_proxy()` is called.
Implementation of the deprecation:
def set_proxy(self, url: str) -> None:
import warnings
warnings.warn(
"Client.set_proxy() is deprecated and will be removed in 2.0.0. "
"Use Client(proxies={'https': url}) instead.",
DeprecationWarning,
stacklevel=2,
)
self._proxies = {"https": url}
Release 2.0.0:
Contains: Change 4 (removal of set_proxy) + Change 5 (rename cookies → cookie_jar).
## [2.0.0] - 2025-01-10
### Breaking Changes
**Migration required - read before upgrading**
1. `Client.set_proxy(url)` has been removed (deprecated since 1.9.0).
Migration: `Client(proxies={"https": url})`
2. `Client.cookies` property renamed to `Client.cookie_jar`.
Migration: replace all uses of `.cookies` with `.cookie_jar`.
A compatibility shim is available for the 2.x lifecycle:
`Client.cookies` raises `AttributeError` with a helpful message pointing to `.cookie_jar`.
### Migration Guide
See https://httpkit.readthedocs.io/2.0/migration for a complete migration guide
with automated codemods using `libcst`.
### Removed
- `Client.set_proxy()` (deprecated in 1.9.0)
### Changed
- `Client.cookies` → `Client.cookie_jar` (breaking rename)
Communication strategy:
-
Release
1.9.0with DeprecationWarning 3+ months before2.0.0. Users who runpython -W error::DeprecationWarningin CI (which they should) get failing tests, prompting them to migrate. -
Provide a migration guide document and ideally a
codemodscript usinglibcstthat automatically renamesclient.cookies→client.cookie_jarand rewritesclient.set_proxy(url)→Client(proxies=...). -
In
2.0.0, implementClient.cookiesas a property that raisesAttributeErrorwith a descriptive message:AttributeError: 'cookies' was renamed to 'cookie_jar' in httpkit 2.0.0. Update: client.cookies → client.cookie_jar. This gives users a clear error at the right call site instead of a confusingAttributeErroror wrong behavior. -
Announce
2.0.0release on the project's communication channels (GitHub Discussions, mailing list if applicable) 2 weeks before release with the migration guide.
Key Takeaways
MAJOR.MINOR.PATCHis a contract, not a label. MAJOR means breaking changes expected; MINOR means new features, nothing broken; PATCH means bug fixes only. All three commitments must be honored for the version system to be trustworthy.- A breaking change is any change that can cause existing correct user code to fail. This includes: removing public names, changing signatures, changing return types, changing exception types, and narrowing accepted input types.
- Adding new optional parameters, new classes, new functions, or fixing bugs is not breaking - these are MINOR or PATCH changes respectively.
- Pre-release versions (
1.0.0a1,1.0.0b2,1.0.0rc1) always have lower precedence than the stable release.pip install mylibignores pre-releases; use--preor explicit pins to install them. 0.x.yis the wild west: the spec explicitly allows breaking changes in MINOR versions before1.0.0. Stay at0.x.yuntil your API is stable; bumping to1.0.0is a public commitment.~=2.2.0(three components) and~=2.2(two components) mean different things: three components restricts to2.2.xpatches; two components allows all2.x.yversions. Count the dots.- Many Python packages do not follow SemVer strictly - especially AWS SDKs, Google Cloud libraries, and large frameworks. Always read release notes before running
poetry updateorpip install --upgrade. - Use
packaging.version.Versionfor all programmatic version parsing and comparison. Never parse version strings with string operations or regex -"1.10.0" > "1.9.0"isFalselexicographically butTruesemantically. - Changelog discipline is inseparable from versioning: a MAJOR release without a migration guide is an incomplete release. Use the Keep a Changelog format; document breaking changes explicitly in a
Breaking Changessection. - Releasing a breaking change as a MINOR version destroys user trust permanently. Users respond by pinning to the last good version with
==constraints, which prevents them from receiving all future security fixes. The cost of a SemVer violation is paid by users indefinitely.
