-
Notifications
You must be signed in to change notification settings - Fork 0
refactor: centralize template claim ambiguity #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
marcinpsk
wants to merge
2
commits into
chore/ruff-rule-expansion
from
refactor/unify-claim-ambiguity
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| --- | ||
| status: accepted | ||
| --- | ||
|
|
||
| # Centralize template claim ambiguity | ||
|
|
||
| The engine and installed-family discovery each implemented the same two-sided uniqueness rule. | ||
| Exhaustive checks over 4096 relations established their equivalence. | ||
| The family package owns this rule through immutable `TemplateClaim` values and | ||
| `resolve_template_claims`, exported at the package boundary. | ||
|
|
||
| The primitive accepts the complete relation for one selection scope. | ||
| Callers must retain claims from templates that claim multiple labels. | ||
| Repeated edges count once, and duplicate claimant IDs are invalid. | ||
| A template with multiple labels or a label with multiple templates disqualifies | ||
| every involved claim. Accepted pairs retain input claimant order. | ||
| The primitive returns accepted pairs and rendered collision messages. | ||
| It uses only the standard library and does not discover candidates. | ||
|
|
||
| The engine retains regex matching, comparison forms, exact-name precedence, | ||
| forced-base ordering and the preference for channel `:0`. | ||
| Its admission guard stays at the same point in `_collect_unrenamed`, before | ||
| interfaces that intend one family collapse, as required by ADR 0011. | ||
|
|
||
| Message construction belongs to the primitive so callers cannot drift in wording. | ||
| The primitive does not log. Callers emit its messages through their own logger, | ||
| which preserves the engine warning source and keeps the decision free of side effects. | ||
|
|
||
| Increment 1 adopts the primitive only in the engine. | ||
| Installed-family adoption belongs to increment 2. | ||
| The `_singly_claimed` rule counts member primary keys across complete family | ||
| candidates and remains separate. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> | ||
| """Resolve two-sided uniqueness for complete template claim relations.""" | ||
|
|
||
| from collections import defaultdict | ||
| from dataclasses import dataclass | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| class TemplateClaim: | ||
| """Labels claimed by one template in a selection scope.""" | ||
|
|
||
| claimant_id: int | ||
| template_name: str | ||
| labels: tuple[str, ...] | ||
|
|
||
| def __post_init__(self): | ||
| object.__setattr__(self, "labels", tuple(self.labels)) | ||
|
|
||
|
|
||
| def resolve_template_claims(claims, *, module, label_kind): | ||
| """Return accepted pairs in claimant order and rendered collision messages. | ||
|
|
||
| Pass the complete relation, including templates with multiple labels. | ||
| Repeated edges count once. Claimant IDs must be unique. | ||
| The caller emits the messages through its own logger. | ||
| """ | ||
| if label_kind not in ("interface name", "family base"): | ||
| raise ValueError("label_kind must be 'interface name' or 'family base'") | ||
|
|
||
| by_id = {} | ||
| claimants = defaultdict(list) | ||
| ambiguous = set() | ||
| messages = [] | ||
| for claim in claims: | ||
| if claim.claimant_id in by_id: | ||
| raise ValueError(f"Duplicate claimant_id: {claim.claimant_id}") | ||
| labels = tuple(dict.fromkeys(claim.labels)) | ||
| by_id[claim.claimant_id] = (claim.template_name, labels) | ||
| for label in labels: | ||
| claimants[label].append(claim.claimant_id) | ||
| if len(labels) > 1: | ||
| ambiguous.add(claim.claimant_id) | ||
| messages.append( | ||
| f"Interface template {claim.template_name!r} of {module} could name any of {sorted(labels)} " | ||
| "since this device's virtual-chassis position changed; " | ||
| "skipping them all rather than renaming a guess." | ||
| ) | ||
|
|
||
| subject = "Interface" if label_kind == "interface name" else "Family base" | ||
| for label, ids in claimants.items(): | ||
| if len(ids) > 1: | ||
| messages.append( | ||
| f"{subject} {label!r} on {module} could be the drifted name of any of the templates " | ||
| f"{sorted(by_id[claimant_id][0] for claimant_id in ids)}; " | ||
| "skipping it rather than renaming a guess." | ||
| ) | ||
| ambiguous.update(ids) | ||
|
|
||
| accepted = tuple( | ||
| (claimant_id, labels[0]) | ||
| for claimant_id, (_, labels) in by_id.items() | ||
| if labels and claimant_id not in ambiguous | ||
| ) | ||
| return accepted, tuple(messages) | ||
111 changes: 111 additions & 0 deletions
111
netbox_interface_name_rules/tests/test_family_claims.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> | ||
| """Tests for complete template claim relations.""" | ||
|
|
||
| from django.test import SimpleTestCase | ||
|
|
||
| from netbox_interface_name_rules import family | ||
|
|
||
|
|
||
| class TemplateClaimsTest(SimpleTestCase): | ||
| def test_claim_copies_labels_from_a_mutable_list(self): | ||
| labels = ["x"] | ||
| claim = family.TemplateClaim(1, "A", labels) | ||
| self.assertIsInstance(claim.labels, tuple) | ||
| labels.append("y") | ||
| self.assertEqual(claim.labels, ("x",)) | ||
|
|
||
| def test_empty_relation(self): | ||
| self.assertEqual( | ||
| family.resolve_template_claims((), module="module", label_kind="interface name"), | ||
| ((), ()), | ||
| ) | ||
|
|
||
| def test_duplicate_edges_count_once_and_keep_claimant_order(self): | ||
| claims = ( | ||
| family.TemplateClaim(9, "A", ("z", "z")), | ||
| family.TemplateClaim(2, "B", ()), | ||
| family.TemplateClaim(1, "C", ("x",)), | ||
| ) | ||
| self.assertEqual( | ||
| family.resolve_template_claims(iter(claims), module="module", label_kind="interface name"), | ||
| (((9, "z"), (1, "x")), ()), | ||
| ) | ||
|
|
||
| def test_claimant_with_two_labels_is_rejected(self): | ||
| claims = (family.TemplateClaim(1, "A", ("y", "x", "y")),) | ||
| self.assertEqual( | ||
| family.resolve_template_claims(claims, module="module", label_kind="interface name"), | ||
| ( | ||
| (), | ||
| ( | ||
| ( | ||
| "Interface template 'A' of module could name any of ['x', 'y'] since this device's " | ||
| "virtual-chassis position changed; skipping them all rather than renaming a guess." | ||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
| def test_label_with_two_claimants_is_rejected(self): | ||
| claims = (family.TemplateClaim(1, "B", ("x", "x")), family.TemplateClaim(2, "A", ("x",))) | ||
| for label_kind, subject in (("interface name", "Interface"), ("family base", "Family base")): | ||
| with self.subTest(label_kind=label_kind): | ||
| self.assertEqual( | ||
| family.resolve_template_claims(claims, module="module", label_kind=label_kind), | ||
| ( | ||
| (), | ||
| ( | ||
| ( | ||
| f"{subject} 'x' on module could be the drifted name of any of the templates " | ||
| "['A', 'B']; skipping it rather than renaming a guess." | ||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
| def test_complete_mixed_relation_rejects_shared_label(self): | ||
| claims = ( | ||
| family.TemplateClaim(1, "A", ("x", "y")), | ||
| family.TemplateClaim(2, "B", ("y",)), | ||
| family.TemplateClaim(3, "C", ("z",)), | ||
| ) | ||
| self.assertEqual( | ||
| family.resolve_template_claims(claims, module="module", label_kind="family base"), | ||
| ( | ||
| ((3, "z"),), | ||
| ( | ||
| ( | ||
| "Interface template 'A' of module could name any of ['x', 'y'] since this device's " | ||
| "virtual-chassis position changed; skipping them all rather than renaming a guess." | ||
| ), | ||
| ( | ||
| "Family base 'y' on module could be the drifted name of any of the templates " | ||
| "['A', 'B']; skipping it rather than renaming a guess." | ||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
| def test_duplicate_claimant_id_raises(self): | ||
| claims = (family.TemplateClaim(1, "A", ()), family.TemplateClaim(1, "B", ("x",))) | ||
| with self.assertRaisesRegex(ValueError, "Duplicate claimant_id"): | ||
| family.resolve_template_claims(claims, module="module", label_kind="interface name") | ||
|
|
||
| def test_invalid_label_kind_raises(self): | ||
| with self.assertRaisesRegex(ValueError, "label_kind"): | ||
| family.resolve_template_claims((), module="module", label_kind="port") | ||
|
|
||
| def test_claim_is_immutable(self): | ||
| from dataclasses import FrozenInstanceError | ||
|
|
||
| claim = family.TemplateClaim(1, "A", ("x",)) | ||
| with self.assertRaises(FrozenInstanceError): | ||
| claim.claimant_id = 2 | ||
|
|
||
| def test_primitive_does_not_log_collisions(self): | ||
| claims = (family.TemplateClaim(1, "A", ("x", "y")),) | ||
| with self.assertNoLogs("netbox_interface_name_rules", level="WARNING"): | ||
| accepted, messages = family.resolve_template_claims(claims, module="module", label_kind="interface name") | ||
| self.assertEqual(accepted, ()) | ||
| self.assertEqual(len(messages), 1) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.