os-normalizer 0.4.2__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.

Potentially problematic release.


This version of os-normalizer might be problematic. Click here for more details.

@@ -1,19 +1,156 @@
1
1
  """Constants and static lookup tables for the OS fingerprinting package."""
2
2
 
3
+ from enum import StrEnum
4
+
5
+
6
+ class OSFamily(StrEnum):
7
+ """Canonical OS family identifiers used throughout the project."""
8
+
9
+ ANDROID = "android"
10
+ BSD = "bsd"
11
+ ESXI = "esxi"
12
+ HARMONYOS = "harmonyos"
13
+ IOS = "ios"
14
+ LINUX = "linux"
15
+ MACOS = "macos"
16
+ NETWORK = "network-os"
17
+ SOLARIS = "solaris"
18
+ WINDOWS = "windows"
19
+
20
+
21
+ class PrecisionLevel(StrEnum):
22
+ """Granularity levels for parsed OS version information."""
23
+
24
+ UNKNOWN = "unknown"
25
+ FAMILY = "family"
26
+ PRODUCT = "product"
27
+ MAJOR = "major"
28
+ MINOR = "minor"
29
+ PATCH = "patch"
30
+ BUILD = "build"
31
+
32
+
33
+ PRECISION_ORDER = {
34
+ PrecisionLevel.BUILD: 6,
35
+ PrecisionLevel.PATCH: 5,
36
+ PrecisionLevel.MINOR: 4,
37
+ PrecisionLevel.MAJOR: 3,
38
+ PrecisionLevel.PRODUCT: 2,
39
+ PrecisionLevel.FAMILY: 1,
40
+ PrecisionLevel.UNKNOWN: 0,
41
+ }
42
+
43
+
44
+ # Architecture tokens recognised across parsers.
45
+ ARCHITECTURE_TOKENS = (
46
+ "x86_64",
47
+ "amd64",
48
+ "x64",
49
+ "x86",
50
+ "ia32",
51
+ "i386",
52
+ "i486",
53
+ "i586",
54
+ "i686",
55
+ "arm64",
56
+ "aarch64",
57
+ "arm64e",
58
+ "armv8",
59
+ "armv8l",
60
+ "arm",
61
+ "armv7",
62
+ "armv7l",
63
+ "armv7hf",
64
+ "armv6",
65
+ "armhf",
66
+ "armel",
67
+ "ppc64le",
68
+ "powerpc64le",
69
+ "ppc64",
70
+ "powerpc64",
71
+ "ppc",
72
+ "ppc32",
73
+ "ppcle",
74
+ "powerpc",
75
+ "sparc",
76
+ "sparc64",
77
+ "sparcv9",
78
+ "sun4u",
79
+ "sun4v",
80
+ "mips",
81
+ "mips32",
82
+ "mipsel",
83
+ "mips64",
84
+ "mips64el",
85
+ "mips64le",
86
+ "s390x",
87
+ "s390",
88
+ "riscv64",
89
+ "riscv",
90
+ "loongarch64",
91
+ "tilegx",
92
+ "alpha",
93
+ "ia64",
94
+ "itanium",
95
+ "hppa",
96
+ "parisc",
97
+ "m68k",
98
+ )
99
+
3
100
  # Architecture synonyms
4
101
  ARCH_SYNONYMS = {
5
- "x64": "x86_64",
6
102
  "x86_64": "x86_64",
7
103
  "amd64": "x86_64",
104
+ "x64": "x86_64",
8
105
  "x86": "x86",
106
+ "ia32": "x86",
9
107
  "i386": "x86",
108
+ "i486": "x86",
109
+ "i586": "x86",
10
110
  "i686": "x86",
11
- "aarch64": "arm64",
12
111
  "arm64": "arm64",
112
+ "aarch64": "arm64",
113
+ "arm64e": "arm64",
13
114
  "armv8": "arm64",
115
+ "armv8l": "arm64",
116
+ "arm": "arm",
14
117
  "armv7": "arm",
15
118
  "armv7l": "arm",
119
+ "armv7hf": "arm",
120
+ "armv6": "arm",
121
+ "armhf": "arm",
122
+ "armel": "arm",
16
123
  "ppc64le": "ppc64le",
124
+ "powerpc64le": "ppc64le",
125
+ "ppc64": "ppc64",
126
+ "powerpc64": "ppc64",
127
+ "ppc": "ppc",
128
+ "ppc32": "ppc",
129
+ "ppcle": "ppc",
130
+ "powerpc": "ppc",
131
+ "sparc": "sparc",
132
+ "sparc64": "sparc",
133
+ "sparcv9": "sparc",
134
+ "sun4u": "sparc",
135
+ "sun4v": "sparc",
136
+ "mips": "mips",
137
+ "mips32": "mips",
138
+ "mipsel": "mips",
139
+ "mips64": "mips64",
140
+ "mips64el": "mips64",
141
+ "mips64le": "mips64",
142
+ "s390x": "s390x",
143
+ "s390": "s390x",
144
+ "riscv64": "riscv64",
145
+ "riscv": "riscv64",
146
+ "loongarch64": "loongarch64",
147
+ "tilegx": "tilegx",
148
+ "alpha": "alpha",
149
+ "ia64": "ia64",
150
+ "itanium": "ia64",
151
+ "hppa": "parisc",
152
+ "parisc": "parisc",
153
+ "m68k": "m68k",
17
154
  }
18
155
 
19
156
  # Windows build map (build number range -> product name, marketing channel)
os_normalizer/cpe.py CHANGED
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import TYPE_CHECKING
11
11
 
12
- from os_normalizer.constants import WINDOWS_BUILD_MAP
12
+ from os_normalizer.constants import OSFamily, WINDOWS_BUILD_MAP
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from .models import OSData
@@ -39,12 +39,19 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
39
39
 
40
40
  strategy controls version/update selection rules.
41
41
  """
42
- fam = (p.family or "").lower()
42
+ raw_family = p.family
43
+ family = (
44
+ raw_family
45
+ if isinstance(raw_family, OSFamily)
46
+ else OSFamily._value2member_map_.get(str(raw_family).lower())
47
+ if raw_family
48
+ else None
49
+ )
43
50
  vendor = (p.vendor or "").lower() if p.vendor else None
44
51
  product = (p.product or "").lower() if p.product else ""
45
52
 
46
53
  # Windows
47
- if fam == "windows":
54
+ if family == OSFamily.WINDOWS:
48
55
  vtok = "microsoft"
49
56
  prod_map = {
50
57
  "windows 7": "windows_7",
@@ -68,11 +75,11 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
68
75
  return vtok, base, "windows"
69
76
 
70
77
  # macOS
71
- if fam == "macos":
78
+ if family == OSFamily.MACOS:
72
79
  return "apple", "macos", "macos"
73
80
 
74
81
  # Linux distros (use distro when present)
75
- if fam == "linux":
82
+ if family == OSFamily.LINUX:
76
83
  d = (p.distro or "").lower()
77
84
  if d == "ubuntu":
78
85
  return "canonical", "ubuntu_linux", "ubuntu"
@@ -93,7 +100,7 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
93
100
  return vendor or "linux", product or "linux", "linux"
94
101
 
95
102
  # BSDs
96
- if fam == "bsd":
103
+ if family == OSFamily.BSD:
97
104
  if product and "freebsd" in product:
98
105
  return "freebsd", "freebsd", "freebsd"
99
106
  if product and "openbsd" in product:
@@ -102,8 +109,14 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
102
109
  return "netbsd", "netbsd", "netbsd"
103
110
  return vendor or "bsd", product or "bsd", "bsd"
104
111
 
112
+ if family == OSFamily.SOLARIS:
113
+ return "oracle", "solaris", "solaris"
114
+
115
+ if family == OSFamily.ESXI:
116
+ return "vmware", "esxi", "esxi"
117
+
105
118
  # Network OS
106
- if fam == "network-os":
119
+ if family == OSFamily.NETWORK:
107
120
  if vendor == "cisco":
108
121
  if product and ("ios xe" in product or "ios-xe" in product):
109
122
  return "cisco", "ios_xe", "ios_xe"
@@ -120,13 +133,20 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
120
133
  return vendor or "network", (product or "firmware").replace(" ", "_"), "firmware"
121
134
 
122
135
  # Mobile
123
- if fam == "android":
124
- return "google", "android", "android"
125
- if fam == "ios":
126
- return "apple", "iphone_os", "ios"
136
+ if family == OSFamily.ANDROID:
137
+ return "google", "android", OSFamily.ANDROID.value
138
+ if family == OSFamily.IOS:
139
+ return "apple", "iphone_os", OSFamily.IOS.value
140
+ if family == OSFamily.HARMONYOS:
141
+ return "huawei", "harmonyos", OSFamily.HARMONYOS.value
127
142
 
128
143
  # Fallback
129
- return (vendor or fam or "unknown"), (product or (fam or "unknown")).replace(" ", "_"), fam or "unknown"
144
+ family_value = family.value if family else (str(raw_family).lower() if raw_family else "unknown")
145
+ return (
146
+ vendor or family_value or "unknown",
147
+ (product or family_value).replace(" ", "_"),
148
+ family_value,
149
+ )
130
150
 
131
151
 
132
152
  def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
@@ -192,6 +212,18 @@ def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
192
212
  ver = "*"
193
213
  return ver, "*", (edition or "*")
194
214
 
215
+ if strategy in ("solaris", "esxi"):
216
+ if maj is not None:
217
+ ver = str(maj)
218
+ if minr is not None:
219
+ ver = f"{ver}.{minr}"
220
+ if pat is not None:
221
+ ver = f"{ver}.{pat}"
222
+ else:
223
+ ver = "*"
224
+ update = build or "*"
225
+ return ver, update, "*"
226
+
195
227
  if strategy == "fortios":
196
228
  if maj is not None and minr is not None and pat is not None:
197
229
  ver = f"{maj}.{minr}.{pat}"
@@ -216,6 +248,19 @@ def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
216
248
  ver = "*"
217
249
  return ver, "*", "*"
218
250
 
251
+ if strategy == "harmonyos":
252
+ if maj is not None:
253
+ if minr is not None and pat is not None:
254
+ ver = f"{maj}.{minr}.{pat}"
255
+ elif minr is not None:
256
+ ver = f"{maj}.{minr}"
257
+ else:
258
+ ver = f"{maj}"
259
+ else:
260
+ ver = "*"
261
+ update = build or "*"
262
+ return ver, update, "*"
263
+
219
264
  # Generic fallback
220
265
  if build:
221
266
  ver = build
os_normalizer/helpers.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import re
4
4
  from typing import Any
5
5
 
6
- from .constants import ARCH_SYNONYMS
6
+ from .constants import ARCH_SYNONYMS, ARCHITECTURE_TOKENS, PrecisionLevel
7
7
  from .models import OSData
8
8
 
9
9
 
@@ -34,17 +34,17 @@ def precision_from_parts(
34
34
  minor: int | None,
35
35
  patch: int | None,
36
36
  build: str | None,
37
- ) -> str:
37
+ ) -> PrecisionLevel:
38
38
  """Derive a precision label from version components."""
39
39
  if build:
40
- return "build"
40
+ return PrecisionLevel.BUILD
41
41
  if patch is not None:
42
- return "patch"
42
+ return PrecisionLevel.PATCH
43
43
  if minor is not None:
44
- return "minor"
44
+ return PrecisionLevel.MINOR
45
45
  if major is not None:
46
- return "major"
47
- return "product"
46
+ return PrecisionLevel.MAJOR
47
+ return PrecisionLevel.PRODUCT
48
48
 
49
49
 
50
50
  def canonical_key(p: OSData) -> str:
@@ -61,7 +61,10 @@ def canonical_key(p: OSData) -> str:
61
61
 
62
62
 
63
63
  # Regex for extracting an architecture token from free-form text
64
- ARCH_TEXT_RE = re.compile(r"\b(x86_64|amd64|x64|x86|i386|i686|arm64|aarch64|armv8|armv7l?|ppc64le)\b", re.IGNORECASE)
64
+ _ARCH_PATTERN = "|".join(
65
+ sorted((re.escape(token) for token in ARCHITECTURE_TOKENS), key=len, reverse=True)
66
+ )
67
+ ARCH_TEXT_RE = re.compile(rf"\b({_ARCH_PATTERN})\b", re.IGNORECASE)
65
68
 
66
69
 
67
70
  def extract_arch_from_text(text: str) -> str | None:
@@ -92,16 +95,24 @@ def parse_os_release(blob_text: str) -> dict[str, Any]:
92
95
  return out
93
96
 
94
97
 
95
- def update_confidence(p: OSData, precision: str) -> None:
98
+ def update_confidence(p: OSData, precision: PrecisionLevel | str) -> None:
96
99
  """Boost confidence based on the determined precision level.
97
100
 
98
101
  The mapping mirrors the original ad-hoc values used throughout the monolithic file.
99
102
  """
103
+ if isinstance(precision, PrecisionLevel):
104
+ level = precision
105
+ else:
106
+ try:
107
+ level = PrecisionLevel(str(precision))
108
+ except ValueError:
109
+ level = PrecisionLevel.UNKNOWN
110
+
100
111
  boost_map = {
101
- "build": 0.85,
102
- "patch": 0.80,
103
- "minor": 0.75,
104
- "major": 0.70,
105
- "product": 0.60,
112
+ PrecisionLevel.BUILD: 0.85,
113
+ PrecisionLevel.PATCH: 0.80,
114
+ PrecisionLevel.MINOR: 0.75,
115
+ PrecisionLevel.MAJOR: 0.70,
116
+ PrecisionLevel.PRODUCT: 0.60,
106
117
  }
107
- p.confidence = max(p.confidence, boost_map.get(precision, 0.5))
118
+ p.confidence = max(p.confidence, boost_map.get(level, 0.5))
os_normalizer/models.py CHANGED
@@ -2,16 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass, field, fields as dataclass_fields
5
+ from dataclasses import dataclass, field
6
+ from dataclasses import fields as dataclass_fields
7
+ from enum import Enum
6
8
  from typing import Any
7
9
 
10
+ from .constants import OSFamily, PrecisionLevel
11
+
8
12
 
9
13
  @dataclass
10
14
  class OSData:
11
15
  """Structured representation of a parsed operating system."""
12
16
 
13
17
  # Core identity
14
- family: str | None = None # windows, linux, macos, ios, android, bsd, network-os
18
+ family: OSFamily | None = None # windows, linux, macos, ios, android, bsd, solaris, esxi, network-os
15
19
  vendor: str | None = None # Microsoft, Apple, Canonical, Cisco, Juniper, Fortinet, Huawei, Netgear…
16
20
  product: str | None = None # Windows 11, Ubuntu, macOS, IOS XE, Junos, FortiOS, VRP, Firmware
17
21
  edition: str | None = None # Pro/Enterprise/LTSC; universalk9/ipbase; etc.
@@ -37,7 +41,7 @@ class OSData:
37
41
  build_id: str | None = None
38
42
 
39
43
  # Meta information
40
- precision: str = "unknown" # family|product|major|minor|patch|build
44
+ precision: PrecisionLevel = PrecisionLevel.UNKNOWN # family|product|major|minor|patch|build
41
45
  confidence: float = 0.0
42
46
  evidence: dict[str, Any] = field(default_factory=dict)
43
47
 
@@ -47,7 +51,7 @@ class OSData:
47
51
  def __str__(self) -> str: # pragma: no cover - formatting helper
48
52
  parts: list[str] = []
49
53
 
50
- if self.family == "windows":
54
+ if self.family == OSFamily.WINDOWS:
51
55
  return _format_windows(self)
52
56
 
53
57
  # Prefer vendor + product; fallback to pretty_name; then family
@@ -57,7 +61,10 @@ class OSData:
57
61
  elif self.pretty_name:
58
62
  parts.append(self.pretty_name)
59
63
  else:
60
- parts.append(self.family or "Unknown OS")
64
+ if isinstance(self.family, OSFamily):
65
+ parts.append(self.family.value)
66
+ else:
67
+ parts.append(self.family or "Unknown OS")
61
68
 
62
69
  # Version string (major[.minor[.patch]]) and optional build
63
70
  ver_chunks: list[str] = []
@@ -100,8 +107,9 @@ class OSData:
100
107
  parts.append(f"[build: {self.build_id}]")
101
108
 
102
109
  # Precision/confidence summary
103
- if self.precision and self.precision != "unknown":
104
- parts.append(f"{{{self.precision}:{self.confidence:.2f}}}")
110
+ if self.precision and self.precision != PrecisionLevel.UNKNOWN:
111
+ label = self.precision.value if isinstance(self.precision, PrecisionLevel) else str(self.precision)
112
+ parts.append(f"{{{label}:{self.confidence:.2f}}}")
105
113
  elif self.confidence:
106
114
  parts.append(f"{{{self.confidence:.2f}}}")
107
115
 
@@ -129,6 +137,8 @@ class OSData:
129
137
  sval = none_str
130
138
  elif name == "confidence" and isinstance(val, (int, float)):
131
139
  sval = f"{float(val):.2f}"
140
+ elif isinstance(val, Enum):
141
+ sval = val.value
132
142
  elif isinstance(val, list):
133
143
  sval = ", ".join(str(x) for x in val)
134
144
  elif isinstance(val, dict):
@@ -159,7 +169,7 @@ def _format_windows(p: OSData) -> str:
159
169
 
160
170
  if __name__ == "__main__":
161
171
  x = OSData(
162
- family="linux",
172
+ family=OSFamily.LINUX,
163
173
  vendor="Fedora Project",
164
174
  product="Fedora Linux",
165
175
  version_major=33,
@@ -168,7 +178,7 @@ if __name__ == "__main__":
168
178
  distro="fedora",
169
179
  like_distros=[],
170
180
  pretty_name="Fedora Linux",
171
- precision="major",
181
+ precision=PrecisionLevel.MAJOR,
172
182
  confidence=0.7,
173
183
  evidence={"hit": "linux"},
174
184
  )
@@ -1,35 +1,32 @@
1
- from datetime import UTC, datetime
2
- from typing import Any, Iterable
3
- from dataclasses import replace, fields
4
1
  import copy
2
+ from collections.abc import Iterable
3
+ from dataclasses import fields, replace
4
+ from datetime import UTC, datetime
5
+ from typing import Any
5
6
 
6
- from os_normalizer.helpers import extract_arch_from_text, precision_from_parts, update_confidence
7
+ from os_normalizer.constants import PRECISION_ORDER, OSFamily, PrecisionLevel
7
8
  from os_normalizer.cpe import build_cpe23
9
+ from os_normalizer.helpers import extract_arch_from_text, precision_from_parts, update_confidence
8
10
  from os_normalizer.models import OSData
9
11
  from os_normalizer.parsers.bsd import parse_bsd
12
+ from os_normalizer.parsers.esxi import parse_esxi
10
13
  from os_normalizer.parsers.linux import parse_linux
11
14
  from os_normalizer.parsers.macos import parse_macos
12
15
  from os_normalizer.parsers.mobile import parse_mobile
13
16
  from os_normalizer.parsers.network import parse_network
17
+ from os_normalizer.parsers.solaris import parse_solaris
14
18
  from os_normalizer.parsers.windows import parse_windows
15
19
 
16
- PRECISION_ORDER = {
17
- "build": 6,
18
- "patch": 5,
19
- "minor": 4,
20
- "major": 3,
21
- "product": 2,
22
- "family": 1,
23
- "unknown": 0,
24
- }
25
-
26
20
 
27
21
  # ============================================================
28
22
  # Family detection (orchestrator logic)
29
23
  # ============================================================
30
- def detect_family(text: str, data: dict[str, Any]) -> tuple[str | None, float, dict[str, Any]]:
24
+ def detect_family(text: str, data: dict[str, Any]) -> tuple[OSFamily | None, float, dict[str, Any]]:
31
25
  t = text.lower()
32
26
  ev = {}
27
+ if OSFamily.HARMONYOS.value in t:
28
+ ev["hit"] = OSFamily.HARMONYOS
29
+ return OSFamily.HARMONYOS, 0.6, ev
33
30
  # Obvious network signals first
34
31
  if any(
35
32
  x in t
@@ -48,35 +45,50 @@ def detect_family(text: str, data: dict[str, Any]) -> tuple[str | None, float, d
48
45
  ]
49
46
  ):
50
47
  # Special handling for 'ios' - if it's just 'ios' without 'cisco', treat as mobile, not network
51
- if "ios " in t and "cisco" not in t:
52
- ev["hit"] = "ios"
53
- return "ios", 0.6, ev
54
-
55
- ev["hit"] = "network-os"
56
- return "network-os", 0.7, ev
48
+ if f"{OSFamily.IOS.value} " in t and "cisco" not in t:
49
+ ev["hit"] = OSFamily.IOS
50
+ return OSFamily.IOS, 0.6, ev
51
+
52
+ ev["hit"] = OSFamily.NETWORK
53
+ return OSFamily.NETWORK, 0.7, ev
54
+ # VMware ESXi
55
+ if "vmkernel" in t or "vmware esxi" in t or " esxi" in t or t.startswith("esxi"):
56
+ ev["hit"] = OSFamily.ESXI
57
+ return OSFamily.ESXI, 0.65, ev
58
+ # Solaris / SunOS
59
+ if "sunos" in t or "solaris" in t:
60
+ ev["hit"] = OSFamily.SOLARIS
61
+ return OSFamily.SOLARIS, 0.65, ev
57
62
  # Linux
58
- if "linux" in t or any(k in data for k in ("ID", "ID_LIKE", "PRETTY_NAME", "VERSION_ID", "VERSION_CODENAME")):
59
- ev["hit"] = "linux"
60
- return "linux", 0.6, ev
63
+ if OSFamily.LINUX.value in t or any(
64
+ k in data for k in ("ID", "ID_LIKE", "PRETTY_NAME", "VERSION_ID", "VERSION_CODENAME")
65
+ ):
66
+ ev["hit"] = OSFamily.LINUX
67
+ return OSFamily.LINUX, 0.6, ev
61
68
  # Windows
62
- if "windows" in t or "nt " in t or t.startswith("win") or data.get("os", "").lower() == "windows":
63
- ev["hit"] = "windows"
64
- return "windows", 0.6, ev
69
+ if (
70
+ OSFamily.WINDOWS.value in t
71
+ or "nt " in t
72
+ or t.startswith("win")
73
+ or data.get("os", "").lower() == OSFamily.WINDOWS.value
74
+ ):
75
+ ev["hit"] = OSFamily.WINDOWS
76
+ return OSFamily.WINDOWS, 0.6, ev
65
77
  # Apple
66
- if "macos" in t or "os x" in t or "darwin" in t:
67
- ev["hit"] = "macos"
68
- return "macos", 0.6, ev
69
- if "ios" in t or "ipados" in t:
70
- ev["hit"] = "ios"
71
- return "ios", 0.6, ev
78
+ if OSFamily.MACOS.value in t or "os x" in t or "darwin" in t:
79
+ ev["hit"] = OSFamily.MACOS
80
+ return OSFamily.MACOS, 0.6, ev
81
+ if OSFamily.IOS.value in t or "ipados" in t:
82
+ ev["hit"] = OSFamily.IOS
83
+ return OSFamily.IOS, 0.6, ev
72
84
  # Android
73
- if "android" in t:
74
- ev["hit"] = "android"
75
- return "android", 0.6, ev
85
+ if OSFamily.ANDROID.value in t:
86
+ ev["hit"] = OSFamily.ANDROID
87
+ return OSFamily.ANDROID, 0.6, ev
76
88
  # BSD
77
89
  if "freebsd" in t or "openbsd" in t or "netbsd" in t:
78
- ev["hit"] = "bsd"
79
- return "bsd", 0.6, ev
90
+ ev["hit"] = OSFamily.BSD
91
+ return OSFamily.BSD, 0.6, ev
80
92
  return None, 0.0, ev
81
93
 
82
94
 
@@ -93,20 +105,24 @@ def normalize_os(text: str, data: dict | None = None) -> OSData:
93
105
  p.confidence = max(p.confidence, base_conf)
94
106
  p.evidence.update(ev)
95
107
 
96
- if fam == "network-os":
108
+ if fam == OSFamily.NETWORK:
97
109
  p = parse_network(text, data, p)
98
- elif fam == "windows":
110
+ elif fam == OSFamily.WINDOWS:
99
111
  p = parse_windows(text, data, p)
100
- elif fam == "macos":
112
+ elif fam == OSFamily.MACOS:
101
113
  p = parse_macos(text, data, p)
102
- elif fam == "linux":
114
+ elif fam == OSFamily.LINUX:
103
115
  p = parse_linux(text, data, p)
104
- elif fam in ("android", "ios"):
116
+ elif fam == OSFamily.SOLARIS:
117
+ p = parse_solaris(text, data, p)
118
+ elif fam == OSFamily.ESXI:
119
+ p = parse_esxi(text, data, p)
120
+ elif fam in (OSFamily.ANDROID, OSFamily.IOS, OSFamily.HARMONYOS):
105
121
  p = parse_mobile(text, data, p)
106
- elif fam == "bsd":
122
+ elif fam == OSFamily.BSD:
107
123
  p = parse_bsd(text, data, p)
108
124
  else:
109
- p.precision = "unknown"
125
+ p.precision = PrecisionLevel.UNKNOWN
110
126
 
111
127
  # Fallback arch from text if not already set elsewhere
112
128
  if not p.arch:
@@ -127,7 +143,7 @@ def choose_best_fact(candidates: list[OSData]) -> OSData:
127
143
  raise ValueError("No candidates")
128
144
  return sorted(
129
145
  candidates,
130
- key=lambda c: (PRECISION_ORDER.get(c.precision, 0), c.confidence),
146
+ key=lambda c: (PRECISION_ORDER.get(_ensure_precision_enum(c.precision), 0), c.confidence),
131
147
  reverse=True,
132
148
  )[0]
133
149
 
@@ -138,7 +154,18 @@ def choose_best_fact(candidates: list[OSData]) -> OSData:
138
154
 
139
155
 
140
156
  def _score(p: OSData) -> tuple[int, float]:
141
- return (PRECISION_ORDER.get(p.precision, 0), p.confidence)
157
+ return (PRECISION_ORDER.get(_ensure_precision_enum(p.precision), 0), p.confidence)
158
+
159
+
160
+ def _ensure_precision_enum(value: PrecisionLevel | str | None) -> PrecisionLevel:
161
+ if isinstance(value, PrecisionLevel):
162
+ return value
163
+ if value is None:
164
+ return PrecisionLevel.UNKNOWN
165
+ try:
166
+ return PrecisionLevel(str(value))
167
+ except ValueError:
168
+ return PrecisionLevel.UNKNOWN
142
169
 
143
170
 
144
171
  def _union_unique(values: Iterable[str]) -> list[str]:
@@ -227,8 +254,8 @@ def merge_os(a: OSData, b: OSData, policy: str = "auto") -> OSData:
227
254
 
228
255
  # Precision & confidence: recompute based on version parts
229
256
  new_prec = precision_from_parts(r.version_major, r.version_minor, r.version_patch, r.version_build)
230
- if new_prec == "product" and not r.product:
231
- new_prec = "family" if r.family else "unknown"
257
+ if new_prec == PrecisionLevel.PRODUCT and not r.product:
258
+ new_prec = PrecisionLevel.FAMILY if r.family else PrecisionLevel.UNKNOWN
232
259
  r.precision = new_prec
233
260
  r.confidence = max(a.confidence, b.confidence)
234
261
  update_confidence(r, r.precision)
@@ -1,15 +1,19 @@
1
- from .windows import parse_windows
2
- from .macos import parse_macos
1
+ from .bsd import parse_bsd
2
+ from .esxi import parse_esxi
3
3
  from .linux import parse_linux
4
+ from .macos import parse_macos
4
5
  from .mobile import parse_mobile
5
- from .bsd import parse_bsd
6
6
  from .network import parse_network
7
+ from .solaris import parse_solaris
8
+ from .windows import parse_windows
7
9
 
8
10
  __all__ = [
9
11
  "parse_bsd",
12
+ "parse_esxi",
10
13
  "parse_linux",
11
14
  "parse_macos",
12
15
  "parse_mobile",
13
16
  "parse_network",
17
+ "parse_solaris",
14
18
  "parse_windows",
15
19
  ]