Breaking changes & Deprecations¶
Abstract
This page describes how we handle breaking changes and deprecations in the project. It clarifies what is a breaking change, what is a deprecation, how to mark something as deprecated and explains when a function should be deprecated. Finally, it mentions how to properly communicate breaking chagnges and deprecations to end users.
Pre-Requisites
Before reading this page, make sure to familiarize yourself with our versioning model
What is a breaking change¶
A breaking change is a modification that requires developers to adjust their code due to alterations that break previously working functionality. This includes changes such as altering method signatures, changing return types, or removing classes or functions without prior warning.
We follow semantic versioning to manage breaking changes. That means, major version
increments (e.g., from 3.x.x
to 4.0.0
) indicate breaking changes. It’s essential that users can rely on minor and
patch versions (e.g., 3.1.0
or 3.0.1
) being backwards-compatible with the first major release (3.0.0
).
When introducing changes, aim to implement them in a non-breaking way. Breaking changes should be avoided whenever possible. If a breaking change is absolutely necessary, strive to transition gradually through deprecations.
Refer to the versioning model page for some examples of what constitutes a breaking change.
What is a deprecation¶
A deprecation signals that a particular part of the code (commonly a function, class, or argument) should no longer be used because it is outdated, inefficient, or replaced by better alternatives. Deprecations are a temporary measure to guide developers toward transitioning to newer practices, while giving them time to adjust their code without causing immediate disruptions.
Deprecations act as a soft warning: they indicate that the deprecated feature will eventually be removed, but for now, it remains usable with a runtime deprecation warning. This gives developers enough time to adapt before the removal takes place in a future major release.
It’s essential to understand that deprecations are not permanent — every deprecated feature has a defined removal
version, beyond which it will no longer exist. Typically, the removal happens in the next major version after the
deprecation was announced. For example, if a feature is deprecated in version 3.x
, it will usually be removed in
version 4.0.0
.
Recap
Deprecations help to avoid immediate breaking changes by offering a grace period for users to update their code before the feature is entirely removed in the next major release.
Deprecations are primarily used for:
- Phasing out old functions, classes, or methods in favor of improved alternatives.
- Renaming functions, arguments, or classes to align with better conventions.
- Adjusting method signatures, such as adding required arguments or removing old ones.
- Changing behaviors that can’t be applied retroactively without introducing errors.
Deprecating protocol changes
Deprecations are not used for protocol-related changes, as the Minecraft protocol evolves independently of mcproto’s internal development. For these types of changes, mcproto will introduce a major version bump and require users to update.
That said, these changes are still considered as breaking, and will need to be documented as such.
Note
Sometimes, it isn't possible/feasible to deprecate something, as the new change is so different from the original that a breaking change is the only option. That said, this should be a rare case and you should always first do your best to think about how to deprecate something before deciding on just marking your change as breaking.
How to deprecate¶
We have two custom function to mark something as deprecated, both of these live in the mcproto.utils.deprecation
module:
deprecation_warn
: This function triggers a deprecation warning immediately after it is called, alerting developers to the pending removal.deprecated
: This decorator function marks a function as deprecated. It triggers a deprecation warning each time the decorated function is called. Internally, this ultimately just callsdeprecation_warn
.
Removal version¶
These functions take a removal version as an argument, which should be specified as a semantic
version string. Generally, you'll just want to put the next major version of the library here (so
if you're currently on 3.5.2
you'll want to specify the removal version as 4.0.0
; You always want to bump the first
/ major version number.)
The deprecation_warn
function will usually just show a warning, however, if the current version of the library
surpasses the removal version, it will instead throw a runtime error, making it unusable. In most cases, people
shouldn't ever face this, as once the new major version is released, all deprecations with that removal version should
be removed, but it's a nice way to ensure the proper behavior, just in case we'd forget, allowing us to remove them
later on in a patch version without breaking the semantic versioning model.
Note
The removal version is a required argument, as we want to make sure that deprecated code doesn't stay in our codebase forever. Deprecations should always be a temporary step toward the eventual removal of a feature.
If there is a valid reason to extend the deprecation period, you can push back the removal version, keeping the old or compatibility code longer and incrementing the major version number in the argument accordingly.
However, we should never shorten the deprecation period, as that would defeat the purpose of giving developers enough time to adapt to the change. Reducing the deprecation time could result in unexpected breakage for users relying on the deprecated feature.
Examples¶
Function rename¶
from mcproto.utils.deprecation import deprecated
@deprecated(removal_version="4.0.0", replacement="new_function")
def old_function(x: int, y: int) -> int:
...
def new_function(x: int, y: int) -> int:
...
Class removal¶
Argument removal¶
from mcproto.utils.deprecation import deprecation_warn
def old_function(x: int, y: int, z: int) -> int:
...
def new_function(x: int, y: int, z: int | None = None) -> int:
if z is not None:
deprecation_warn(
obj_name="z (new_function argument)",
removal_version="4.0.0",
replacement=None,
extra_msg="Optional extra message, like a reason for removal"
)
... # this logic should still support working with z, just like it did in the old impl
Communicating breaking changes¶
Breaking changes necessitate clear communication, as they directly impact users by forcing updates to their codebases. It’s essential to ensure that users are well-informed about any breaking changes introduced in the project. This is achieved through the project’s changelog.
Every breaking change must be documented using a 'breaking' type changelog fragment. When writing the fragment, adhere to the following guidelines:
- Specify what was deprecated with a fully qualified name (e.g.
module.submodule.MyClass.deprecated_method
). - Suggest an alternative, if applicable, and explain any necessary migration steps.
- Briefly document why the deprecation was made (without going into excessive detail).
- Prioritize clarity and good wording
These entries are critical, as they are likely to be read by end-users of our library (programmers but non-contributors). Keep this in mind when crafting breaking change fragments.
Every breaking change needs its own entry
If your pull request introduces multiple breaking changes across different components, you must create individual changelog entries for each change.
Example of a good breaking changelog fragment
Suppose a library changes the return type of a function from a list to a set. This type change would be difficult to deprecate because the change affects existing code that relies on the specific return type.
Change return type of `mcproto.utils.get_items` from `list[str]` to `set[str]`.
This change was made to improve performance and ensure unique item retrieval.
The previous behavior of returning duplicates in a list has been removed,
which may impact existing code that relies on the previous return type.
Users should adjust their code to handle the new return type accordingly.
Even though it’s technically feasible to implement this as a non-breaking change - such as by creating a new
function or adding a boolean flag to control the return type, these approaches may not suit our use case. For
instance, if we were to introduce a boolean flag, we would need to set it to False
by default and show
deprecation warnings to users unless they explicitly set the flag to True
.
Eventually, when the deprecation period is over, the flag becomes pointless, but removing support for it would necessitate yet another round of deprecation for the flag itself, forcing users to revert to using the function without it. This approach could frustrate users and create unnecessary complexity.
When considering non-breaking changes, it’s crucial to evaluate potential complications like these. If you opt for a breaking change, be sure to include similar reasoning in your pull request description to help convey the rationale behind the decision.
Removing deprecations
We consider deprecation removals as a breaking change, which means that these removals also need to be documented. That said, it is sufficient for these removals to be documented in a single changelog fragment. These removals alongside with writing the fragment will be performed by the project maintainers at the time of the release.
Communicating deprecations¶
Even though a deprecation doesn’t immediately break code, it signals an upcoming change and it's essential to communicate this clearly to the users of our project. We achieve this through the project's changelog.
Benefits of tracking deprecations in changelog
While runtime deprecation warnings provide immediate feedback upon updating the library, it can often be beneficial to give users a chance to plan ahead before updating the library, especially for projects that perform automatic dependency updates through CI, which may not check for warnings, leading to deprecation warnings reaching production.
Additionally, it's often easy for people to miss/overlook the warnings if they're not looking for them in the CLI output, or if their project already produces some other warnings, making ours blend in.
By clearly documenting deprecations, we enable users to identify deprecated features before upgrading, allowing them to address issues proactively or at least prepare for changes.
A changelog entry serves as a permanent, versioned record of changes, providing detailed explanations of why a feature is deprecated, what the recommended replacements are. It's a place where people may look for clarification on why something was removed, or in search of migration steps after seeing the deprecation warning.
Every deprecation must be documented using a 'deprecation' type changelog fragment. When writing the fragment, similar guidelines to writing breaking changelog fragments apply:
- Provide the removal version i.e. version in which the deprecated feature will be removed (e.g.
4.0.0
). (1) - Specify what was deprecated with a fully qualified name (e.g.
module.submodule.MyClass.deprecated_method
). - Suggest an alternative, if applicable, and explain any necessary migration steps.
- Briefly document why the deprecation was made (without going into excessive detail).
- Prioritize clarity and good wording
- This point is specific to deprecations, it's the only additional point in comparison to the breaking changes guidelines.
These entries form the second most important part of the changelog, likely to be read by end-users. Keep this in mind when crafting deprecation fragments.
Every deprecated component needs it's own entry
Just like with breaking changes, if your're deprecating multiple different components, you must make multiple changelog entries, one for each deprecation.
Example of a good deprecation changelog fragment
Suppose we used a simple string configuration parameter but introduced a more flexible configuration object to allow for future extensions and better validation. This would be a good candidate for deprecation rather than an immediate breaking change.
Deprecate string-based `mcproto.utils.connect` configuration attribute in favor of `mcproto.utils.ConnectionConfig`.
The new `ConnectionConfig` object offers more flexibility by allowing users to specify multiple options (like
timeouts, retries, etc.) in a structured way, instead of relying on a string. Users are encouraged to migrate
to this object when calling `mcproto.utils.connect` to take full advantage of future improvements and
additional connection parameters.
- The string-based configuration support will be removed in version `4.0.0`.