Coverage for netbox_data_import/values.py: 100%

53 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-09 20:50 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Share source-value normalization between interpretation and target planning.""" 

4 

5from __future__ import annotations 

6 

7from decimal import Decimal, InvalidOperation 

8import math 

9 

10NUMERIC_TARGET_FIELDS: frozenset[str] = frozenset({"u_position", "u_height"}) 

11 

12# A spreadsheet exports an empty cell as one of these words, and no caller wants the literal text. 

13_NONE_LIKE = frozenset({"none", "nan", "null", "n/a", "#n/a"}) 

14 

15 

16def source_text(value) -> str: 

17 """Return a source value as stripped text, with a null-like word read as empty.""" 

18 if value is None: 

19 return "" 

20 text = str(value).strip() 

21 return "" if text.lower() in _NONE_LIKE else text 

22 

23 

24def identity_text(value) -> str: 

25 """Return the case-insensitive comparison form of a source identity.""" 

26 return " ".join(source_text(value).split()).casefold() 

27 

28 

29def source_position(value, default=None): 

30 """Return a finite rack position without losing half-unit precision.""" 

31 text = source_text(value) 

32 if not text: 

33 return default 

34 try: 

35 position = Decimal(text) 

36 except (InvalidOperation, TypeError, ValueError): 

37 return default 

38 if not position.is_finite() or not math.isfinite(float(position)): 

39 return default 

40 if position == position.to_integral_value(): 

41 return int(position) 

42 return float(position) 

43 

44 

45def effective_device_name(row) -> str: 

46 """Return the imported device name, including the asset-tag fallback.""" 

47 return source_text(row.get("device_name")) or source_text(row.get("asset_tag"))[:50] 

48 

49 

50def has_below_rack_position(row) -> bool: 

51 """Return whether a source row explicitly names a position below rack unit one.""" 

52 position = source_position(row.get("u_position")) 

53 return position is not None and position < 1 

54 

55 

56def normalize_for_compare(value) -> str: 

57 """Normalize a value for field-diff comparison. 

58 

59 Whole-number floats (e.g. 35.0, "35.0") are normalized to their integer string form ("35") to 

60 avoid false diffs caused by type differences between the source file and what NetBox returns. 

61 """ 

62 if value is None: 

63 return "" 

64 try: 

65 number = float(value) 

66 if number == int(number): 

67 return str(int(number)) 

68 return str(number) 

69 except (TypeError, ValueError, OverflowError): 

70 return str(value).strip() 

71 

72 

73def comparison_key(target_field: str, value) -> str: 

74 """Return the comparison key for *value* appropriate to the field's kind.""" 

75 if target_field in NUMERIC_TARGET_FIELDS: 

76 return normalize_for_compare(value) 

77 return "" if value is None else str(value).strip() 

78 

79 

80def status_map() -> dict[str, str]: 

81 """Return the source words that name a NetBox device status, taken from NetBox's own choices.""" 

82 from dcim.choices import DeviceStatusChoices 

83 

84 return { 

85 "live": DeviceStatusChoices.STATUS_ACTIVE, 

86 "production": DeviceStatusChoices.STATUS_ACTIVE, 

87 "planned": DeviceStatusChoices.STATUS_PLANNED, 

88 "staged": DeviceStatusChoices.STATUS_STAGED, 

89 "inventory": DeviceStatusChoices.STATUS_INVENTORY, 

90 "failed": DeviceStatusChoices.STATUS_FAILED, 

91 "offline": DeviceStatusChoices.STATUS_OFFLINE, 

92 "decommissioning": DeviceStatusChoices.STATUS_DECOMMISSIONING, 

93 } 

94 

95 

96def translation_maps(): 

97 """Return (side, airflow, status) source-word tables, with the choice values imported lazily.""" 

98 from dcim.choices import DeviceAirflowChoices, DeviceFaceChoices 

99 

100 side = { 

101 "front": DeviceFaceChoices.FACE_FRONT, 

102 "back": DeviceFaceChoices.FACE_REAR, 

103 "rear": DeviceFaceChoices.FACE_REAR, 

104 } 

105 airflow = { 

106 "front to back": DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, 

107 "back to front": DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, 

108 "passive": DeviceAirflowChoices.AIRFLOW_PASSIVE, 

109 } 

110 return side, airflow, status_map() 

111 

112 

113__all__ = ( 

114 "NUMERIC_TARGET_FIELDS", 

115 "comparison_key", 

116 "effective_device_name", 

117 "has_below_rack_position", 

118 "identity_text", 

119 "normalize_for_compare", 

120 "source_position", 

121 "source_text", 

122 "status_map", 

123 "translation_maps", 

124)