-
Notifications
You must be signed in to change notification settings - Fork 30
✨ Introduce changelog-driven FastAPI route configuration system #7620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pcrespov
merged 11 commits into
ITISFoundation:master
from
pcrespov:mai/new-changelog-for-apis
May 5, 2025
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
44af0a7
✨ Refactor changelog handling in API routes and add tests for route c…
pcrespov 7de9f94
✨ Enhance changelog handling in route configuration and update tests …
pcrespov 3f9c566
✨ Add changelog management classes and validation functions for API r…
pcrespov 77094d7
revert
pcrespov 027aab2
✨ Refactor changelog management: update documentation, remove unused …
pcrespov 72d2ffa
✨ Update DeprecatedEndpoint to include version information in string …
pcrespov 38c677f
renamed removed by retired
pcrespov 3366d1b
✨ Refactor changelog entry classes to use abstract base class for imp…
pcrespov 045c8bd
@sanderegg review:newlines
pcrespov dac49f5
@sanderegg review:newlines
pcrespov d1c397d
Merge branch 'master' into mai/new-changelog-for-apis
mergify[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
243 changes: 243 additions & 0 deletions
243
packages/common-library/src/common_library/changelog.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
""" | ||
CHANGELOG formatted-messages for API routes | ||
|
||
- Append at the bottom of the route's description | ||
- These are displayed in the swagger/redoc doc | ||
- These are displayed in client's doc as well (auto-generator) | ||
- Inspired on this idea https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#describing-changes-between-versions | ||
""" | ||
|
||
from abc import ABC, abstractmethod | ||
from collections.abc import Sequence | ||
from enum import Enum, auto | ||
from typing import Any, ClassVar, cast | ||
|
||
from packaging.version import Version | ||
|
||
|
||
class ChangelogType(Enum): | ||
"""Types of changelog entries in their lifecycle order""" | ||
|
||
NEW = auto() | ||
CHANGED = auto() | ||
DEPRECATED = auto() | ||
RETIRED = auto() | ||
|
||
|
||
class ChangelogEntryAbstract(ABC): | ||
"""Base class for changelog entries""" | ||
|
||
entry_type: ClassVar[ChangelogType] | ||
|
||
@abstractmethod | ||
def to_string(self) -> str: | ||
"""Converts entry to a formatted string for documentation""" | ||
|
||
@abstractmethod | ||
def get_version(self) -> Version | None: | ||
"""Returns the version associated with this entry, if any""" | ||
|
||
|
||
class NewEndpoint(ChangelogEntryAbstract): | ||
"""Indicates when an endpoint was first added""" | ||
|
||
entry_type = ChangelogType.NEW | ||
|
||
def __init__(self, version: str): | ||
self.version = version | ||
|
||
def to_string(self) -> str: | ||
return f"New in *version {self.version}*" | ||
|
||
def get_version(self) -> Version: | ||
return Version(self.version) | ||
|
||
|
||
class ChangedEndpoint(ChangelogEntryAbstract): | ||
"""Indicates a change to an existing endpoint""" | ||
|
||
entry_type = ChangelogType.CHANGED | ||
|
||
def __init__(self, version: str, message: str): | ||
self.version = version | ||
self.message = message | ||
|
||
def to_string(self) -> str: | ||
return f"Changed in *version {self.version}*: {self.message}" | ||
|
||
def get_version(self) -> Version: | ||
return Version(self.version) | ||
|
||
|
||
class DeprecatedEndpoint(ChangelogEntryAbstract): | ||
"""Indicates an endpoint is deprecated and should no longer be used""" | ||
|
||
entry_type = ChangelogType.DEPRECATED | ||
|
||
def __init__(self, alternative_route: str, version: str | None = None): | ||
self.alternative_route = alternative_route | ||
self.version = version | ||
|
||
def to_string(self) -> str: | ||
base_message = "🚨 **Deprecated**" | ||
if self.version: | ||
base_message += f" in *version {self.version}*" | ||
|
||
return ( | ||
f"{base_message}: This endpoint is deprecated and will be removed in a future release.\n" | ||
f"Please use `{self.alternative_route}` instead." | ||
) | ||
|
||
def get_version(self) -> Version | None: | ||
return Version(self.version) if self.version else None | ||
|
||
|
||
class RetiredEndpoint(ChangelogEntryAbstract): | ||
"""Indicates when an endpoint will be or was removed""" | ||
|
||
entry_type = ChangelogType.RETIRED | ||
|
||
def __init__(self, version: str, message: str): | ||
self.version = version | ||
self.message = message | ||
|
||
def to_string(self) -> str: | ||
return f"Retired in *version {self.version}*: {self.message}" | ||
|
||
def get_version(self) -> Version: | ||
return Version(self.version) | ||
|
||
|
||
def create_route_description( | ||
*, | ||
base: str = "", | ||
changelog: Sequence[ChangelogEntryAbstract] | None = None, | ||
) -> str: | ||
""" | ||
Builds a consistent route description with optional changelog information. | ||
|
||
Args: | ||
base (str): Main route description. | ||
changelog (Sequence[ChangelogEntry]): List of changelog entries. | ||
|
||
Returns: | ||
str: Final description string. | ||
""" | ||
parts = [] | ||
|
||
if base: | ||
parts.append(base) | ||
|
||
if changelog: | ||
# NOTE: Adds a markdown section as : | New in version 0.6.0 | ||
changelog_strings = [f"> {entry.to_string()}\n" for entry in changelog] | ||
parts.append("\n".join(changelog_strings)) | ||
pcrespov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return "\n".join(parts) | ||
|
||
|
||
def validate_changelog(changelog: Sequence[ChangelogEntryAbstract]) -> None: | ||
""" | ||
Validates that the changelog entries follow the correct lifecycle order. | ||
|
||
Args: | ||
changelog: List of changelog entries to validate | ||
|
||
Raises: | ||
ValueError: If the changelog entries are not in a valid order | ||
""" | ||
if not changelog: | ||
return | ||
|
||
# Check each entry's type is greater than or equal to the previous | ||
prev_type = None | ||
for entry in changelog: | ||
if prev_type is not None and entry.entry_type.value < prev_type.value: | ||
msg = ( | ||
f"Changelog entries must be in lifecycle order. " | ||
f"Found {entry.entry_type.name} after {prev_type.name}." | ||
) | ||
raise ValueError(msg) | ||
prev_type = entry.entry_type | ||
|
||
# Ensure there's exactly one NEW entry as the first entry | ||
if changelog and changelog[0].entry_type != ChangelogType.NEW: | ||
msg = "First changelog entry must be NEW type" | ||
raise ValueError(msg) | ||
|
||
# Ensure there's at most one DEPRECATED entry | ||
deprecated_entries = [ | ||
e for e in changelog if e.entry_type == ChangelogType.DEPRECATED | ||
] | ||
if len(deprecated_entries) > 1: | ||
msg = "Only one DEPRECATED entry is allowed in a changelog" | ||
raise ValueError(msg) | ||
|
||
# Ensure all versions are valid | ||
for entry in changelog: | ||
version = entry.get_version() | ||
if version is None and entry.entry_type != ChangelogType.DEPRECATED: | ||
msg = f"Entry of type {entry.entry_type.name} must have a valid version" | ||
raise ValueError(msg) | ||
|
||
|
||
def create_route_config( | ||
base_description: str = "", | ||
*, | ||
current_version: str | Version, | ||
changelog: Sequence[ChangelogEntryAbstract] | None = None, | ||
) -> dict[str, Any]: | ||
""" | ||
Creates route configuration options including description based on changelog entries. | ||
|
||
The function analyzes the changelog to determine if the endpoint: | ||
- Is released and visible (if the earliest entry version is not in the future and not removed) | ||
- Is deprecated (if there's a DEPRECATED entry in the changelog) | ||
|
||
Args: | ||
base_description: Main route description | ||
current_version: Current version of the API | ||
changelog: List of changelog entries indicating version history | ||
|
||
Returns: | ||
dict: Route configuration options that can be used as kwargs for route decorators | ||
""" | ||
route_options: dict[str, Any] = {} | ||
changelog_list = list(changelog) if changelog else [] | ||
|
||
validate_changelog(changelog_list) | ||
|
||
if isinstance(current_version, str): | ||
current_version = Version(current_version) | ||
|
||
# Determine endpoint state | ||
is_deprecated = False | ||
is_released = True # Assume released by default | ||
is_removed = False | ||
|
||
# Get the first entry (NEW) to check if released | ||
if changelog_list and changelog_list[0].entry_type == ChangelogType.NEW: | ||
first_entry = cast(NewEndpoint, changelog_list[0]) | ||
first_version = first_entry.get_version() | ||
if first_version and first_version > current_version: | ||
is_released = False | ||
|
||
# Check for deprecation and removal | ||
for entry in changelog_list: | ||
if entry.entry_type == ChangelogType.DEPRECATED: | ||
is_deprecated = True | ||
elif entry.entry_type == ChangelogType.RETIRED: | ||
is_removed = True | ||
|
||
# Set route options based on endpoint state | ||
# An endpoint is included in schema if it's released and not removed | ||
route_options["include_in_schema"] = is_released and not is_removed | ||
route_options["deprecated"] = is_deprecated | ||
|
||
# Create description | ||
route_options["description"] = create_route_description( | ||
base=base_description, | ||
changelog=changelog_list, | ||
) | ||
|
||
return route_options |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.