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 re
import subprocess
import sys
from functools import cache
from os import environ
from pathlib import Path, PurePosixPath
from typing import TypeVar

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

_TTrait = TypeVar("_TTrait", bound="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_chromeos() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.CHROMEOS`. Matches ChromeOS itself, whose ``os-release`` file sets ``ID=chromeos`` (the environment targeted by [Chromebrew](https://chromebrew.github.io)), as well as open-source ChromiumOS builds and their derivatives like [FydeOS](https://fydeos.io) and openFyde, which set ``ID=chromiumos``. Accepting both IDs is the practice of Google's own [flashrom tester](https://github.com/flashrom/flashrom/blob/main/util/flashrom_tester/src/tests.rs). Also matches the [Crostini](https://chromeos.dev/en/linux) Linux development environment. Crostini being a Debian-based container, its ``os-release`` file reports Debian. So the ``/dev/.cros_milestone`` file exposed by the ChromeOS host is used as the detection marker instead, a practice shared with LXD, Incus and the Dart SDK. ```{caution} Within Crostini, both this function and {func}`~extra_platforms.is_debian` return {data}`True`, the same way WSL sessions match both {func}`~extra_platforms.is_wsl2` and the distribution it hosts. In that situation, {func}`~extra_platforms.current_platform` prefers the container's own distribution. ``` """ return ( os_release_id() in ("chromeos", "chromiumos") or Path("/dev/.cros_milestone").is_file() )
[docs] @cache def is_clearlinux() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.CLEARLINUX`. ```{note} Clear Linux OS sets ``ID=clear-linux-os`` in its ``os-release`` file. Being a [stateless](https://www.clearlinux.org/clear-linux-documentation/guides/clear/stateless.html) distribution, it ships that file at ``/usr/lib/os-release`` rather than ``/etc/os-release``, relying on the fallback path mandated by the [`os-release` specification](https://www.freedesktop.org/software/systemd/man/latest/os-release.html). ``` """ return os_release_id() == "clear-linux-os"
[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 ``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 ``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 {data}`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`. ```{note} Matches the main ``opensuse`` ID as well as any ``opensuse-*`` release channel (Tumbleweed, Leap, Slowroll, MicroOS, ...), per the granularity policy documented in the ``platform_data`` module. The raw channel ID remains available through ``Platform.info()``. ``` """ distro_id = os_release_id() return distro_id == "opensuse" or distro_id.startswith("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`. ```{note} Oracle Linux sets ``ID=ol`` in its ``os-release`` file. The platform keeps the more explicit ``oracle`` ID, mirroring the naming of the [`distro` library](https://github.com/python-distro/distro). ``` """ return os_release_id() == "ol"
[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_pikaos() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.PIKAOS`. ```{note} PikaOS, an Ubuntu-based gaming distribution, sets ``ID=pika`` (not ``pikaos``) in its ``os-release`` file. Its ``ID_LIKE=debian`` field is ignored, so this stays distinct from {func}`~extra_platforms.is_debian`. ``` """ return os_release_id() == "pika"
[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_slitaz() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.SLITAZ`. ```{note} SliTaz does not ship any ``os-release`` file: its ``slitaz-base-files`` package only provides ``/etc/slitaz-release``, whose existence is used as the detection marker. The standard ``os-release`` check is still performed first, in case future releases adopt the specification. ``` """ return os_release_id() == "slitaz" or Path("/etc/slitaz-release").is_file()
[docs] @cache def is_solaris() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.SOLARIS`. .. note:: Gates on {data}`sys.platform` being ``"sunos5"`` before invoking {func}`platform.platform`, which on Windows would shell out via ``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_sourcemage() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.SOURCEMAGE`. """ return os_release_id() == "sourcemage"
[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_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` being ``"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 ``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 ) 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 _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 if shell_ids is None: return False # Normalize shell_ids for consistent handling. 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. return bool(ids & _parent_process_exe_names())
[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_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 # ============================================================================= def _drop_fallbacks(matching: set[_TTrait], *fallbacks: _TTrait) -> None: """Discard low-specificity traits while more specific matches remain. Mutates ``matching`` in place: each fallback is removed in turn, but never below a single remaining match, so the most specific trait survives. """ for fallback in fallbacks: if len(matching) <= 1: break matching.discard(fallback) def _single_match( matching: set[_TTrait], unknown: _TTrait, label: str, *, strict: bool, plural: str | None = None, expected: bool = True, ) -> _TTrait: """Resolve a set of matching traits to a single result. Returns the only match, or ``unknown`` when nothing matched (logging or raising along the way, see {func}`_report_unrecognized`). Raises {exc}`RuntimeError` when multiple traits still match, as category-specific disambiguation is the caller's job. :param matching: Traits matching the current environment, already disambiguated by the caller. :param unknown: The ``UNKNOWN_*`` trait to return when nothing matched. :param label: Human-readable trait type (like ``"architecture"``), used in messages. The plural defaults to ``label + "s"``; pass ``plural`` for irregular forms (like ``"CI systems"``). :param strict: If `True`, raise on an unrecognized environment instead of logging. :param expected: If `False`, the trait may legitimately be absent, and an empty match is only logged at `INFO` level. """ if len(matching) == 1: return matching.pop() if matching: raise RuntimeError( f"Multiple {plural or label + 's'} match: {matching!r}. " f"{_unrecognized_message()}" ) _report_unrecognized(label, strict=strict, expected=expected) return unknown
[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 _single_match(matching, UNKNOWN_ARCHITECTURE, "architecture", strict=strict)
[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 CHROMEOS, 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 } # A lone GENERIC_LINUX match means the Linux distribution itself was not # identified, which is worth reporting. if matching == {GENERIC_LINUX}: _report_unrecognized("Linux distribution", strict=False) return GENERIC_LINUX # Remove low-specificity platforms until a single match remains. Starts # with 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 # ChromeOS gets the same treatment: within Crostini, the hosting ChromeOS # is detected alongside the container's own distribution (Debian by # default), and the latter is the informative one to return. GENERIC_LINUX # goes last, as any distribution match is more specific. _drop_fallbacks(matching, WSL1, WSL2, CHROMEOS, GENERIC_LINUX) return _single_match(matching, UNKNOWN_PLATFORM, "platform", strict=strict)
[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 ``_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 low-specificity shells until a single match remains. SH goes # first: 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). PowerShell is deprioritized next, as PSModulePath leaks # into child processes and falsely flags PowerShell as active. _drop_fallbacks(matching, SH, POWERSHELL) return _single_match(matching, UNKNOWN_SHELL, "shell", strict=strict)
[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 (like {data}`~extra_platforms.TMUX` inside {data}`~extra_platforms.KITTY`), multiplexers are filtered out first to report the terminal emulator hosting the session. 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 } # If multiple terminals match, filter out multiplexers to report the # hosting terminal emulator (like KITTY hosting a TMUX session). if len(matching) > 1: non_mux = {term for term in matching if term not in MULTIPLEXERS} if len(non_mux) == 1: return non_mux.pop() # The TERM env var signals a terminal emulator is expected to be present. return _single_match( matching, UNKNOWN_TERMINAL, "terminal", strict=strict, expected="TERM" in environ, )
[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] # The CI env var signals a CI system is expected to be present. return _single_match( matching, UNKNOWN_CI, "CI", strict=strict, plural="CI systems", expected="CI" in environ, )
[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 } # The LLM env var signals an AI agent is expected to be present. return _single_match( matching, UNKNOWN_AGENT, "agent", strict=strict, expected="LLM" in environ, )
[docs] @cache def current_traits() -> frozenset[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. Immutability matters: this function is # cached, so a mutable result would let callers corrupt the cache for # everybody else. matching = frozenset(trait for trait in ALL_TRAITS - UNKNOWN if trait.current) if not matching: raise SystemError(f"Unrecognized environment: {_unrecognized_message()}") return matching