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.
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/PKG-INFO +1 -1
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/__init__.py +2 -2
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/engine.py +37 -13
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_misc.py +19 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_rules.py +180 -15
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/PKG-INFO +1 -1
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/pyproject.toml +1 -1
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/LICENSE +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/README.md +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/__init__.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/serializers.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/urls.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/api/views.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/filters.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/forms.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/jobs.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0001_initial.py +0 -0
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0003_constraints.py +0 -0
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/0005_platform.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/migrations/__init__.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/models.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/navigation.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/signals.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tables.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/__init__.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_api.py +0 -0
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_e2e.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_engine.py +0 -0
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_regex.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_signals.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/tests/test_views.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/urls.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules/views.py +0 -0
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/netbox_interface_name_rules.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {netbox_interface_name_rules-1.4.2 → netbox_interface_name_rules-1.4.3}/setup.cfg +0 -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.
|
|
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.
|
|
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
|
|
228
|
+
if cache["version"] != version:
|
|
212
229
|
rules = list(InterfaceNameRule.objects.filter(enabled=True).order_by("module_type__model", "pk"))
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
_pin.
|
|
227
|
-
_pin.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
672
|
+
find_matching_rule(self.module_type, None, self.device_type) # primes + memoizes into the copy
|
|
549
673
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
561
|
-
"pinned
|
|
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
|
+
)
|
|
@@ -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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|