Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ def base_version(self) -> str:

@async_property(cache=True)
async def data(self) -> typing.ChangelogDict:
# parse changelog data in executor
# return self.get_data(self.path)
return await self.project.execute(self.get_data, self.path)
return self.project.changelogs.validate_sections(
await self.project.execute(self.get_data, self.path),
self.path)

@property
@abstracts.interfacemethod
Expand Down Expand Up @@ -283,6 +283,31 @@ def sections(self) -> typing.ChangelogSectionsDict:
f"({self.sections_path})\n{e}")
return cast(typing.ChangelogSectionsDict, e.value)

def validate_sections(
self,
data: typing.ChangelogDict,
path: pathlib.Path | None = None) -> typing.ChangelogDict:
"""Validate changelog sections loaded from any parse source.

This should be called for every parsed `ChangelogDict`, whether
parsed from a YAML changelog file or assembled from per-entry
changelog data.

:param data: Parsed changelog data to validate.
:param path: Optional source path for error context.
:returns: The input data, unchanged.
:raises ChangelogParseError: If any section key is unknown.
"""
allowed = set(self.sections) | {"date"}
unknown = sorted(k for k in data if k not in allowed)
if unknown:
where = f" ({path})" if path is not None else ""
raise exceptions.ChangelogParseError(
f"Unknown changelog section(s){where}: "
f"{', '.join(unknown)}. "
f"Valid sections come from {CHANGELOG_SECTIONS_PATH}.")
return data

@property
def sections_path(self) -> pathlib.Path:
return self.project.path.joinpath(CHANGELOG_SECTIONS_PATH)
Expand Down
8 changes: 8 additions & 0 deletions py/envoy.base.utils/envoy/base/utils/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ def sections(self) -> typing.ChangelogSectionsDict:
"""Changelog groupings/sections."""
raise NotImplementedError

@abstracts.interfacemethod
def validate_sections(
self,
data: typing.ChangelogDict,
path: pathlib.Path | None = None) -> typing.ChangelogDict:
"""Validate parsed changelog data against configured sections."""
raise NotImplementedError

@abstracts.interfacemethod
def blank_summary(self) -> None:
"""Make the changelog summary empty."""
Expand Down
11 changes: 8 additions & 3 deletions py/envoy.base.utils/envoy/base/utils/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,17 @@ class BaseChangelogDict(TypedDict):
date: str


ChangelogSourceDict = dict[str, Any]


# Section-name -> entries. Section names are arbitrary at the type level
# and validated at runtime against the project's `changelogs/sections.yaml`
# (see AChangelogs.sections / AChangelogs.validate_sections).
ChangelogChangeSectionsDict = dict[str, ChangeList]
SourceChangelogChangeSectionsDict = dict[str, SourceChangeList]


# `date` is always typed via `BaseChangelogDict`; section keys live
# alongside it but are runtime-validated in
# `AChangelogs.validate_sections`.
ChangelogSourceDict = dict[str, Any]
ChangelogDict = dict[str, Any]


Expand Down
77 changes: 76 additions & 1 deletion py/envoy.base.utils/tests/test_abstract_project_changelogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,48 @@ def test_abstract_changelogs_sections_path():
assert "sections_path" not in changelogs.__dict__


def test_abstract_changelogs_validate_sections():
changelogs = DummyChangelogs("PROJECT")
changelogs.sections = {"known": {"title": "Known"}}
valid = {
"date": "Pending",
"known": [{"area": "api", "change": "updated"}]}
date_only = {"date": "Pending"}
assert changelogs.validate_sections(valid) is valid
assert changelogs.validate_sections(date_only) is date_only


@pytest.mark.parametrize(
"path",
[None, pathlib.Path("changelogs/current.yaml")])
def test_abstract_changelogs_validate_sections_unknown(path):
changelogs = DummyChangelogs("PROJECT")
changelogs.sections = {"known": {"title": "Known"}}
with pytest.raises(exceptions.ChangelogParseError) as e:
changelogs.validate_sections(
{"date": "Pending", "unknown": []},
path)

message = e.value.args[0]
assert "unknown" in message
assert abstract.project.changelog.CHANGELOG_SECTIONS_PATH in message
if path is None:
assert "changelogs/current.yaml" not in message
assert "(None)" not in message
else:
assert f"({path})" in message


def test_abstract_changelogs_validate_sections_unknown_sorted():
changelogs = DummyChangelogs("PROJECT")
changelogs.sections = {"known": {"title": "Known"}}
with pytest.raises(exceptions.ChangelogParseError) as e:
changelogs.validate_sections(
{"date": "Pending", "zeta": [], "alpha": [], "beta": []})

assert "alpha, beta, zeta" in e.value.args[0]


def test_abstract_changelogs_summary_path():
project = MagicMock()
changelogs = DummyChangelogs(project)
Expand Down Expand Up @@ -1125,6 +1167,7 @@ def test_abstract_changelog_base_version(patches):
async def test_abstract_changelog_data(patches):
project = MagicMock()
project.execute = AsyncMock()
project.changelogs.validate_sections.return_value = "VALIDATED"
changelog = DummyChangelog(project, "VERSION", "PATH")
patched = patches(
"AChangelog.get_data",
Expand All @@ -1135,14 +1178,46 @@ async def test_abstract_changelog_data(patches):
with patched as (m_get, m_path):
assert (
await changelog.data
== project.execute.return_value
== project.changelogs.validate_sections.return_value
== getattr(
changelog,
abstract.AChangelog.data.cache_name)["data"])

assert (
project.execute.call_args
== [(m_get, m_path.return_value), {}])
assert (
project.changelogs.validate_sections.call_args
== [(project.execute.return_value, m_path.return_value), {}])


async def test_abstract_changelog_data_unknown_section(tmp_path):
changelog_path = tmp_path.joinpath("changelogs/current.yaml")
changelog_path.parent.mkdir()
changelog_path.write_text(
"date: Pending\n"
"unknown:\n"
" - area: api\n"
" change: changed\n")
tmp_path.joinpath("changelogs/sections.yaml").write_text(
"known:\n"
" title: Known\n")

project = MagicMock()
project.path = tmp_path

async def execute(func, path):
return func(path)

project.execute = AsyncMock(side_effect=execute)
project.changelogs = DummyChangelogs(project)
changelog = DummyChangelog(project, "VERSION", changelog_path)

with pytest.raises(exceptions.ChangelogParseError) as e:
await changelog.data

assert "unknown" in e.value.args[0]
assert f"({changelog_path})" in e.value.args[0]


async def test_abstract_changelog_release_date(patches):
Expand Down