Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e35d6a2
Replace tqdm with rich.progress for progress bars
marcinpsk Feb 17, 2026
5caf2be
Add module-type image uploads and improve image handling
marcinpsk Feb 17, 2026
a1b8f7f
Fix verbose hint, per-file image dedup, and error handling
marcinpsk Feb 17, 2026
b50e754
Add OSError handling to upload_images and simplify image dedup
marcinpsk Feb 17, 2026
32fe3e1
Improve module-type image upload robustness
marcinpsk Feb 17, 2026
07b2409
Clarify skip message and use last-occurrence path replacement
marcinpsk Feb 17, 2026
aa2a931
Improve import progress and scope behavior
marcinpsk Feb 19, 2026
637848e
Code review fixes: mutable defaults, console routing, duplicate API c…
marcinpsk Feb 19, 2026
44c838c
Fix log markup handling, progress cleanup, and module image coercion bug
marcinpsk Feb 19, 2026
01f9779
fix: 1. Module-type progress tracking — wrapped files with get_progre…
marcinpsk Feb 19, 2026
957fb63
Add docstrings to reach 92% coverage across all source files
marcinpsk Feb 19, 2026
36ac83c
Fix nonlocal, log_run_mode scoping, remove preload_vendor_scope, bulk…
marcinpsk Feb 19, 2026
88d6bda
Fix path building in get_devices() and callers to use os.path.join an…
marcinpsk Feb 19, 2026
c3e0ad5
Fix parse_files docstring, module_type None guard, and _dt_sort_key K…
marcinpsk Feb 19, 2026
d98c0af
Fix executor leak in start_component_preload and add fixture teardown…
marcinpsk Feb 19, 2026
ffe4658
Fix executor ordering/cancel_futures, task_ids/futures alignment, mod…
marcinpsk Feb 19, 2026
e203d98
Fix executor leak: move executor creation and future submission insid…
marcinpsk Feb 19, 2026
adf894a
Fix redundant except tuples and extend start_component_preload try/ex…
marcinpsk Feb 19, 2026
07f5e07
Fix executor leak in vendor_slugs branch and merge cached_components …
marcinpsk Feb 19, 2026
b0d4805
Add FILTER_CHUNK_SIZE chunking to preload_module_type_components and …
marcinpsk Feb 19, 2026
b3c43ee
Fix _chunked to use itertools.islice, path-aware image_base, differen…
marcinpsk Feb 19, 2026
d272333
Guard version_split IndexError, skip image glob when path unresolvabl…
marcinpsk Feb 19, 2026
cd20545
Remove misleading Singleton-style docstring, collect finished_endpoin…
marcinpsk Feb 19, 2026
687ec6e
Sanitize version suffixes, pre-index change_report for O(1) lookup, d…
marcinpsk Feb 20, 2026
012892c
Log skipped image discovery when path lacks device-types, stop alread…
marcinpsk Feb 20, 2026
f53a1dc
Normalize dt_change identity check and mark already_done tasks comple…
marcinpsk Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 96 additions & 9 deletions change_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class DeviceTypeChange:

@property
def has_changes(self) -> bool:
"""Return True if this device type is new or has any property or component changes."""
return self.is_new or bool(self.property_changes) or bool(self.component_changes)

@property
Expand Down Expand Up @@ -82,6 +83,10 @@ class ChangeReport:
"comments",
]

# Image properties: YAML uses boolean flags, NetBox stores URL strings.
# Only existence is compared (YAML=true vs NetBox=empty).
IMAGE_PROPERTIES = ["front_image", "rear_image"]

# Component type mapping: YAML key -> (cache_key, comparable_properties)
COMPONENT_TYPES = {
"interfaces": ("interface_templates", ["name", "type", "mgmt_only", "label", "enabled", "poe_mode", "poe_type"]),
Expand All @@ -101,6 +106,11 @@ class ChangeReport:
"power-port": "power-ports",
}

# canonical key -> list of aliases
COMPONENT_ALIASES_BY_CANONICAL = {}
for alias, canonical_key in COMPONENT_ALIASES.items():
COMPONENT_ALIASES_BY_CANONICAL.setdefault(canonical_key, []).append(alias)


class ChangeDetector:
"""Detects changes between YAML device types and NetBox cached data."""
Expand All @@ -122,25 +132,27 @@ def detect_changes(self, device_types: List[dict], progress=None) -> ChangeRepor

Args:
device_types: List of parsed YAML device type dictionaries
progress: Optional iterable wrapper (e.g. tqdm) for progress display
progress: Optional iterable wrapper (e.g. rich.progress) for progress display

Returns:
ChangeReport with categorized changes
"""
report = ChangeReport()
iterable = progress if progress is not None else device_types
existing_by_model = self.device_types.existing_device_types
existing_by_slug = self.device_types.existing_device_types_by_slug

for dt_data in iterable:
manufacturer_slug = dt_data["manufacturer"]["slug"]
model = dt_data["model"]
slug = dt_data.get("slug", "")

# Try to find existing device type
existing_dt = self.device_types.existing_device_types.get((manufacturer_slug, model))
existing_dt = existing_by_model.get((manufacturer_slug, model))

# Fallback to slug lookup
if existing_dt is None and slug:
existing_dt = self.device_types.existing_device_types_by_slug.get((manufacturer_slug, slug))
existing_dt = existing_by_slug.get((manufacturer_slug, slug))

change = DeviceTypeChange(
manufacturer_slug=manufacturer_slug,
Expand All @@ -156,6 +168,7 @@ def detect_changes(self, device_types: List[dict], progress=None) -> ChangeRepor
# Existing - check for changes
change.netbox_id = existing_dt.id
change.property_changes = self._compare_device_type_properties(dt_data, existing_dt)
change.property_changes.extend(self._compare_image_properties(dt_data, existing_dt))
change.component_changes = self._compare_components(dt_data, existing_dt.id)

if change.has_changes:
Expand Down Expand Up @@ -235,6 +248,38 @@ def _compare_device_type_properties(self, yaml_data: dict, netbox_dt) -> List[Pr

return changes

@staticmethod
def _compare_image_properties(yaml_data: dict, netbox_dt) -> List[PropertyChange]:
"""
Compare image properties between YAML and NetBox device type.

YAML uses boolean flags (front_image: true) meaning "an image should exist",
while NetBox stores a URL string (or None). This only flags missing images
(YAML=true, NetBox=empty). Omitted keys and false values are ignored.

Args:
yaml_data: Parsed YAML device type dictionary
netbox_dt: pynetbox Record object for existing device type

Returns:
List of PropertyChange objects for missing images
"""
changes = []
for prop in IMAGE_PROPERTIES:
yaml_value = yaml_data.get(prop)
if yaml_value is not True:
continue
netbox_value = getattr(netbox_dt, prop, None)
if not netbox_value:
changes.append(
PropertyChange(
property_name=prop,
old_value=None,
new_value=True,
)
)
return changes

def _compare_components(
self,
yaml_data: dict,
Expand All @@ -259,7 +304,7 @@ def _compare_components(
yaml_components = list(yaml_data.get(yaml_key) or [])

# Check whether the canonical key or any alias is actually present in YAML
aliases_for_key = [a for a, canonical in COMPONENT_ALIASES.items() if canonical == yaml_key]
aliases_for_key = COMPONENT_ALIASES_BY_CANONICAL.get(yaml_key, [])
key_present = yaml_key in yaml_data or any(alias in yaml_data for alias in aliases_for_key)

# Merge components from any aliases that map to this canonical key
Expand Down Expand Up @@ -361,11 +406,43 @@ def log_change_report(self, report: ChangeReport):

# Summary
self.handle.log(f"New device types: {len(report.new_device_types)}")
self.handle.log(f"Modified device types: {len(report.modified_device_types)}")
self.handle.log(f"Unchanged device types: {report.unchanged_count}")

# Details for modified device types (verbose mode)
if report.modified_device_types:
# Compute category counts across all modified device types
ct_props = 0
ct_images = 0
ct_added = 0
ct_changed = 0
ct_removed = 0
for dt in report.modified_device_types:
if any(pc.property_name not in IMAGE_PROPERTIES for pc in dt.property_changes):
ct_props += 1
if any(pc.property_name in IMAGE_PROPERTIES for pc in dt.property_changes):
ct_images += 1
if any(c.change_type == ChangeType.COMPONENT_ADDED for c in dt.component_changes):
ct_added += 1
if any(c.change_type == ChangeType.COMPONENT_CHANGED for c in dt.component_changes):
ct_changed += 1
if any(c.change_type == ChangeType.COMPONENT_REMOVED for c in dt.component_changes):
ct_removed += 1

self.handle.log(f"Modified device types: {len(report.modified_device_types)}")
parts = []
if ct_props:
parts.append(f"{ct_props} property")
if ct_images:
parts.append(f"{ct_images} missing image")
if ct_added:
parts.append(f"{ct_added} new component")
if ct_changed:
parts.append(f"{ct_changed} changed component")
if ct_removed:
parts.append(f"{ct_removed} removed component")
if parts:
self.handle.log(f" Breakdown: {', '.join(parts)}")

# Per-device details
self.handle.log("-" * 60)
self.handle.log("MODIFIED DEVICE TYPES:")
for dt in report.modified_device_types:
Expand All @@ -382,9 +459,13 @@ def log_change_report(self, report: ChangeReport):

# Property changes
for pc in dt.property_changes:
self.handle.verbose_log(
f" Property '{pc.property_name}': '{pc.old_value}' -> '{pc.new_value}'"
)
if pc.property_name in IMAGE_PROPERTIES:
label = pc.property_name.replace("_", " ").title()
self.handle.verbose_log(f" {label}: missing in NetBox (YAML defines image)")
else:
self.handle.verbose_log(
f" Property '{pc.property_name}': '{pc.old_value}' -> '{pc.new_value}'"
)

if added:
self.handle.verbose_log(f" + {len(added)} new component(s)")
Expand All @@ -397,4 +478,10 @@ def log_change_report(self, report: ChangeReport):
for comp in removed:
self.handle.verbose_log(f" - {comp.component_type}: {comp.component_name}")

verbose_only = len(report.modified_device_types) - ct_removed
if verbose_only > 0 and not self.handle.args.verbose:
self.handle.log(f" ({verbose_only} more without removals — use --verbose to list)")
else:
self.handle.log("Modified device types: 0")

self.handle.log("=" * 60)
66 changes: 62 additions & 4 deletions log_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ def __init__(self, args):
stored on the instance as `self.args`.
"""
self.args = args
self.console = None
self._defer_depth = 0
self._deferred_messages = []

def exception(self, exception_type, exception, stack_trace=None):
"""
Expand Down Expand Up @@ -48,16 +51,60 @@ def exception(self, exception_type, exception, stack_trace=None):
system_exit(exception_dict[exception_type])

def _timestamp(self):
"""Return the current time formatted as HH:MM:SS."""
return datetime.now().strftime("%H:%M:%S")

def set_console(self, console):
"""Set the Rich Console instance used for output, or None to fall back to print()."""
self.console = console

def start_progress_group(self):
"""Begin a progress group that defers log output until the group ends."""
self._defer_depth += 1

def end_progress_group(self):
"""End the current progress group, flushing deferred messages when the depth returns to zero."""
if self._defer_depth == 0:
return
self._defer_depth -= 1
if self._defer_depth == 0 and self._deferred_messages:
for message in self._deferred_messages:
if self.console is not None and hasattr(self.console, "print"):
self.console.print(message, markup=False)
else:
print(message)
self._deferred_messages = []

def _emit(self, message):
"""Emit *message* immediately, or defer it if inside a progress group."""
if self._defer_depth > 0:
self._deferred_messages.append(message)
elif self.console is not None:
self.console.print(message, markup=False)
else:
print(message)

def verbose_log(self, message):
"""Log *message* only when verbose mode is enabled."""
if self.args.verbose:
print(f"[{self._timestamp()}] {message}")
self._emit(f"[{self._timestamp()}] {message}")

def log(self, message):
print(f"[{self._timestamp()}] {message}")
"""Emit a timestamped log message unconditionally."""
self._emit(f"[{self._timestamp()}] {message}")

def log_device_ports_created(self, created_ports=None, port_type: str = "port"):
"""Log creation of device port templates and return the count created.

def log_device_ports_created(self, created_ports: list = [], port_type: str = "port"):
Args:
created_ports (list | None): Port template records returned by the API.
port_type (str): Human-readable port type label used in log messages.

Returns:
int: Number of ports logged.
"""
if created_ports is None:
created_ports = []
for port in created_ports:
self.verbose_log(
f"{port_type} Template Created: {port.name} - "
Expand All @@ -66,7 +113,18 @@ def log_device_ports_created(self, created_ports: list = [], port_type: str = "p
)
return len(created_ports)

def log_module_ports_created(self, created_ports: list = [], port_type: str = "port"):
def log_module_ports_created(self, created_ports=None, port_type: str = "port"):
"""Log creation of module port templates and return the count created.

Args:
created_ports (list | None): Port template records returned by the API.
port_type (str): Human-readable port type label used in log messages.

Returns:
int: Number of ports logged.
"""
if created_ports is None:
created_ports = []
for port in created_ports:
self.verbose_log(
f"{port_type} Template Created: {port.name} - "
Expand Down
Loading