napt 0.3.1__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.
napt/versioning/msi.py ADDED
@@ -0,0 +1,725 @@
1
+ # Copyright 2025 Roger Cibrian
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """MSI metadata extraction for NAPT.
16
+
17
+ This module extracts metadata from Windows Installer (MSI) database files,
18
+ including ProductVersion, ProductName, and architecture (from Template).
19
+ It tries multiple backends in order of preference to maximize cross-platform
20
+ compatibility.
21
+
22
+ Backend Priority:
23
+
24
+ On Windows:
25
+
26
+ 1. msilib (Python standard library, Python 3.10 and earlier only - removed in 3.13)
27
+ 2. _msi (CPython extension module, Windows-specific)
28
+ 3. PowerShell COM (Windows Installer COM API, always available)
29
+
30
+ On Linux/macOS:
31
+
32
+ 1. msiinfo (from msitools package, must be installed separately)
33
+
34
+ The PowerShell fallback makes this truly universal on Windows systems,
35
+ even when Python MSI libraries aren't available.
36
+
37
+ Installation Requirements:
38
+
39
+ Windows:
40
+
41
+ - No additional packages required (PowerShell fallback always works)
42
+ - Optional: Ensure msilib is available for better performance
43
+
44
+ Linux/macOS:
45
+
46
+ - Install msitools package:
47
+ - Debian/Ubuntu: `sudo apt-get install msitools`
48
+ - RHEL/Fedora: `sudo dnf install msitools`
49
+ - macOS: `brew install msitools`
50
+
51
+ Example:
52
+ Extract version from MSI:
53
+ ```python
54
+ from pathlib import Path
55
+ from napt.versioning.msi import version_from_msi_product_version
56
+ discovered = version_from_msi_product_version("chrome.msi")
57
+ print(f"{discovered.version} from {discovered.source}")
58
+ # 141.0.7390.123 from msi
59
+ ```
60
+
61
+ Error handling:
62
+ ```python
63
+ try:
64
+ discovered = version_from_msi_product_version("missing.msi")
65
+ except PackagingError as e:
66
+ print(f"Extraction failed: {e}")
67
+ ```
68
+
69
+ Note:
70
+ This is pure file introspection; no network calls are made. All backends
71
+ query the MSI Property table for ProductVersion. The PowerShell approach
72
+ uses COM (WindowsInstaller.Installer). Errors are chained for debugging
73
+ (check 'from err' clause).
74
+
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ from pathlib import Path
80
+ import shutil
81
+ import subprocess
82
+ import sys
83
+ from typing import Literal
84
+
85
+ try:
86
+ import msilib # type: ignore # Windows-only standard library module
87
+ except ImportError:
88
+ msilib = None # type: ignore
89
+
90
+ from dataclasses import dataclass
91
+
92
+ from napt.exceptions import ConfigError, PackagingError
93
+
94
+ from .keys import DiscoveredVersion # reuse the shared DTO
95
+
96
+ # MSI Template platform mapping
97
+ # See: https://learn.microsoft.com/en-us/windows/win32/msi/template-summary
98
+ _TEMPLATE_TO_ARCH: dict[str, str] = {
99
+ "intel": "x86", # Official 32-bit
100
+ "x64": "x64", # Official 64-bit (AMD64/x86-64)
101
+ "amd64": "x64", # Unofficial alias (defensive)
102
+ "arm64": "arm64", # Official 64-bit ARM
103
+ # Note: Empty string defaults to Intel (x86) per MS docs
104
+ }
105
+
106
+ # Unsupported platforms that raise ConfigError
107
+ _UNSUPPORTED_PLATFORMS: dict[str, str] = {
108
+ "intel64": "Itanium (Intel64) is not supported by Intune",
109
+ "arm": "Windows RT 32-bit ARM is not supported by Intune",
110
+ }
111
+
112
+ # Type alias for architecture values
113
+ Architecture = Literal["x86", "x64", "arm64"]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class MSIMetadata:
118
+ """Represents metadata extracted from an MSI Property table.
119
+
120
+ Attributes:
121
+ product_name: ProductName from MSI (display name).
122
+ product_version: ProductVersion from MSI.
123
+
124
+ """
125
+
126
+ product_name: str
127
+ product_version: str
128
+
129
+
130
+ def version_from_msi_product_version(
131
+ file_path: str | Path,
132
+ ) -> DiscoveredVersion:
133
+ """Extract ProductVersion from an MSI file.
134
+
135
+ Uses cross-platform backends to read the MSI Property table. On Windows,
136
+ tries msilib (Python 3.10 and earlier - removed in 3.13), _msi extension,
137
+ then PowerShell COM API as universal fallback. On Linux/macOS, requires
138
+ msitools package.
139
+
140
+ Args:
141
+ file_path: Path to the MSI file.
142
+
143
+ Returns:
144
+ Discovered version with source information.
145
+
146
+ Raises:
147
+ PackagingError: If the MSI file doesn't exist or version extraction fails.
148
+ NotImplementedError: If no extraction backend is available on this system.
149
+
150
+ """
151
+ from napt.logging import get_global_logger
152
+
153
+ logger = get_global_logger()
154
+ p = Path(file_path)
155
+ if not p.exists():
156
+ raise PackagingError(f"MSI not found: {p}")
157
+
158
+ logger.verbose("VERSION", "Strategy: msi")
159
+ logger.verbose("VERSION", f"Extracting version from: {p.name}")
160
+
161
+ # Try msilib first (standard library on Windows)
162
+ if sys.platform.startswith("win") and msilib is not None:
163
+ logger.debug("VERSION", "Trying backend: msilib...")
164
+ try:
165
+ db = msilib.OpenDatabase(str(p), msilib.MSIDBOPEN_READONLY)
166
+ view = db.OpenView(
167
+ "SELECT `Value` FROM `Property` WHERE `Property`='ProductVersion'"
168
+ )
169
+ view.Execute(None)
170
+ rec = view.Fetch()
171
+ if rec is None:
172
+ db.Close()
173
+ raise PackagingError("ProductVersion not found in MSI Property table.")
174
+ version = rec.GetString(1)
175
+ db.Close()
176
+ if not version:
177
+ raise PackagingError("Empty ProductVersion in MSI Property table.")
178
+ logger.verbose("VERSION", f"Success! Extracted: {version} (via msilib)")
179
+ return DiscoveredVersion(version=version, source="msi")
180
+ except Exception as err:
181
+ logger.debug("VERSION", "msilib failed, trying next backend...")
182
+ raise PackagingError(
183
+ f"failed to read MSI ProductVersion via msilib: {err}"
184
+ ) from err
185
+
186
+ # Try _msi module (alternative Windows approach)
187
+ if sys.platform.startswith("win"):
188
+ logger.debug("VERSION", "Trying backend: _msi...")
189
+ try:
190
+ import _msi # type: ignore
191
+ except ImportError:
192
+ # _msi not available, fall through to msiinfo
193
+ logger.debug("VERSION", "_msi not available, trying next backend...")
194
+ pass
195
+ else:
196
+ try:
197
+ db = _msi.OpenDatabase(str(p), 0) # 0: read-only
198
+ view = db.OpenView(
199
+ "SELECT `Value` FROM `Property` WHERE `Property`='ProductVersion'"
200
+ )
201
+ view.Execute(None)
202
+ rec = view.Fetch()
203
+ if rec is None:
204
+ raise PackagingError(
205
+ "ProductVersion not found in MSI Property table."
206
+ )
207
+ version = rec.GetString(1)
208
+ if not version:
209
+ raise PackagingError("Empty ProductVersion in MSI Property table.")
210
+ view.Close()
211
+ db.Close()
212
+ logger.verbose("VERSION", f"Success! Extracted: {version} (via _msi)")
213
+ return DiscoveredVersion(version=version, source="msi")
214
+ except Exception as err:
215
+ logger.debug("VERSION", "_msi failed, trying next backend...")
216
+ raise PackagingError(
217
+ f"failed to read MSI ProductVersion via _msi: {err}"
218
+ ) from err
219
+
220
+ # Try PowerShell with Windows Installer COM on Windows
221
+ if sys.platform.startswith("win"):
222
+ logger.debug("VERSION", "Trying backend: PowerShell COM...")
223
+ try:
224
+ ps_script = f"""
225
+ $installer = New-Object -ComObject WindowsInstaller.Installer
226
+ $db = $installer.OpenDatabase('{p}', 0)
227
+ $view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductVersion'")
228
+ $view.Execute()
229
+ $record = $view.Fetch()
230
+ if ($record) {{
231
+ $record.StringData(1)
232
+ }} else {{
233
+ Write-Error "ProductVersion not found"
234
+ exit 1
235
+ }}
236
+ """
237
+ result = subprocess.run(
238
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
239
+ check=True,
240
+ capture_output=True,
241
+ text=True,
242
+ timeout=10,
243
+ )
244
+ version = result.stdout.strip()
245
+ if version:
246
+ logger.verbose(
247
+ "VERSION", f"Success! Extracted: {version} (via PowerShell COM)"
248
+ )
249
+ return DiscoveredVersion(version=version, source="msi")
250
+ except subprocess.CalledProcessError as err:
251
+ logger.debug("VERSION", "PowerShell COM failed, trying next backend...")
252
+ raise PackagingError(f"PowerShell MSI query failed: {err}") from err
253
+ except subprocess.TimeoutExpired:
254
+ logger.debug("VERSION", "PowerShell COM timed out, trying next backend...")
255
+ raise PackagingError("PowerShell MSI query timed out") from None
256
+
257
+ # Try msiinfo on Linux/macOS
258
+ msiinfo = shutil.which("msiinfo")
259
+ if msiinfo:
260
+ logger.debug("VERSION", "Trying backend: msiinfo (msitools)...")
261
+ try:
262
+ # msiinfo export <package> Property -> stdout (tab-separated)
263
+ result = subprocess.run(
264
+ [msiinfo, "export", str(p), "Property"],
265
+ check=True,
266
+ capture_output=True,
267
+ text=True,
268
+ )
269
+ version_str: str | None = None
270
+ for line in result.stdout.splitlines():
271
+ parts = line.strip().split("\t", 1) # "Property<TAB>Value"
272
+ if len(parts) == 2 and parts[0] == "ProductVersion":
273
+ version_str = parts[1]
274
+ break
275
+ if not version_str:
276
+ raise PackagingError("ProductVersion not found in MSI Property output.")
277
+ logger.verbose(
278
+ "VERSION", f"Success! Extracted: {version_str} (via msiinfo)"
279
+ )
280
+ return DiscoveredVersion(version=version_str, source="msi")
281
+ except subprocess.CalledProcessError as err:
282
+ logger.debug("VERSION", "msiinfo failed")
283
+ raise PackagingError(f"msiinfo failed: {err}") from err
284
+
285
+ logger.debug("VERSION", "No MSI extraction backend available on this system")
286
+ raise NotImplementedError(
287
+ "MSI version extraction is not available on this host. "
288
+ "On Windows, ensure PowerShell is available. "
289
+ "On Linux/macOS, install 'msitools'."
290
+ )
291
+
292
+
293
+ def extract_msi_metadata(file_path: str | Path) -> MSIMetadata:
294
+ """Extract ProductName and ProductVersion from MSI file.
295
+
296
+ Uses cross-platform backends to read the MSI Property table. On Windows,
297
+ tries msilib (Python 3.10 and earlier - removed in 3.13), _msi extension,
298
+ then PowerShell COM API as universal fallback. On Linux/macOS, requires
299
+ msitools package.
300
+
301
+ This function extracts multiple properties in one pass for efficiency.
302
+
303
+ Args:
304
+ file_path: Path to the MSI file.
305
+
306
+ Returns:
307
+ MSIMetadata with product information.
308
+
309
+ Raises:
310
+ PackagingError: If the MSI file doesn't exist or metadata extraction
311
+ fails.
312
+ NotImplementedError: If no extraction backend is available on this
313
+ system.
314
+
315
+ Example:
316
+ Extract MSI metadata:
317
+ ```python
318
+ from pathlib import Path
319
+ from napt.versioning.msi import extract_msi_metadata
320
+
321
+ metadata = extract_msi_metadata(Path("chrome.msi"))
322
+ print(f"{metadata.product_name} {metadata.product_version}")
323
+ # Google Chrome 131.0.6778.86
324
+ ```
325
+
326
+ Note:
327
+ ProductName may be empty string if not found in MSI. This is handled
328
+ gracefully - build phase will fallback to recipe AppName if ProductName
329
+ is empty.
330
+
331
+ """
332
+ from napt.logging import get_global_logger
333
+
334
+ logger = get_global_logger()
335
+ p = Path(file_path)
336
+ if not p.exists():
337
+ raise PackagingError(f"MSI not found: {p}")
338
+
339
+ logger.verbose("MSI", f"Extracting metadata from: {p.name}")
340
+
341
+ # Try msilib first (standard library on Windows)
342
+ if sys.platform.startswith("win") and msilib is not None:
343
+ logger.debug("MSI", "Trying backend: msilib...")
344
+ try:
345
+ db = msilib.OpenDatabase(str(p), msilib.MSIDBOPEN_READONLY)
346
+ # Query for ProductName and ProductVersion
347
+ view = db.OpenView(
348
+ "SELECT `Property`,`Value` FROM `Property` "
349
+ "WHERE `Property` IN ('ProductName','ProductVersion')"
350
+ )
351
+ view.Execute(None)
352
+
353
+ metadata = {"ProductName": "", "ProductVersion": ""}
354
+ while True:
355
+ rec = view.Fetch()
356
+ if rec is None:
357
+ break
358
+ prop_name = rec.GetString(1)
359
+ prop_value = rec.GetString(2)
360
+ if prop_name in metadata:
361
+ metadata[prop_name] = prop_value
362
+
363
+ view.Close()
364
+ db.Close()
365
+
366
+ if not metadata["ProductVersion"]:
367
+ raise PackagingError("ProductVersion not found in MSI Property table.")
368
+
369
+ logger.verbose(
370
+ "MSI",
371
+ f"Success! Extracted: {metadata['ProductName']} "
372
+ f"{metadata['ProductVersion']} (via msilib)",
373
+ )
374
+ return MSIMetadata(
375
+ product_name=metadata["ProductName"] or "",
376
+ product_version=metadata["ProductVersion"],
377
+ )
378
+ except Exception as err:
379
+ logger.debug("MSI", f"msilib failed: {err}, trying next backend...")
380
+
381
+ # Try _msi module (alternative Windows approach)
382
+ if sys.platform.startswith("win"):
383
+ logger.debug("MSI", "Trying backend: _msi...")
384
+ try:
385
+ import _msi # type: ignore
386
+ except ImportError:
387
+ logger.debug("MSI", "_msi not available, trying next backend...")
388
+ else:
389
+ try:
390
+ db = _msi.OpenDatabase(str(p), 0) # 0: read-only
391
+ view = db.OpenView(
392
+ "SELECT `Property`,`Value` FROM `Property` "
393
+ "WHERE `Property` IN ('ProductName','ProductVersion')"
394
+ )
395
+ view.Execute(None)
396
+
397
+ metadata = {
398
+ "ProductName": "",
399
+ "ProductVersion": "",
400
+ }
401
+ while True:
402
+ rec = view.Fetch()
403
+ if rec is None:
404
+ break
405
+ prop_name = rec.GetString(1)
406
+ prop_value = rec.GetString(2)
407
+ if prop_name in metadata:
408
+ metadata[prop_name] = prop_value
409
+
410
+ view.Close()
411
+ db.Close()
412
+
413
+ if not metadata["ProductVersion"]:
414
+ raise PackagingError(
415
+ "ProductVersion not found in MSI Property table."
416
+ )
417
+
418
+ logger.verbose(
419
+ "MSI",
420
+ f"Success! Extracted: {metadata['ProductName']} "
421
+ f"{metadata['ProductVersion']} (via _msi)",
422
+ )
423
+ return MSIMetadata(
424
+ product_name=metadata["ProductName"] or "",
425
+ product_version=metadata["ProductVersion"],
426
+ )
427
+ except Exception as err:
428
+ logger.debug("MSI", f"_msi failed: {err}, trying next backend...")
429
+
430
+ # Try PowerShell with Windows Installer COM on Windows
431
+ if sys.platform.startswith("win"):
432
+ logger.debug("MSI", "Trying backend: PowerShell COM...")
433
+ try:
434
+ # Escape single quotes in path by doubling them (PowerShell escaping)
435
+ escaped_path = str(p).replace("'", "''")
436
+ # Use double quotes for SQL string (PowerShell), single quotes for string literals (MSI SQL)
437
+ # No backticks needed - Property and Value aren't reserved words
438
+ ps_script = f"""
439
+ $installer = New-Object -ComObject WindowsInstaller.Installer
440
+ $db = $installer.OpenDatabase('{escaped_path}', 0)
441
+ if ($null -eq $db) {{
442
+ Write-Error "Failed to open database: '{escaped_path}'"
443
+ exit 1
444
+ }}
445
+ $sqlQuery = "SELECT Property, Value FROM Property WHERE Property = 'ProductName' OR Property = 'ProductVersion'"
446
+ $view = $db.OpenView($sqlQuery)
447
+ if ($null -eq $view) {{
448
+ Write-Error "OpenView returned null for SQL: $sqlQuery"
449
+ exit 1
450
+ }}
451
+ $view.Execute()
452
+ $metadata = @{{}}
453
+ while ($record = $view.Fetch()) {{
454
+ $prop = $record.StringData(1)
455
+ $value = $record.StringData(2)
456
+ if ($prop -in @('ProductName','ProductVersion')) {{
457
+ $metadata[$prop] = $value
458
+ }}
459
+ }}
460
+ $view.Close()
461
+ $db.Close()
462
+ if (-not $metadata['ProductVersion']) {{
463
+ Write-Error "ProductVersion not found"
464
+ exit 1
465
+ }}
466
+ @($metadata['ProductName'], $metadata['ProductVersion']) -join "`n"
467
+ """
468
+ result = subprocess.run(
469
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
470
+ check=True,
471
+ capture_output=True,
472
+ text=True,
473
+ timeout=10,
474
+ )
475
+ lines = result.stdout.strip().split("\n")
476
+ product_name = lines[0] if len(lines) > 0 else ""
477
+ product_version = lines[1] if len(lines) > 1 else ""
478
+
479
+ if not product_version:
480
+ raise PackagingError("ProductVersion not found in MSI Property table.")
481
+
482
+ logger.verbose(
483
+ "MSI",
484
+ f"Success! Extracted: {product_name} {product_version} "
485
+ "(via PowerShell COM)",
486
+ )
487
+ return MSIMetadata(
488
+ product_name=product_name or "",
489
+ product_version=product_version,
490
+ )
491
+ except subprocess.CalledProcessError as err:
492
+ # Capture stderr to see what PowerShell actually reported
493
+ stderr_output = err.stderr if err.stderr else "No stderr captured"
494
+ stdout_output = err.stdout if err.stdout else "No stdout captured"
495
+ logger.debug(
496
+ "MSI",
497
+ f"PowerShell COM failed: {err}. stdout: {stdout_output}, stderr: {stderr_output}. Trying next backend...",
498
+ )
499
+ except subprocess.TimeoutExpired:
500
+ logger.debug("MSI", "PowerShell COM timed out, trying next backend...")
501
+ except Exception as err:
502
+ logger.debug("MSI", f"PowerShell COM failed: {err}, trying next backend...")
503
+
504
+ # Try msiinfo on Linux/macOS (or as last resort on Windows)
505
+ msiinfo = shutil.which("msiinfo")
506
+ if msiinfo:
507
+ logger.debug("MSI", "Trying backend: msiinfo (msitools)...")
508
+ try:
509
+ # msiinfo export <package> Property -> stdout (tab-separated)
510
+ result = subprocess.run(
511
+ [msiinfo, "export", str(p), "Property"],
512
+ check=True,
513
+ capture_output=True,
514
+ text=True,
515
+ )
516
+ metadata = {"ProductName": "", "ProductVersion": ""}
517
+ for line in result.stdout.splitlines():
518
+ parts = line.strip().split("\t", 1) # "Property<TAB>Value"
519
+ if len(parts) == 2:
520
+ prop_name = parts[0]
521
+ prop_value = parts[1]
522
+ if prop_name in metadata:
523
+ metadata[prop_name] = prop_value
524
+
525
+ if not metadata["ProductVersion"]:
526
+ raise PackagingError("ProductVersion not found in MSI Property output.")
527
+
528
+ logger.verbose(
529
+ "MSI",
530
+ f"Success! Extracted: {metadata['ProductName']} "
531
+ f"{metadata['ProductVersion']} (via msiinfo)",
532
+ )
533
+ return MSIMetadata(
534
+ product_name=metadata["ProductName"] or "",
535
+ product_version=metadata["ProductVersion"],
536
+ )
537
+ except Exception as err:
538
+ logger.debug("MSI", f"msiinfo failed: {err}")
539
+
540
+ # All backends failed
541
+ logger.debug("MSI", "No MSI extraction backend available on this system")
542
+ raise NotImplementedError(
543
+ "MSI metadata extraction is not available on this host. "
544
+ "On Windows, ensure PowerShell is available. "
545
+ "On Linux/macOS, install 'msitools'."
546
+ )
547
+
548
+
549
+ def architecture_from_template(template: str) -> Architecture:
550
+ """Parse MSI Template property into NAPT architecture value.
551
+
552
+ The Template property format is platform;language_id (semicolon, then
553
+ optional language codes). Examples: "x64;1033", "Intel;1033,1041",
554
+ ";1033" (empty platform defaults to Intel).
555
+
556
+ Args:
557
+ template: Raw Template property string from MSI Summary Information.
558
+
559
+ Returns:
560
+ Architecture value: "x86", "x64", or "arm64".
561
+
562
+ Raises:
563
+ ConfigError: If the platform is not supported by Intune (Itanium, ARM32).
564
+
565
+ Example:
566
+ Parse template strings:
567
+ ```python
568
+ arch = architecture_from_template("x64;1033")
569
+ # Returns: "x64"
570
+
571
+ arch = architecture_from_template("Intel;1033")
572
+ # Returns: "x86"
573
+
574
+ arch = architecture_from_template(";1033")
575
+ # Returns: "x86" (empty defaults to Intel)
576
+ ```
577
+
578
+ """
579
+ # Split on semicolon and take only the first token (platform)
580
+ # Discard remaining tokens (language codes like 1033)
581
+ platform = template.split(";")[0].strip().lower()
582
+
583
+ # Empty platform defaults to Intel (x86) per Microsoft docs
584
+ if not platform:
585
+ return "x86"
586
+
587
+ # Check for unsupported platforms first
588
+ if platform in _UNSUPPORTED_PLATFORMS:
589
+ raise ConfigError(
590
+ f"MSI platform '{platform}' is not supported. "
591
+ f"{_UNSUPPORTED_PLATFORMS[platform]}."
592
+ )
593
+
594
+ # Map to NAPT architecture
595
+ arch = _TEMPLATE_TO_ARCH.get(platform)
596
+ if arch is None:
597
+ raise ConfigError(
598
+ f"Unknown MSI platform '{platform}' in Template property. "
599
+ f"Expected one of: Intel, x64, AMD64, Arm64."
600
+ )
601
+
602
+ return arch # type: ignore[return-value]
603
+
604
+
605
+ def extract_msi_architecture(file_path: str | Path) -> Architecture:
606
+ """Extract architecture from MSI Summary Information Template property.
607
+
608
+ Uses cross-platform backends to read the MSI Summary Information stream.
609
+ On Windows, uses PowerShell COM API. On Linux/macOS, requires msitools.
610
+
611
+ Args:
612
+ file_path: Path to the MSI file.
613
+
614
+ Returns:
615
+ Architecture value: "x86", "x64", or "arm64".
616
+
617
+ Raises:
618
+ PackagingError: If the MSI file doesn't exist or extraction fails.
619
+ ConfigError: If the platform is not supported by Intune.
620
+ NotImplementedError: If no extraction backend is available.
621
+
622
+ Example:
623
+ Extract architecture from MSI:
624
+ ```python
625
+ from napt.versioning.msi import extract_msi_architecture
626
+
627
+ arch = extract_msi_architecture("chrome.msi")
628
+ print(f"Architecture: {arch}")
629
+ # Architecture: x64
630
+ ```
631
+
632
+ """
633
+ from napt.logging import get_global_logger
634
+
635
+ logger = get_global_logger()
636
+ p = Path(file_path)
637
+ if not p.exists():
638
+ raise PackagingError(f"MSI not found: {p}")
639
+
640
+ logger.verbose("MSI", f"Extracting architecture from: {p.name}")
641
+
642
+ template: str | None = None
643
+
644
+ # Try PowerShell with Windows Installer COM on Windows
645
+ # Summary Information Property ID 7 is Template
646
+ if sys.platform.startswith("win"):
647
+ logger.debug("MSI", "Trying backend: PowerShell COM (SummaryInformation)...")
648
+ try:
649
+ escaped_path = str(p).replace("'", "''")
650
+ ps_script = f"""
651
+ $installer = New-Object -ComObject WindowsInstaller.Installer
652
+ $db = $installer.OpenDatabase('{escaped_path}', 0)
653
+ if ($null -eq $db) {{
654
+ Write-Error "Failed to open database: '{escaped_path}'"
655
+ exit 1
656
+ }}
657
+ $sumInfo = $db.SummaryInformation(0)
658
+ $template = $sumInfo.Property(7)
659
+ $db.Close()
660
+ $template
661
+ """
662
+ result = subprocess.run(
663
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
664
+ check=True,
665
+ capture_output=True,
666
+ text=True,
667
+ timeout=10,
668
+ )
669
+ template = result.stdout.strip()
670
+ logger.verbose(
671
+ "MSI",
672
+ f"Success! Extracted Template: '{template}' (via PowerShell COM)",
673
+ )
674
+ except subprocess.CalledProcessError as err:
675
+ stderr_output = err.stderr if err.stderr else "No stderr"
676
+ logger.debug(
677
+ "MSI",
678
+ f"PowerShell COM failed: {err}. stderr: {stderr_output}. "
679
+ "Trying next backend...",
680
+ )
681
+ except subprocess.TimeoutExpired:
682
+ logger.debug("MSI", "PowerShell COM timed out, trying next backend...")
683
+ except Exception as err:
684
+ logger.debug("MSI", f"PowerShell COM failed: {err}, trying next backend...")
685
+
686
+ # Try msiinfo on Linux/macOS (or as fallback on Windows)
687
+ if template is None:
688
+ msiinfo = shutil.which("msiinfo")
689
+ if msiinfo:
690
+ logger.debug("MSI", "Trying backend: msiinfo suminfo...")
691
+ try:
692
+ # msiinfo suminfo <package> outputs summary information
693
+ result = subprocess.run(
694
+ [msiinfo, "suminfo", str(p)],
695
+ check=True,
696
+ capture_output=True,
697
+ text=True,
698
+ )
699
+ # Parse output for Template line
700
+ # Format: "Template: x64;1033"
701
+ for line in result.stdout.splitlines():
702
+ if line.startswith("Template:"):
703
+ template = line.split(":", 1)[1].strip()
704
+ break
705
+
706
+ if template is not None:
707
+ logger.verbose(
708
+ "MSI",
709
+ f"Success! Extracted Template: '{template}' (via msiinfo)",
710
+ )
711
+ except subprocess.CalledProcessError as err:
712
+ logger.debug("MSI", f"msiinfo suminfo failed: {err}")
713
+ except Exception as err:
714
+ logger.debug("MSI", f"msiinfo failed: {err}")
715
+
716
+ if template is None:
717
+ logger.debug("MSI", "No backend could extract Template property")
718
+ raise NotImplementedError(
719
+ "MSI architecture extraction is not available on this host. "
720
+ "On Windows, ensure PowerShell is available. "
721
+ "On Linux/macOS, install 'msitools'."
722
+ )
723
+
724
+ # Parse the template into architecture
725
+ return architecture_from_template(template)