os-normalizer 0.3.2__tar.gz → 0.4.0__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.

Potentially problematic release.


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

Files changed (26) hide show
  1. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/CHANGELOG.md +15 -2
  2. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/PKG-INFO +1 -1
  3. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/constants.py +43 -3
  4. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/__init__.py +3 -4
  5. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/linux.py +15 -5
  6. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/fortinet.py +1 -3
  7. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/netgear.py +2 -6
  8. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/windows.py +70 -21
  9. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/pyproject.toml +1 -1
  10. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/.gitignore +0 -0
  11. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/.python-version +0 -0
  12. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/LICENSE +0 -0
  13. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/README.md +0 -0
  14. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/RELEASING.md +0 -0
  15. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/__init__.py +0 -0
  16. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/cpe.py +0 -0
  17. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/helpers.py +0 -0
  18. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/models.py +0 -0
  19. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/os_normalizer.py +0 -0
  20. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/bsd.py +0 -0
  21. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/macos.py +0 -0
  22. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/mobile.py +0 -0
  23. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/__init__.py +0 -0
  24. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/cisco.py +0 -0
  25. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/huawei.py +0 -0
  26. {os_normalizer-0.3.2 → os_normalizer-0.4.0}/os_normalizer/parsers/network/juniper.py +0 -0
@@ -3,7 +3,19 @@
3
3
  All notable changes to this project are documented here.
4
4
  This file adheres to Keep a Changelog and Semantic Versioning.
5
5
 
6
- ## [Unreleased]
6
+ ## [0.3.3] - 2025-09-21
7
+
8
+ - Added: `tests/case_utils.py` to share parametrization helpers across suites.
9
+ - Added: Platform-specific suites for clearer test changes.
10
+ - Removed: Legacy `tests/test_os_normalizer.py` harness now that coverage lives beside each platform.
11
+
12
+ ## [0.3.2] - 2025-09-11
13
+
14
+ - Added: More `pyproject.toml` metadata (description, keywords, classifiers, project URLs).
15
+ - Added: `LICENSE` (MIT) and referenced it from project metadata.
16
+ - Added: `RELEASING.md` with step-by-step TestPyPI/PyPI instructions.
17
+ - Changed: Switched to Hatchling build backend via `[build-system]` in `pyproject.toml`.
18
+ - Changed: Exclude dev artifacts from sdist (`tests/`, caches, lockfiles, egg-info).
7
19
 
8
20
  ## [0.3.1] - 2025-09-09
9
21
 
@@ -28,7 +40,8 @@ This file adheres to Keep a Changelog and Semantic Versioning.
28
40
 
29
41
  - Initial release.
30
42
 
31
- [Unreleased]: https://github.com/johnscillieri/os-normalizer/compare/v0.3.1...HEAD
43
+ [Unreleased]: https://github.com/johnscillieri/os-normalizer/compare/v0.3.3...HEAD
44
+ [0.3.3]: https://github.com/johnscillieri/os-normalizer/compare/v0.3.2...v0.3.3
32
45
  [0.3.1]: https://github.com/johnscillieri/os-normalizer/compare/v0.3.0...v0.3.1
33
46
  [0.3.0]: https://github.com/johnscillieri/os-normalizer/compare/v0.2.0...v0.3.0
34
47
  [0.2.0]: https://github.com/johnscillieri/os-normalizer/compare/v0.1.0...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: os-normalizer
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Normalize raw OS strings/metadata into structured data (family, product, version, arch).
5
5
  Project-URL: Homepage, https://github.com/johnscillieri/os-normalizer
6
6
  Project-URL: Repository, https://github.com/johnscillieri/os-normalizer
@@ -17,8 +17,27 @@ ARCH_SYNONYMS = {
17
17
  }
18
18
 
19
19
  # Windows build map (build number range -> product name, marketing channel)
20
+ # Notes:
21
+ # - This focuses on common client builds; server detection is handled separately
22
+ # and this map is not applied if a server product was already detected.
23
+ # - Marketing/channel labels use common public naming where applicable
24
+ # (e.g., 21H2/22H2 for Windows 10/11, RTM/SPx for older releases).
20
25
  WINDOWS_BUILD_MAP = [
21
- # Windows 10
26
+ # NT era (pre-Windows 10)
27
+ (1381, 1381, "Windows NT 4.0", "RTM"),
28
+ (2195, 2195, "Windows 2000", "RTM"),
29
+ (2600, 2600, "Windows XP", "RTM"),
30
+ # NT 5.2 builds are ambiguous (XP x64 vs Server 2003); keep consistent label
31
+ (3790, 3790, "Windows XP x64/Server 2003", "RTM"),
32
+ # Vista/7/8/8.1
33
+ (6000, 6000, "Windows Vista", "RTM"),
34
+ (6001, 6001, "Windows Vista", "SP1"),
35
+ (6002, 6002, "Windows Vista", "SP2"),
36
+ (7600, 7600, "Windows 7", "RTM"),
37
+ (7601, 7601, "Windows 7", "SP1"),
38
+ (9200, 9200, "Windows 8", "RTM"),
39
+ (9600, 9600, "Windows 8.1", "RTM"),
40
+ # Windows 10 (builds and marketing versions)
22
41
  (10240, 10240, "Windows 10", "1507"),
23
42
  (10586, 10586, "Windows 10", "1511"),
24
43
  (14393, 14393, "Windows 10", "1607"),
@@ -30,7 +49,7 @@ WINDOWS_BUILD_MAP = [
30
49
  (19041, 19045, "Windows 10", "2004/20H2/21H1/21H2/22H2"),
31
50
  # Windows 11
32
51
  (22000, 22000, "Windows 11", "21H2"),
33
- (22621, 22630, "Windows 11", "22H2"),
52
+ (22621, 22621, "Windows 11", "22H2"),
34
53
  (22631, 25999, "Windows 11", "23H2"),
35
54
  (26100, 26199, "Windows 11", "24H2"),
36
55
  ]
@@ -80,8 +99,29 @@ MACOS_DARWIN_MAP = {
80
99
  22: ("macOS", "13", "Ventura"),
81
100
  23: ("macOS", "14", "Sonoma"),
82
101
  24: ("macOS", "15", "Sequoia"),
102
+ 25: ("macOS", "26", "Tahoe"),
83
103
  }
84
104
 
105
+ # Windows Server build map (build number range -> product name, marketing channel)
106
+ # This is consulted only when the input looks server-like or when an explicit
107
+ # Windows Server product is already detected. Client mapping will not override
108
+ # explicit server detections.
109
+ WINDOWS_SERVER_BUILD_MAP = [
110
+ # Legacy server releases aligned with Vista/7/8/8.1
111
+ (3790, 3790, "Windows Server 2003", "RTM"),
112
+ (6001, 6001, "Windows Server 2008", "RTM"), # 6001 corresponds to 2008 RTM
113
+ (6002, 6002, "Windows Server 2008", "SP2"),
114
+ (7600, 7600, "Windows Server 2008 R2", "RTM"),
115
+ (7601, 7601, "Windows Server 2008 R2", "SP1"),
116
+ (9200, 9200, "Windows Server 2012", "RTM"),
117
+ (9600, 9600, "Windows Server 2012 R2", "RTM"),
118
+ # NT 10.0 based server releases
119
+ (14393, 14393, "Windows Server 2016", "1607"),
120
+ (17763, 17763, "Windows Server 2019", "1809"),
121
+ (20348, 20348, "Windows Server 2022", "21H2"),
122
+ # Windows Server 2025 (vNext) uses the 26100 train alongside client 24H2
123
+ (26100, 26199, "Windows Server 2025", "24H2"),
124
+ ]
125
+
85
126
  # Cisco train names (used for codename detection)
86
127
  CISCO_TRAIN_NAMES = {"Everest", "Fuji", "Gibraltar", "Amsterdam", "Denali"}
87
-
@@ -6,11 +6,10 @@ from .bsd import parse_bsd
6
6
  from .network import parse_network
7
7
 
8
8
  __all__ = [
9
- "parse_windows",
10
- "parse_macos",
9
+ "parse_bsd",
11
10
  "parse_linux",
11
+ "parse_macos",
12
12
  "parse_mobile",
13
- "parse_bsd",
14
13
  "parse_network",
14
+ "parse_windows",
15
15
  ]
16
-
@@ -18,7 +18,7 @@ LINUX_VER_FALLBACK_RE = re.compile(
18
18
 
19
19
 
20
20
  def parse_linux(text: str, data: dict[str, Any], p: OSData) -> OSData:
21
- """Populate an OSData instance with Linuxspecific details."""
21
+ """Populate an OSData instance with Linux-specific details."""
22
22
  p.kernel_name = "linux"
23
23
 
24
24
  osrel = _coerce_os_release(data.get("os_release")) if isinstance(data, dict) else None
@@ -37,7 +37,7 @@ def parse_linux(text: str, data: dict[str, Any], p: OSData) -> OSData:
37
37
  return p
38
38
 
39
39
 
40
- def _coerce_os_release(obj: Any) -> Optional[dict[str, Any]]:
40
+ def _coerce_os_release(obj: Any) -> dict[str, Any] | None:
41
41
  if isinstance(obj, str):
42
42
  return parse_os_release(obj)
43
43
  if isinstance(obj, dict):
@@ -45,7 +45,7 @@ def _coerce_os_release(obj: Any) -> Optional[dict[str, Any]]:
45
45
  return None
46
46
 
47
47
 
48
- def _extract_kernel_version(text: str) -> Optional[str]:
48
+ def _extract_kernel_version(text: str) -> str | None:
49
49
  m = KERNEL_RE.search(text)
50
50
  if m:
51
51
  return m.group(2)
@@ -66,6 +66,14 @@ def _apply_os_release(osrel: dict[str, Any], p: OSData) -> None:
66
66
 
67
67
  p.pretty_name = osrel.get("PRETTY_NAME") or osrel.get("NAME")
68
68
 
69
+ if not p.codename and p.pretty_name:
70
+ # Fallback: try to extract codename from parenthetical in pretty name (e.g. "Debian ... (buster)")
71
+ m = re.search(r"\(([^)]+)\)", str(p.pretty_name))
72
+ if m:
73
+ candidate = m.group(1).strip()
74
+ if candidate:
75
+ p.codename = candidate.title()
76
+
69
77
  _apply_version_id(osrel.get("VERSION_ID"), p)
70
78
 
71
79
  vcode = osrel.get("VERSION_CODENAME")
@@ -78,7 +86,9 @@ def _apply_os_release(osrel: dict[str, Any], p: OSData) -> None:
78
86
  p.vendor = _vendor_for_distro(p.distro) if p.distro else p.vendor
79
87
 
80
88
  name = osrel.get("NAME")
81
- p.product = (name if name else (p.distro or "Linux")).replace('"', "") if isinstance(name, str) else (p.distro or "Linux")
89
+ p.product = (
90
+ (name if name else (p.distro or "Linux")).replace('"', "") if isinstance(name, str) else (p.distro or "Linux")
91
+ )
82
92
 
83
93
  # Precision from version parts
84
94
  if p.version_patch is not None:
@@ -103,7 +113,7 @@ def _apply_version_id(vid: Any, p: OSData) -> None:
103
113
  p.version_patch = int(parts[2])
104
114
 
105
115
 
106
- def _vendor_for_distro(distro: Optional[str]) -> Optional[str]:
116
+ def _vendor_for_distro(distro: str | None) -> str | None:
107
117
  vendor_by_distro = {
108
118
  "ubuntu": "Canonical",
109
119
  "debian": "Debian",
@@ -30,9 +30,7 @@ def parse_fortinet(text: str, p: OSData) -> OSData:
30
30
  if len(nums) >= 3:
31
31
  p.version_patch = int(nums[2])
32
32
  p.version_build = v
33
- p.precision = (
34
- "patch" if p.version_patch is not None else ("minor" if p.version_minor is not None else "major")
35
- )
33
+ p.precision = "patch" if p.version_patch is not None else ("minor" if p.version_minor is not None else "major")
36
34
 
37
35
  bld = FORTI_BUILD_RE.search(text)
38
36
  if bld:
@@ -6,9 +6,7 @@ from os_normalizer.helpers import update_confidence
6
6
  from os_normalizer.models import OSData
7
7
 
8
8
  NETGEAR_RE = re.compile(r"\bnetgear\b|\bfirmware\b", re.IGNORECASE)
9
- NETGEAR_VER_RE = re.compile(
10
- r"\bV(\d+\.\d+\.\d+(?:\.\d+)?(?:_\d+\.\d+\.\d+)?)\b", re.IGNORECASE
11
- )
9
+ NETGEAR_VER_RE = re.compile(r"\bV(\d+\.\d+\.\d+(?:\.\d+)?(?:_\d+\.\d+\.\d+)?)\b", re.IGNORECASE)
12
10
  NETGEAR_MODEL_RE = re.compile(r"\b([RN][0-9]{3,4}[A-Z]?)\b", re.IGNORECASE)
13
11
 
14
12
 
@@ -29,9 +27,7 @@ def parse_netgear(text: str, p: OSData) -> OSData:
29
27
  if len(nums) >= 3:
30
28
  p.version_patch = int(nums[2])
31
29
  p.version_build = v
32
- p.precision = (
33
- "patch" if p.version_patch is not None else ("minor" if p.version_minor is not None else "major")
34
- )
30
+ p.precision = "patch" if p.version_patch is not None else ("minor" if p.version_minor is not None else "major")
35
31
 
36
32
  mdl = NETGEAR_MODEL_RE.search(text)
37
33
  if mdl:
@@ -10,6 +10,7 @@ from typing import Any, Optional
10
10
 
11
11
  from os_normalizer.constants import (
12
12
  WINDOWS_BUILD_MAP,
13
+ WINDOWS_SERVER_BUILD_MAP,
13
14
  WINDOWS_NT_CLIENT_MAP,
14
15
  WINDOWS_NT_SERVER_MAP,
15
16
  )
@@ -18,14 +19,18 @@ from os_normalizer.models import OSData
18
19
 
19
20
  # Regex patterns used only by the Windows parser
20
21
  WIN_EDITION_RE = re.compile(
21
- r"\b(professional|enterprise|home|education|ltsc|datacenter)\b",
22
+ r"\b(professional|pro|enterprise|home|education|ltsc|datacenter|standard)\b",
22
23
  re.IGNORECASE,
23
24
  )
24
25
  WIN_SP_RE = re.compile(r"\bSP\s?([0-9]+)\b", re.IGNORECASE)
25
26
  WIN_BUILD_RE = re.compile(r"\bbuild\s?(\d{4,6})\b", re.IGNORECASE)
26
27
  WIN_NT_RE = re.compile(r"\bnt\s?(\d+)\.(\d+)\b", re.IGNORECASE)
27
28
  WIN_FULL_NT_BUILD_RE = re.compile(r"\b(10)\.(0)\.(\d+)(?:\.(\d+))?\b")
28
- WIN_CHANNEL_RE = re.compile(r"\b(20H2|21H2|22H2|23H2|24H2|19H2|18H2|17H2|2004|1909|1809|1709|1511|1507)\b", re.IGNORECASE)
29
+ WIN_GENERIC_VERSION_RE = re.compile(r"\b(\d+)\.(\d+)\.(\d{3,6})(?:\.(\d+))?\b")
30
+ WIN_CHANNEL_RE = re.compile(
31
+ r"\b(24H2|23H2|22H2|21H2|21H1|20H2|2004|1909|1903|1809|1803|1709|1703|1607|1511|1507)\b",
32
+ re.IGNORECASE,
33
+ )
29
34
 
30
35
 
31
36
  def parse_windows(text: str, data: dict[str, Any], p: OSData) -> OSData:
@@ -47,7 +52,7 @@ def parse_windows(text: str, data: dict[str, Any], p: OSData) -> OSData:
47
52
  _apply_full_kernel_and_channel(text, p)
48
53
 
49
54
  # 5) Build number + marketing channel (fallback when only 'build 22631' is present)
50
- _apply_build_mapping(text, p)
55
+ _apply_build_mapping(text, p, server_like)
51
56
 
52
57
  # 6) Precision and version_major if applicable
53
58
  _finalize_precision_and_version(p)
@@ -59,6 +64,9 @@ def parse_windows(text: str, data: dict[str, Any], p: OSData) -> OSData:
59
64
 
60
65
 
61
66
  def _detect_product_from_text(t: str) -> str:
67
+ # Normalize common typos before matching
68
+ t = t.replace("windws", "windows")
69
+
62
70
  if "windows 11" in t or "win11" in t:
63
71
  return "Windows 11"
64
72
  if "windows 10" in t or "win10" in t:
@@ -75,19 +83,23 @@ def _detect_product_from_text(t: str) -> str:
75
83
  return "Windows 98"
76
84
 
77
85
  # Server explicit names
78
- if "windows server 2022" in t or "win2k22" in t or "win2022" in t:
86
+ if "windows server 2012 r2" in t or "windows 2012 r2" in t or "win2k12r2" in t or "win2012r2" in t:
87
+ return "Windows Server 2012 R2"
88
+ if "windows server 2022" in t or "windows 2022" in t or "win2k22" in t or "win2022" in t:
79
89
  return "Windows Server 2022"
80
- if "windows server 2019" in t or "win2k19" in t or "win2019" in t:
90
+ if "windows server 2019" in t or "windows 2019" in t or "win2k19" in t or "win2019" in t:
81
91
  return "Windows Server 2019"
82
- if "windows server 2016" in t or "win2k16" in t or "win2016" in t:
92
+ if "windows server 2016" in t or "windows 2016" in t or "win2k16" in t or "win2016" in t:
83
93
  return "Windows Server 2016"
84
- if "windows server 2012" in t or "win2k12" in t or "win2012" in t:
94
+ if "windows server 2012" in t or "windows 2012" in t or "win2k12" in t or "win2012" in t:
85
95
  return "Windows Server 2012"
86
- if "windows server 2008" in t or "win2k8" in t or "win2008" in t:
96
+ if "windows server 2008 r2" in t or "windows 2008 r2" in t or "win2k8r2" in t or "win2008r2" in t:
97
+ return "Windows Server 2008 R2"
98
+ if "windows server 2008" in t or "windows 2008" in t or "win2k8" in t or "win2008" in t:
87
99
  return "Windows Server 2008"
88
- if "windows server 2003" in t or "win2k3" in t or "win2003" in t:
100
+ if "windows server 2003" in t or "windows 2003" in t or "win2k3" in t or "win2003" in t:
89
101
  return "Windows Server 2003"
90
- if "windows server 2000" in t or "win2k" in t or "win2000" in t:
102
+ if "windows server 2000" in t or "windows 2000" in t or "win2k" in t or "win2000" in t:
91
103
  return "Windows Server 2000"
92
104
 
93
105
  if "windows" in t:
@@ -97,7 +109,20 @@ def _detect_product_from_text(t: str) -> str:
97
109
 
98
110
  def _detect_edition(text: str) -> str | None:
99
111
  m = WIN_EDITION_RE.search(text)
100
- return m.group(1).title() if m else None
112
+ if not m:
113
+ return None
114
+ token = m.group(1).lower()
115
+ norm = {
116
+ "pro": "Professional",
117
+ "professional": "Professional",
118
+ "enterprise": "Enterprise",
119
+ "home": "Home",
120
+ "education": "Education",
121
+ "ltsc": "LTSC",
122
+ "datacenter": "Datacenter",
123
+ "standard": "Standard",
124
+ }
125
+ return norm.get(token, token.title())
101
126
 
102
127
 
103
128
  def _parse_service_pack(text: str, p: OSData) -> None:
@@ -138,26 +163,42 @@ def _apply_nt_mapping(text: str, p: OSData, server_like: bool) -> None:
138
163
  p.product = product
139
164
 
140
165
 
141
- def _apply_build_mapping(text: str, p: OSData) -> None:
166
+ def _apply_build_mapping(text: str, p: OSData, server_like: bool) -> None:
142
167
  m = WIN_BUILD_RE.search(text)
143
168
  if not m:
144
169
  return
145
170
  build_num = int(m.group(1))
146
171
  p.version_build = str(build_num)
147
172
 
148
- # Kernel version for recent Windows 10/11
149
- if (p.product == "Windows 10/11") or ("10.0" in text):
150
- p.kernel_version = f"{10}.{0}.{build_num}"
151
- else:
152
- p.kernel_version = None
173
+ # Kernel version string
174
+ if not p.kernel_version:
175
+ if (p.product == "Windows 10/11") or ("10.0" in text):
176
+ p.kernel_version = f"10.0.{build_num}"
177
+ else:
178
+ nt_mm = WIN_NT_RE.search(text)
179
+ if nt_mm:
180
+ maj, minr = int(nt_mm.group(1)), int(nt_mm.group(2))
181
+ p.kernel_version = f"{maj}.{minr}.{build_num}"
153
182
 
154
- # Only apply client build mapping if current product isn't an explicit Server
155
183
  is_server_product = isinstance(p.product, str) and "server" in p.product.lower()
156
- if not is_server_product:
184
+ if is_server_product or server_like:
185
+ # Apply server build mapping; do not override explicit server product names
186
+ for lo, hi, product_name, marketing in WINDOWS_SERVER_BUILD_MAP:
187
+ if lo <= build_num <= hi:
188
+ if not p.product or p.product in ("Windows", "Windows 10/11"):
189
+ p.product = product_name
190
+ # Only set channel for modern Server (2016+)
191
+ if build_num >= 14393:
192
+ p.channel = p.channel or marketing
193
+ break
194
+ else:
195
+ # Apply client build mapping
157
196
  for lo, hi, product_name, marketing in WINDOWS_BUILD_MAP:
158
197
  if lo <= build_num <= hi:
159
- p.product = product_name
160
- p.channel = marketing
198
+ # Only use build map to set product for Windows 10/11 trains
199
+ if build_num >= 10240:
200
+ p.product = product_name
201
+ p.channel = p.channel or marketing
161
202
  break
162
203
 
163
204
 
@@ -177,6 +218,14 @@ def _apply_full_kernel_and_channel(text: str, p: OSData) -> None:
177
218
  if ch and not p.channel:
178
219
  p.channel = ch.group(1).upper()
179
220
 
221
+ if not p.kernel_version:
222
+ m2 = WIN_GENERIC_VERSION_RE.search(text)
223
+ if m2:
224
+ major, minor, build, suffix = m2.groups()
225
+ p.kernel_version = f"{major}.{minor}.{build}{('.' + suffix) if suffix else ''}"
226
+ p.version_build = p.version_build or build
227
+ p.evidence.setdefault("nt_version", f"{major}.{minor}")
228
+
180
229
 
181
230
  def _finalize_precision_and_version(p: OSData) -> None:
182
231
  if p.version_build:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "os-normalizer"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Normalize raw OS strings/metadata into structured data (family, product, version, arch)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes
File without changes