diff --git a/.changelog/030.yaml b/.changelog/030.yaml new file mode 100644 index 0000000..c1309d8 --- /dev/null +++ b/.changelog/030.yaml @@ -0,0 +1,13 @@ +name: generate +ts: 2026-04-12 16:53:43.865828+00:00 +type: fix +author: Espen Albert +changelog_message: 'fix(docs): Filter changelog actions by group to prevent duplicate entries for same-named symbols' +group: generate +message: 'fix(docs): Filter changelog actions by group to prevent duplicate entries for same-named symbols' +short_sha: 03015c +--- +name: action_group +ts: 2026-04-12 17:09:03.607681+00:00 +type: keep_private +full_path: _internal.changelog.actions.action_group diff --git a/.groups.yaml b/.groups.yaml index 4c3645d..86b55a7 100644 --- a/.groups.yaml +++ b/.groups.yaml @@ -47,7 +47,6 @@ groups: - _internal.cli.gen_cmds owned_refs: - _internal.cli.gen_cmds.gen_docs - - _internal.cli.gen_cmds.gen_tests - name: stability owned_modules: - _internal.cli.stability_cmds diff --git a/pkg_ext/_internal/changelog/__init__.py b/pkg_ext/_internal/changelog/__init__.py index 2de6c92..713cf53 100644 --- a/pkg_ext/_internal/changelog/__init__.py +++ b/pkg_ext/_internal/changelog/__init__.py @@ -25,6 +25,7 @@ ReleaseAction, RenameAction, StabilityTarget, + action_group, changelog_filepath, default_changelog_path, dump_changelog_actions, @@ -51,6 +52,7 @@ "ReleaseAction", "RenameAction", "StabilityTarget", + "action_group", "changelog_filepath", "default_changelog_path", "dump_changelog_actions", diff --git a/pkg_ext/_internal/changelog/actions.py b/pkg_ext/_internal/changelog/actions.py index e77f8db..a6363c3 100644 --- a/pkg_ext/_internal/changelog/actions.py +++ b/pkg_ext/_internal/changelog/actions.py @@ -141,19 +141,27 @@ def stable_sort_key(self) -> tuple[str, ...]: class FixAction(ChangelogActionBase): type: Literal["fix"] = "fix" + group: str = "" short_sha: str message: str changelog_message: str = "" rephrased: bool = False ignored: bool = False + @model_validator(mode="after") + def _backfill_group(self) -> Self: + """Legacy YAML files store the group in `name`; copy it to `group`.""" + if not self.group and self.name: + self.group = self.name + return self + @property def bump_type(self) -> BumpType: return BumpType.PATCH @property def stable_sort_key(self) -> tuple[str, ...]: - return (self.type, self.short_sha, self.name) + return (self.type, self.group, self.short_sha, self.name) class DeleteAction(ChangelogActionBase): @@ -354,6 +362,28 @@ def stable_sort_key(self) -> tuple[str, ...]: ] +def action_group(action: ChangelogAction) -> str | None: + match action: + case ( + MakePublicAction(group=g) + | DeleteAction(group=g) + | RenameAction(group=g) + | BreakingChangeAction(group=g) + | AdditionalChangeAction(group=g) + | FixAction(group=g) + ): + return g or None + case ( + ExperimentalAction(target=StabilityTarget.group, name=name) + | GAAction(target=StabilityTarget.group, name=name) + | DeprecatedAction(target=StabilityTarget.group, name=name) + ): + return name + case ExperimentalAction(group=g) | GAAction(group=g) | DeprecatedAction(group=g): + return g + return None + + _changelog_action_adapter: TypeAdapter[ChangelogAction] = TypeAdapter(ChangelogAction) diff --git a/pkg_ext/_internal/changelog/committer.py b/pkg_ext/_internal/changelog/committer.py index d2b0122..b546fb1 100644 --- a/pkg_ext/_internal/changelog/committer.py +++ b/pkg_ext/_internal/changelog/committer.py @@ -120,6 +120,7 @@ def fix_changelog_action(commit: GitCommit, ctx: pkg_ctx) -> FixAction | None: if public_group == SKIPPED: return FixAction( name="", + group="", short_sha=commit_sha, message=commit_message, ignored=True, @@ -131,6 +132,7 @@ def fix_changelog_action(commit: GitCommit, ctx: pkg_ctx) -> FixAction | None: prompt_text = f"commit({commit_sha}): {commit_message}" fix = prompt_for_fix(commit_sha, commit_message, prompt_text) fix.name = group + fix.group = group fix.author = commit.author return fix diff --git a/pkg_ext/_internal/changelog/rebase_test.py b/pkg_ext/_internal/changelog/rebase_test.py index 1f3feec..3b3aea2 100644 --- a/pkg_ext/_internal/changelog/rebase_test.py +++ b/pkg_ext/_internal/changelog/rebase_test.py @@ -31,6 +31,7 @@ def _fix(sha: str, message: str, ts: datetime | None = None, ignored: bool = Fal short_sha=sha, message=message, name="group1", + group="group1", ts=ts or datetime(2025, 1, 1, tzinfo=UTC), author="test", ignored=ignored, diff --git a/pkg_ext/_internal/changelog/write_changelog_md.py b/pkg_ext/_internal/changelog/write_changelog_md.py index 33a9f40..9007b05 100644 --- a/pkg_ext/_internal/changelog/write_changelog_md.py +++ b/pkg_ext/_internal/changelog/write_changelog_md.py @@ -140,7 +140,7 @@ def _group_changelog_entries( for action in BumpType.sort_by_bump(actions): line = as_changelog_line(action, remote_url, ctx) try: - group: PublicGroup = ctx.action_group(action) + group: PublicGroup = ctx.get_action_group(action) group_sections[group.name].append(line) except NoPublicGroupMatch: other_sections.append(line) diff --git a/pkg_ext/_internal/context.py b/pkg_ext/_internal/context.py index 647789d..f0d05ad 100644 --- a/pkg_ext/_internal/context.py +++ b/pkg_ext/_internal/context.py @@ -4,18 +4,9 @@ from pathlib import Path from pkg_ext._internal.changelog import ( - AdditionalChangeAction, - BreakingChangeAction, ChangelogAction, ChangelogActionBase, - DeleteAction, - DeprecatedAction, - ExperimentalAction, - FixAction, - GAAction, - MakePublicAction, - RenameAction, - StabilityTarget, + action_group, changelog_filepath, default_changelog_path, dump_changelog_actions, @@ -82,30 +73,9 @@ def pr_changelog_actions(self) -> list[ChangelogAction]: return parse_changelog_file_path(self.changelog_path) return self._actions - def action_group(self, action: ChangelogAction) -> PublicGroup: - match action: - case ( - MakePublicAction(group=group) - | DeleteAction(group=group) - | RenameAction(group=group) - | BreakingChangeAction(group=group) - | AdditionalChangeAction(group=group) - ): - return self.tool_state.groups.get_or_create_group(group) - case ( - ExperimentalAction(target=StabilityTarget.symbol, group=group) - | GAAction(target=StabilityTarget.symbol, group=group) - | DeprecatedAction(target=StabilityTarget.symbol, group=group) - ): - return self.tool_state.groups.get_or_create_group(group) # type: ignore[arg-type] - case ( - ExperimentalAction(target=StabilityTarget.group, name=name) - | GAAction(target=StabilityTarget.group, name=name) - | DeprecatedAction(target=StabilityTarget.group, name=name) - ): - return self.tool_state.groups.get_or_create_group(name) - case FixAction(name=group_name): - return self.tool_state.groups.get_or_create_group(group_name) + def get_action_group(self, action: ChangelogAction) -> PublicGroup: + if group_name := action_group(action): + return self.tool_state.groups.get_or_create_group(group_name) raise NoPublicGroupMatch() def __enter__(self) -> pkg_ctx: diff --git a/pkg_ext/_internal/generation/docs.py b/pkg_ext/_internal/generation/docs.py index 3da4993..05227ad 100644 --- a/pkg_ext/_internal/generation/docs.py +++ b/pkg_ext/_internal/generation/docs.py @@ -10,6 +10,7 @@ from model_lib import Entity from zero_3rdparty.sections import slug, wrap_section +from pkg_ext._internal.changelog import actions as changelog_actions_mod from pkg_ext._internal.changelog.actions import ChangelogAction from pkg_ext._internal.config import ( PKG_EXT_TOOL_NAME, @@ -39,6 +40,7 @@ class GeneratedDocsOutput(Entity): @dataclass class SymbolContext: symbol: SymbolDump + group_name: str = "" has_env_vars: bool = False has_meaningful_changes: bool = False is_primary: bool = False @@ -72,11 +74,15 @@ def build_symbol_context( changelog_actions: list[ChangelogAction], ) -> SymbolContext: has_changes = any( - action.name == symbol.name and isinstance(action, MEANINGFUL_CHANGE_ACTIONS) for action in changelog_actions + action.name == symbol.name + and isinstance(action, MEANINGFUL_CHANGE_ACTIONS) + and ((ag := changelog_actions_mod.action_group(action)) is None or ag == group_name) + for action in changelog_actions ) is_primary = symbol.name.lower() == group_name.lower() return SymbolContext( symbol=symbol, + group_name=group_name, has_env_vars=has_env_vars(symbol), has_meaningful_changes=has_changes, is_primary=is_primary, @@ -140,7 +146,7 @@ def render_group_index( for ctx in sorted_contexts: if not ctx.needs_own_page: section_id = f"{slug(ctx.symbol.name)}_def" - symbol_changes = build_symbol_changes(ctx.symbol.name, changelog_actions_list) + symbol_changes = build_symbol_changes(ctx.symbol.name, changelog_actions_list, ctx.group_name) example_link = _build_example_link(ctx.symbol.name, group.name, examples_set, examples_dir, "../examples/") inline_content = render_inline_symbol( ctx, @@ -213,7 +219,7 @@ def generate_docs( symbol_path = f"{dir_name}/{ctx.page_filename}" if docs_dir and pkg_src_dir: symbol_doc_path = docs_dir / symbol_path - symbol_changes = build_symbol_changes(ctx.symbol.name, changelog_actions) + symbol_changes = build_symbol_changes(ctx.symbol.name, changelog_actions, ctx.group_name) example_link = _build_example_link( ctx.symbol.name, group.name, examples_set, examples_dir, "../examples/" ) diff --git a/pkg_ext/_internal/generation/docs_render.py b/pkg_ext/_internal/generation/docs_render.py index ee91e25..9e180a1 100644 --- a/pkg_ext/_internal/generation/docs_render.py +++ b/pkg_ext/_internal/generation/docs_render.py @@ -254,13 +254,14 @@ def _build_field_versions( symbol_name: str, fields: list[ClassFieldInfo] | None, changelog_actions: Sequence[ChangelogAction], + group_name: str, ) -> dict[str, str]: if not fields: return {} return { f.name: v for f in fields - if not f.is_computed and (v := get_field_since_version(symbol_name, f.name, changelog_actions)) + if not f.is_computed and (v := get_field_since_version(symbol_name, f.name, changelog_actions, group_name)) } @@ -296,12 +297,14 @@ def calculate_source_link( return str(rel_path) -def _render_symbol_type_table(symbol: SymbolDump, changelog_actions: Sequence[ChangelogAction]) -> str | None: +def _render_symbol_type_table( + symbol: SymbolDump, changelog_actions: Sequence[ChangelogAction], group_name: str +) -> str | None: if isinstance(symbol, CLICommandDump) and symbol.cli_params: if table := render_cli_params_table(symbol.cli_params): return "\n".join(["**CLI Options:**", "", table]) if isinstance(symbol, ClassDump) and symbol.fields: - field_versions = _build_field_versions(symbol.name, symbol.fields, changelog_actions) + field_versions = _build_field_versions(symbol.name, symbol.fields, changelog_actions, group_name) if should_show_field_table(symbol.fields, field_versions): return render_field_table(symbol.fields, field_versions) return None @@ -325,7 +328,7 @@ def render_inline_symbol( symbol = ctx.symbol changelog_actions = changelog_actions or [] - since_badge = render_since_badge(get_symbol_since_version(symbol.name, changelog_actions)) + since_badge = render_since_badge(get_symbol_since_version(symbol.name, changelog_actions, ctx.group_name)) anchor_id = f"{slug(symbol.name)}_def" lines = [f'\n\n### {symbol.type.value}: `{symbol.name}`'] if symbol_doc_path and pkg_src_dir and pkg_import_name: @@ -346,7 +349,7 @@ def render_inline_symbol( docstring = format_docstring(symbol.docstring) if docstring: lines.extend(["", docstring]) - if type_table := _render_symbol_type_table(symbol, changelog_actions): + if type_table := _render_symbol_type_table(symbol, changelog_actions, ctx.group_name): lines.extend(["", type_table]) if changes: lines.extend(["", _render_changes_content(changes)]) @@ -363,7 +366,7 @@ def _render_symbol_main_section( ) -> str: section_id = f"{slug(symbol.name)}_def" stability = render_stability_badge(symbol.name, group.name, changelog_actions) - since_badge = render_since_badge(get_symbol_since_version(symbol.name, changelog_actions)) + since_badge = render_since_badge(get_symbol_since_version(symbol.name, changelog_actions, group.name)) docstring = format_docstring(symbol.docstring) lines = [f"## {symbol.type.value}: {symbol.name}", f"- [source]({source_link})"] @@ -413,7 +416,7 @@ def render_symbol_page( parts.extend(["", "### CLI Options", "", table]) if isinstance(symbol, ClassDump) and symbol.fields: - field_versions = _build_field_versions(symbol.name, symbol.fields, changelog_actions) + field_versions = _build_field_versions(symbol.name, symbol.fields, changelog_actions, group.name) if should_show_field_table(symbol.fields, field_versions): if table := render_field_table(symbol.fields, field_versions): parts.extend(["", "### Fields", "", table]) diff --git a/pkg_ext/_internal/generation/docs_render_test.py b/pkg_ext/_internal/generation/docs_render_test.py index 3b06576..e49f46f 100644 --- a/pkg_ext/_internal/generation/docs_render_test.py +++ b/pkg_ext/_internal/generation/docs_render_test.py @@ -202,7 +202,7 @@ def test_render_inline_symbol_shows_since_badge(): from pkg_ext._internal.generation.docs import SymbolContext func = _func_dump("my_func") - ctx = SymbolContext(symbol=func) + ctx = SymbolContext(symbol=func, group_name="config") actions = [ MakePublicAction(name="my_func", group="config", full_path="mod.my_func", ts=datetime(2025, 1, 1, tzinfo=UTC)), ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 10, tzinfo=UTC)), diff --git a/pkg_ext/_internal/generation/docs_test.py b/pkg_ext/_internal/generation/docs_test.py index c055738..250b372 100644 --- a/pkg_ext/_internal/generation/docs_test.py +++ b/pkg_ext/_internal/generation/docs_test.py @@ -83,7 +83,7 @@ def test_build_symbol_context_only_make_public_not_complex(): def test_build_symbol_context_fix_action_is_complex(): func = _func_dump("my_func") - action = FixAction(name="my_func", short_sha="abc123", message="fix", ts=datetime.now(UTC)) + action = FixAction(name="my_func", group="config", short_sha="abc123", message="fix", ts=datetime.now(UTC)) ctx = build_symbol_context(func, "config", [action]) assert ctx.has_meaningful_changes assert ctx.needs_own_page diff --git a/pkg_ext/_internal/generation/docs_version.py b/pkg_ext/_internal/generation/docs_version.py index 87e74ce..fdbcacd 100644 --- a/pkg_ext/_internal/generation/docs_version.py +++ b/pkg_ext/_internal/generation/docs_version.py @@ -5,6 +5,7 @@ from model_lib import Event, fields +from pkg_ext._internal.changelog import actions as changelog_actions_mod from pkg_ext._internal.changelog.actions import ( AdditionalChangeAction, BreakingChangeAction, @@ -71,9 +72,11 @@ def _matches_symbol_or_group( return False -def get_symbol_since_version(symbol_name: str, changelog_actions: Sequence[ChangelogAction]) -> str | None: +def get_symbol_since_version( + symbol_name: str, changelog_actions: Sequence[ChangelogAction], group_name: str +) -> str | None: for action in sorted(changelog_actions): - if isinstance(action, MakePublicAction) and action.name == symbol_name: + if isinstance(action, MakePublicAction) and action.name == symbol_name and action.group == group_name: if version := find_release_version(action.ts, changelog_actions): return version return UNRELEASED_VERSION @@ -84,17 +87,19 @@ def get_field_since_version( symbol_name: str, field_name: str, changelog_actions: Sequence[ChangelogAction], + group_name: str, ) -> str | None: for action in sorted(changelog_actions): if ( isinstance(action, AdditionalChangeAction) and action.name == symbol_name and action.field_name == field_name + and action.group == group_name ): if version := find_release_version(action.ts, changelog_actions): return version return UNRELEASED_VERSION - return get_symbol_since_version(symbol_name, changelog_actions) + return get_symbol_since_version(symbol_name, changelog_actions, group_name) def _action_description(action: ChangelogAction) -> str: @@ -116,13 +121,17 @@ def _action_description(action: ChangelogAction) -> str: return "" -def build_symbol_changes(symbol_name: str, changelog_actions: Sequence[ChangelogAction]) -> list[SymbolChange]: +def build_symbol_changes( + symbol_name: str, changelog_actions: Sequence[ChangelogAction], group_name: str +) -> list[SymbolChange]: changes: list[SymbolChange] = [] for action in sorted(changelog_actions): if isinstance(action, ReleaseAction): continue if action.name != symbol_name: continue + if (ag := changelog_actions_mod.action_group(action)) and ag != group_name: + continue version = find_release_version(action.ts, changelog_actions) or UNRELEASED_VERSION if isinstance(action, MakePublicAction): changes.append(SymbolChange(version=version, description="Made public", ts=action.ts)) diff --git a/pkg_ext/_internal/generation/docs_version_test.py b/pkg_ext/_internal/generation/docs_version_test.py index 006352b..d93b2bb 100644 --- a/pkg_ext/_internal/generation/docs_version_test.py +++ b/pkg_ext/_internal/generation/docs_version_test.py @@ -41,14 +41,14 @@ def test_get_symbol_since_version_with_release(): MakePublicAction(name="my_func", group="config", full_path="mod.my_func", ts=datetime(2025, 1, 1, tzinfo=UTC)), ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 10, tzinfo=UTC)), ] - assert get_symbol_since_version("my_func", actions) == "1.0.0" + assert get_symbol_since_version("my_func", actions, "config") == "1.0.0" def test_get_symbol_since_version_unreleased(): actions = [ MakePublicAction(name="my_func", group="config", full_path="mod.my_func", ts=datetime(2025, 1, 1, tzinfo=UTC)), ] - assert get_symbol_since_version("my_func", actions) == UNRELEASED_VERSION + assert get_symbol_since_version("my_func", actions, "config") == UNRELEASED_VERSION def test_get_field_since_version_from_action(): @@ -62,7 +62,7 @@ def test_get_field_since_version_from_action(): ), ReleaseAction(name="1.1.0", old_version="1.0.0", ts=datetime(2025, 1, 10, tzinfo=UTC)), ] - assert get_field_since_version("MyClass", "new_field", actions) == "1.1.0" + assert get_field_since_version("MyClass", "new_field", actions, "config") == "1.1.0" def test_get_field_since_version_falls_back_to_symbol(): @@ -70,7 +70,7 @@ def test_get_field_since_version_falls_back_to_symbol(): MakePublicAction(name="MyClass", group="config", full_path="mod.MyClass", ts=datetime(2025, 1, 1, tzinfo=UTC)), ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 10, tzinfo=UTC)), ] - assert get_field_since_version("MyClass", "existing_field", actions) == "1.0.0" + assert get_field_since_version("MyClass", "existing_field", actions, "config") == "1.0.0" def test_build_symbol_changes_unreleased(): @@ -78,12 +78,13 @@ def test_build_symbol_changes_unreleased(): MakePublicAction(name="my_func", group="config", full_path="mod.my_func", ts=datetime(2025, 1, 1, tzinfo=UTC)), FixAction( name="my_func", + group="config", short_sha="abc", message="fix bug", ts=datetime(2025, 1, 2, tzinfo=UTC), ), ] - changes = build_symbol_changes("my_func", actions) + changes = build_symbol_changes("my_func", actions, "config") assert len(changes) == 2 assert all(c.version == UNRELEASED_VERSION for c in changes) @@ -94,12 +95,13 @@ def test_build_symbol_changes_with_releases(): ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 5, tzinfo=UTC)), FixAction( name="parse", + group="config", short_sha="def", message="fix parse", ts=datetime(2025, 1, 10, tzinfo=UTC), ), ] - changes = build_symbol_changes("parse", actions) + changes = build_symbol_changes("parse", actions, "config") assert len(changes) == 2 versions = [c.version for c in changes] assert "1.0.0" in versions @@ -116,7 +118,7 @@ def test_build_symbol_changes_deprecated_action(): ts=datetime(2025, 1, 1, tzinfo=UTC), ), ] - changes = build_symbol_changes("old_func", actions) + changes = build_symbol_changes("old_func", actions, "config") assert len(changes) == 1 assert "new_func" in changes[0].description @@ -130,11 +132,61 @@ def test_build_symbol_changes_rename_action(): ts=datetime(2025, 1, 1, tzinfo=UTC), ), ] - changes = build_symbol_changes("new_name", actions) + changes = build_symbol_changes("new_name", actions, "config") assert len(changes) == 1 assert "old_name" in changes[0].description +def test_get_symbol_since_version_filters_by_group(): + actions = [ + MakePublicAction(name="init_cmd", group="core", full_path="core.init_cmd", ts=datetime(2025, 1, 1, tzinfo=UTC)), + ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 5, tzinfo=UTC)), + MakePublicAction( + name="init_cmd", group="config", full_path="config.init_cmd", ts=datetime(2025, 2, 1, tzinfo=UTC) + ), + ] + assert get_symbol_since_version("init_cmd", actions, "core") == "1.0.0" + assert get_symbol_since_version("init_cmd", actions, "config") == UNRELEASED_VERSION + + +def test_build_symbol_changes_filters_by_group(): + actions = [ + MakePublicAction(name="init_cmd", group="core", full_path="core.init_cmd", ts=datetime(2025, 1, 1, tzinfo=UTC)), + ReleaseAction(name="1.0.0", old_version="0.0.0", ts=datetime(2025, 1, 5, tzinfo=UTC)), + MakePublicAction( + name="init_cmd", group="config", full_path="config.init_cmd", ts=datetime(2025, 2, 1, tzinfo=UTC) + ), + ] + core_changes = build_symbol_changes("init_cmd", actions, "core") + assert len(core_changes) == 1 + assert core_changes[0].version == "1.0.0" + + config_changes = build_symbol_changes("init_cmd", actions, "config") + assert len(config_changes) == 1 + assert config_changes[0].version == UNRELEASED_VERSION + + +def test_build_symbol_changes_fix_action_filters_by_group(): + actions = [ + MakePublicAction(name="parse", group="core", full_path="core.parse", ts=datetime(2025, 1, 1, tzinfo=UTC)), + MakePublicAction(name="parse", group="config", full_path="config.parse", ts=datetime(2025, 1, 1, tzinfo=UTC)), + FixAction( + name="parse", + group="core", + short_sha="abc", + message="fix parse in core", + ts=datetime(2025, 1, 3, tzinfo=UTC), + ), + ] + core_changes = build_symbol_changes("parse", actions, "core") + assert len(core_changes) == 2 + assert any("fix parse" in c.description for c in core_changes) + + config_changes = build_symbol_changes("parse", actions, "config") + assert len(config_changes) == 1 + assert all("fix parse" not in c.description for c in config_changes) + + def test_get_symbol_stability_defaults_to_ga(): assert get_symbol_stability("f", "g", []) == Stability.ga