# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Heuristics to detect all traits of the current environment.
This collection of heuristics is designed as a set of separate function with minimal
logic and dependencies. They're the building blocks to evaluate the current environment.
All these heuristics can be hard-cached as the underlying system is not changing
between code execution. They are still allowed to depends on each others, as long as
you're careful of not implementing circular dependencies.
```{warning}
Even if highly unlikely, it is possible to have multiple platforms detected for the
same environment.
Typical example is [Ubuntu WSL](https://documentation.ubuntu.com/wsl/), which
will make both the {func}`~extra_platforms.is_wsl2` and
{func}`~extra_platforms.is_ubuntu` functions return `True` at the same time.
That's because of the environment metadata, where:
.. code-block:: shell-session
$ uname -a
Linux 5.15.167.4-microsoft-standard-WSL2
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.5 LTS"
That way we have the possibility elsewhere in `extra-platforms` to either decide
if we only allow one, and only one, heuristic to match the current system, or allow
for considering multiple systems at the same time.
```
Detection of Linux distributions relies on `/etc/os-release`, as specified by the
[`os-release` specification](https://www.freedesktop.org/software/systemd/man/latest/os-release.html).
Every modern Linux distribution (since 2012) ships this file.
For all other traits, we either rely on:
- [`sys.platform`](https://docs.python.org/3/library/sys.html#sys.platform)
- [`platform.platform`](https://docs.python.org/3/library/platform.html#platform.platform)
- [`platform.release`](https://docs.python.org/3/library/platform.html#platform.release)
- environment variables
```{todo}
`hostnamectl` could be used as a fallback detection source when
`/etc/os-release` is missing (e.g., stripped CloudLinux VMs). This approach was
[proposed upstream in python-distro](https://github.com/python-distro/distro/pull/369)
but rejected. The technique is sound and could be implemented here.
```
```{seealso}
Other source of inspiration for platform detection:
- [Rust's `sysinfo` crate](https://github.com/stanislav-tkach/os_info/tree/master/os_info/src).
```
```{currentmodule} extra_platforms
```
"""
from __future__ import annotations
import os
import platform
import re
import subprocess
import sys
from functools import cache
from os import environ
from pathlib import Path, PurePosixPath
from .platform_info import os_release_id
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from .trait import CI, Agent, Architecture, Platform, Shell, Terminal, Trait
_detection_registry: dict[str, Callable[[], bool]] = {}
"""Maps detection function IDs (like ``"is_bash"``) to their callables.
Populated automatically after all ``is_*()`` functions are defined. Group
detection functions generated in ``__init__.py`` are also registered here.
Used by :attr:`Trait.current <extra_platforms.Trait.current>` to look up
detection functions without a fragile string-based module attribute search.
"""
def _unrecognized_message(report: bool = True) -> str:
"""Generate a message for unrecognized environments.
```{important}
This message must contain all the primitives used in the `detection` module so
maintainers can debug heuristics from user reports.
```
:param report: If `True`, append a request to report the issue on GitHub.
Set to `False` for environments where the trait is legitimately absent
(e.g., no terminal in CI, no CI locally).
"""
msg = (
"Environment:\n"
f" sys.platform: {sys.platform!r}\n"
" platform.platform: "
f"{platform.platform(aliased=True, terse=True)!r}\n"
f" platform.release: {platform.release()!r}\n"
f" platform.uname: {platform.uname()!r}\n"
f" platform.machine: {platform.machine()!r}\n"
f" platform.architecture: {platform.architecture()!r}\n"
f" os_release_id: {os_release_id()!r}"
)
if report:
msg += (
"\n\nPlease report this at "
"https://github.com/kdeldycke/extra-platforms/issues "
"to improve detection heuristics."
)
return msg
def _report_unrecognized(
trait_name: str,
*,
strict: bool,
expected: bool = True,
) -> None:
"""Log or raise on unrecognized trait detection.
:param trait_name: Human-readable name of the trait type (e.g., `"architecture"`).
:param strict: If `True`, raise {exc}`SystemError` instead of logging.
:param expected: If `True`, the trait is always expected to be detected
(architecture, platform, shell), so an unrecognized result logs a `WARNING`
and asks users to report the issue. If `False` (terminal, CI), the trait may
legitimately be absent, so only `INFO` is logged without a report request.
"""
msg = f"Unrecognized {trait_name}: {_unrecognized_message(report=expected)}"
if strict:
raise SystemError(msg)
# Defer logging import to keep cold module load fast: stdlib logging pulls
# in traceback (and in 3.14+, _colorize), which dominates import time on
# slow architectures like i586. See issue #494.
import logging
logger = logging.getLogger(__name__)
if expected:
logger.warning(msg)
else:
logger.info(msg)
# =============================================================================
# Architecture detection heuristics
# =============================================================================
[docs]
@cache
def is_aarch64() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.AARCH64`.
```{caution}
{func}`platform.machine` returns different values depending on the OS:
- Linux: `aarch64`
- macOS: `arm64`
- Windows: `ARM64`
```
"""
return platform.machine().lower() in ("aarch64", "arm64")
[docs]
@cache
def is_armv5tel() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.ARMV5TEL`.
"""
return platform.machine() == "armv5tel"
[docs]
@cache
def is_armv6l() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.ARMV6L`.
"""
return platform.machine() == "armv6l"
[docs]
@cache
def is_armv7l() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.ARMV7L`.
"""
return platform.machine() == "armv7l"
[docs]
@cache
def is_armv8l() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.ARMV8L`.
"""
return platform.machine() == "armv8l"
[docs]
@cache
def is_arm() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.ARM`.
```{hint}
This is a fallback detection for generic ARM architecture. It will return
`True` for any ARM architecture not specifically covered by the more precise
variants: {func}`~extra_platforms.is_aarch64`,
{func}`~extra_platforms.is_armv5tel`,
{func}`~extra_platforms.is_armv6l`,
{func}`~extra_platforms.is_armv7l` or
{func}`~extra_platforms.is_armv8l`.
```
"""
return bool(
platform.machine().startswith("arm")
and not any((
is_aarch64(),
is_armv5tel(),
is_armv6l(),
is_armv7l(),
is_armv8l(),
))
)
[docs]
@cache
def is_i386() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.I386`."""
return platform.machine() in ("i386", "i486")
[docs]
@cache
def is_i586() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.I586`."""
return platform.machine() == "i586"
[docs]
@cache
def is_i686() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.I686`."""
return platform.machine() == "i686"
[docs]
@cache
def is_x86_64() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.X86_64`.
```{caution}
Windows returns `AMD64` in uppercase, so we normalize to lowercase.
```
"""
return platform.machine().lower() in ("x86_64", "amd64")
[docs]
@cache
def is_mips() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.MIPS`."""
return platform.machine() == "mips"
[docs]
@cache
def is_mipsel() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.MIPSEL`.
"""
return platform.machine() == "mipsel"
[docs]
@cache
def is_mips64() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.MIPS64`.
"""
return platform.machine() == "mips64"
[docs]
@cache
def is_mips64el() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.MIPS64EL`.
"""
return platform.machine() == "mips64el"
[docs]
@cache
def is_ppc() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.PPC`."""
return platform.machine() in ("ppc", "powerpc")
[docs]
@cache
def is_ppc64() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.PPC64`."""
return platform.machine() == "ppc64"
[docs]
@cache
def is_ppc64le() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.PPC64LE`.
"""
return platform.machine() == "ppc64le"
[docs]
@cache
def is_riscv32() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.RISCV32`.
"""
return platform.machine() == "riscv32"
[docs]
@cache
def is_riscv64() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.RISCV64`.
"""
return platform.machine() == "riscv64"
[docs]
@cache
def is_sparc() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.SPARC`."""
return platform.machine() == "sparc"
[docs]
@cache
def is_sparc64() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.SPARC64`.
"""
return platform.machine() in ("sparc64", "sun4u", "sun4v")
[docs]
@cache
def is_s390x() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.S390X`."""
return platform.machine() == "s390x"
[docs]
@cache
def is_loongarch64() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.LOONGARCH64`.
"""
return platform.machine() == "loongarch64"
[docs]
@cache
def is_wasm32() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.WASM32`.
```{hint}
WebAssembly detection is based on Emscripten's platform identifier.
```
"""
return sys.platform == "emscripten" and platform.architecture()[0] == "32bit"
[docs]
@cache
def is_wasm64() -> bool:
"""Return {data}`True` if current architecture is {data}`~extra_platforms.WASM64`.
```{hint}
WebAssembly detection is based on Emscripten's platform identifier.
```
"""
return sys.platform == "emscripten" and platform.architecture()[0] == "64bit"
[docs]
@cache
def is_unknown_architecture() -> bool:
"""Return {data}`True` if current architecture is
{data}`~extra_platforms.UNKNOWN_ARCHITECTURE`.
"""
# Lazy import to avoid circular dependencies.
from .architecture_data import UNKNOWN_ARCHITECTURE
return current_architecture() is UNKNOWN_ARCHITECTURE
# =============================================================================
# Platform detection heuristics
# =============================================================================
[docs]
@cache
def is_aix() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.AIX`."""
return sys.platform.startswith("aix") or os_release_id() == "aix"
[docs]
@cache
def is_alpine() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ALPINE`."""
return os_release_id() == "alpine"
[docs]
@cache
def is_altlinux() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ALTLINUX`."""
return os_release_id() == "altlinux"
[docs]
@cache
def is_amzn() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.AMZN`."""
return os_release_id() == "amzn"
[docs]
@cache
def is_android() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ANDROID`.
```{seealso}
Source:
<https://github.com/kivy/kivy/blob/master/kivy/utils.py>
```
"""
return "ANDROID_ROOT" in environ or "P4A_BOOTSTRAP" in environ
[docs]
@cache
def is_arch() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ARCH`."""
return os_release_id() == "arch"
[docs]
@cache
def is_buildroot() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.BUILDROOT`."""
return os_release_id() == "buildroot"
[docs]
@cache
def is_cachyos() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.CACHYOS`."""
return os_release_id() == "cachyos"
[docs]
@cache
def is_centos() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.CENTOS`."""
return os_release_id() == "centos"
[docs]
@cache
def is_cloudlinux() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.CLOUDLINUX`.
"""
return os_release_id() == "cloudlinux"
[docs]
@cache
def is_cygwin() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.CYGWIN`."""
return sys.platform.startswith("cygwin")
[docs]
@cache
def is_debian() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.DEBIAN`."""
return os_release_id() == "debian"
[docs]
@cache
def is_dragonfly_bsd() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.DRAGONFLY_BSD`.
"""
return sys.platform.startswith("dragonfly")
[docs]
@cache
def is_exherbo() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.EXHERBO`."""
return os_release_id() == "exherbo"
[docs]
@cache
def is_fedora() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.FEDORA`."""
return os_release_id() == "fedora"
[docs]
@cache
def is_freebsd() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.FREEBSD`."""
return sys.platform.startswith("freebsd") or os_release_id() == "freebsd"
[docs]
@cache
def is_generic_linux() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.GENERIC_LINUX`.
Matches when running on a Linux kernel but `distro` cannot identify
the specific distribution (like minimal containers or build chroots without
`/etc/os-release`).
"""
return sys.platform == "linux" and not os_release_id()
[docs]
@cache
def is_gentoo() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.GENTOO`."""
return os_release_id() == "gentoo"
[docs]
@cache
def is_guix() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.GUIX`."""
return os_release_id() == "guix"
[docs]
@cache
def is_haiku() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.HAIKU`."""
return sys.platform.startswith("haiku")
[docs]
@cache
def is_hurd() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.HURD`.
```{caution}
{data}`sys.platform` can returns `GNU` or `gnu0`, see:
<https://github.com/kdeldycke/extra-platforms/issues/308>
```
"""
return sys.platform.lower().startswith("gnu")
[docs]
@cache
def is_ibm_powerkvm() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.IBM_POWERKVM`.
"""
return os_release_id() == "ibm_powerkvm"
[docs]
@cache
def is_illumos() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ILLUMOS`.
```{hint}
Illumos is a Unix OS derived from OpenSolaris. It shares
`sys.platform == 'sunos5'` with Solaris, but can be distinguished by checking
`platform.uname().version` which contains "illumos" on Illumos-based systems
(like OpenIndiana, SmartOS, OmniOS).
```
.. note::
Gates on {data}`sys.platform` before reading {func}`platform.uname` so
the function returns immediately on non-SunOS hosts. This matters on
Windows, where {func}`platform.uname` shells out via
{func}`platform._syscmd_ver` to populate its ``version`` field.
"""
return sys.platform == "sunos5" and "illumos" in platform.uname().version.lower()
[docs]
@cache
def is_kali() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.KALI`."""
return os_release_id() == "kali"
[docs]
@cache
def is_kvmibm() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.KVMIBM`."""
return os_release_id() == "kvmibm"
[docs]
@cache
def is_linuxmint() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.LINUXMINT`."""
return os_release_id() == "linuxmint"
[docs]
@cache
def is_macos() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.MACOS`.
.. note::
Uses {data}`sys.platform` rather than {func}`platform.platform`. The
former is a constant baked in by CPython at compile time (always
``"darwin"`` on macOS), while the latter performs runtime introspection
and, on Windows, shells out via {func}`platform._syscmd_ver` to invoke
``cmd /c ver``. Calling {func}`platform.platform` here would therefore
spawn a subprocess on every non-macOS host, and break test suites that
globally patch {func}`subprocess.run`. Since ``sys.platform == "darwin"``
is unambiguous for macOS (unlike Solaris vs. SunOS, which both report
``"sunos5"`` and genuinely need {func}`platform.platform` to be told
apart), there is no reason to pay that cost here.
"""
return sys.platform == "darwin"
[docs]
@cache
def is_mageia() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.MAGEIA`."""
return os_release_id() == "mageia"
[docs]
@cache
def is_mandriva() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.MANDRIVA`."""
return os_release_id() == "mandriva"
[docs]
@cache
def is_manjaro() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.MANJARO`."""
return os_release_id() == "manjaro"
[docs]
@cache
def is_midnightbsd() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.MIDNIGHTBSD`.
"""
return sys.platform.startswith("midnightbsd") or os_release_id() == "midnightbsd"
[docs]
@cache
def is_netbsd() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.NETBSD`."""
return sys.platform.startswith("netbsd") or os_release_id() == "netbsd"
[docs]
@cache
def is_nixos() -> bool:
"""Return `True` if current platform is {data}`~extra_platforms.NIXOS`."""
return os_release_id() == "nixos"
[docs]
@cache
def is_nobara() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.NOBARA`."""
return os_release_id() == "nobara"
[docs]
@cache
def is_openbsd() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.OPENBSD`."""
return sys.platform.startswith("openbsd") or os_release_id() == "openbsd"
[docs]
@cache
def is_opensuse() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.OPENSUSE`."""
return os_release_id() == "opensuse"
[docs]
@cache
def is_openwrt() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.OPENWRT`."""
return os_release_id() == "openwrt"
[docs]
@cache
def is_oracle() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ORACLE`."""
return os_release_id() == "oracle"
[docs]
@cache
def is_os400() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.OS400`.
```{note}
Detects [IBM i](https://en.wikipedia.org/wiki/IBM_i) (formerly OS/400),
whose AIX-compatible PASE runtime hosts Python. Python 3.9+ on IBM i reports
`sys.platform` as `os400` rather than `aix` (as older versions did), so PASE
is distinguished from {data}`~extra_platforms.AIX` despite their binary
compatibility.
```
```{note}
{data}`~extra_platforms.OS400` is grouped under
{data}`~extra_platforms.UNIX_LAYERS` (beside {data}`~extra_platforms.CYGWIN`),
not {data}`~extra_platforms.SYSTEM_V` where its AIX kin lives. IBM i's native
OS is not Unix: its only Unix surface is the PASE runtime that `os400` runs
in. The [Wikipedia Unix taxonomy](https://en.wikipedia.org/wiki/Template:Unix)
that these platform families track classes PASE as a Unix *compatibility
layer* (a peer of Cygwin on Windows), so the trait sits there rather than
with its System V binary kin.
```
"""
return sys.platform.startswith("os400")
[docs]
@cache
def is_parallels() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.PARALLELS`."""
return os_release_id() == "parallels"
[docs]
@cache
def is_pidora() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.PIDORA`."""
return os_release_id() == "pidora"
[docs]
@cache
def is_raspbian() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.RASPBIAN`."""
return os_release_id() == "raspbian"
[docs]
@cache
def is_rhel() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.RHEL`."""
return os_release_id() == "rhel"
[docs]
@cache
def is_rocky() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.ROCKY`."""
return os_release_id() == "rocky"
[docs]
@cache
def is_scientific() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.SCIENTIFIC`.
"""
return os_release_id() == "scientific"
[docs]
@cache
def is_slackware() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.SLACKWARE`."""
return os_release_id() == "slackware"
[docs]
@cache
def is_sles() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.SLES`."""
return os_release_id() == "sles"
[docs]
@cache
def is_solaris() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.SOLARIS`.
.. note::
Gates on {data}`sys.platform == "sunos5"` before invoking
{func}`platform.platform`, which on Windows would shell out via
{func}`platform._syscmd_ver`. Solaris and SunOS share the same
{data}`sys.platform` value, so {func}`platform.platform` is still needed
to tell them apart, but only when we already know we're on a SunOS-based
host.
"""
return sys.platform == "sunos5" and platform.platform(
aliased=True, terse=True
).startswith("Solaris")
[docs]
@cache
def is_sunos() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.SUNOS`.
.. note::
See {func}`is_solaris` for the rationale behind the {data}`sys.platform`
guard.
"""
return sys.platform == "sunos5" and platform.platform(
aliased=True, terse=True
).startswith("SunOS")
[docs]
@cache
def is_tumbleweed() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.TUMBLEWEED`.
"""
return os_release_id() == "opensuse-tumbleweed"
[docs]
@cache
def is_tuxedo() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.TUXEDO`."""
return os_release_id() == "tuxedo"
[docs]
@cache
def is_ubuntu() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.UBUNTU`."""
return os_release_id() == "ubuntu"
[docs]
@cache
def is_ultramarine() -> bool:
"""Return {data}`True` if current platform is
{data}`~extra_platforms.ULTRAMARINE`.
"""
return os_release_id() == "ultramarine"
[docs]
@cache
def is_void() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.VOID`."""
return os_release_id() == "void"
[docs]
@cache
def is_windows() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.WINDOWS`."""
return sys.platform.startswith("win32")
[docs]
@cache
def is_wsl1() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.WSL1`.
```{caution}
The only difference between WSL1 and WSL2 is
[the case of the kernel release version](https://github.com/andweeb/presence.nvim/pull/64#issue-1174430662):
- WSL 1:
.. code-block:: shell-session
$ uname -r
4.4.0-22572-Microsoft
- WSL 2:
.. code-block:: shell-session
$ uname -r
5.10.102.1-microsoft-standard-WSL2
```
.. note::
Gates on {data}`sys.platform == "linux"` before invoking
{func}`platform.release`. WSL is by definition a Linux subsystem, so on
any other host the answer is trivially {data}`False`. The guard also
avoids a {func}`platform._syscmd_ver` subprocess on Windows, where
{func}`platform.release` is implemented as a ``cmd /c ver`` shell-out.
"""
return sys.platform == "linux" and "Microsoft" in platform.release()
[docs]
@cache
def is_wsl2() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.WSL2`.
.. note::
See {func}`is_wsl1` for the rationale behind the {data}`sys.platform`
guard.
"""
return sys.platform == "linux" and "microsoft" in platform.release()
[docs]
@cache
def is_xenserver() -> bool:
"""Return {data}`True` if current platform is {data}`~extra_platforms.XENSERVER`."""
return os_release_id() == "xenserver"
# =============================================================================
# Shell detection heuristics
# =============================================================================
@cache
def _resolved_shell_id() -> str | None:
"""Resolve the ``SHELL`` environment variable through symlinks.
Returns the stem of the resolved path (like ``"bash"`` when
``/bin/sh`` symlinks to ``/bin/bash``), or ``None`` when ``SHELL`` is
not set.
"""
shell_path = environ.get("SHELL", "")
if not shell_path:
return None
try:
return Path(shell_path).resolve(strict=True).stem.lower()
except OSError:
return PurePosixPath(shell_path).stem.lower()
@cache
def _active_env_var_shell_ids() -> frozenset[str]:
"""Return shell IDs whose startup environment variable is currently set.
Reads ``version_env_var`` from each {class}`~extra_platforms.Shell`
instance. PowerShell is naturally excluded because
{data}`~extra_platforms.POWERSHELL` has ``version_env_var=None``
(``PSModulePath`` is a presence signal, not a version variable).
This identifies shells that are *actively running*, not merely configured
as the login shell.
"""
# Lazy import to avoid circular dependencies.
from .group_data import ALL_SHELLS
return frozenset(
shell.id
for shell in ALL_SHELLS
if (env_var := getattr(shell, "version_env_var", None)) and env_var in environ
)
def _shell_name(command: str) -> str:
"""Normalize a process command into a comparable shell name.
Strips a leading ``-`` (which marks a login shell, like ``-bash``), then
returns the lowercased filename stem (``/usr/bin/bash`` becomes ``bash``).
Returns an empty string when no name can be extracted.
"""
return PurePosixPath(command.lstrip("-")).stem.lower()
def _parse_proc_ppid(record: str) -> int:
"""Extract the parent PID from a ``/proc/<pid>`` process record.
Two on-disk layouts are recognized, told apart by the presence of the
parenthesized ``comm`` field:
- Linux ``stat`` (and NetBSD's Linux-compatible ``stat``):
``"pid (comm) state ppid ..."``. The ``comm`` field may itself contain
spaces and parentheses, so the PPID is taken as the second field after
the last ``)``.
- BSD ``status`` (FreeBSD, DragonFly): ``"comm pid ppid pgid ..."``, where
the PPID is the third whitespace-separated field.
Raises {class}`ValueError` or {class}`IndexError` on an unparsable record.
"""
marker = record.rfind(")")
if marker != -1:
return int(record[marker + 2 :].split()[1])
return int(record.split()[2])
def _ppid_from_proc(pid: int) -> int | None:
"""Read the parent PID of ``pid`` from ``/proc``.
Tries the Linux-style ``stat`` file first, then the BSD-style ``status``
file, so the same walk works across Linux, NetBSD, and FreeBSD. Returns
{data}`None` when neither file can be read or parsed.
```{note}
System V ``/proc`` (illumos, Solaris, AIX) exposes ``status`` as a binary
``pstatus_t`` rather than text. Bytes are decoded with ``errors="replace"``
so a binary record degrades to an unparsable string (and {data}`None`)
instead of raising {exc}`UnicodeDecodeError`. {func}`_parent_process_tree`
then falls back to ``ps`` on those systems.
```
"""
for proc_file in ("stat", "status"):
try:
record = Path(f"/proc/{pid}/{proc_file}").read_bytes()
except OSError:
continue
try:
return _parse_proc_ppid(record.decode(errors="replace"))
except (ValueError, IndexError):
return None
return None
@cache
def _interpreter_shell_specs() -> tuple[tuple[re.Pattern[str], str], ...]:
"""Compiled ``(interpreter pattern, launcher name)`` pairs from the registry.
Built from {class}`~extra_platforms.Shell` instances that declare an
{attr}`~extra_platforms.Shell.interpreter`. The interpreter base name
becomes a version-tolerant pattern (so ``python`` matches ``python3`` and
``python3.11``); the launcher name to look for is the shell's ``id``.
"""
# Lazy import to avoid circular dependencies.
from .group_data import ALL_SHELLS
return tuple(
(re.compile(rf"^{re.escape(interp)}(\d+(\.\d+)?)?$"), shell.id)
for shell in ALL_SHELLS
if (interp := getattr(shell, "interpreter", None))
)
def _interpreter_shell(argv: list[str]) -> tuple[str, str] | None:
"""Detect a shell hosted by an interpreter from a process command line.
When ``argv[0]`` is a known interpreter (like ``python``) and a later
argument is an existing file whose name is a hosted shell's launcher (like
``xonsh``), returns ``(shell_id, launcher_path)``, else {data}`None`.
The three guards (interpreter-name pattern, exact launcher basename, and an
{meth}`~pathlib.Path.is_file` check) keep unrelated invocations like
``python -m pytest test_xonsh.py`` from matching. Mirrors the approach in
[shellingham](https://github.com/sarugaku/shellingham).
"""
if not argv:
return None
proc_name = Path(argv[0]).name.lower()
for pattern, launcher in _interpreter_shell_specs():
if not pattern.fullmatch(proc_name):
continue
for arg in argv[1:]:
if Path(arg).name.lower() == launcher and Path(arg).is_file():
return launcher, arg
return None
_EMULATOR_NAMES = re.compile(r"rosetta|qemu-[a-z0-9_]+(?:-static)?")
"""User-mode CPU emulators that wrap the real command in their arguments."""
def _unwrap_emulator(argv: list[str]) -> list[str]:
"""Strip a leading user-mode emulator (``qemu-<arch>``, ``rosetta``) from argv.
Under foreign-architecture emulation (like ``docker run --platform``), a
process shows up as ``qemu-aarch64 /bin/bash β¦``; the real command is the
remaining arguments. Returns ``argv`` with the emulator prefix removed, or
``argv`` unchanged when ``argv[0]`` is not an emulator (or nothing follows
it). Composes with {func}`_interpreter_shell`: an emulated ``python`` running
xonsh unwraps to ``python β¦`` first, then resolves to xonsh.
```{seealso}
Mirrors the emulator handling in
[shellingham](https://github.com/sarugaku/shellingham).
```
"""
if len(argv) >= 2 and _EMULATOR_NAMES.fullmatch(_shell_name(argv[0])):
return argv[1:]
return argv
def _pairs_from_argv(argv: list[str]) -> list[tuple[str, str]]:
"""Derive ``(name, path)`` pairs a single process contributes from its argv.
Unwraps a user-mode emulator prefix (so the emulated shell is seen), then
yields the ``argv[0]`` shell name (with its path only when absolute, since a
login dash carries none) plus any interpreter-hosted shell found in the
arguments (like xonsh run under python). Shared by the ``/proc`` and ``ps``
walks, which differ only in how they obtain ``argv``.
"""
pairs: list[tuple[str, str]] = []
argv = _unwrap_emulator(argv)
if argv:
# argv[0] recovers login shells and survives an unreadable exe; keep it
# as a path only when absolute (a login dash carries none).
if name := _shell_name(argv[0]):
pairs.append((name, argv[0] if argv[0].startswith("/") else ""))
# A shell hosted by an interpreter (like xonsh run under python).
if hosted := _interpreter_shell(argv):
pairs.append(hosted)
return pairs
def _tree_from_proc() -> tuple[tuple[str, str], ...]:
"""Walk the parent process tree through ``/proc`` (Linux and BSD procfs).
Returns ordered ``(name, path)`` pairs, nearest ancestor first. For each
process it reads the resolved executable (``/proc/<pid>/exe``, an absolute
path that follows a ``/bin/sh`` -> Bash symlink) and the full ``argv``
(``/proc/<pid>/cmdline``). Reading both means a login shell is still
recognized when the ``exe`` symlink is unreadable (hardened ``/proc``
mounted with ``hidepid``), and vice versa. ``argv`` also lets an
interpreter-hosted shell (xonsh under python) or an emulated shell (under
qemu/rosetta) be recognized. ``path`` is empty when only a non-absolute
``argv[0]`` is available.
"""
pairs: list[tuple[str, str]] = []
pid = os.getpid()
visited: set[int] = set()
while pid > 1 and pid not in visited:
visited.add(pid)
# Resolved executable: an absolute path that follows symlinks.
try:
target = os.readlink(f"/proc/{pid}/exe")
if name := _shell_name(target):
pairs.append((name, target))
except OSError:
pass
# Full argv from the raw, null-separated command line.
try:
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
argv = [a for a in raw.decode(errors="replace").split("\0") if a]
except OSError:
argv = []
pairs.extend(_pairs_from_argv(argv))
ppid = _ppid_from_proc(pid)
if ppid is None:
break
pid = ppid
return tuple(pairs)
def _tree_from_ps() -> tuple[tuple[str, str], ...]:
"""Walk the parent process tree through ``ps``.
Used on POSIX systems without a usable text ``/proc`` (macOS and BSDs
without procfs, plus the System V ``/proc`` of illumos, Solaris, and
AIX/IBM i). Parses a single ``ps`` snapshot into a ``{pid: (ppid, args)}``
table, then walks from the current process up to the root, returning ordered
``(name, path)`` pairs (nearest first).
The full argument list is requested (rather than just the executable) so
that interpreter-hosted shells (like xonsh under python) can be recognized
from their arguments. ``path`` is taken from ``argv[0]`` when absolute; a
login shell (``-zsh``) or a bare name carries no path, so callers fall back
to ``SHELL``.
The invocation is the portable POSIX form (``-A``, ``-o field=``, and the
``args`` specifier) with no ``-ww``, so it works across macOS, the BSDs,
Linux, and the System V ``ps`` of illumos, Solaris, and AIX/IBM i. Those
System V variants lack the ``command`` specifier (only ``args``) and reject
``-ww`` (AIX accepts ``-w`` only in Berkeley mode, which has no ``-o``);
``args`` may be truncated there, but ``argv[0]`` stays intact. Parsing reads
positional columns from empty (``field=``) headers, sidestepping the
header-name differences (``COMMAND`` vs ``CMD``) that complicate name-based
parsing. Mirrors [shellingham](https://github.com/sarugaku/shellingham).
```{important}
``-A`` (select every process) is essential, not merely convenient. Without
it, `ps` defaults to processes sharing the caller's controlling terminal,
and macOS returns *nothing* when run outside a tty (as in CI or any
non-interactive context, unlike Linux which lists all processes regardless).
``-A`` makes the snapshot tty-independent.
```
"""
try:
result = subprocess.run(
("ps", "-A", "-o", "pid=,ppid=,args="),
capture_output=True,
text=True,
check=True,
timeout=2,
)
except (OSError, subprocess.SubprocessError):
return ()
# str() coerces an unexpected stdout (like a globally mocked subprocess.run
# returning a Mock) to text, so parsing degrades to an empty result instead
# of raising.
output = str(result.stdout)
# Parse "<pid> <ppid> <args...>" rows into {pid: (ppid, args)}. The
# argument list is the last field and is kept whole.
table: dict[int, tuple[int, str]] = {}
for line in output.splitlines():
fields = line.split(maxsplit=2)
if len(fields) < 3:
continue
try:
child, parent = int(fields[0]), int(fields[1])
except ValueError:
continue
table[child] = (parent, fields[2])
pairs: list[tuple[str, str]] = []
pid = os.getpid()
visited: set[int] = set()
while pid > 1 and pid in table and pid not in visited:
visited.add(pid)
ppid, command = table[pid]
pairs.extend(_pairs_from_argv(command.split()))
pid = ppid
return tuple(pairs)
def _walk_process_map(
process_map: dict[int, tuple[int, str]],
start_pid: int,
path_getter: Callable[[int], str],
) -> tuple[tuple[str, str], ...]:
"""Walk a ``{pid: (ppid, executable)}`` map into ordered ``(name, path)`` pairs.
Climbs from ``start_pid`` to the root, nearest ancestor first. Each
``executable`` is normalized into a shell name via {func}`_shell_name`, and
``path_getter`` resolves a matched pid's full executable path. This is the
platform-agnostic core of the Windows walk: the snapshot acquisition is the
only Win32-specific part.
"""
pairs: list[tuple[str, str]] = []
pid = start_pid
visited: set[int] = set()
while pid in process_map and pid not in visited:
visited.add(pid)
ppid, executable = process_map[pid]
if name := _shell_name(executable):
pairs.append((name, path_getter(pid)))
pid = ppid
return tuple(pairs)
def _tree_from_windows() -> tuple[tuple[str, str], ...]:
"""Walk the parent process tree through the Win32 Tool Help API.
Used on Windows, which has neither ``/proc`` nor ``ps``. Snapshots every
process into a ``{pid: (ppid, executable)}`` map (see
{mod}`extra_platforms._windows`), then walks from the current process up to
the root, resolving each ancestor's full path via
``QueryFullProcessImageNameW``.
Returns an empty tuple off Windows or on any Win32 failure, so detection
degrades to the environment-variable heuristics.
```{caution}
Windows parent-PID links can be stale: a parent may exit and its PID be
reused. The ``visited`` set bounds the walk, but a reused PID could in
theory divert it. This matches the limitation in
[shellingham](https://github.com/sarugaku/shellingham).
```
"""
tree: tuple[tuple[str, str], ...] = ()
if sys.platform == "win32":
try:
from . import _windows
tree = _walk_process_map(
_windows.process_map(), os.getpid(), _windows.process_path
)
except (OSError, ImportError):
tree = ()
return tree
@cache
def _parent_process_tree() -> tuple[tuple[str, str], ...]:
"""Ordered ``(name, path)`` pairs for the process tree, nearest first.
``name`` is the normalized shell name (see {func}`_shell_name`); ``path`` is
the best-effort executable path, absolute when the source provides it and
empty otherwise. Dispatches on what the platform exposes:
- ``/proc`` when present (Linux always, BSDs that mount procfs): no
subprocess is spawned.
- ``ps`` otherwise (macOS, BSDs without procfs).
- The Win32 Tool Help API on Windows.
- An empty tuple on platforms exposing none of these.
"""
# Linux (and procfs BSDs) expose a text /proc; this needs no subprocess.
if Path("/proc").is_dir():
tree = _tree_from_proc()
if tree:
return tree
# /proc exists but yielded nothing: System V procfs (illumos, Solaris,
# AIX) uses a binary layout the Linux reader cannot parse. Use `ps`.
# macOS, procfs-less BSDs, and System V procfs systems: walk via `ps`.
if os.name == "posix":
return _tree_from_ps()
# Windows: snapshot via the Win32 Tool Help API. _tree_from_windows()
# returns an empty tuple on any other platform.
return _tree_from_windows()
@cache
def _parent_process_exe_names() -> frozenset[str]:
"""Collect executable names from the parent process tree.
Returns a {class}`frozenset` of lowercased executable stems (like
``"bash"`` or ``"python3"``), derived from {func}`_parent_process_tree`.
"""
return frozenset(name for name, _ in _parent_process_tree())
def _running_shell_path(shell_id: str) -> str | None:
"""Return the executable path of the nearest ancestor matching ``shell_id``.
Walks {func}`_parent_process_tree` and returns the first (nearest) absolute
path whose normalized name equals ``shell_id``. Non-absolute sources (a
login dash, a bare name, or a truncated BSD ``ps`` ``comm``) are skipped so
callers can fall back to ``SHELL``. A path is considered absolute when it
starts with ``/`` (POSIX) or satisfies ``os.path.isabs`` (Windows drive
paths like ``C:\\...``). Returns {data}`None` when no running path is
found.
"""
for name, path in _parent_process_tree():
if name == shell_id and (path.startswith("/") or os.path.isabs(path)):
return path
return None
def _parent_process_shells(shell_ids: str | tuple[str, ...]) -> bool:
"""Check if any parent process in the tree matches the given shell IDs.
:param shell_ids: Shell executable name(s) to match. Can be a single string
(like ``"bash"``) or a tuple of strings (like ``("powershell", "pwsh")``).
:returns: ``True`` if a matching shell is found in the parent process tree,
``False`` otherwise or on non-Linux platforms where ``/proc`` is
unavailable.
"""
id_set = (
frozenset({shell_ids}) if isinstance(shell_ids, str) else frozenset(shell_ids)
)
return bool(id_set & _parent_process_exe_names())
def _detect_shell(
version_env_var: str | None = None,
shell_ids: str | Iterable[str] | None = None,
) -> bool:
"""Detect a specific shell from the environment.
```{caution}
This function is designed primarily for POSIX/Unix systems. The `SHELL`
environment variable and `/proc` filesystem are Unix-specific conventions.
For Windows shells like {data}`~extra_platforms.CMD`, use platform-specific
detection instead.
```
Uses a tiered detection strategy:
1. Checks for shell-specific version environment variable (most reliable).
2. Resolves symlinks in the ``SHELL`` environment variable path, then
matches the resolved executable name against known shell IDs. This
reports the actual shell implementation rather than the interface name:
when ``/bin/sh`` symlinks to ``/bin/bash``, ``bash`` is detected, not
``sh``.
3. Falls back to walking the parent process tree (via `/proc` on Linux,
`ps` on macOS and the BSDs) to find the active shell, for stripped
environments without shell env vars.
:param version_env_var: Shell-specific environment variable name
(like ``"BASH_VERSION"``).
:param shell_ids: Shell executable name(s) to match. Can be a single string
(like ``"bash"``) or a tuple of strings (like ``("powershell", "pwsh")``).
:returns: ``True`` if the shell is detected, ``False`` otherwise.
"""
# Check shell-specific version environment variable.
if version_env_var and version_env_var in environ:
return True
# Normalize shell_ids for consistent handling.
if shell_ids is None:
return False
ids = (
frozenset((shell_ids,)) if isinstance(shell_ids, str) else frozenset(shell_ids)
)
# Check resolved SHELL environment variable against known shell IDs.
if _resolved_shell_id() in ids:
return True
# Fallback: walk the parent process tree to find the active shell. This
# covers stripped containers (like ubuntu-slim) where SHELL is not set.
normalized_ids = (shell_ids,) if isinstance(shell_ids, str) else tuple(shell_ids)
return _parent_process_shells(normalized_ids)
[docs]
@cache
def is_ash() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.ASH`.
```{hint}
Detected via the `SHELL` environment variable path, as Almquist
Shell does not set its own version variable.
```
```{note}
[BusyBox](https://busybox.net)'s built-in shell is an {data}`~extra_platforms.ASH`
derivative. On BusyBox-based systems ({data}`~extra_platforms.ALPINE`,
{data}`~extra_platforms.OPENWRT`), `$SHELL` typically resolves to `/bin/ash`,
so BusyBox environments are detected as {data}`~extra_platforms.ASH`.
```
"""
return _detect_shell(shell_ids="ash")
[docs]
@cache
def is_bash() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.BASH`.
```{hint}
Detected via the `BASH_VERSION` environment variable (set by Bash
on startup), or via the `SHELL` path as a fallback.
```
```{attention}
GitHub's `ubuntu-slim` runner is a
[stripped-down environments, running as a WSL2 container](https://docs.github.com/en/actions/reference/runners/github-hosted-runners#single-cpu-runners)
on top of Windows. It
[uses Bash as the default shell](https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md),
but does not set neither `BASH_VERSION` nor `SHELL`.
In that case we fall back to walking the parent process tree via `/proc`
to find it.
```
"""
return _detect_shell(version_env_var="BASH_VERSION", shell_ids="bash")
[docs]
@cache
def is_cmd() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.CMD`.
```{hint}
Detected on Windows via `cmd.exe` in the parent process tree, or when the
`PROMPT` environment variable is set and `PSModulePath` is not (to exclude
PowerShell).
```
"""
# cmd.exe in the parent process tree is the strong signal; the PROMPT/
# PSModulePath heuristic covers the case where the tree is unavailable.
return sys.platform == "win32" and (
_detect_shell(shell_ids="cmd")
or ("PROMPT" in environ and "PSModulePath" not in environ)
)
[docs]
@cache
def is_csh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.CSH`.
```{hint}
Detected via the `SHELL` environment variable path.
```
"""
return _detect_shell(shell_ids="csh")
[docs]
@cache
def is_dash() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.DASH`.
```{hint}
Detected via the `SHELL` environment variable path, as Dash does
not set its own version variable.
```
"""
return _detect_shell(shell_ids="dash")
[docs]
@cache
def is_fish() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.FISH`.
```{hint}
Detected via the `FISH_VERSION` environment variable (set by Fish
on startup), or via the `SHELL` path as a fallback.
```
"""
return _detect_shell(version_env_var="FISH_VERSION", shell_ids="fish")
[docs]
@cache
def is_ksh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.KSH`.
```{hint}
Detected via the `KSH_VERSION` environment variable (set by Korn
shell on startup), or via the `SHELL` path as a fallback.
```
"""
return _detect_shell(version_env_var="KSH_VERSION", shell_ids="ksh")
[docs]
@cache
def is_nushell() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.NUSHELL`.
```{hint}
Detected via the `NU_VERSION` environment variable (set by Nushell
on startup), or via the `SHELL` path as a fallback.
```
"""
return _detect_shell(version_env_var="NU_VERSION", shell_ids="nu")
[docs]
@cache
def is_powershell() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.POWERSHELL`.
```{note}
PowerShell is cross-platform and
[available on Linux](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-linux)
and macOS. Detection covers all platforms via `PSModulePath`,
`SHELL` path, and parent process tree.
```
```{attention}
`PSModulePath` can leak into non-PowerShell child processes via two
vectors:
1. **Process-level inheritance** (all platforms): PowerShell
modifies `PSModulePath` at startup, and
[all non-PowerShell children inherit it](https://github.com/PowerShell/PowerShell/issues/9957).
2. **System-wide registry variable** (Windows only):
`PSModulePath` is a [persistent machine-level
environment variable](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_psmodulepath)
visible to all processes.
This is the case for all GitHub Ubuntu runners, where
`PSModulePath` leaks from Azure infrastructure. This leads to multiple
shell detections, which is arbitraged by `current_shell()`,
which deprioritizes PowerShell when other shells are detected.
```
"""
# PSModulePath is a presence signal, not a version variable. Check it
# inline instead of routing through _detect_shell(version_env_var=...).
if "PSModulePath" in environ:
return True
return _detect_shell(shell_ids=("powershell", "powershell_ise", "pwsh"))
[docs]
@cache
def is_sh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.SH`.
```{hint}
Detected via the ``SHELL`` environment variable path, after symlink
resolution. Only matches when the resolved shell binary is literally
``sh``, not when ``/bin/sh`` is a symlink to another shell (like bash or
dash).
```
```{note}
On most modern systems, ``/bin/sh`` is a symlink to a concrete shell
(``bash``, ``dash``, etc.). In that case, ``is_sh()`` returns ``False``
and the concrete shell's detection function returns ``True`` instead.
To test whether the environment provides a Bourne-compatible *interface*
regardless of the underlying implementation, use
{func}`~extra_platforms.is_bourne_shells` instead.
```
```{important}
{data}`~extra_platforms.SH` is treated as a low-specificity fallback by
{func}`~extra_platforms.current_shell` (like
{data}`~extra_platforms.GENERIC_LINUX` for platforms): when both
{data}`~extra_platforms.SH` and a more specific shell are detected,
{func}`~extra_platforms.current_shell` returns the specific shell.
```
"""
return _detect_shell(shell_ids="sh")
[docs]
@cache
def is_tcsh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.TCSH`.
```{hint}
Detected via the `SHELL` environment variable path.
```
"""
return _detect_shell(shell_ids="tcsh")
[docs]
@cache
def is_xonsh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.XONSH`.
```{hint}
Detected via the `XONSH_VERSION` environment variable (set by Xonsh
on startup), or via the `SHELL` path as a fallback.
```
```{note}
Xonsh runs as a Python script rather than a standalone binary, so the
parent process tree shows `python` rather than `xonsh`. When neither
`XONSH_VERSION` nor `SHELL` identifies it, the tree walk inspects
interpreter arguments for the `xonsh` launcher (see
{attr}`~extra_platforms.Shell.interpreter`).
```
"""
return _detect_shell(version_env_var="XONSH_VERSION", shell_ids="xonsh")
[docs]
@cache
def is_zsh() -> bool:
"""Return {data}`True` if current shell is {data}`~extra_platforms.ZSH`.
```{hint}
Detected via the `ZSH_VERSION` environment variable (set by Zsh on
startup), or via the `SHELL` path as a fallback.
```
"""
return _detect_shell(version_env_var="ZSH_VERSION", shell_ids="zsh")
[docs]
@cache
def is_unknown_shell() -> bool:
"""Return {data}`True` if current shell is
{data}`~extra_platforms.UNKNOWN_SHELL`.
"""
# Lazy import to avoid circular dependencies.
from .shell_data import UNKNOWN_SHELL
return current_shell() is UNKNOWN_SHELL
# =============================================================================
# Terminal detection heuristics
# =============================================================================
[docs]
@cache
def is_alacritty() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.ALACRITTY`."""
return "ALACRITTY_SOCKET" in environ or "ALACRITTY_WINDOW_ID" in environ
[docs]
@cache
def is_apple_terminal() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.APPLE_TERMINAL`.
"""
return environ.get("TERM_PROGRAM") == "Apple_Terminal"
[docs]
@cache
def is_contour() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.CONTOUR`."""
return environ.get("TERMINAL_NAME") == "contour"
[docs]
@cache
def is_ghostty() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.GHOSTTY`."""
return "GHOSTTY_RESOURCES_DIR" in environ
[docs]
@cache
def is_gnome_terminal() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.GNOME_TERMINAL`.
"""
return "GNOME_TERMINAL_SCREEN" in environ
[docs]
@cache
def is_gnu_screen() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.GNU_SCREEN`.
"""
return "STY" in environ
[docs]
@cache
def is_hyper() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.HYPER`."""
return environ.get("TERM_PROGRAM") == "Hyper"
[docs]
@cache
def is_iterm2() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.ITERM2`."""
return "ITERM_SESSION_ID" in environ or environ.get("TERM_PROGRAM") == "iTerm.app"
[docs]
@cache
def is_kitty() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.KITTY`."""
return "KITTY_WINDOW_ID" in environ
[docs]
@cache
def is_konsole() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.KONSOLE`."""
return "KONSOLE_VERSION" in environ
[docs]
@cache
def is_rio() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.RIO`."""
return "RIO_WINDOW_ID" in environ
[docs]
@cache
def is_tabby() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.TABBY`."""
return "TABBY" in environ or environ.get("TERM_PROGRAM") == "Tabby"
[docs]
@cache
def is_tilix() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.TILIX`."""
return "TILIX_ID" in environ
[docs]
@cache
def is_tmux() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.TMUX`."""
return "TMUX" in environ
[docs]
@cache
def is_unknown_terminal() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.UNKNOWN_TERMINAL`.
"""
# Lazy import to avoid circular dependencies.
from .terminal_data import UNKNOWN_TERMINAL
return current_terminal() is UNKNOWN_TERMINAL
[docs]
@cache
def is_vscode_terminal() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.VSCODE_TERMINAL`.
"""
return environ.get("TERM_PROGRAM") == "vscode"
[docs]
@cache
def is_wezterm() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.WEZTERM`."""
return "WEZTERM_EXECUTABLE" in environ
[docs]
@cache
def is_windows_terminal() -> bool:
"""Return {data}`True` if current terminal is
{data}`~extra_platforms.WINDOWS_TERMINAL`.
"""
return "WT_SESSION" in environ
[docs]
@cache
def is_xterm() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.XTERM`.
```{note}
We check for `XTERM_VERSION` rather than `TERM=xterm` because many
headless environments (e.g., GitHub Actions `ubuntu-slim` runners) set
`TERM=xterm` for termcap/terminfo compatibility without actually running
xterm.
```
"""
return "XTERM_VERSION" in environ
[docs]
@cache
def is_zellij() -> bool:
"""Return {data}`True` if current terminal is {data}`~extra_platforms.ZELLIJ`."""
return "ZELLIJ" in environ
# =============================================================================
# CI/CD detection heuristics
# =============================================================================
[docs]
@cache
def is_azure_pipelines() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.AZURE_PIPELINES`.
```{seealso}
Environment variables reference:
<https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&viewFallbackFrom=vsts&tabs=yaml#system-variables>.
```
"""
return "TF_BUILD" in environ
[docs]
@cache
def is_bamboo() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.BAMBOO`.
```{seealso}
Environment variables reference:
<https://confluence.atlassian.com/bamboo/bamboo-variables-289277087.html#Bamboovariables-Build-specificvariables>.
```
"""
return "bamboo.buildKey" in environ
[docs]
@cache
def is_buildkite() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.BUILDKITE`.
```{seealso}
Environment variables reference:
<https://buildkite.com/docs/pipelines/environment-variables>.
```
"""
return "BUILDKITE" in environ
[docs]
@cache
def is_circle_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.CIRCLE_CI`.
```{seealso}
Environment variables reference:
<https://circleci.com/docs/reference/variables/#built-in-environment-variables>.
```
"""
return "CIRCLECI" in environ
[docs]
@cache
def is_cirrus_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.CIRRUS_CI`.
```{seealso}
Environment variables reference:
<https://cirrus-ci.org/guide/writing-tasks/#environment-variables>.
```
"""
return "CIRRUS_CI" in environ
[docs]
@cache
def is_codebuild() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.CODEBUILD`.
```{seealso}
Environment variables reference:
<https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html>.
```
"""
return "CODEBUILD_BUILD_ID" in environ
[docs]
@cache
def is_github_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.GITHUB_CI`.
```{seealso}
Environment variables reference:
<https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables>.
```
"""
return "GITHUB_ACTIONS" in environ or "GITHUB_RUN_ID" in environ
[docs]
@cache
def is_gitlab_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.GITLAB_CI`.
```{seealso}
Environment variables reference:
<https://docs.gitlab.com/ci/variables/predefined_variables/#predefined-variables>.
```
"""
return "GITLAB_CI" in environ
[docs]
@cache
def is_guix_build() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.GUIX_BUILD`.
```{note}
The Guix build daemon runs packages in an isolated sandbox with
``HOME`` set to ``/homeless-shelter`` (a non-existent directory). This
prevents builds from reading or writing to a real home directory.
```
```{seealso}
Build environment reference:
<https://guix.gnu.org/manual/en/html_node/Build-Environment-Setup.html>.
```
"""
return environ.get("HOME") == "/homeless-shelter"
[docs]
@cache
def is_heroku_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.HEROKU_CI`.
```{seealso}
Environment variables reference:
<https://devcenter.heroku.com/articles/heroku-ci#immutable-environment-variables>.
```
"""
return "HEROKU_TEST_RUN_ID" in environ
[docs]
@cache
def is_teamcity() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.TEAMCITY`.
```{seealso}
Environment variables reference:
<https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters>.
```
"""
return "TEAMCITY_VERSION" in environ
[docs]
@cache
def is_travis_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.TRAVIS_CI`.
```{seealso}
Environment variables reference:
<https://docs.travis-ci.com/user/environment-variables/#default-environment-variables>.
```
"""
return "TRAVIS" in environ
[docs]
@cache
def is_unknown_ci() -> bool:
"""Return {data}`True` if current CI is {data}`~extra_platforms.UNKNOWN_CI`."""
# Lazy import to avoid circular dependencies.
from .ci_data import UNKNOWN_CI
return current_ci() is UNKNOWN_CI
# =============================================================================
# Agent detection heuristics
# =============================================================================
[docs]
@cache
def is_claude_code() -> bool:
"""Return {data}`True` if current agent is {data}`~extra_platforms.CLAUDE_CODE`.
```{seealso}
Claude Code sets the `CLAUDECODE` environment variable when running.
```
"""
return "CLAUDECODE" in environ
[docs]
@cache
def is_cline() -> bool:
"""Return {data}`True` if current agent is {data}`~extra_platforms.CLINE`.
```{seealso}
Cline sets the `CLINE_ACTIVE` environment variable when running.
```
"""
return "CLINE_ACTIVE" in environ
[docs]
@cache
def is_cursor() -> bool:
"""Return {data}`True` if current agent is {data}`~extra_platforms.CURSOR`.
```{seealso}
Cursor sets the `CURSOR_AGENT` environment variable when running.
```
"""
return "CURSOR_AGENT" in environ
[docs]
@cache
def is_unknown_agent() -> bool:
"""Return {data}`True` if current agent is
{data}`~extra_platforms.UNKNOWN_AGENT`.
"""
# Lazy import to avoid circular dependencies.
from .agent_data import UNKNOWN_AGENT
return current_agent() is UNKNOWN_AGENT
# Populate the detection registry with all is_*() functions defined above.
_detection_registry.update({
_name: _func
for _name in dir()
if _name.startswith("is_") and callable(_func := globals()[_name])
})
# =============================================================================
# Current environment detection
# =============================================================================
[docs]
@cache
def current_architecture(strict: bool = False) -> Architecture:
"""Returns the {class}`~extra_platforms.Architecture` matching the
current environment.
Returns {data}`~extra_platforms.UNKNOWN_ARCHITECTURE` if not running inside a
recognized architecture. To raise an error instead, set `strict` to `True`.
```{important}
Always raises an error if multiple architectures match.
```
```{warning}
An architecture is always expected to be detected. An unrecognized result
logs a `WARNING` and likely indicates a missing detection heuristic that
should be [reported](https://github.com/kdeldycke/extra-platforms/issues).
```
"""
# Lazy imports to avoid circular dependencies.
from .architecture_data import UNKNOWN_ARCHITECTURE
from .group_data import ALL_ARCHITECTURES
# Collect all matching architectures.
matching: set[Architecture] = {
arch # type: ignore[misc]
for arch in ALL_ARCHITECTURES
if arch.current
}
# Return the only matching architecture.
if len(matching) == 1:
return matching.pop()
if len(matching) > 1:
raise RuntimeError(
f"Multiple architectures matches: {matching!r}. {_unrecognized_message()}"
)
_report_unrecognized("architecture", strict=strict)
return UNKNOWN_ARCHITECTURE
[docs]
@cache
def current_shell(strict: bool = False) -> Shell:
"""Returns the {class}`~extra_platforms.Shell` matching the current environment.
Uses a tiered disambiguation strategy with cached signals shared with
{func}`_detect_shell`:
1. Shell-specific environment variables (strongest: the Python process
*is* the shell).
2. Parent process tree, read from ``/proc`` on Linux or ``ps`` on macOS
and the BSDs (strong: the shell is an ancestor process actively
running).
3. ``SHELL`` environment variable resolved through symlinks (weak:
configured login shell, may differ from the active shell).
{data}`~extra_platforms.SH` is treated as a low-specificity fallback (like
{data}`~extra_platforms.GENERIC_LINUX` for platforms): on modern systems
``/bin/sh`` is almost always a symlink to a concrete shell, so
{data}`~extra_platforms.SH` is stripped when a more specific shell is also
detected.
Returns {data}`~extra_platforms.UNKNOWN_SHELL` if not running inside a
recognized shell. To raise an error instead, set `strict` to `True`.
```{important}
If both {data}`~extra_platforms.POWERSHELL` and another shell are
detected (because `PSModulePath`
[leaks into child processes](https://github.com/PowerShell/PowerShell/issues/9957)),
the other shell is preferred.
```
```{note}
This returns the **single primary shell**, after disambiguation.
{func}`~extra_platforms.current_traits` may include additional shells
that are detectable but not primary (like
{data}`~extra_platforms.POWERSHELL` on GitHub Ubuntu runners).
```
```{warning}
A shell is always expected to be detected. An unrecognized result logs a
`WARNING` and likely indicates a missing detection heuristic that should be
[reported](https://github.com/kdeldycke/extra-platforms/issues).
```
```{seealso}
Inspired by [UV's cross-platform shell detection](https://github.com/astral-sh/uv/blob/0.10.2/crates/uv-shell/src/lib.rs).
```
"""
# Lazy imports to avoid circular dependencies.
from .group_data import ALL_SHELLS
from .shell_data import POWERSHELL, SH, UNKNOWN_SHELL
# Collect all matching shells via the full scan (env vars, SHELL=,
# parent process tree, Windows defaults).
matching: set[Shell] = {
shell # type: ignore[misc]
for shell in ALL_SHELLS
if shell.current
}
if len(matching) == 1:
return matching.pop()
# Tier 1: prefer shells whose startup env var is set (strongest signal
# for the *active* shell). These env vars (like BASH_VERSION) are
# shell-internal and typically not exported to child processes, so this
# tier only fires when the Python process IS the shell itself.
active = _active_env_var_shell_ids()
if active:
active_matches = {s for s in matching if s.id in active}
if len(active_matches) == 1:
return active_matches.pop()
if active_matches:
matching = active_matches
# Tier 2: prefer shells found in the parent process tree (strong signal:
# the shell is actively running as an ancestor process). This resolves
# the common CI conflict where SHELL=/bin/sh resolves to /bin/dash
# (configured login shell) while bash is the actual parent process
# running the step.
proc_names = _parent_process_exe_names()
if proc_names:
proc_matches = {s for s in matching if s.id in proc_names}
if len(proc_matches) == 1:
return proc_matches.pop()
if proc_matches:
matching = proc_matches
# Tier 3: prefer the shell resolved from SHELL= over remaining matches.
resolved = _resolved_shell_id()
if resolved:
resolved_matches = {s for s in matching if s.id == resolved}
if len(resolved_matches) == 1:
return resolved_matches.pop()
# Remove SH if a more specific shell was also detected. On modern
# systems /bin/sh is almost always a symlink to a concrete shell, so
# SH is a low-specificity fallback (like GENERIC_LINUX for platforms).
if SH in matching and len(matching) > 1:
matching.discard(SH)
if len(matching) == 1:
return matching.pop()
# If PowerShell is detected alongside another shell, prefer the other.
if POWERSHELL in matching and len(matching) > 1:
matching.discard(POWERSHELL)
if len(matching) == 1:
return matching.pop()
if len(matching) > 1:
raise RuntimeError(
f"Multiple shells matches: {matching!r}. {_unrecognized_message()}"
)
_report_unrecognized("shell", strict=strict)
return UNKNOWN_SHELL
[docs]
@cache
def current_shell_path() -> str | None:
"""Returns the executable path of the current shell, or {data}`None`.
Resolution order:
1. The actual running shell binary, taken from the nearest ancestor in the
parent process tree that matches {func}`current_shell` (read from
``/proc`` on Linux, ``ps`` on macOS and the BSDs). This is the true
interpreter, even when ``SHELL`` is unset or points elsewhere.
2. The ``SHELL`` environment variable (the configured login shell), as a
fallback.
Returns {data}`None` when neither is available: no recognized shell, or a
stripped environment without ``SHELL``.
```{note}
On some BSDs, ``ps`` reports only a truncated process name rather than a
full path. The non-absolute name is discarded, so this falls back to
``SHELL`` there.
```
```{seealso}
Comparable to [shellingham](https://github.com/sarugaku/shellingham)'s
`detect_shell()`, which returns the shell name paired with its path. Here
the name is {func}`current_shell` and the path is this function.
```
"""
path = _running_shell_path(current_shell().id)
if path:
return path
return environ.get("SHELL") or None
[docs]
@cache
def current_terminal(strict: bool = False) -> Terminal:
"""Returns the {class}`~extra_platforms.Terminal` matching the current environment.
Returns {data}`~extra_platforms.UNKNOWN_TERMINAL` if not running inside a
recognized terminal. To raise an error instead, set `strict` to `True`.
```{important}
If multiple terminals match (e.g., {data}`~extra_platforms.TMUX` inside
{data}`~extra_platforms.KITTY`), multiplexers are filtered out first to
identify the innermost terminal. If multiple non-multiplexer terminals still
match, a {class}`RuntimeError` is raised.
```
```{note}
Unlike architectures, platforms, and shells, a terminal is not always present.
Headless environments (CI runners, cron jobs, Docker containers, SSH
non-interactive commands) have no terminal emulator attached.
If the `TERM` environment variable is set, an unrecognized terminal logs at
`WARNING` level, as it suggests a terminal emulator is present but not
recognized. Otherwise, it logs at `INFO` level.
```
"""
# Lazy imports to avoid circular dependencies.
from .group_data import ALL_TERMINALS, MULTIPLEXERS
from .terminal_data import UNKNOWN_TERMINAL
# Collect all matching terminals.
matching: set[Terminal] = {
term # type: ignore[misc]
for term in ALL_TERMINALS
if term.current
}
# Return the only matching terminal.
if len(matching) == 1:
return matching.pop()
# If multiple terminals match, filter out multiplexers to find the innermost.
if len(matching) > 1:
non_mux = {t for t in matching if t not in MULTIPLEXERS}
if len(non_mux) == 1:
return non_mux.pop()
raise RuntimeError(
f"Multiple terminals matches: {matching!r}. {_unrecognized_message()}"
)
# TERM env var signals a terminal emulator is present.
if "TERM" in environ:
_report_unrecognized("terminal", strict=strict)
else:
_report_unrecognized("terminal", strict=strict, expected=False)
return UNKNOWN_TERMINAL
[docs]
@cache
def current_ci(strict: bool = False) -> CI:
"""Returns the {class}`~extra_platforms.CI` system matching the current environment.
Returns {data}`~extra_platforms.UNKNOWN_CI` if not running inside a recognized CI
system. To raise an error instead, set `strict` to `True`.
```{important}
Always raises an error if multiple CI systems match.
```
```{note}
Unlike architectures, platforms, and shells, a CI system is not always present.
Local development environments have no CI system running.
If the `CI` environment variable is set, an unrecognized CI system logs at
`WARNING` level, as it suggests a CI system is present but not recognized.
Otherwise, it logs at `INFO` level.
```
"""
# Lazy imports to avoid circular dependencies.
from .ci_data import UNKNOWN_CI
from .group_data import ALL_CI
# Collect all matching CI systems.
matching: set[CI] = {ci for ci in ALL_CI if ci.current} # type: ignore[misc]
# Return the only matching CI system.
if len(matching) == 1:
return matching.pop()
if len(matching) > 1:
raise RuntimeError(
f"Multiple CI matches: {matching!r}. {_unrecognized_message()}"
)
# CI env var signals a CI system is present.
if "CI" in environ:
_report_unrecognized("CI", strict=strict)
else:
_report_unrecognized("CI", strict=strict, expected=False)
return UNKNOWN_CI
[docs]
@cache
def current_agent(strict: bool = False) -> Agent:
"""Returns the {class}`~extra_platforms.Agent` matching the current environment.
Returns {data}`~extra_platforms.UNKNOWN_AGENT` if not running inside a recognized
agent. To raise an error instead, set `strict` to `True`.
```{important}
Always raises an error if multiple agents match.
```
```{note}
Unlike architectures, platforms, and shells, an agent is not always present.
Local development without AI agents has no agent running.
If the `LLM` environment variable is set, an unrecognized agent logs at
`WARNING` level, as it suggests an AI agent is present but not recognized.
Otherwise, it logs at `INFO` level.
```
"""
# Lazy imports to avoid circular dependencies.
from .agent_data import UNKNOWN_AGENT
from .group_data import ALL_AGENTS
# Collect all matching agents.
matching: set[Agent] = {
agent # type: ignore[misc]
for agent in ALL_AGENTS
if agent.current
}
# Return the only matching agent.
if len(matching) == 1:
return matching.pop()
if len(matching) > 1:
raise RuntimeError(
f"Multiple agent matches: {matching!r}. {_unrecognized_message()}"
)
# LLM env var signals an AI agent is present.
if "LLM" in environ:
_report_unrecognized("agent", strict=strict)
else:
_report_unrecognized("agent", strict=strict, expected=False)
return UNKNOWN_AGENT
[docs]
@cache
def current_traits() -> set[Trait]:
"""Returns all traits matching the current environment.
This includes {class}`~extra_platforms.Architecture`,
{class}`~extra_platforms.Platform`, {class}`~extra_platforms.Shell`,
{class}`~extra_platforms.Terminal`, {class}`~extra_platforms.CI` systems,
and {class}`~extra_platforms.Agent` environments.
```{caution}
Never returns {data}`~extra_platforms.UNKNOWN` traits.
```
Raises {exc}`SystemError` if the current environment is not recognized at all.
```{important}
This function returns **all detectable traits**, not the disambiguated
primary trait per category. Multiple shells, platforms, or other traits
may appear in the result (for example, both
{data}`~extra_platforms.BASH` and {data}`~extra_platforms.POWERSHELL`
on GitHub Ubuntu runners where ``PSModulePath`` leaks from Azure).
Use the individual ``current_*()`` functions
({func}`~extra_platforms.current_shell`,
{func}`~extra_platforms.current_platform`, etc.) to get the single
best match per trait type.
```
```{attention}
At this point it is too late to worry about caching. This function has no
choice but to evaluate all detection heuristics.
```
"""
# Lazy imports to avoid circular dependencies.
from .group_data import ALL_TRAITS, UNKNOWN
# Collect all matching traits.
matching = {trait for trait in ALL_TRAITS - UNKNOWN if trait.current}
if not matching:
raise SystemError(f"Unrecognized environment: {_unrecognized_message()}")
return matching