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. A system hiding
both copies of it is identified through `systemd-hostnamed` instead, as
implemented in {mod}`~extra_platforms.platform_info`.

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

```{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, PureWindowsPath

from . import _process_tree
from .agent_data import UNKNOWN_AGENT
from .architecture_data import AARCH64, UNKNOWN_ARCHITECTURE, X86_64
from .ci_data import UNKNOWN_CI
from .group_data import (
    ALL_AGENTS,
    ALL_ARCHITECTURES,
    ALL_CI,
    ALL_GROUPS,
    ALL_PLATFORMS,
    ALL_SHELLS,
    ALL_TERMINALS,
    ALL_TRAITS,
    LINUX,
    MULTIPLEXERS,
    UNKNOWN,
)
from .platform_data import CHROMEOS, GENERIC_LINUX, UNKNOWN_PLATFORM, WSL1, WSL2
from .platform_info import invalidate_os_release_cache, os_release_id
from .shell_data import (
    ASH,
    BASH,
    CMD,
    CSH,
    DASH,
    FISH,
    KSH,
    NUSHELL,
    POWERSHELL,
    SH,
    TCSH,
    UNKNOWN_SHELL,
    XONSH,
    ZSH,
)
from .terminal_data import UNKNOWN_TERMINAL
from .trait import CI, Agent, Architecture, Platform, Shell, Terminal

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Iterable
    from typing import TypeVar

    from .group import Group
    from .trait import Trait

    _TTrait = TypeVar("_TTrait", bound=Trait)


_detection_registry: dict[str, Callable[[], bool]] = {}
"""Maps detection function IDs (like ``"is_bash"``) to their callables.

Holds the ``is_*()`` function of every trait and group, registered once this
module defines or generates them.

Used by {attr}`Trait.current <extra_platforms.Trait.current>` to look up
detection functions without a fragile string-based module attribute search.
"""


_SHELLS = tuple(shell for shell in ALL_SHELLS if isinstance(shell, Shell))
"""The shells of the registry, typed as {class}`~extra_platforms.Shell`."""


_SHELL_BY_EXECUTABLE = {
    name: shell for shell in _SHELLS for name in shell.executable_names
}
"""Maps every file name a shell's binary goes by to its shell."""


_AARCH64_MACHINES = frozenset(("aarch64", "arm64"))
"""Lowercased {func}`platform.machine` values of {data}`~extra_platforms.AARCH64`.

Linux reports ``aarch64``, macOS ``arm64`` and Windows ``ARM64``.
"""


_AGENT_PRESENCE_ENV_VARS = ("LLM", "AI_AGENT")
"""Environment variables signalling an AI agent runs, whichever one it is.

Unlike the per-agent markers the ``is_*()`` heuristics read, these carry no
product identity of their own, so they cannot name an agent. They answer the
weaker question {func}`current_agent` needs when nothing matched: was an agent
expected here at all? A miss with one of these set is a gap in the registry and
logs at ``WARNING``; a miss without them is a machine running no agent, and logs
at ``INFO``.

``AI_AGENT`` carries the agent's own name as its value, and is set by at least
[Crush](https://github.com/charmbracelet/crush) (``AI_AGENT=crush``, from
``CrushEnvMarkers()`` in ``internal/shell/shell.go``) and
[Pi](https://github.com/earendil-works/pi) (``AI_AGENT=pi``, from
``packages/coding-agent/src/cli.ts``). Both document it as a marker for other
tools to read, so an unrecognized value here names the agent to add next.

```{note}
Crush also sets a bare ``AGENT=crush``, deliberately left out: the word is too
common to attribute to an AI agent on its own, and Crush is already covered by
{func}`is_crush` and by ``AI_AGENT``.
```
"""


_CATEGORY_ENV_SIGNALS: dict[str, tuple[str, ...]] = {
    "shell": (
        "SHELL",
        *sorted(shell.version_env_var for shell in _SHELLS if shell.version_env_var),
        "PSModulePath",
    ),
    "terminal": (
        "TERM",
        "TERM_PROGRAM",
        "TERM_PROGRAM_VERSION",
        "COLORTERM",
        "TERMINAL_NAME",
    ),
    "ci": ("CI",),
    "agent": _AGENT_PRESENCE_ENV_VARS,
}
"""Environment variables to surface in an unrecognized-trait report, per category.

Architectures and platforms are diagnosed from the ``sys.platform`` /
``platform.*`` / ``os_release_id()`` primitives the report already prints. Shells,
terminals, CI systems and agents are instead identified by environment variables,
which those primitives never expose. This maps each such category (keyed by the
{attr}`~extra_platforms.Trait.type_id` of its class) to the variables its
``is_*()`` heuristics read.

The first entry is the category's primary signal, always shown (even when unset, so
a reader sees why an unrecognized trait logged at ``INFO`` rather than ``WARNING``).
The rest are shown only when set: an unrecognized trait leaves every presence-only
marker unset, so the value-bearing variables (a ``SHELL`` path, a ``TERM_PROGRAM``
name) are what carry the smoking gun.

The ``shell`` entry reads the {attr}`~extra_platforms.Shell.version_env_var`
of every shell from the registry.

```{caution}
Only gating and value-bearing variables belong here, never a blanket dump of
{data}`os.environ`: the environment routinely holds credentials and tokens.
```
"""


_CHROMEOS_IDS = frozenset(("chromeos", "chromiumos"))
"""``os-release`` IDs of ChromeOS and of the open-source ChromiumOS builds.

Only these IDs identify the running system as {data}`~extra_platforms.CHROMEOS`.
The Crostini marker that {func}`is_chromeos` also reads shows inside a container
too, so {func}`is_generic_linux` checks the IDs alone.
"""


_DUMB_TERMS = frozenset(("", "dumb", "su", "unknown"))
"""``TERM`` values declaring a terminal with no capabilities, so no emulator.

``dumb`` is that terminal's terminfo entry, and FreeBSD's termcap lists ``su`` and
``unknown`` as its aliases (``dumb|su|unknown`` in ``/usr/share/misc/termcap``).
Non-interactive SSH sessions set them: ``TERM=dumb`` on NixOS, ``TERM=su`` on
FreeBSD. {func}`current_terminal` does not expect a terminal emulator there.
"""


_WINDOWS_INTERPRETER_MACHINES = {
    "win-amd64": "x86_64",
    "win-arm64": "aarch64",
    "win32": "i686",
}
"""Machine a Windows interpreter runs as, keyed by {func}`sysconfig.get_platform`.

On Windows, CPython derives that platform from the build of the interpreter
([`Modules/_sysconfig.c`](https://github.com/python/cpython/blob/e8c0584877e0b0a82ee8e0707336d8df4d68ccb4/Modules/_sysconfig.c#L95-L108)),
while {func}`platform.machine` reads the native CPU
([`_get_machine_win32()`](https://github.com/python/cpython/blob/e8c0584877e0b0a82ee8e0707336d8df4d68ccb4/Lib/platform.py#L799-L824)).
"""


_X86_64_MACHINES = frozenset(("amd64", "i86pc", "x86_64"))
"""Lowercased {func}`platform.machine` values of {data}`~extra_platforms.X86_64`.

Windows reports ``AMD64``, the BSDs ``amd64``, Solaris and illumos ``i86pc``.
"""


def _env_signal_lines(var_names: tuple[str, ...]) -> str:
    """Render environment variables as aligned ``name: value`` report lines.

    The first variable is always shown (rendering ``<not set>`` when absent); the
    rest only when present. See {data}`_CATEGORY_ENV_SIGNALS`.
    """
    primary, *rest = var_names
    shown = [primary, *(name for name in rest if name in environ)]
    width = max(len(name) for name in shown) + 1  # +1 for the trailing colon.
    rows: list[str] = []
    for name in shown:
        value = repr(environ[name]) if name in environ else "<not set>"
        rows.append(f"  {name + ':':<{width}} {value}")
    return "\n".join(rows)


def _unrecognized_message(report: bool = True, category: str | None = None) -> str:
    """Generate a message for unrecognized environments.

    The report opens with the ``sys.platform`` / ``platform.*`` / ``os_release_id()``
    primitives that identify architectures and platforms. When ``category`` names an
    environment-variable-driven trait type, the variables that category's heuristics
    read are appended, since the primitives above never expose them (see
    {data}`_CATEGORY_ENV_SIGNALS`).

    :param report: If `True`, append a request to report the issue on GitHub.
        Set to `False` for environments where the trait is legitimately absent
        (like no terminal in CI, or no CI locally).
    :param category: Type ID of the trait category whose environment-variable
        signals to append, or `None` for the primitives alone.
    """
    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}"
    )
    env_signals = _CATEGORY_ENV_SIGNALS.get(category) if category else None
    if env_signals:
        msg += f"\n{_env_signal_lines(env_signals)}"
    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,
    category: str | None = None,
) -> None:
    """Log or raise on unrecognized trait detection.

    :param trait_name: Human-readable name of the trait type (like `"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.
    :param category: Type ID of the trait category whose environment-variable
        signals to include in the message (see {data}`_CATEGORY_ENV_SIGNALS`).
    """
    msg = (
        f"Unrecognized {trait_name}: "
        f"{_unrecognized_message(report=expected, category=category)}"
    )
    if strict:
        raise SystemError(msg)
    # Deferred import: stdlib logging pulls in traceback, which dominates the
    # cold import time of the package on slow architectures.
    import logging

    logger = logging.getLogger(__name__)
    if expected:
        logger.warning(msg)
    else:
        logger.info(msg)


# =============================================================================
# Architecture detection heuristics
# =============================================================================


@cache
def _interpreter_machine() -> str:
    """Return the machine the Python interpreter runs as, in `platform.machine` terms.

    The architecture detection functions read this value, not
    {func}`platform.machine`, which names the machine of the operating system. The
    two differ for an interpreter narrower than the system, or emulated by it:

    - On Windows, {data}`_WINDOWS_INTERPRETER_MACHINES` maps the platform the
      interpreter was built for to its machine.
    - Elsewhere, a 32-bit interpreter on a 64-bit ARM or x86 kernel runs as
      ``armv8l`` or ``i686``. Under the ``linux32`` personality, the kernel reports
      these names to a 32-bit process itself, and
      [`packaging`](https://github.com/pypa/packaging/blob/843887fd431f8b3c10f1b39e1d4066ba3516147c/src/packaging/tags.py#L878-L890)
      tags the wheels of such an interpreter the same way.

    An interpreter is 32-bit when {data}`sys.maxsize` fits in 32 bits, the test
    that [Python's documentation](https://docs.python.org/3/library/platform.html#platform.architecture)
    recommends.
    """
    if sys.platform == "win32":
        # Deferred import: only Windows needs sysconfig.
        import sysconfig

        machine = _WINDOWS_INTERPRETER_MACHINES.get(sysconfig.get_platform())
        if machine:
            return machine
    machine = platform.machine()
    if sys.maxsize <= 2**32:
        if machine.lower() in _AARCH64_MACHINES:
            return "armv8l"
        if machine.lower() in _X86_64_MACHINES:
            return "i686"
    return machine


[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 _interpreter_machine().lower() in _AARCH64_MACHINES
[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 _interpreter_machine().startswith("arm") and not ( is_aarch64() or is_armv5tel() or is_armv6l() or is_armv7l() or is_armv8l() )
[docs] @cache def is_armv5tel() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.ARMV5TEL`.""" return _interpreter_machine() == "armv5tel"
[docs] @cache def is_armv6l() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.ARMV6L`.""" return _interpreter_machine() == "armv6l"
[docs] @cache def is_armv7l() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.ARMV7L`.""" return _interpreter_machine() == "armv7l"
[docs] @cache def is_armv8l() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.ARMV8L`.""" return _interpreter_machine() == "armv8l"
[docs] @cache def is_i386() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.I386`.""" return _interpreter_machine() in ("i386", "i486")
[docs] @cache def is_i586() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.I586`.""" return _interpreter_machine() == "i586"
[docs] @cache def is_i686() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.I686`.""" return _interpreter_machine() == "i686"
[docs] @cache def is_loongarch64() -> bool: """Return {data}`True` on {data}`~extra_platforms.LOONGARCH64`.""" return _interpreter_machine() == "loongarch64"
[docs] @cache def is_mips() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.MIPS`.""" return _interpreter_machine() == "mips"
[docs] @cache def is_mips64() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.MIPS64`.""" return _interpreter_machine() == "mips64"
[docs] @cache def is_mips64el() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.MIPS64EL`.""" return _interpreter_machine() == "mips64el"
[docs] @cache def is_mipsel() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.MIPSEL`.""" return _interpreter_machine() == "mipsel"
[docs] @cache def is_ppc() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.PPC`.""" return _interpreter_machine() in ("ppc", "powerpc")
[docs] @cache def is_ppc64() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.PPC64`.""" return _interpreter_machine() == "ppc64"
[docs] @cache def is_ppc64le() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.PPC64LE`.""" return _interpreter_machine() == "ppc64le"
[docs] @cache def is_riscv32() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.RISCV32`.""" return _interpreter_machine() == "riscv32"
[docs] @cache def is_riscv64() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.RISCV64`.""" return _interpreter_machine() == "riscv64"
[docs] @cache def is_s390x() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.S390X`.""" return _interpreter_machine() == "s390x"
[docs] @cache def is_sparc() -> bool: """Return {data}`True` if current architecture is {data}`~extra_platforms.SPARC`.""" return _interpreter_machine() == "sparc"
[docs] @cache def is_sparc64() -> bool: """Return {data}`True` if the architecture is {data}`~extra_platforms.SPARC64`.""" return _interpreter_machine() in ("sparc64", "sun4u", "sun4v")
[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_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. ``` ```{hint} Solaris and illumos answer `i86pc`, which names the machine family rather than the ISA, the same way `sun4u` and `sun4v` do for {func}`is_sparc64`. Both keep that string on 64-bit hardware, and neither ships a 32-bit kernel any more. Measured on OpenIndiana Hipster 2026.04. ``` """ return _interpreter_machine().lower() in _X86_64_MACHINES
[docs] @cache def is_unknown_architecture() -> bool: """Return {data}`True` if the architecture is unknown. See {data}`~extra_platforms.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_almalinux() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.ALMALINUX`.""" return os_release_id() == "almalinux"
[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_artix() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.ARTIX`.""" return os_release_id() == "artix"
[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 was the practice of Google's own [flashrom tester](https://github.com/flashrom/flashrom/blob/81e5b58a2086f6c6b084f42a93c30ce73790928f/util/flashrom_tester/src/tests.rs#L226). 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_IDS 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://github.com/clearlinux/clear-linux-documentation/blob/22d818e12b6e66a2c0be02953d72641510350562/source/guides/clear/stateless.rst) 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 the platform is {data}`~extra_platforms.CLOUDLINUX`.""" return os_release_id() == "cloudlinux"
[docs] @cache def is_crux() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.CRUX`.""" return os_release_id() == "crux"
[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 the platform is {data}`~extra_platforms.DRAGONFLY_BSD`.""" return sys.platform.startswith("dragonfly")
[docs] @cache def is_endeavouros() -> bool: """Return {data}`True` if the platform is {data}`~extra_platforms.ENDEAVOUROS`.""" return os_release_id() == "endeavouros"
[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 the platform is {data}`~extra_platforms.GENERIC_LINUX`. Matches when running on a Linux kernel whose distribution no other platform identifies: a distribution with an `os-release` ID no platform recognizes yet, or a minimal container or build chroot shipping no `/etc/os-release` and reaching no `systemd-hostnamed`. This keeps an unrecognized distribution in the {data}`~extra_platforms.LINUX` group, and in every group built on it. ```{note} A distribution identified by another marker is no generic Linux, like {data}`~extra_platforms.SLITAZ` from its `/etc/slitaz-release` file. {data}`~extra_platforms.CHROMEOS` counts only through its own `os-release` ID: its Crostini marker also shows inside a container, whose own distribution stays unidentified. ``` """ return ( sys.platform == "linux" and os_release_id() not in _CHROMEOS_IDS and not any( plat.current for plat in LINUX if plat not in (CHROMEOS, GENERIC_LINUX) ) )
[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 the 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 the 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_nutyx() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.NUTYX`.""" return os_release_id() == "nutyx"
[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 the 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`. ```{hint} An illumos distribution reports `Solaris-2.11` from {func}`platform.platform`, because it inherits the SunOS 5.11 release string from OpenSolaris. That prefix alone therefore cannot tell Oracle Solaris from its fork, and {func}`is_illumos` has to exclude it, or both match at once and {func}`current_platform` raises. Measured on OpenIndiana Hipster 2026.04. ``` ```{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") and not is_illumos() )
[docs] @cache def is_solus() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.SOLUS`.""" return os_release_id() == "solus"
[docs] @cache def is_sourcemage() -> bool: """Return {data}`True` if the 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 the 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_webos() -> bool: """Return {data}`True` if current platform is {data}`~extra_platforms.WEBOS`. ```{note} webOS TV, the edition LG ships on its TVs, sets ``ID=starfish`` in its ``os-release`` file: ``starfish`` is the name of LG's TV build. The platform keeps the more explicit ``webos`` ID. Captured on an LG OLED77B46LA TV, running webOS TV ``10.3.2`` (firmware ``33.31.75``). ``` ```{todo} Also match the ID of webOS Open Source Edition, once an ``os-release`` file captured on it confirms that ID. LG manages both editions, so they share this platform. Its Yocto layer sets ``DISTRO = "webos"`` in [`meta-webos/conf/distro/webos.conf`](https://github.com/webosose/meta-webosose/blob/d7ed46c33c54e6e28a8f379fb321d9ce2944ffd1/meta-webos/conf/distro/webos.conf#L3), which suggests ``ID=webos``. ``` """ return os_release_id() == "starfish"
[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 the platform is unknown. See {data}`~extra_platforms.UNKNOWN_PLATFORM`. """ return current_platform() is UNKNOWN_PLATFORM
# ============================================================================= # Shell detection heuristics # ============================================================================= def _shell_stem(path: str | os.PathLike[str]) -> str: """Lowercased file name of a shell path, with its suffix dropped. The path is resolved through symlinks when the file exists. ``/bin/sh`` on a system linking it to ``/bin/bash`` gives ``"bash"``, and ``pwsh.exe`` gives ``"pwsh"``. A link keeps its own name when its target goes by no shell's name, the way a multi-call binary picks its program from the name it is called by. BusyBox is one: Alpine's ``/bin/sh`` and ``/bin/ash`` both give ``"ash"``, see `extra_platforms._process_tree`. """ # The file name as written, read as text: the Windows flavor accepts both # separators, and the path need not exist here. stem = PureWindowsPath(path).stem.lower() try: resolved = Path(path).resolve(strict=True).stem.lower() except OSError: return stem if resolved == "busybox": return _process_tree.busybox_applet(stem) return resolved if resolved in _SHELL_BY_EXECUTABLE else stem @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") return _shell_stem(shell_path) if shell_path else None
[docs] def shell_from_path(path: str | os.PathLike[str]) -> Shell: """Return the {class}`~extra_platforms.Shell` a binary path names. For a shell path read from configuration rather than from the running process: the ``SHELL`` variable, a passwd entry, a terminal profile. {func}`current_shell` answers a different question, which shell the process runs in. The path is resolved through symlinks when it exists, so ``/bin/sh`` on a system linking it to ``bash`` gives {data}`~extra_platforms.BASH`, and its lowercased file name, suffix dropped, is matched against every shell's {attr}`~extra_platforms.Shell.executable_names`. A link to a binary going by no shell's name keeps its own name, the way a multi-call binary picks its program, and Alpine's ``/bin/sh`` and ``/bin/ash``, both links to BusyBox, give {data}`~extra_platforms.ASH`. An unmatched name gives {data}`~extra_platforms.UNKNOWN_SHELL`. """ return _SHELL_BY_EXECUTABLE.get(_shell_stem(path), UNKNOWN_SHELL)
@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. """ return frozenset( shell.id for shell in _SHELLS if shell.version_env_var and shell.version_env_var in environ ) def _shell_path(names: frozenset[str]) -> str | None: """Return the executable path of the shell going by ``names``. Prefers the running binary, the nearest of the parent process tree. Falls back to the ``SHELL`` environment variable only when it names that same shell: a login shell of another kind says nothing about where this one lives. Returns {data}`None` otherwise. """ if path := _process_tree.nearest_path(names): return path if _resolved_shell_id() in names: return environ["SHELL"] return None def _nearest_running_shell(shells: Iterable[Shell]) -> Shell | None: """Return the shell of ``shells`` running nearest to the current process. Walks the parent process tree from the current process up, and returns the first shell one of whose {attr}`~extra_platforms.Shell.executable_names` names an ancestor. Returns {data}`None` when no ancestor runs any of ``shells``. """ for name, _ in _process_tree.parent_process_tree(): for shell in shells: if name in shell.executable_names: return shell return None def _detect_shell(shell: Shell) -> bool: """Detect ``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. The {attr}`~extra_platforms.Shell.version_env_var` of the shell is set (most reliable). 2. The ``SHELL`` environment variable, resolved through symlinks, names one of the {attr}`~extra_platforms.Shell.executable_names` of the shell. This reports the actual shell implementation rather than the interface name: when ``/bin/sh`` symlinks to ``/bin/bash``, ``bash`` is detected, not ``sh``. 3. A process of the parent tree goes by one of those names. The tree is read from `/proc` on Linux, and from `ps` on macOS, the BSDs and illumos. This covers stripped containers, like `ubuntu-slim`, where `SHELL` is not set. """ if shell.version_env_var and shell.version_env_var in environ: return True names = shell.executable_names return _resolved_shell_id() in names or not names.isdisjoint( _process_tree.parent_process_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, which its ``sh`` and ``ash`` applets both run (see `extra_platforms._process_tree`). On BusyBox-based systems ({data}`~extra_platforms.ALPINE`, {data}`~extra_platforms.OPENWRT`), `/bin/sh` and `/bin/ash` both link to BusyBox, so either one, as `$SHELL` or running, is detected as {data}`~extra_platforms.ASH`. ``` """ return _detect_shell(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(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(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(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(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(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(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(NUSHELL)
[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, so POWERSHELL # declares no version_env_var for _detect_shell() to read. return "PSModulePath" in environ or _detect_shell(POWERSHELL)
[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(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(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(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(ZSH)
[docs] @cache def is_unknown_shell() -> bool: """Return {data}`True` if the shell is {data}`~extra_platforms.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` in {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` in {data}`~extra_platforms.GNOME_TERMINAL`.""" return "GNOME_TERMINAL_SCREEN" in environ
[docs] @cache def is_gnu_screen() -> bool: """Return {data}`True` if the 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_vscode_terminal() -> bool: """Return {data}`True` in {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` in {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, like 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
[docs] @cache def is_unknown_terminal() -> bool: """Return {data}`True` if the terminal is unknown. See {data}`~extra_platforms.UNKNOWN_TERMINAL`. """ return current_terminal() is UNKNOWN_TERMINAL
# ============================================================================= # 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://github.com/cirruslabs/cirrus-ci-docs/blob/4ca6123b9832eb5f0ead8a27ef5a5806ed2f2157/docs/guide/writing-tasks.md#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_hermetic_build() -> bool: """Return {data}`True` if current CI is {data}`~extra_platforms.HERMETIC_BUILD`. ```{note} Keys on ``HOME=/homeless-shelter``: a non-existent home directory the build daemon assigns so a package build cannot read or write a real home. The value originated in Nix (2003) and was inherited verbatim by GNU Guix, so this matches both, plus Nix-ecosystem wrappers that reuse the sandbox (like devbox). It does not catch hermetic builders that isolate ``HOME`` differently, such as Bazel, Buck2 or Spack. ``` ```{todo} Grow extra signals to recognize the other hermetic builders above, should users [ask for it](https://github.com/kdeldycke/extra-platforms/issues). Only the ``/homeless-shelter`` signal is checked for now. ``` ```{seealso} The Guix build environment, which documents the sentinel: <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`.""" 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_codex() -> bool: """Return {data}`True` if current agent is {data}`~extra_platforms.CODEX`. ```{seealso} Codex sets the `CODEX_CI` environment variable when running. The name says CI, but the variable is one entry of the `UNIFIED_EXEC_ENV` array Codex applies to every process it spawns, in [`codex-rs/core/src/unified_exec/process_manager.rs`](https://github.com/openai/codex/blob/main/codex-rs/core/src/unified_exec/process_manager.rs). ``` """ return "CODEX_CI" in environ
[docs] @cache def is_copilot_cli() -> bool: """Return {data}`True` if current agent is {data}`~extra_platforms.COPILOT_CLI`. ```{seealso} GitHub Copilot CLI sets the `COPILOT_CLI` environment variable in its subprocesses, so [git hooks can detect it](https://github.com/github/copilot-cli/blob/main/changelog.md). ``` """ return "COPILOT_CLI" in environ
[docs] @cache def is_crush() -> bool: """Return {data}`True` if current agent is {data}`~extra_platforms.CRUSH`. ```{seealso} Crush sets the `CRUSH` environment variable on every shell it spawns, next to `AGENT=crush` and `AI_AGENT=crush`, from `CrushEnvMarkers()` in [`internal/shell/shell.go`](https://github.com/charmbracelet/crush/blob/main/internal/shell/shell.go). The bash tool and the hook runner both apply them. ``` """ return "CRUSH" 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_gemini_cli() -> bool: """Return {data}`True` if current agent is {data}`~extra_platforms.GEMINI_CLI`. ```{seealso} Gemini CLI sets the `GEMINI_CLI` environment variable in the subprocesses it spawns, so [scripts can detect it](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/commands.md). ``` """ return "GEMINI_CLI" in environ
[docs] @cache def is_pi() -> bool: """Return {data}`True` if current agent is {data}`~extra_platforms.PI`. ```{seealso} Pi sets the `PI_CODING_AGENT` environment variable at startup, next to a generic `AI_AGENT=pi` marker, in [`packages/coding-agent/src/cli.ts`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/cli.ts). Child processes inherit both, but neither is set when Pi is embedded through its SDK. ``` """ return "PI_CODING_AGENT" in environ
[docs] @cache def is_unknown_agent() -> bool: """Return {data}`True` if the agent is {data}`~extra_platforms.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, func in globals().items() if name.startswith("is_") and callable(func) ) # ============================================================================= # Group detection functions # ============================================================================= def _group_detection_function(group: Group) -> Callable[[], bool]: """Build the cached detection function of `group`. The function reads the members of the group alone, and stops at the first one matching the current environment: checking the platforms of {data}`~extra_platforms.LINUX` never walks the process tree for a shell. """ def detect() -> bool: return any(trait.current for trait in group) detect.__name__ = detect.__qualname__ = group.detection_func_id detect.__doc__ = ( "Return {data}`True` if at least one member of the " f"{{data}}`~extra_platforms.{group.symbol_id}` group matches the current " "environment." ) return cache(detect) # Generate the is_<group>() function of each group, beside the trait ones: traits # and groups have unique, non-overlapping IDs. for _group in ALL_GROUPS: _func_id = _group.detection_func_id assert _func_id not in _detection_registry, f"{_func_id}() is already registered." globals()[_func_id] = _group_detection_function(_group) _detection_registry[_func_id] = globals()[_func_id] del _group, _func_id # Declare the generated group detection functions, so that mypy and other static # type checkers can see them. if TYPE_CHECKING:
[docs] def is_any_agent() -> bool: ...
[docs] def is_any_architecture() -> bool: ...
[docs] def is_any_arm() -> bool: ...
[docs] def is_any_ci() -> bool: ...
[docs] def is_any_mips() -> bool: ...
[docs] def is_any_platform() -> bool: ...
[docs] def is_any_shell() -> bool: ...
[docs] def is_any_sparc() -> bool: ...
[docs] def is_any_terminal() -> bool: ...
[docs] def is_any_trait() -> bool: ...
[docs] def is_any_windows() -> bool: ...
[docs] def is_arch_32_bit() -> bool: ...
[docs] def is_arch_64_bit() -> bool: ...
[docs] def is_big_endian() -> bool: ...
[docs] def is_bourne_shells() -> bool: ...
[docs] def is_bsd() -> bool: ...
[docs] def is_bsd_not_macos() -> bool: ...
[docs] def is_c_shells() -> bool: ...
[docs] def is_gpu_terminals() -> bool: ...
[docs] def is_ibm_mainframe() -> bool: ...
[docs] def is_linux() -> bool: ...
[docs] def is_linux_layers() -> bool: ...
[docs] def is_linux_like() -> bool: ...
[docs] def is_little_endian() -> bool: ...
[docs] def is_loongarch() -> bool: ...
[docs] def is_multiplexers() -> bool: ...
[docs] def is_native_terminals() -> bool: ...
[docs] def is_other_posix() -> bool: ...
[docs] def is_other_shells() -> bool: ...
[docs] def is_powerpc() -> bool: ...
[docs] def is_riscv() -> bool: ...
[docs] def is_system_v() -> bool: ...
[docs] def is_unix() -> bool: ...
[docs] def is_unix_layers() -> bool: ...
[docs] def is_unix_not_macos() -> bool: ...
[docs] def is_unknown() -> bool: ...
[docs] def is_web_terminals() -> bool: ...
[docs] def is_webassembly() -> bool: ...
[docs] def is_windows_shells() -> bool: ...
[docs] def is_x86() -> bool: ...
# ============================================================================= # 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 _matching(traits: Iterable[Trait], kind: type[_TTrait]) -> set[_TTrait]: """Collect the `traits` of class `kind` matching the current environment.""" return {trait for trait in traits if isinstance(trait, kind) and trait.current} def _single_match( matching: set[_TTrait], unknown: _TTrait, *, strict: bool, 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. Messages name the category after the {attr}`~extra_platforms.Trait.type_name` of the class of ``unknown``, like ``"CI system"``. :param matching: Traits matching the current environment, already disambiguated by the caller. :param unknown: The ``UNKNOWN_*`` trait to return when nothing matched. :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() kind = type(unknown) if matching: raise RuntimeError( f"Multiple {kind.type_name}s match: {matching!r}. " f"{_unrecognized_message(category=kind.type_id)}" ) _report_unrecognized( kind.type_name, strict=strict, expected=expected, category=kind.type_id ) return unknown
[docs] @cache def current_architecture(strict: bool = False) -> Architecture: """Return the {class}`~extra_platforms.Architecture` the Python interpreter runs as. This is the architecture the detection functions, the groups and the `pytest` decorators all follow. A 32-bit Python on a 64-bit ARM kernel is {data}`~extra_platforms.ARMV8L`, and in {data}`~extra_platforms.ARCH_32_BIT`. The architecture of the machine itself comes from {func}`machine_architecture`. 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). ``` ```{todo} Also rename the machine of a 32-bit interpreter on a 64-bit PowerPC, SPARC, MIPS or RISC-V kernel, once a measured host gives the name its kernel reports to a 32-bit process. Only ARM and x86 get a name for now. ``` """ return _single_match( _matching(ALL_ARCHITECTURES, Architecture), UNKNOWN_ARCHITECTURE, strict=strict )
[docs] @cache def machine_architecture(strict: bool = False) -> Architecture: """Return the {class}`~extra_platforms.Architecture` of the machine. This is the architecture the operating system reports, from {func}`platform.machine`. It differs from {func}`current_architecture` only on a 64-bit ARM or x86 machine, for an interpreter that is narrower or emulated: - a 32-bit Python on a 64-bit Linux kernel, like the one of LG's webOS TVs, - a 32-bit Python on 64-bit Windows, - an x64 Python emulated on Windows for ARM. Everywhere else, it returns {func}`current_architecture`, with the same `strict` behavior. ```{caution} The machine architecture is what the kernel runs, not what the system can load. A 64-bit kernel with a 32-bit userland, like the one of LG's webOS TVs, runs a static 64-bit binary, but lacks the loader and libraries that a dynamically linked one needs. ``` ```{todo} Read the CPU under Rosetta 2 on macOS, where `platform.machine` reports the translated architecture, once a measured host shows how. ``` """ machine = platform.machine().lower() if machine in _AARCH64_MACHINES: return AARCH64 if machine in _X86_64_MACHINES: return X86_64 # A call without arguments shares the cache entry of a plain # current_architecture() call, so an unrecognized machine logs one warning. return current_architecture(strict=True) if strict else current_architecture()
[docs] @cache def current_platform(strict: bool = False) -> Platform: """Return the best matching {class}`~extra_platforms.Platform`, always. Returns {data}`~extra_platforms.UNKNOWN_PLATFORM` if not running inside a recognized platform. To raise an error instead, set `strict` to `True`. ```{note} When {data}`sys.platform` is `linux`, the result is never {data}`~extra_platforms.UNKNOWN_PLATFORM`. A distribution that no platform recognizes gives {data}`~extra_platforms.GENERIC_LINUX`, and logs a warning that asks for a report. In `strict` mode, an unrecognized `os-release` ID raises an error instead. ``` ```{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). ``` """ matching = _matching(ALL_PLATFORMS, Platform) # A lone GENERIC_LINUX match means the Linux distribution itself was not # identified, which is worth reporting. Strict mode raises only on an # os-release ID that no platform recognizes: a system with no os-release # leaves nothing more to identify. if matching == {GENERIC_LINUX}: _report_unrecognized( "Linux distribution", strict=strict and bool(os_release_id()) ) 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, 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, the BSDs and illumos (strong: the shell is an ancestor process actively running). When shells nest, the nearest ancestor wins. 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). ``` """ # Collect all matching shells via the full scan (env vars, SHELL=, # parent process tree, Windows defaults). matching = _matching(ALL_SHELLS, Shell) 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. if active := _active_env_var_shell_ids(): 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. if proc_names := _process_tree.parent_process_names(): proc_matches = { s for s in matching if not proc_names.isdisjoint(s.executable_names) } if len(proc_matches) == 1: return proc_matches.pop() if proc_matches: # Shells nest: the nearest ancestor launched this process, whatever # runs above it, the login shell included. SH stays the fallback # described below, so any other running shell outranks it. if nearest := _nearest_running_shell(proc_matches - {SH}): return nearest matching = proc_matches # Tier 3: prefer the shell resolved from SHELL= over remaining matches. if resolved := _resolved_shell_id(): resolved_matches = {s for s in matching if resolved in s.executable_names} 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, 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, the BSDs and illumos). 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, when it names the same shell as {func}`current_shell`. Returns {data}`None` when neither is available: no recognized shell, a stripped environment without ``SHELL``, or a ``SHELL`` naming another shell. ```{note} On some BSDs, ``ps`` reports only the bare ``argv[0]`` rather than a full path. The non-absolute name is discarded, so this falls back to ``SHELL`` there. illumos and Solaris get the path from procfs instead (see `extra_platforms._process_tree`). ``` ```{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. ``` """ return _shell_path(current_shell().executable_names)
[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 names a terminal type, an unrecognized terminal logs at `WARNING` level, as it suggests a terminal emulator is present but not recognized. Otherwise, it logs at `INFO` level: `TERM` is unset, or declares a dumb terminal (see `_DUMB_TERMS`). An SSH session also logs at `INFO`: its emulator runs on the client, out of reach of any heuristic on the remote end. ``` """ matching = _matching(ALL_TERMINALS, Terminal) # 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() # A TERM naming a terminal type signals a terminal emulator is expected to # be present. A dumb terminal declares there is none, and an SSH session # forwards TERM alone from the client running the emulator. return _single_match( matching, UNKNOWN_TERMINAL, strict=strict, expected=( environ.get("TERM", "") not in _DUMB_TERMS and "SSH_CONNECTION" not 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. ``` """ # The CI env var signals a CI system is expected to be present. return _single_match( _matching(ALL_CI, CI), UNKNOWN_CI, strict=strict, 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 any variable of `_AGENT_PRESENCE_ENV_VARS` 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. ``` """ return _single_match( _matching(ALL_AGENTS, Agent), UNKNOWN_AGENT, strict=strict, expected=any(var in environ for var in _AGENT_PRESENCE_ENV_VARS), )
[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. ``` """ # 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
# ============================================================================= # Cache management # =============================================================================
[docs] def invalidate_caches() -> None: """Invalidate every cached detection result. Inspired by [`platform.invalidate_caches()`](https://docs.python.org/3.14/library/platform.html#platform.invalidate_caches), which is also called here on Python 3.14 and newer. """ if sys.version_info >= (3, 14): platform.invalidate_caches() invalidate_os_release_cache() # Every cached function of the detection modules: trait and group heuristics, # current_*() resolvers, and the signals they share, like the walk of the # parent process tree. for value in (*globals().values(), *vars(_process_tree).values()): if cache_clear := getattr(value, "cache_clear", None): cache_clear()