netbox-interface-name-rules 1.4.2__tar.gz → 1.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/PKG-INFO +1 -1
  2. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/__init__.py +2 -2
  3. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/engine.py +37 -13
  4. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_misc.py +19 -0
  5. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_rules.py +180 -15
  6. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/PKG-INFO +1 -1
  7. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/pyproject.toml +1 -1
  8. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/LICENSE +0 -0
  9. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/README.md +0 -0
  10. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/__init__.py +0 -0
  11. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/serializers.py +0 -0
  12. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/urls.py +0 -0
  13. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/views.py +0 -0
  14. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/filters.py +0 -0
  15. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/forms.py +0 -0
  16. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/jobs.py +0 -0
  17. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
  18. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0002_regex_pattern_matching.py +0 -0
  19. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
  20. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0004_nulls_distinct.py +0 -0
  21. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
  22. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0006_alter_interfacenamerule_options.py +0 -0
  23. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0007_alter_optional_fks_set_null.py +0 -0
  24. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0008_constraint_nonempty_pattern.py +0 -0
  25. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0009_interfacenamerule_enabled.py +0 -0
  26. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0010_interfacenamerule_applies_to_device_interfaces.py +0 -0
  27. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0011_remove_interfacenamerule_interfacenamerule_module_type_mode_check_and_more.py +0 -0
  28. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0012_remove_interfacenamerule_interfacenamerule_unique_exact_and_more.py +0 -0
  29. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
  30. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/models.py +0 -0
  31. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/navigation.py +0 -0
  32. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/signals.py +0 -0
  33. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tables.py +0 -0
  34. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/buttons/export_yaml_only.html +0 -0
  35. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule.html +0 -0
  36. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/interfacenamerule_list.html +0 -0
  37. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply.html +0 -0
  38. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +0 -0
  39. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_test.html +0 -0
  40. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
  41. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_api.py +0 -0
  42. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_device_rules.py +0 -0
  43. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
  44. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_engine.py +0 -0
  45. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_engine_advanced.py +0 -0
  46. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_regex.py +0 -0
  47. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_signals.py +0 -0
  48. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_views.py +0 -0
  49. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/urls.py +0 -0
  50. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/views.py +0 -0
  51. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +0 -0
  52. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/dependency_links.txt +0 -0
  53. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/top_level.txt +0 -0
  54. {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: NetBox plugin for automatic interface renaming when modules are installed
5
5
  Author: Marcin Zieba
6
6
  License-Expression: Apache-2.0
@@ -2,7 +2,7 @@
2
2
  # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
3
  from netbox.plugins import PluginConfig
4
4
 
5
- __version__ = "1.4.2"
5
+ __version__ = "1.4.3"
6
6
 
7
7
 
8
8
  class InterfaceNameRulesConfig(PluginConfig):
@@ -13,7 +13,7 @@ class InterfaceNameRulesConfig(PluginConfig):
13
13
  description = "Automatic interface renaming when modules are installed into bays."
14
14
  version = __version__
15
15
  base_url = "interface-name-rules"
16
- min_version = "4.2.0"
16
+ min_version = "4.3.0"
17
17
  required_settings = []
18
18
  default_settings = {}
19
19
  author = "Marcin Zieba"
@@ -27,6 +27,9 @@ logger = logging.getLogger(__name__)
27
27
  # per call and the set reloaded only when it changes, so it self-invalidates on any
28
28
  # create/delete/edit — including raw bulk ``.update()`` of any matching field and SET_NULL
29
29
  # cascades that bypass auto_now, and across test transactions — with no signal wiring.
30
+ # A reload publishes the new set by rebinding this name to a fresh dict in one atomic assignment
31
+ # (see _get_enabled_rules), so a concurrent reader on another worker thread sees either the whole
32
+ # old set or the whole new one — never exact/regex/memo torn across two versions.
30
33
  _RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}}
31
34
 
32
35
  # Per-version cap on the find_matching_rule memo. A long-lived worker that sees many distinct
@@ -35,6 +38,13 @@ _RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}}
35
38
  # of contexts in any single module-sync render, so it never churns mid-render.
36
39
  _MEMO_MAX = 4096
37
40
 
41
+ # Sentinel for the memo read. A single ``memo.get(sig, _MEMO_MISS)`` is one atomic dict lookup, so a
42
+ # concurrent ``memo.clear()`` at the cap can't wedge between a membership test and the subscript the
43
+ # way ``if sig in memo: return memo[sig]`` could — that compound read can raise a sporadic KeyError
44
+ # under threaded workers sharing one per-version memo. A real "no rule matched" is memoized as None,
45
+ # so the miss sentinel must be a distinct object, not None.
46
+ _MEMO_MISS = object()
47
+
38
48
  # Per-thread pin depth (see pinned_rule_cache). While > 0 on the current thread, _get_enabled_rules
39
49
  # trusts the loaded set without re-reading the fingerprint, so an internal batch that wraps its loop
40
50
  # turns N per-call fingerprint queries into one. Thread-local so concurrent requests don't affect
@@ -198,6 +208,8 @@ def _get_enabled_rules():
198
208
  lookup in the block) the fingerprint query is skipped and the snapshot captured at prime time is
199
209
  returned — never the live ``_RULE_CACHE``, which another thread may reload mid-block.
200
210
  """
211
+ global _RULE_CACHE
212
+
201
213
  pinned = getattr(_pin, "depth", 0) > 0
202
214
  if pinned and getattr(_pin, "primed", False):
203
215
  # Serve the snapshot captured when this block primed, not the shared cache: a concurrent
@@ -207,27 +219,36 @@ def _get_enabled_rules():
207
219
 
208
220
  from .models import InterfaceNameRule
209
221
 
222
+ # Read the module global exactly once. A reload below publishes the new set by rebinding
223
+ # _RULE_CACHE to a brand-new dict (a single atomic name assignment) rather than mutating this
224
+ # one in place, so this local is a consistent snapshot: exact/regex/memo can never be torn
225
+ # across two rule-set versions even if another thread reloads between the reads at the end.
226
+ cache = _RULE_CACHE
210
227
  version = _enabled_rules_version()
211
- if _RULE_CACHE["version"] != version:
228
+ if cache["version"] != version:
212
229
  rules = list(InterfaceNameRule.objects.filter(enabled=True).order_by("module_type__model", "pk"))
213
- _RULE_CACHE["exact"] = tuple(r for r in rules if not r.module_type_is_regex)
230
+ exact = tuple(r for r in rules if not r.module_type_is_regex)
214
231
  regex_rules = sorted(
215
232
  (r for r in rules if r.module_type_is_regex),
216
233
  key=lambda r: (-len(r.module_type_pattern or ""), r.pk),
217
234
  )
218
- _RULE_CACHE["regex"] = tuple((_compile_pattern(r.module_type_pattern), r) for r in regex_rules)
219
- _RULE_CACHE["memo"] = {}
220
- _RULE_CACHE["version"] = version
235
+ regex = tuple((_compile_pattern(r.module_type_pattern), r) for r in regex_rules)
236
+ # Publish the whole new version atomically: a reader either sees the old dict or this one,
237
+ # never a mix of the two. Last writer wins; a concurrent reload to the same version just
238
+ # rebuilds redundantly, never corrupts.
239
+ cache = {"version": version, "exact": exact, "regex": regex, "memo": {}}
240
+ _RULE_CACHE = cache
221
241
  if pinned:
222
242
  # Pin this thread to the freshly-resolved set for the rest of the block. The tuples are
223
- # immutable and the memo dict is now this thread's own reference, so a later global reload
224
- # leaves our snapshot — and the entries we memoize into it — untouched.
225
- _pin.exact = _RULE_CACHE["exact"]
226
- _pin.regex = _RULE_CACHE["regex"]
227
- _pin.memo = _RULE_CACHE["memo"]
243
+ # immutable and the memo is COPIED into thread-local state — not aliased — so the pinned batch
244
+ # neither shares nor races the global memo: another thread clearing the shared memo at the cap
245
+ # can't evict our warmed entries (or wedge a KeyError) mid-loop, and entries we add stay private.
246
+ _pin.exact = cache["exact"]
247
+ _pin.regex = cache["regex"]
248
+ _pin.memo = dict(cache["memo"])
228
249
  _pin.primed = True
229
250
  return _pin.exact, _pin.regex, _pin.memo
230
- return _RULE_CACHE["exact"], _RULE_CACHE["regex"], _RULE_CACHE["memo"]
251
+ return cache["exact"], cache["regex"], cache["memo"]
231
252
 
232
253
 
233
254
  def _get_parent_module_type(module_bay):
@@ -675,8 +696,11 @@ def find_matching_rule(module_type, parent_module_type, device_type, platform=No
675
696
  # The regex tier matches against module_type.model (a live string), so the memo must key on
676
697
  # it too — otherwise a ModuleType.model rename (same pk) would return a stale regex result.
677
698
  sig = (module_type.pk, module_type.model, *_scope_ids(parent_module_type, device_type, platform))
678
- if sig in memo:
679
- return memo[sig]
699
+ # One atomic lookup, not `if sig in memo: return memo[sig]`: another thread sharing this per-version
700
+ # memo can clear it at the cap between a membership test and the subscript, raising KeyError.
701
+ cached = memo.get(sig, _MEMO_MISS)
702
+ if cached is not _MEMO_MISS:
703
+ return cached
680
704
 
681
705
  candidates = _build_candidates(parent_module_type, device_type, platform)
682
706
  result = _find_exact_match(module_type, candidates, exact_rules) or _find_regex_match(
@@ -801,3 +801,22 @@ class ModelCSVExportTest(TestCase):
801
801
  """csv_headers must not contain dots (regression guard for KeyError 'Ch' bug)."""
802
802
  for header in InterfaceNameRule.csv_headers:
803
803
  self.assertNotIn(".", header, f"csv_headers entry '{header}' contains a dot — would break import")
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # __init__.py — PluginConfig version gate (must match the documented floor)
808
+ # ---------------------------------------------------------------------------
809
+
810
+
811
+ class PluginConfigVersionTest(TestCase):
812
+ """The enforced min_version gate must match the compatibility floor stated in the docs."""
813
+
814
+ def test_min_version_matches_documented_floor(self):
815
+ """min_version is the gate NetBox uses to refuse loading the plugin, so it must equal the
816
+ floor advertised in README.md / docs/installation.md (NetBox >= 4.3.0). A drift here would
817
+ let an unsupported NetBox load the plugin (or reject a supported one) with no other guard —
818
+ the inconsistency a reviewer flagged when the docs were raised to 4.3.0 but the gate stayed 4.2.0.
819
+ """
820
+ from netbox_interface_name_rules import InterfaceNameRulesConfig
821
+
822
+ self.assertEqual(InterfaceNameRulesConfig.min_version, "4.3.0")
@@ -485,10 +485,25 @@ class FindMatchingRuleCachingTest(TestCase):
485
485
  ModuleType.objects.create(manufacturer=mfr, model=f"MEMO-{i}", part_number=f"MEMO-{i}")
486
486
  for i in range(5)
487
487
  ]
488
- for mt in module_types: # five distinct contexts → five distinct memo keys
488
+ for i, mt in enumerate(module_types, start=1): # five distinct contexts → five distinct memo keys
489
489
  find_matching_rule(mt, None, None)
490
-
491
- self.assertLessEqual(len(engine._RULE_CACHE["memo"]), engine._MEMO_MAX)
490
+ # Check the bound after EVERY insert, not just once at the end. The memo size must never
491
+ # exceed the cap at any point. A regression that let it leak toward 2*cap before clearing
492
+ # would slip past an end-of-loop `<=` snapshot (which only sees the post-clear size) but
493
+ # trips here on the very insert that crosses the cap.
494
+ self.assertLessEqual(
495
+ len(engine._RULE_CACHE["memo"]),
496
+ engine._MEMO_MAX,
497
+ f"memo exceeded the cap mid-insertion after {i} contexts",
498
+ )
499
+
500
+ # And the eviction actually fired: the final size is below the number of distinct contexts,
501
+ # so the test isn't vacuously green on a memo that simply never reached the cap.
502
+ self.assertLess(
503
+ len(engine._RULE_CACHE["memo"]),
504
+ len(module_types),
505
+ "memo never evicted — the cap was never exercised",
506
+ )
492
507
  finally:
493
508
  engine._MEMO_MAX = original_max
494
509
 
@@ -533,32 +548,141 @@ class FindMatchingRuleCachingTest(TestCase):
533
548
  self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), specific)
534
549
 
535
550
  def test_pinned_block_holds_snapshot_across_concurrent_cache_reload(self):
536
- """A pinned batch keeps its rule-set snapshot even if another thread reloads the shared cache.
551
+ """A pinned batch keeps its rule-set snapshot even when a real second thread reloads the cache.
537
552
 
538
553
  The pin must capture exact/regex/memo at prime time; if it re-read the live module-level
539
554
  _RULE_CACHE, a concurrent request reloading it mid-block would silently switch this batch to a
540
- different rule set — renaming one device's modules with mixed rule versions.
555
+ different rule set — renaming one device's modules with mixed rule versions. This exercises the
556
+ guarantee across an actual thread: the worker must NOT inherit this thread's pin (``_pin`` is
557
+ ``threading.local``), and its reload — published the way ``_get_enabled_rules`` does, by
558
+ rebinding ``_RULE_CACHE`` to a fresh dict — must leave our primed snapshot untouched. A plain
559
+ global ``_pin`` would leak the pin into the worker and pass the old same-thread test, but fail here.
541
560
  """
561
+ import threading
562
+
542
563
  from netbox_interface_name_rules import engine
543
564
  from netbox_interface_name_rules.engine import pinned_rule_cache
544
565
 
545
566
  universal = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="a{bay_position}")
546
567
 
568
+ worker_pin_depth = []
569
+
570
+ def concurrent_reload():
571
+ # A genuine second thread. _pin is thread-local, so this worker must see depth 0 — it never
572
+ # inherits the main thread's active pin. It then publishes a different rule-set version the
573
+ # way _get_enabled_rules() does: one atomic rebind of the module global. (No DB access — a
574
+ # separate thread has its own connection and cannot see this TestCase's uncommitted rows.)
575
+ worker_pin_depth.append(getattr(engine._pin, "depth", 0))
576
+ engine._RULE_CACHE = {"version": "concurrent-reload-other-version", "exact": (), "regex": (), "memo": {}}
577
+
578
+ try:
579
+ with pinned_rule_cache():
580
+ self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), universal) # primes
581
+
582
+ worker = threading.Thread(target=concurrent_reload)
583
+ worker.start()
584
+ worker.join()
585
+
586
+ # The worker did not inherit our pin — proves _pin is per-thread, not a shared global.
587
+ self.assertEqual(worker_pin_depth, [0], "pin leaked across threads — _pin is not thread-local")
588
+ # ...and its reload really did replace the shared cache.
589
+ self.assertEqual(engine._RULE_CACHE["version"], "concurrent-reload-other-version")
590
+
591
+ # Still inside the pin: must serve the snapshot captured at entry, not the worker's reload.
592
+ self.assertEqual(
593
+ find_matching_rule(self.module_type, None, self.device_type),
594
+ universal,
595
+ "pinned block switched rule sets after a concurrent _RULE_CACHE reload",
596
+ )
597
+ finally:
598
+ # This test deliberately rebinds the module global from another thread; restore a clean
599
+ # sentinel so the simulated reload can't leak into sibling tests even if setUp() is weakened.
600
+ engine._RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}}
601
+
602
+ def test_reload_publishes_a_fresh_cache_dict_atomically(self):
603
+ """A version change rebinds _RULE_CACHE to a new dict instead of mutating the old one in place.
604
+
605
+ This is what makes the unpinned three-key read a consistent snapshot: a reader that grabbed the
606
+ cache before a concurrent reload keeps one whole rule-set version, never exact from V1 paired
607
+ with memo from V2. We assert the published dict is a *new object* and that a reference captured
608
+ before the reload is left untouched — the in-place mutation the previous code did would fail both.
609
+ """
610
+ from netbox_interface_name_rules import engine
611
+
612
+ InterfaceNameRule.objects.create(module_type=self.module_type, name_template="a{bay_position}")
613
+ find_matching_rule(self.module_type, None, self.device_type) # prime version 1
614
+
615
+ snap = engine._RULE_CACHE
616
+ snap_version = snap["version"]
617
+ snap_exact = snap["exact"]
618
+
619
+ # Change the rule set so the next lookup must reload to a new version.
620
+ InterfaceNameRule.objects.create(
621
+ module_type=self.module_type, device_type=self.device_type, name_template="b{bay_position}"
622
+ )
623
+ find_matching_rule(self.module_type, None, self.device_type) # reload to version 2
624
+
625
+ self.assertIsNot(
626
+ engine._RULE_CACHE, snap, "reload mutated the cache dict in place instead of publishing a new one"
627
+ )
628
+ self.assertEqual(snap["version"], snap_version, "a reload mutated a previously-published cache dict")
629
+ self.assertEqual(snap["exact"], snap_exact, "the captured exact snapshot changed under a concurrent reload")
630
+
631
+ def test_find_matching_rule_survives_concurrent_memo_clear(self):
632
+ """A memo cleared between the membership check and the lookup must not raise KeyError.
633
+
634
+ Threaded workers share one per-version memo dict; if one thread clears it at the cap while
635
+ another is mid-read, the old ``if sig in memo: return memo[sig]`` raised KeyError because the
636
+ membership test and the subscript are two operations with a thread switch possible between them.
637
+ We reproduce that exact interleave deterministically with a dict whose membership test clears
638
+ itself, then assert find_matching_rule still returns the rule. The sentinel ``.get()`` read is a
639
+ single atomic lookup, so it never performs the separate membership test the race needs.
640
+ """
641
+ from netbox_interface_name_rules import engine
642
+
643
+ rule = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="p{bay_position}")
644
+ self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), rule) # warm the memo
645
+
646
+ class _RacingMemo(dict):
647
+ # Emulate another thread clearing the shared memo the instant a membership test sees the key.
648
+ def __contains__(self, key):
649
+ present = super().__contains__(key)
650
+ if present:
651
+ self.clear()
652
+ return present
653
+
654
+ # Same version → no reload; the racing memo is what find_matching_rule reads next.
655
+ engine._RULE_CACHE["memo"] = _RacingMemo(engine._RULE_CACHE["memo"])
656
+
657
+ self.assertEqual(
658
+ find_matching_rule(self.module_type, None, self.device_type),
659
+ rule,
660
+ "find_matching_rule raised/missed when the shared memo was cleared mid-read",
661
+ )
662
+
663
+ def test_pinned_block_uses_a_private_memo_copy(self):
664
+ """A pinned batch gets its own memo copy, so another thread clearing the shared memo can't
665
+ evict the batch's warmed entries (or wedge a KeyError) mid-loop."""
666
+ from netbox_interface_name_rules import engine
667
+ from netbox_interface_name_rules.engine import pinned_rule_cache
668
+
669
+ rule = InterfaceNameRule.objects.create(module_type=self.module_type, name_template="p{bay_position}")
670
+
547
671
  with pinned_rule_cache():
548
- self.assertEqual(find_matching_rule(self.module_type, None, self.device_type), universal) # primes
672
+ find_matching_rule(self.module_type, None, self.device_type) # primes + memoizes into the copy
549
673
 
550
- # Simulate a concurrent request on another thread reloading _RULE_CACHE to a different
551
- # version mid-block — exactly what _get_enabled_rules() does on a version change.
552
- engine._RULE_CACHE["version"] = "concurrent-reload-other-version"
553
- engine._RULE_CACHE["exact"] = ()
554
- engine._RULE_CACHE["regex"] = ()
555
- engine._RULE_CACHE["memo"] = {}
674
+ self.assertIsNot(
675
+ engine._pin.memo,
676
+ engine._RULE_CACHE["memo"],
677
+ "pinned memo aliases the shared cache memo instead of holding a private copy",
678
+ )
556
679
 
557
- # Still inside the pin: must serve the snapshot captured at entry, not the now-empty global.
680
+ # An unpinned thread clearing the shared memo at the cap must not disturb the pinned batch.
681
+ engine._RULE_CACHE["memo"].clear()
558
682
  self.assertEqual(
559
683
  find_matching_rule(self.module_type, None, self.device_type),
560
- universal,
561
- "pinned block switched rule sets after a concurrent _RULE_CACHE reload",
684
+ rule,
685
+ "pinned lookup was disturbed by a clear of the shared memo",
562
686
  )
563
687
 
564
688
  def test_fingerprint_resists_separator_injection_in_text_fields(self):
@@ -599,3 +723,44 @@ class FindMatchingRuleCachingTest(TestCase):
599
723
  fp_forged_one_rule,
600
724
  "distinct rule sets collided — a text-field separator forged a row boundary in the fingerprint",
601
725
  )
726
+
727
+
728
+ class EnabledRuleFingerprintColumnsTest(TestCase):
729
+ """_VERSION_COLUMNS must stay exhaustive over the model's match-affecting columns."""
730
+
731
+ def test_version_columns_account_for_every_concrete_column(self):
732
+ """Every concrete InterfaceNameRule column must be fingerprinted or deliberately excluded.
733
+
734
+ The enabled-rule fingerprint (engine._enabled_rules_version) hashes exactly
735
+ engine._VERSION_COLUMNS. If a future field that affects matching is added to the model but not
736
+ to that hand-maintained tuple, edits to it won't change the fingerprint and find_matching_rule
737
+ will keep serving a stale cached rule. This guard fails the moment a new concrete column appears
738
+ that hasn't been consciously classified — forcing the author to either add it to _VERSION_COLUMNS
739
+ (so the cache invalidates on its edits) or list it below with a reason it can't affect a match.
740
+ """
741
+ from netbox_interface_name_rules import engine
742
+ from netbox_interface_name_rules.models import InterfaceNameRule
743
+
744
+ # Columns intentionally NOT in the fingerprint, each with the reason it cannot change a match:
745
+ excluded = {
746
+ # the fingerprint's own filter predicate (filter(enabled=True)) — toggling it adds/removes
747
+ # the row from the aggregate, so the hash already changes; it must not also be a column
748
+ "enabled",
749
+ "description", # operator notes; never consulted by the engine
750
+ "created", # audit timestamp
751
+ "last_updated", # audit timestamp
752
+ "custom_field_data", # NetBox custom fields; not part of rule matching
753
+ }
754
+ concrete_columns = {
755
+ f.column
756
+ for f in InterfaceNameRule._meta.get_fields()
757
+ if getattr(f, "concrete", False) and not f.many_to_many and f.column
758
+ }
759
+ classified = set(engine._VERSION_COLUMNS) | excluded
760
+ self.assertEqual(
761
+ concrete_columns,
762
+ classified,
763
+ "InterfaceNameRule has concrete columns that are neither fingerprinted nor excluded. "
764
+ "Classify each: add match-affecting columns to engine._VERSION_COLUMNS so the rule cache "
765
+ "invalidates when they change, or add audit/notes columns to this test's `excluded` set.",
766
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-interface-name-rules
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: NetBox plugin for automatic interface renaming when modules are installed
5
5
  Author: Marcin Zieba
6
6
  License-Expression: Apache-2.0
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "netbox-interface-name-rules"
9
- version = "1.4.2"
9
+ version = "1.4.3"
10
10
  description = "NetBox plugin for automatic interface renaming when modules are installed"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.12.0"