Source code for meta_package_manager.managers.winget

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import re
import subprocess

from extra_platforms import WINDOWS

from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Generator, Iterator

    from ..package import Package


[docs] class WinGet(PackageManager): homepage_url = "https://github.com/microsoft/winget-cli" platforms = WINDOWS requirement = ">=1.28.190" post_args = ("--accept-source-agreements", "--disable-interactivity") """ ``--accept-source-agreements``: Used to accept the source license agreement, and avoid the following prompt: .. code-block:: pwsh-session PS C:\\Users\\kev> winget list The "msstore' source requires that you view the following agreements before using. Terms of Transaction: https://aka.ms/microsoft-store-terms-of-transaction The source requires the current machine's 2-letter geographic region to be sent to the backend service to function prope rly (ex. "US"). Do you agree to all the source agreements terms? [Y] Yes [N] No: ``--disable-interactivity``: Disable interactive prompts. ..todo:: Add the ``--no-progress`` option once it is available in the stable release: - https://github.com/microsoft/winget-cli/pull/6049 - https://github.com/microsoft/winget-cli/issues/3494#issuecomment-3921618377 """ version_regexes = (r"v(?P<version>\S+)",) """ .. code-block:: pwsh-session PS C:\\Users\\kev> winget --version v1.28.220 """ windows_creation_flags = getattr(subprocess, "DETACHED_PROCESS", 0) """Detach winget from the calling process's console. When winget runs, the Windows COM infrastructure activates ``WindowsPackageManagerServer.exe`` as a separate process. Installer EXEs launched by ``winget upgrade`` or ``winget install`` are also spawned as grandchildren. Both the COM server and any installer EXEs call ``GenerateConsoleCtrlEvent(0)`` during their own shutdown, which broadcasts a ``CTRL_C_EVENT`` to every process sharing the same console β€” including the Python test runner β€” causing it to exit with code 1 even after all tests pass. ``DETACHED_PROCESS`` breaks the shared-console link: winget has no console, so neither the COM server nor any installer EXE can broadcast console events that reach us. Output is still captured because stdout and stderr are redirected to pipes, which are independent of console attachment. """ windows_processes_to_cleanup = ("WindowsPackageManagerServer.exe",) """Kill winget's COM server after each call. ``WindowsPackageManagerServer.exe`` is activated by the Windows COM infrastructure when winget runs, not as a direct child process. It therefore does not inherit our pipe handles and is not reaped by ``communicate()``. Kill it by image name after each call to avoid accumulating orphan COM server processes. """ # Microsoft Store IDs are either 12-char product IDs or 14-char extension # IDs prefixed with ``XP`` (like ``XP99BNH2JZBBQR``). _store_id_re = re.compile(r"^(?:[0-9A-Z]{12}|XP[0-9A-Z]{12})$") # Header line pattern. The ``(N/M)`` prefix is present only when multiple # packages are listed; a single result omits it. _header_re = re.compile( r"^(?:\(\d+/\d+\)\s+)?(.+)\s+\[([^\]]+)\]\s*$", ) def _parse_details( self, output: str, filter_by_source: bool = False ) -> Iterator[tuple[str, str, str, str | None]]: """Parse ``--details`` output from ``winget list``. Each package block starts with a header line and is followed by ``Key: Value`` metadata lines: .. code-block:: text (N/M) <Name> [<Id>] Version: <version> Publisher: <publisher> Origin Source: winget Available Upgrades: winget [<latest_version>] :param filter_by_source: If ``True``, only yield packages whose ``Origin Source`` is ``winget``. """ # Split output into per-package blocks on the header line. # The \S anchor after the optional (N/M) prefix prevents the split from # firing on indented upgrade lines like `` winget [1.2.3]``, which also # end with a bracketed token but are not package headers. blocks = re.split( r"(?=^(?:\(\d+/\d+\)\s+)?\S.+\[[^\]]+\]\s*$)", output, flags=re.MULTILINE, ) for block in blocks: block = block.strip() if not block: continue lines = block.splitlines() header_match = self._header_re.match(lines[0]) if not header_match: continue name = header_match.group(1).strip() package_id = header_match.group(2).strip() # Collect key:value fields from the remaining lines. fields: dict[str, str] = {} for line in lines[1:]: if ":" in line: key, _, value = line.partition(":") fields[key.strip()] = value.strip() version = fields.get("Version") if not version: continue if filter_by_source and fields.get("Origin Source") != "winget": continue # Extract latest version from the "Available Upgrades" section. # The upgrade line looks like `` winget [1.2.3]``. latest_version = None upgrade_match = re.search( r"^Available Upgrades:\s*\n\s+\S+\s+\[([^\]]+)\]", block, flags=re.MULTILINE, ) if upgrade_match: latest_version = upgrade_match.group(1) yield name, package_id, version, latest_version def _parse_table(self, output: str) -> Iterator[Generator[str, None, None]]: """Parse a table from the output of a winget command and returns a generator of cells.""" # Extract table. table_start = "Name " if table_start not in output: return assert output.count(table_start) == 1, ( f"{table_start!r} not unique in:\n{output}" ) table = output[output.index(table_start) :] # Check table format. lines = table.splitlines() assert re.match(r"^-+$", lines[1]), ( f"Table headers not followed by expected separator:\n{table}" ) # Use the separator line as the authoritative table width; winget may # omit trailing spaces from the header line, making it one character # shorter than the dashes. table_width = len(lines[1]) assert all(len(line) <= table_width for line in lines[2:]), ( f"Table lines with different width:\n{table}" ) # Guess column positions. headers = [] col_str = "" for char in lines[0]: if col_str and char != " " and " " in col_str: headers.append(col_str) col_str = "" col_str += char if col_str: headers.append(col_str) col_ranges = [] for header in headers: start = lines[0].index(header) end = start + len(header) col_ranges.append((start, end)) for line in lines[2:]: yield (line[start:end].strip() for start, end in col_ranges) def _build_package(self, name: str, package_id: str, version: str) -> Package: """Build a :class:`Package` from a search result row. Microsoft Store packages have their ``latest_version`` set to the ``msstore`` sentinel because their real version cannot be queried via ``winget``. The sentinel is later used by :meth:`search` to push Store results below winget-native ones. """ if self._store_id_re.match(package_id): return self.package(id=package_id, name=name, latest_version="msstore") return self.package(id=package_id, name=name, latest_version=version) @property def installed(self) -> Iterator[Package]: """Fetch installed packages. .. code-block:: pwsh-session PS C:\\Users\\kev> winget list --details --accept-source-agreements --disable-interactivity (1/7) CCleaner [CCleaner] Version: 6.08 Publisher: Piriform Software Ltd Local Identifier: ARP\\Machine\\X64\\CCleaner Product Code: CCleaner Installer Category: exe Installed Scope: Machine Installed Architecture: X64 Installed Locale: en-US Origin Source: winget Available Upgrades: (2/7) Git [Git.Git] Version: 2.37.3 Publisher: The Git Development Community ... Only returns packages with Origin Source: winget to exclude packages installed via other sources (e.g., sideload, portable). """ output = self.run_cli("list", "--details") for name, package_id, installed_version, _ in self._parse_details( output, filter_by_source=True ): yield self.package( id=package_id, name=name, installed_version=installed_version, ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. .. code-block:: pwsh-session PS C:\\Users\\kev> winget list --upgrade-available --details --accept-source-agreements --disable-interactivity (1/4) Git [Git.Git] Version: 2.37.3 Publisher: The Git Development Community ... Available Upgrades: winget [2.45.1] (2/4) Microsoft Edge [Microsoft.Edge] Version: 109.0.1518.70 Publisher: Microsoft ... Available Upgrades: winget [125.0.2535.51] Only returns packages with Origin Source: winget to exclude packages installed via other sources (e.g., sideload, portable). """ output = self.run_cli("list", "--upgrade-available", "--details") for name, package_id, installed_version, latest_version in self._parse_details( output, filter_by_source=True ): yield self.package( id=package_id, name=name, installed_version=installed_version, latest_version=latest_version, )
[docs] def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --query vscode --accept-source-agreements --disable-interactivity Name Id Version Match Source --------------------------------------------------------------------------------------------------------- Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.89.1 Moniker: vscode winget MrCode zokugun.MrCode 1.82.0.23253 Tag: vscode winget VSCodium Insiders VSCodium.VSCodium.Insiders 1.88.0.24095 Tag: vscode winget VSCodium VSCodium.VSCodium 1.89.1.24130 Tag: vscode winget Upgit pluveto.Upgit 0.2.18 Tag: vscode winget vscli michidk.vscli 0.3.0 Tag: vscode winget Huawei QuickApp IDE Huawei.QuickAppIde 14.0.1 Tag: vscode winget TheiaBlueprint EclipseFoundation.TheiaBlueprint 1.44.0 Tag: vscode winget Codium Alex313031.Codium 1.86.2.24053 Tag: vscode winget Cursor Editor CursorAI,Inc.Cursor latest Tag: vscode winget Microsoft Visual Studio Code CLI Microsoft.VisualStudioCode.CLI 1.89.1 Moniker: vscode-cli winget .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --query vscode --exact --accept-source-agreements --disable-interactivity Name Id Version Match Source ------------------------------------------------------------------------------------------------- Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.89.1 Moniker: vscode winget MrCode zokugun.MrCode 1.82.0.23253 Tag: vscode winget VSCodium Insiders VSCodium.VSCodium.Insiders 1.88.0.24095 Tag: vscode winget VSCodium VSCodium.VSCodium 1.89.1.24130 Tag: vscode winget Upgit pluveto.Upgit 0.2.18 Tag: vscode winget vscli michidk.vscli 0.3.0 Tag: vscode winget Huawei QuickApp IDE Huawei.QuickAppIde 14.0.1 Tag: vscode winget TheiaBlueprint EclipseFoundation.TheiaBlueprint 1.44.0 Tag: vscode winget Codium Alex313031.Codium 1.86.2.24053 Tag: vscode winget Cursor Editor CursorAI,Inc.Cursor latest Tag: vscode winget .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --id VSCodium.VSCodium --accept-source-agreements --disable-interactivity Name Id Version Source ---------------------------------------------------------------- VSCodium Insiders VSCodium.VSCodium.Insiders 1.88.0.24095 winget VSCodium VSCodium.VSCodium 1.89.1.24130 winget .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --name Codium --accept-source-agreements --disable-interactivity Name Id Version Source ---------------------------------------------------------------- Codium Alex313031.Codium 1.86.2.24053 winget VSCodium Insiders VSCodium.VSCodium.Insiders 1.88.0.24095 winget VSCodium VSCodium.VSCodium 1.89.1.24130 winget .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --id VSCodium.VSCodium --exact --accept-source-agreements --disable-interactivity Name Id Version Source ---------------------------------------------- VSCodium VSCodium.VSCodium 1.89.1.24130 winget .. code-block:: pwsh-session PS C:\\Users\\kev> winget search --name Codium --exact --accept-source-agreements --disable-interactivity Name Id Version Source -------------------------------------------- Codium Alex313031.Codium 1.86.2.24053 winget """ results: list[Package] = [] # Default search is extended to all metadata: id, name, moniker and tag. if extended: args = ["search", "--query", query] # Exact search deactivates substring search. if exact: args.append("--exact") output = self.run_cli(args) for name, package_id, version, _, _ in self._parse_table(output): results.append(self._build_package(name, package_id, version)) # For non-extended search, we need to perform 2 queries, one for id and # one for name. else: for field in "--id", "--name": output = self.run_cli( "search", field, query, "--exact" if exact else None ) for name, package_id, version, _ in self._parse_table(output): results.append(self._build_package(name, package_id, version)) # Yield winget-native packages first, Microsoft Store packages last. yield from sorted(results, key=lambda p: bool(self._store_id_re.match(p.id)))
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package. .. code-block:: pwsh-session PS C:\\Users\\kev> winget install --id Microsoft.PowerToys --accept-package-agreements --version 0.15.2 --accept-source-agreements --disable-interactivity Found Power Toys [Microsoft.PowerToys] Version 0.15.2 This application is licensed to you by its owner. Microsoft is not responsible for, nor does it grant any licenses to, third-party packages. Successfully verified installer hash Starting package install... β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 100% Successfully installed """ args = ["install", "--id", package_id, "--accept-package-agreements"] if version: args += ["--version", version] return self.run_cli(args)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all packages (default) or only the one provided as parameter. .. code-block:: pwsh-session PS C:\\Users\\kev> winget upgrade --all --accept-package-agreements --accept-source-agreements --disable-interactivity Name Id Version Available Source ------------------------------------------------------------------------------------------------ Microsoft Edge Microsoft.Edge 109.0.1518.70 125.0.2535.51 winget Microsoft Edge WebView2 Runtime Microsoft.EdgeWebView2Runtime 109.0.1518.70 125.0.2535.51 winget Python Launcher Python.Launchez < 3.12.0 3.12.0 winget Microsoft Visual C++ (x86)... Microsoft.VCRedist.2015+.X86 14.34.31931.0 14.38.33135.0 winget 4 upgrades available. Installing dependencies: This package requires the following dependencies: - Packages Microsoft.UI.Xaml.2.8 [>= 8.2306.22001.0] (1/3) Found Microsoft Edge WebView2 Runtime [Microsoft.EdgeWebView2Runtime] Version 125. 0.2535.51 This application is licensed to you by its owner. Microsoft is not responsible for, nor does it grant any licenses to, third-party packages. Downloading https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/e5dd841e-17ff-43b7-a2c0-ff759f55c202/MicrosoftEdgeWebView2RuntimeInstallerARM64.exe β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 166 MB / 166 MB Successfully verified installer hash Starting package install... Successfully installed (...) """ return self.build_cli("update", "--all", "--accept-package-agreements")
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None ) -> tuple[str, ...]: """Generates the CLI to upgrade all packages (default) or only the one provided as parameter. .. code-block:: pwsh-session PS C:\\Users\\kev> winget upgrade --id Git.Git --accept-package-agreements --accept-source-agreements --disable-interactivity Found Git [Git.Git] Version 2.45.1 This application is licensed to you by its owner. Microsoft is not responsible for, nor does it grant any licenses to, third-party packages. Downloading https://github.com/git-for-windows/git/releases/download/v2.45.1.windows.1/Git-2.45.1-64-bit.exe β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 64.7 MB / 64.7 MB Successfully verified installer hash Starting package install... Successfully installed .. todo:: Automatically uninstall the package if the technology is different: .. code-block:: pwsh-session PS C:\\Users\\kev> winget upgrade --id Microsoft.Edge A newer version was found, but the install technology is different from the current version installed. Please uninstall the package and install the newer version. """ args = ["install", "--id", package_id, "--accept-package-agreements"] if version: args += ["--version", version] return self.build_cli(args)
[docs] def remove(self, package_id: str) -> str: """Remove one package. .. code-block:: pwsh-session PS C:\\Users\\kev> winget uninstall --id Microsoft.PowerToys --source winget --accept-source-agreements --disable-interactivity Found PowerToys (Preview) [Microsoft.PowerToys] Starting package uninstall... β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 100% Successfully uninstalled """ return self.run_cli("uninstall", "--id", package_id, "--source", "winget")
[docs] def sync(self) -> None: """Sync package metadata from remote sources. .. code-block:: pwsh-session PS C:\\Users\\kev> winget source update --accept-source-agreements --disable-interactivity """ self.run_cli("source", "update")