Skip to content

Commit

Permalink
Allow to update deprecations/removals (#177)
Browse files Browse the repository at this point in the history
* Allow to update deprecations/removals.

* Add tests.

* Exit loop early.

Co-authored-by: Maxwell G <[email protected]>

* First draft of extra info.

* Improve code, add tests.

---------

Co-authored-by: Maxwell G <[email protected]>
  • Loading branch information
felixfontein and gotmax23 authored Nov 14, 2024
1 parent 24131a3 commit 6d814fb
Show file tree
Hide file tree
Showing 4 changed files with 345 additions and 6 deletions.
4 changes: 4 additions & 0 deletions changelogs/fragments/177-removal-updates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- "Allow information on removed or deprecated collections to be updated.
This is needed to generate a consistent changelog
(https://github.com/ansible-community/antsibull-core/pull/177)."
112 changes: 109 additions & 3 deletions src/antsibull_core/collection_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@

import pydantic as p
from antsibull_fileutils.yaml import load_yaml_file
from packaging.version import Version as PypiVer

from .pydantic import forbid_extras, get_formatted_error_messages
from .schemas.collection_meta import (
BaseRemovalInformation,
CollectionMetadata,
CollectionsMetadata,
RemovalInformation,
RemovalUpdate,
RemovedCollectionMetadata,
RemovedRemovalInformation,
)
Expand All @@ -46,6 +48,95 @@ def __init__(self, *, all_collections: list[str], major_release: int):
self.all_collections = all_collections
self.major_release = major_release

def _update_state_value(
self,
state: str | None,
accepted_states: list[str | None],
prefix: str,
index: int,
field_name: str,
) -> str:
if state not in accepted_states:
if state is not None:
self.errors.append(
f"{prefix}[{index}] -> {field_name}: Unexpected update after {state}"
)
else:
self.errors.append(
f"{prefix}[{index}] -> {field_name}: Unexpected first update"
)
return field_name

def _update_state(
self, state: str | None, index: int, update: RemovalUpdate, prefix: str
) -> tuple[str | None, PypiVer | None, str]:
if update.cancelled_version:
state = self._update_state_value(
state,
[None, "deprecated_version", "redeprecated_version"],
prefix,
index,
"cancelled_version",
)
return state, update.cancelled_version, "cancelled_version"
if update.deprecated_version:
state = self._update_state_value(
state, [None], prefix, index, "deprecated_version"
)
return state, update.deprecated_version, "deprecated_version"
if update.redeprecated_version:
state = self._update_state_value(
state,
["readded_version", "cancelled_version"],
prefix,
index,
"redeprecated_version",
)
return state, update.redeprecated_version, "redeprecated_version"
if update.removed_version:
state = self._update_state_value(
state, [None], prefix, index, "removed_version"
)
return state, update.removed_version, "removed_version"
if update.readded_version:
state = self._update_state_value(
state, ["removed_version"], prefix, index, "readded_version"
)
return state, update.readded_version, "readded_version"
# The following lines should never be reached:
self.errors.append(f"{prefix}[{index}]: Internal error") # pragma: no cover
return state, None, "" # pragma: no cover

def _validate_removal_updates(
self,
removal: BaseRemovalInformation,
indirect_updates: list[RemovalUpdate],
prefix: str,
) -> None:
prefix += " -> updates"
state = None
for update in indirect_updates:
state, _, __ = self._update_state(state, -1, update, prefix)
last_version = None
for index, update in enumerate(removal.updates):
state, version, field_name = self._update_state(
state, index, update, prefix
)
if version is None:
# The following line should never be reached:
pass # pragma: no cover
elif version.major != self.major_release:
self.errors.append(
f"{prefix}[{index}] -> {field_name}: Version's major version {version.major}"
f" must be the current major version {self.major_release}"
)
elif last_version is not None and version <= last_version:
self.errors.append(
f"{prefix}[{index}] -> {field_name}: Version {version}"
f" must be after the previous update's version {last_version}"
)
last_version = version

def _validate_removal_base(
self, collection: str, removal: BaseRemovalInformation, prefix: str
) -> None:
Expand All @@ -59,10 +150,14 @@ def _validate_removal(
removal.major_version != "TBD"
and removal.major_version <= self.major_release # pyre-ignore[58]
):
self.errors.append(
f"{prefix} major_version: Removal major version {removal.major_version} must"
f" be larger than current major version {self.major_release}"
is_ok = removal.major_version == self.major_release and any(
update.removed_version for update in removal.updates
)
if not is_ok:
self.errors.append(
f"{prefix} major_version: Removal major version {removal.major_version} must"
f" be larger than current major version {self.major_release}"
)

if (
removal.announce_version is not None
Expand All @@ -75,6 +170,17 @@ def _validate_removal(

self._validate_removal_base(collection, removal, prefix)

indirect_updates = []
if removal.announce_version is not None:
indirect_updates.append(
RemovalUpdate(
deprecated_version=removal.announce_version,
reason=removal.reason,
reason_text=removal.reason_text,
)
)
self._validate_removal_updates(removal, indirect_updates, prefix)

def _validate_collection(
self, collection: str, meta: CollectionMetadata, prefix: str
) -> None:
Expand Down
101 changes: 101 additions & 0 deletions src/antsibull_core/schemas/collection_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,88 @@ def _convert_pypi_version(v: t.Any) -> t.Any:
PydanticPypiVersion = Annotated[PypiVer, BeforeValidator(_convert_pypi_version)]


class RemovalUpdate(p.BaseModel):
"""
Stores metadata about removal updates, like when a deprecation has been cancelled,
the collection has been re-deprecated, when a removal has been undone, etc.
"""

model_config = p.ConfigDict(arbitrary_types_allowed=True)

# Exactly one of the following must be provided
cancelled_version: t.Optional[PydanticPypiVersion] = None
deprecated_version: t.Optional[PydanticPypiVersion] = None
redeprecated_version: t.Optional[PydanticPypiVersion] = None
removed_version: t.Optional[PydanticPypiVersion] = None
readded_version: t.Optional[PydanticPypiVersion] = None

# Overwrites the discussion link from BaseRemovalInformation if present
discussion: t.Optional[str] = None

# If deprecated_version or redeprecated_version: the reason because of which the
# collection will be removed.
reason: t.Optional[
t.Literal[
"deprecated",
"considered-unmaintained",
"renamed",
"guidelines-violation",
"other",
]
] = None

# If reason is not provided, or if reason is 'other', an optional extra text appended
# to the message.
reason_text: t.Optional[str] = None

@p.model_validator(mode="after") # pyre-ignore[56]
def _exactly_one_required(self) -> Self:
count = sum(
1 if x is not None else 0
for x in (
self.cancelled_version,
self.deprecated_version,
self.redeprecated_version,
self.removed_version,
self.readded_version,
)
)
if count != 1:
fields = (
"cancelled_version",
"deprecated_version",
"redeprecated_version",
"removed_version",
"readded_version",
)
raise ValueError(f"Exactly one of {', '.join(fields)} must be specified")
return self

@p.model_validator(mode="after") # pyre-ignore[56]
def _check_reason(self) -> Self:
if self.reason and not (self.deprecated_version or self.redeprecated_version):
raise ValueError(
"Reason can only be provided if 'deprecated_version'"
" or 'redeprecated_version' is used"
)
return self

@p.model_validator(mode="after") # pyre-ignore[56]
def _check_reason_text(self) -> Self:
reasons_with_text = ("other", "guidelines-violation")
if self.reason in reasons_with_text:
if self.reason_text is None:
raise ValueError(
f"Reason text must be provided if reason is '{self.reason}'"
)
elif self.reason:
if self.reason_text is not None:
raise ValueError(
f"Reason text must not be provided if reason is '{self.reason}'"
)
return self


class BaseRemovalInformation(p.BaseModel):
"""
Stores metadata on why a collection was/will get removed.
Expand Down Expand Up @@ -75,6 +157,9 @@ class BaseRemovalInformation(p.BaseModel):
# contents have been replaced by deprecated redirects.
redirect_replacement_major_version: t.Optional[int] = None

# Updates to the removal
updates: list[RemovalUpdate] = []

@p.model_validator(mode="after") # pyre-ignore[56]
def _check_reason_text(self) -> Self:
reasons_with_text = ("other", "guidelines-violation")
Expand Down Expand Up @@ -135,6 +220,22 @@ def _check_renamed(self) -> Self:
)
return self

def get_updates_including_indirect(self) -> list[RemovalUpdate]:
prefix = []
if self.announce_version:
prefix.append(RemovalUpdate(deprecated_version=self.announce_version))
return prefix + self.updates

def is_deprecated(self) -> bool:
result = True
for update in self.get_updates_including_indirect():
result = bool(
update.deprecated_version
or update.redeprecated_version
or update.removed_version
)
return result


class RemovedRemovalInformation(BaseRemovalInformation):
"""
Expand Down
Loading

0 comments on commit 6d814fb

Please sign in to comment.