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
18 changes: 18 additions & 0 deletions py/envoy.base.utils/envoy/base/utils/abstract/project/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
CHANGELOG_CURRENT_DIR_PATH = "changelogs/current"
CHANGELOG_ENTRY_GLOB = "*/*.rst"
CHANGELOG_SECTIONS_PATH = "changelogs/sections.yaml"
CHANGELOG_AREAS_PATH = "changelogs/areas.yaml"
ENTRY_SEPARATOR = "__"
CHANGELOG_SUMMARY_PATH = "changelogs/summary.md"
CHANGELOG_URL_TPL = (
Expand Down Expand Up @@ -331,6 +332,19 @@ def sections(self) -> typing.ChangelogSectionsDict:
f"({self.sections_path})\n{e}")
return cast(typing.ChangelogSectionsDict, e.value)

@cached_property
def areas(self) -> typing.ChangelogAreasDict:
if not self.areas_path.exists():
return cast(typing.ChangelogAreasDict, {})
try:
return utils.from_yaml(
self.areas_path,
typing.ChangelogAreasDict)
except (_yaml.reader.ReaderError, utils.TypeCastingError) as e:
raise exceptions.ChangelogError(
"Failed to parse changelog areas "
f"({self.areas_path}): {e}")

def validate_sections(
self,
data: typing.ChangelogDict,
Expand Down Expand Up @@ -360,6 +374,10 @@ def validate_sections(
def sections_path(self) -> pathlib.Path:
return self.project.path.joinpath(CHANGELOG_SECTIONS_PATH)

@property
def areas_path(self) -> pathlib.Path:
return self.project.path.joinpath(CHANGELOG_AREAS_PATH)

@property
def summary_path(self) -> pathlib.Path:
return self.project.path.joinpath(CHANGELOG_SUMMARY_PATH)
Expand Down
6 changes: 6 additions & 0 deletions py/envoy.base.utils/envoy/base/utils/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ async def is_pending(self) -> bool:
`Pending`."""
raise NotImplementedError

@property
@abstracts.interfacemethod
def areas(self) -> typing.ChangelogAreasDict:
"""Changelog areas."""
raise NotImplementedError

@property
@abstracts.interfacemethod
def sections(self) -> typing.ChangelogSectionsDict:
Expand Down
7 changes: 7 additions & 0 deletions py/envoy.base.utils/envoy/base/utils/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ class ChangelogSectionDict(BaseChangelogSectionDict, total=False):

ChangelogSectionsDict = dict[str, ChangelogSectionDict]


class ChangelogAreaDict(BaseChangelogSectionDict):
pass


ChangelogAreasDict = dict[str, ChangelogAreaDict]

VersionConfigDict = dict[str, str]


Expand Down
57 changes: 57 additions & 0 deletions py/envoy.base.utils/tests/test_abstract_project_changelogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,51 @@ def test_abstract_changelogs_sections(patches, raises):
f"({m_path.return_value}): {str(error)}"))


@pytest.mark.parametrize("exists", [True, False])
@pytest.mark.parametrize(
"raises",
[None, Exception, yaml.reader.ReaderError, exceptions.TypeCastingError])
def test_abstract_changelogs_areas(patches, exists, raises):
changelogs = DummyChangelogs("PROJECT")
patched = patches(
"utils.from_yaml",
("AChangelogs.areas_path",
dict(new_callable=PropertyMock)),
prefix="envoy.base.utils.abstract.project.changelog")

with patched as (m_yaml, m_path):
m_path.return_value.exists.return_value = exists
if exists and raises:
error = raises("AN ERROR OCCURRED", 7, 23, "Y", "Z")
m_yaml.side_effect = error
if not exists:
assert changelogs.areas == {}
elif raises == Exception:
with pytest.raises(Exception):
changelogs.areas
elif raises in (yaml.reader.ReaderError, exceptions.TypeCastingError):
with pytest.raises(exceptions.ChangelogError) as e:
changelogs.areas
else:
assert changelogs.areas == m_yaml.return_value

assert (
("areas" in changelogs.__dict__)
== (not raises or not exists))
if not exists:
assert not m_yaml.called
return
assert (
m_yaml.call_args
== [(m_path.return_value, typing.ChangelogAreasDict), {}])
if raises not in (yaml.reader.ReaderError, exceptions.TypeCastingError):
return
assert (
e.value.args[0]
== ("Failed to parse changelog areas "
f"({m_path.return_value}): {str(error)}"))


def test_abstract_changelogs_sections_path():
project = MagicMock()
changelogs = DummyChangelogs(project)
Expand All @@ -399,6 +444,18 @@ def test_abstract_changelogs_sections_path():
assert "sections_path" not in changelogs.__dict__


def test_abstract_changelogs_areas_path():
project = MagicMock()
changelogs = DummyChangelogs(project)
assert (
changelogs.areas_path
== project.path.joinpath.return_value)
assert (
project.path.joinpath.call_args
== [(abstract.project.changelog.CHANGELOG_AREAS_PATH, ), {}])
assert "areas_path" not in changelogs.__dict__


def test_abstract_changelogs_validate_sections():
changelogs = DummyChangelogs("PROJECT")
changelogs.sections = {"known": {"title": "Known"}}
Expand Down
58 changes: 55 additions & 3 deletions py/envoy.code.check/envoy/code/check/abstract/changelog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

import itertools
import pathlib
import re
from datetime import datetime
from functools import cached_property
from collections.abc import Iterator
Expand All @@ -20,6 +21,15 @@


MAX_VERSION_FOR_CHANGES_SECTION = "1.16"
try:
from envoy.base.utils.abstract.project.changelog import (
CHANGELOG_AREAS_PATH,
)
except ImportError:
CHANGELOG_AREAS_PATH = "changelogs/areas.yaml"
VALID_CHANGELOG_AREA_RE = re.compile(r"^[a-z0-9_\-/]+$")
VALID_CHANGELOG_AREA_PATTERN = r"[a-z0-9_\-/]+"
CHANGELOG_AREAS_FILE = pathlib.Path(CHANGELOG_AREAS_PATH)


@abstracts.implementer(interface.IChangelogChangesChecker)
Expand All @@ -30,8 +40,10 @@ class AChangelogChangesChecker(metaclass=abstracts.Abstraction):

def __init__(
self,
sections: utils.typing.ChangelogSectionsDict) -> None:
sections: utils.typing.ChangelogSectionsDict,
areas: "utils.typing.ChangelogAreasDict") -> None:
self.sections = sections
self.areas = areas

@property # type:ignore
@abstracts.interfacemethod
Expand Down Expand Up @@ -120,10 +132,41 @@ def check_entry_filename(
area, slug = path.stem.split(ENTRY_SEPARATOR, 1)
if not area:
return f"{path}: Area part of filename is empty"
if self.areas and area not in self.areas:
return (
f"{path}: Invalid area '{area}'. "
f"Valid areas come from {CHANGELOG_AREAS_PATH}")
if not slug:
return f"{path}: Slug part of filename is empty"
return None

def check_areas_file(self) -> tuple[str, ...]:
if not self.areas:
return ()
title_areas: dict[str, list[str]] = {}
errors = []
for area, area_data in self.areas.items():
title = area_data["title"]
title_areas.setdefault(title, []).append(area)
if not VALID_CHANGELOG_AREA_RE.match(area):
errors.append(
f"{CHANGELOG_AREAS_FILE}: "
f"Invalid area key '{area}' "
f"(must match {VALID_CHANGELOG_AREA_PATTERN})")
if not VALID_CHANGELOG_AREA_RE.match(title):
errors.append(
f"{CHANGELOG_AREAS_FILE}: "
f"Invalid title '{title}' for area '{area}' "
f"(must match {VALID_CHANGELOG_AREA_PATTERN})")
for title, areas in sorted(title_areas.items()):
if len(areas) < 2:
continue
errors.append(
f"{CHANGELOG_AREAS_FILE}: "
f"Duplicate title '{title}' used by areas: "
f"{', '.join(sorted(areas))}")
return tuple(errors)

def check_entry_content(
self,
path: pathlib.Path) -> str | None:
Expand Down Expand Up @@ -196,15 +239,17 @@ def entry_dir(self) -> pathlib.Path | None:

@async_property(cache=True)
async def errors(self) -> tuple[str, ...]:
areas_errors = await self.check_areas_file()
entry_errors = await self.check_entry_files()
try:
return (
*self.check_version(),
*await self.check_date(),
*await self.check_sections(),
*areas_errors,
*entry_errors)
except utils.exceptions.ChangelogParseError as e:
return (*entry_errors, f"{self.version}: {e}")
return (*areas_errors, *entry_errors, f"{self.version}: {e}")

@async_property
async def invalid_date(self) -> str | None:
Expand Down Expand Up @@ -286,6 +331,12 @@ async def check_entry_files(self) -> tuple[str, ...]:
self.checker.check_entry_files,
paths)

async def check_areas_file(self) -> tuple[str, ...]:
areas = self.project.changelogs.areas
if not self.is_current or not areas:
return ()
return await self.project.execute(self.checker.check_areas_file)

def check_version(self) -> tuple[str, ...]:
errors = []
if self.duplicate_current:
Expand Down Expand Up @@ -322,7 +373,8 @@ def changes_checker_class(
@cached_property
def changes_checker(self) -> interface.IChangelogChangesChecker:
return self.changes_checker_class(
self.project.changelogs.sections)
self.project.changelogs.sections,
self.project.changelogs.areas)

@property # type:ignore
@abstracts.interfacemethod
Expand Down
4 changes: 4 additions & 0 deletions py/envoy.code.check/envoy/code/check/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ def check_entry_content(
path: pathlib.Path) -> str | None:
raise NotImplementedError

@abstracts.interfacemethod
def check_areas_file(self) -> tuple[str, ...]:
raise NotImplementedError

@abstracts.interfacemethod
def check_entry_files(
self,
Expand Down
Loading