Skip to content

Commit a96cf46

Browse files
committed
✨ Add changelog management classes and validation functions for API routes
1 parent e3dd09f commit a96cf46

File tree

2 files changed

+520
-0
lines changed

2 files changed

+520
-0
lines changed
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
from abc import ABC, abstractmethod
2+
from collections.abc import Sequence
3+
from enum import Enum, auto
4+
from typing import Any, ClassVar, Final, cast
5+
6+
from packaging.version import Version
7+
8+
from ..._meta import API_VERSION
9+
10+
#
11+
# CHANGELOG formatted-messages for API routes
12+
#
13+
# - Append at the bottom of the route's description
14+
# - These are displayed in the swagger doc
15+
# - These are displayed in client's doc as well (auto-generator)
16+
# - Inspired on this idea https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#describing-changes-between-versions
17+
#
18+
19+
# newly created endpoint in given version
20+
FMSG_CHANGELOG_NEW_IN_VERSION: Final[str] = "New in *version {}*\n"
21+
22+
# changes in given version with message
23+
FMSG_CHANGELOG_CHANGED_IN_VERSION: Final[str] = "Changed in *version {}*: {}\n"
24+
25+
# marked as deprecated
26+
FMSG_CHANGELOG_DEPRECATED_IN_VERSION: Final[str] = (
27+
"🚨 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\n"
28+
"Please use `{}` instead.\n\n"
29+
)
30+
31+
# marked as deprecated and will be removed in given version
32+
FMSG_CHANGELOG_REMOVED_IN_VERSION: Final[str] = "Removed in *version {}*: {}\n"
33+
34+
35+
DEFAULT_MAX_STRING_LENGTH: Final[int] = 500
36+
37+
38+
class ChangelogType(Enum):
39+
"""Types of changelog entries in their lifecycle order"""
40+
41+
NEW = auto()
42+
CHANGED = auto()
43+
DEPRECATED = auto()
44+
REMOVED = auto()
45+
46+
47+
class ChangelogEntry(ABC):
48+
"""Base class for changelog entries"""
49+
50+
entry_type: ClassVar[ChangelogType]
51+
52+
@abstractmethod
53+
def to_string(self) -> str:
54+
"""Converts entry to a formatted string for documentation"""
55+
pass
56+
57+
@abstractmethod
58+
def get_version(self) -> Version | None:
59+
"""Returns the version associated with this entry, if any"""
60+
pass
61+
62+
63+
class NewEndpoint(ChangelogEntry):
64+
"""Indicates when an endpoint was first added"""
65+
66+
entry_type = ChangelogType.NEW
67+
68+
def __init__(self, version: str):
69+
self.version = version
70+
71+
def to_string(self) -> str:
72+
return f"New in *version {self.version}*\n"
73+
74+
def get_version(self) -> Version:
75+
return Version(self.version)
76+
77+
78+
class ChangedEndpoint(ChangelogEntry):
79+
"""Indicates a change to an existing endpoint"""
80+
81+
entry_type = ChangelogType.CHANGED
82+
83+
def __init__(self, version: str, message: str):
84+
self.version = version
85+
self.message = message
86+
87+
def to_string(self) -> str:
88+
return f"Changed in *version {self.version}*: {self.message}\n"
89+
90+
def get_version(self) -> Version:
91+
return Version(self.version)
92+
93+
94+
class DeprecatedEndpoint(ChangelogEntry):
95+
"""Indicates an endpoint is deprecated and should no longer be used"""
96+
97+
entry_type = ChangelogType.DEPRECATED
98+
99+
def __init__(self, alternative_route: str, version: str | None = None):
100+
self.alternative_route = alternative_route
101+
self.version = version
102+
103+
def to_string(self) -> str:
104+
return (
105+
"🚨 **Deprecated**: This endpoint is deprecated and will be removed in a future release.\n"
106+
f"Please use `{self.alternative_route}` instead.\n\n"
107+
)
108+
109+
def get_version(self) -> Version | None:
110+
return Version(self.version) if self.version else None
111+
112+
113+
class RemovedEndpoint(ChangelogEntry):
114+
"""Indicates when an endpoint will be or was removed"""
115+
116+
entry_type = ChangelogType.REMOVED
117+
118+
def __init__(self, version: str, message: str):
119+
self.version = version
120+
self.message = message
121+
122+
def to_string(self) -> str:
123+
return f"Removed in *version {self.version}*: {self.message}\n"
124+
125+
def get_version(self) -> Version:
126+
return Version(self.version)
127+
128+
129+
def create_route_description(
130+
*,
131+
base: str = "",
132+
changelog: Sequence[ChangelogEntry] | None = None,
133+
) -> str:
134+
"""
135+
Builds a consistent route description with optional changelog information.
136+
137+
Args:
138+
base (str): Main route description.
139+
changelog (Sequence[ChangelogEntry]): List of changelog entries.
140+
141+
Returns:
142+
str: Final description string.
143+
"""
144+
parts = []
145+
146+
if base:
147+
parts.append(base)
148+
149+
if changelog:
150+
changelog_strings = [entry.to_string() for entry in changelog]
151+
parts.append("\n".join(changelog_strings))
152+
153+
return "\n\n".join(parts)
154+
155+
156+
def validate_changelog(changelog: Sequence[ChangelogEntry]) -> None:
157+
"""
158+
Validates that the changelog entries follow the correct lifecycle order.
159+
160+
Args:
161+
changelog: List of changelog entries to validate
162+
163+
Raises:
164+
ValueError: If the changelog entries are not in a valid order
165+
"""
166+
if not changelog:
167+
return
168+
169+
# Check each entry's type is greater than or equal to the previous
170+
prev_type = None
171+
for entry in changelog:
172+
if prev_type is not None and entry.entry_type.value < prev_type.value:
173+
msg = (
174+
f"Changelog entries must be in lifecycle order. "
175+
f"Found {entry.entry_type.name} after {prev_type.name}."
176+
)
177+
raise ValueError(msg)
178+
prev_type = entry.entry_type
179+
180+
# Ensure there's exactly one NEW entry as the first entry
181+
if changelog and changelog[0].entry_type != ChangelogType.NEW:
182+
msg = "First changelog entry must be NEW type"
183+
raise ValueError(msg)
184+
185+
# Ensure there's at most one DEPRECATED entry
186+
deprecated_entries = [
187+
e for e in changelog if e.entry_type == ChangelogType.DEPRECATED
188+
]
189+
if len(deprecated_entries) > 1:
190+
msg = "Only one DEPRECATED entry is allowed in a changelog"
191+
raise ValueError(msg)
192+
193+
# Ensure all versions are valid
194+
for entry in changelog:
195+
version = entry.get_version()
196+
if version is None and entry.entry_type != ChangelogType.DEPRECATED:
197+
msg = f"Entry of type {entry.entry_type.name} must have a valid version"
198+
raise ValueError(msg)
199+
200+
201+
def create_route_config(
202+
base_description: str = "",
203+
*,
204+
changelog: Sequence[ChangelogEntry] | None = None,
205+
) -> dict[str, Any]:
206+
"""
207+
Creates route configuration options including description based on changelog entries.
208+
209+
The function analyzes the changelog to determine if the endpoint:
210+
- Is not yet released (if the earliest entry version is in the future)
211+
- Is deprecated (if there's a DEPRECATED entry in the changelog)
212+
213+
Args:
214+
base_description: Main route description
215+
changelog: List of changelog entries indicating version history
216+
217+
Returns:
218+
dict: Route configuration options that can be used as kwargs for route decorators
219+
"""
220+
route_options: dict[str, Any] = {}
221+
changelog_list = list(changelog) if changelog else []
222+
223+
validate_changelog(changelog_list)
224+
225+
# Determine endpoint state
226+
is_deprecated = False
227+
is_unreleased = False
228+
current_version = Version(API_VERSION)
229+
230+
# Get the first entry (NEW) to check if unreleased
231+
if changelog_list and changelog_list[0].entry_type == ChangelogType.NEW:
232+
first_entry = cast(NewEndpoint, changelog_list[0])
233+
first_version = first_entry.get_version()
234+
if first_version and first_version > current_version:
235+
is_unreleased = True
236+
237+
# Check for deprecation
238+
for entry in changelog_list:
239+
if entry.entry_type == ChangelogType.DEPRECATED:
240+
is_deprecated = True
241+
break
242+
243+
# Set route options based on endpoint state
244+
route_options["include_in_schema"] = not is_unreleased
245+
route_options["deprecated"] = is_deprecated
246+
247+
# Create description
248+
route_options["description"] = create_route_description(
249+
base=base_description,
250+
changelog=changelog_list,
251+
)
252+
253+
return route_options

0 commit comments

Comments
 (0)