# 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
from typing import ClassVar
from extra_platforms import LINUX_LIKE, MACOS, WINDOWS
from ..capabilities import search_capabilities
from ..manager import PackageManager
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
[docs]
class DotNet(PackageManager):
""".NET global tools, the CLI programs the .NET SDK installs from NuGet.
Every operation goes through the `dotnet tool` subcommand group, forced by
{attr}`DotNet.pre_args`, and targets the user-wide scope with `--global`. Global
tools land under `~/.dotnet/tools` and are never shared between users, so
no operation escalates: elevation is only ever needed for the separate
`--tool-path` scenario, which this wrapper does not drive.
Documentation: [.NET global tools](https://learn.microsoft.com/dotnet/core/tools/global-tools).
```{note}
The listing and the search results are column tables whose headers are
localized resource strings, translated into thirteen languages. Rather
than match English literals, {attr}`DotNet.extra_env` pins the CLI language and
both parsers key on the shape of the row: a package ID, two or more
spaces, then a version starting with a digit. That skips the header, the
dashed rule and any diagnostic prose the SDK prints above the table, such
as the broken-tool warning of
[dotnet/sdk#4111](https://github.com/dotnet/sdk/issues/4111).
```
```{note}
`dotnet tool list` also speaks JSON, through an undocumented
`--format json` that landed in the `9.0.100` SDK
([dotnet/sdk#37394](https://github.com/dotnet/sdk/pull/37394)). mpm
deliberately parses the table instead: the three columns of the global
listing are all whitespace-free, so nothing is gained, while keying on
JSON would raise the floor past `8.0.4xx`, the oldest SDK band still
supported.
```
```{caution}
No `outdated` operation is declared: the SDK ships no way to compare
installed tools against NuGet without mutating them. A spec for
`dotnet tool list --outdated` was written by an SDK maintainer in
[dotnet/sdk#22853](https://github.com/dotnet/sdk/issues/22853), which was
then closed as not planned. `upgrade --all` is unaffected and maps to the
native `dotnet tool update --all`.
```
```{note}
No `cleanup` either. The obvious candidate, `dotnet nuget locals all
--clear`, empties the machine-wide NuGet package folder every .NET project
restores against, so a tool-scoped cleanup would invalidate unrelated
builds. Nothing clears only what the global tools pulled.
```
"""
name = "dotnet tool"
homepage_url = "https://learn.microsoft.com/dotnet/core/tools/global-tools"
logo = "dotnet"
platforms = LINUX_LIKE, MACOS, WINDOWS
requirement = ">=8.0.400"
"""`--all` on `dotnet tool update` first shipped in the `8.0.400` SDK, the
opening release of the `8.0.4xx` feature band
([dotnet/sdk#38996](https://github.com/dotnet/sdk/pull/38996), merged onto
`release/8.0.4xx`). It was never backported to `8.0.3xx`, so that is the
binding floor: every other operation this wrapper drives predates it by
years, `dotnet tool search` being the youngest at `5.0.100`.
"""
extra_env: ClassVar = {
# Pin the CLI language so the localized table headers and messages stay
# in English, whatever the host locale.
"DOTNET_CLI_UI_LANGUAGE": "en-us",
# Keep the first-run banner and the telemetry notice off stdout, where
# they would precede the table.
"DOTNET_NOLOGO": "1",
"DOTNET_CLI_TELEMETRY_OPTOUT": "1",
}
pre_args = ("tool",)
"""Every operation is a `dotnet tool` subcommand, so the group is forced
onto each call. The version probe is exempt: it runs with
`auto_pre_args=False`, which is what leaves it as a bare `dotnet --version`.
"""
version_regexes = (r"^(?P<version>\d+\.\d+\.\d+\S*)",)
"""`dotnet --version` prints the SDK version alone, on a single unlabelled
line.
It is deliberately preferred over `dotnet --info` as the probe: on a machine
carrying the .NET runtime but no SDK, `--version` fails while `--info` still
exits `0` and reports its inventory. Since `dotnet tool` needs the SDK, the
failure is the correct availability signal.
```{code-block} shell-session
$ dotnet --version
9.0.306
```
"""
_LIST_REGEXP = re.compile(
r"""
^ # Anchor on the start of the line.
(?P<package_id>[a-zA-Z0-9._-]+) # NuGet package ID.
\ {2,} # Column delimiter, six spaces wide.
(?P<installed_version>\d\S*) # Version, always starting with a digit.
""",
re.VERBOSE,
)
_SEARCH_REGEXP = re.compile(
r"""
^ # Anchor on the start of the line.
(?P<package_id>[a-zA-Z0-9._-]+) # NuGet package ID.
\ {2,} # Column delimiter, six spaces wide.
(?P<latest_version>\d\S*) # Version, always starting with a digit.
""",
re.VERBOSE,
)
"""Match one data row of a `dotnet tool` column table.
Both listings are rendered by the same `PrintableTable`, which pads every
cell and joins the columns with six spaces, so the two patterns differ only
in which package field the version lands in. Requiring a digit-leading
second column is what discriminates a data row from the header, whose own
second column is a word in whatever language the CLI runs under.
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ dotnet tool list --global
Package Id Version Commands
--------------------------------------
dotnet-ef 2.1.11 dotnet-ef
```
"""
output = self.run_cli("list", "--global")
yield from self.parse_regex_lines(self._LIST_REGEXP, output)
[docs]
@search_capabilities(extended_support=False, exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
`dotnet tool search` queries NuGet's search endpoint with
`packageType=dotnettool`, so only .NET tools come back. NuGet offers no
flag to restrict or widen that match, so both refinements are left to
{meth}`meta_package_manager.manager.PackageManager.refiltered_search`.
Only the first two columns are captured: `Authors` and `Downloads` have
no package field to land in, and `Verified` is a marker rather than a
value.
```{caution}
NuGet matches the query against descriptions and tags as well as
package IDs, but the default table prints no description column, so
`mpm` cannot see why a row matched. `--extended` is therefore declared
unsupported rather than claimed: refiltering keeps only the ID and name
matches, and a package that matched on its description alone is
dropped. Widening this means parsing `dotnet tool search --detail`,
whose per-package blocks do carry a `Description:` line, at the cost of
keying the parser on localized field labels instead of on the row
shape.
```
```{code-block} shell-session
$ dotnet tool search format
Package ID Latest Version Authors Downloads Verified
---------------------------------------------------------------------------------------------------------------------------------------------------------------
dotnet-format 4.1.131201 Microsoft 496746
bsoa.generator 1.0.0 Microsoft 533
```
"""
output = self.run_cli("search", query)
yield from self.parse_regex_lines(self._SEARCH_REGEXP, output)
[docs]
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
```{code-block} shell-session
$ dotnet tool install --global dotnetsay
```
```{code-block} shell-session
$ dotnet tool install --global dotnetsay --version 2.1.7
```
"""
args = ["install", "--global", package_id]
if version:
# --version is the portable pin. The dotnetsay@2.1.7 shorthand only
# parses from the 10.0.100 SDK onwards.
args += ["--version", version]
return self.run_cli(*args)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages.
```{code-block} shell-session
$ dotnet tool update --global --all
```
"""
return self.build_cli("update", "--global", "--all")
[docs]
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade one package.
`dotnet tool update` is implemented upstream as an uninstall followed by
a reinstall, and refuses to move a tool backwards: a `version` older
than the installed one needs a `remove` first.
```{code-block} shell-session
$ dotnet tool update --global dotnetsay
```
```{code-block} shell-session
$ dotnet tool update --global dotnetsay --version 2.1.7
```
"""
args = ["update", "--global", package_id]
if version:
args += ["--version", version]
return self.build_cli(*args)
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
```{code-block} shell-session
$ dotnet tool uninstall --global dotnetsay
```
"""
return self.run_cli("uninstall", "--global", package_id)