Source code for extra_platforms.detection

# 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 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_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"
[docs] @cache def is_unknown_platform() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.UNKNOWN_PLATFORM`. """ # Lazy import to avoid circular dependencies. from .platform_data import UNKNOWN_PLATFORM return current_platform() is UNKNOWN_PLATFORM
# ============================================================================= # 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 ) @cache def _parent_process_exe_names() -> frozenset[str]: """Collect executable names from the parent process tree. On Linux, reads ``/proc/<pid>/exe`` symlinks up the process tree via ``/proc/<pid>/stat`` to find parent PIDs. Returns a {class}`frozenset` of lowercased executable stems (like ``"bash"``, ``"python3"``). Returns an empty set on non-Linux platforms where ``/proc`` is unavailable. """ names: set[str] = set() try: pid = os.getpid() visited: set[int] = set() while pid > 1 and pid not in visited: visited.add(pid) try: names.add(Path(os.readlink(f"/proc/{pid}/exe")).stem.lower()) except OSError: pass try: stat_content = Path(f"/proc/{pid}/stat").read_text() # Format: "pid (comm) state ppid ...". The comm field may # contain spaces and parentheses, so find the last ')'. ppid_str = stat_content[stat_content.rfind(")") + 2 :].split()[1] pid = int(ppid_str) except (OSError, ValueError, IndexError): break except OSError: pass return frozenset(names) 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` 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 when the `PROMPT` environment variable is set and `PSModulePath` is not (to exclude PowerShell). ``` """ return ( sys.platform == "win32" and "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. ``` """ 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_foot() -> bool: """Return {data}`True` if current terminal is {data}`~extra_platforms.FOOT`.""" return environ.get("TERM", "").startswith("foot")
[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_platform(strict: bool = False) -> Platform: """Always returns the best matching {class}`~extra_platforms.Platform` for the current environment. Returns {data}`~extra_platforms.UNKNOWN_PLATFORM` if not running inside a recognized platform. To raise an error instead, set `strict` to `True`. ```{important} If multiple platforms match the current environment, this function will try to select the best, informative one. Raises an error if we can't decide on a single, appropriate platform. ``` ```{warning} A platform 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 .group_data import ALL_PLATFORMS from .platform_data import GENERIC_LINUX, UNKNOWN_PLATFORM, WSL1, WSL2 # Collect all matching platforms. matching: set[Platform] = { plat # type: ignore[misc] for plat in ALL_PLATFORMS if plat.current } # Return the only matching platform. if len(matching) == 1: (result,) = matching if result is GENERIC_LINUX: _report_unrecognized("Linux distribution", strict=False) return result # Removes some generic platforms from the matching, until we have a single match. # Starts by removing the least specific WSL1, then WSL2: WSL is a generic platform, # so we should prefer the remaining, more specific platform matches # like Ubuntu. See: # - https://github.com/kdeldycke/extra-platforms/issues/158 # - https://github.com/kdeldycke/meta-package-manager/issues/944 for wsl in (WSL1, WSL2): if wsl in matching: matching.remove(wsl) if len(matching) == 1: return matching.pop() # Remove GENERIC_LINUX if a more specific platform was also detected. if GENERIC_LINUX in matching and len(matching) > 1: matching.remove(GENERIC_LINUX) if len(matching) == 1: return matching.pop() if len(matching) > 1: raise RuntimeError( f"Multiple platforms matches: {matching!r}. {_unrecognized_message()}" ) _report_unrecognized("platform", strict=strict) return UNKNOWN_PLATFORM
[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. ``/proc`` parent process tree (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=, # /proc 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_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