# 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"
# =============================================================================
# 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_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_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_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()