Source code for xonsh.xoreutils.xcontext

"""The xontext command."""

import errno
import functools
import json
import os
import subprocess
import sys
from dataclasses import dataclass

from xonsh.built_ins import XSH
from xonsh.cli_utils import ArgParserAlias
from xonsh.platform import IN_APPIMAGE
from xonsh.procs.executables import locate_executable, locate_relative_path
from xonsh.tools import print_color


def _get_version(binary, arg_ver="--version"):
    """Return ``(version_string, ok)`` for a python/xonsh/pip binary.

    ``ok`` is False when the spawn itself failed — the binary "exists"
    on disk but couldn't be executed. The canonical Windows trigger is
    the "Microsoft Store" App Execution Alias at
    ``%LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe``: a zero-byte
    reparse point present in ``$PATH`` by default on Windows 11, which
    :func:`locate_executable` happily finds but :class:`subprocess.Popen`
    rejects with ``OSError: [WinError 1920] The file cannot be accessed
    by the system``. The caller uses ``ok=False`` to mark the row red.

    We deliberately use :mod:`subprocess` directly instead of
    ``XSH.subproc_captured_stdout``. The xonsh pipeline framework
    (``xonsh/procs/pipelines.py``) catches spawn errors and calls
    ``print_exception()`` unconditionally, which would leak a full
    traceback into the ``xcontext`` output for this exact case.
    Going through :func:`subprocess.run` lets us swallow the error
    silently (unless ``$DEBUG`` is set).
    """
    if isinstance(binary, str):
        cmd = [binary, arg_ver]
    elif isinstance(binary, list):
        cmd = list(binary) + [arg_ver]
    else:
        return "", True
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        if XSH.env.get("DEBUG", False):
            raise
        return "", False
    # Some tools (pip) print version on stdout, others (older python)
    # on stderr — fall back to stderr when stdout is empty.
    version = result.stdout or result.stderr or ""
    cleaned = (version.split("from")[0] if "from" in version else version).strip()
    return cleaned, True


def _has_symlink_cycle(path, max_depth=40):
    """Walk the symlink chain starting at ``path`` and detect cycles.

    Cross-platform fallback for when :func:`os.path.realpath` with
    ``strict=True`` raises an ``OSError`` whose ``errno`` is not
    :data:`errno.ELOOP`. On Windows, symlink loops surface as winerror
    codes (e.g. ``ERROR_CANT_ACCESS_FILE``) rather than ``ELOOP``, so
    we can't rely on errno alone — this function walks the link chain
    via :func:`os.readlink` and flags the path as cyclic if it revisits
    a normalized node or exceeds ``max_depth``.
    """
    seen: set[str] = set()
    current = path
    try:
        for _ in range(max_depth):
            normalized = os.path.normcase(os.path.abspath(current))
            if normalized in seen:
                return True
            seen.add(normalized)
            if not os.path.islink(current):
                return False
            target = os.readlink(current)
            if not os.path.isabs(target):
                target = os.path.join(os.path.dirname(current), target)
            current = target
    except OSError:
        return False
    # Depth exceeded without a non-link terminus → treat as cyclic.
    return True


def _is_executable_file(path):
    """Return True if ``path`` is an existing, accessible, executable file.

    Used by :func:`_resolve_one` to flag a displayed path as "bad" when it
    is inaccessible (missing file, stat error) or not marked executable.
    Cross-platform:

    * POSIX — checks the ``+x`` bit for the current user via
      :func:`os.access`.
    * Windows — :func:`os.access` with ``X_OK`` inspects the file
      extension against ``PATHEXT`` (Python 3.12+) or always returns
      ``True`` for readable files (earlier versions). Either way the
      accessibility check (``isfile``) still catches missing/unreachable
      paths, which is the more important signal for xcontext.

    Special case: a file named ``__main__.py`` is treated as "good" as
    long as it exists. Such files are module entry points invoked via
    ``python -m <pkg>`` and are never marked ``+x`` by convention, but
    they are a perfectly valid way to launch the package — flagging
    them red would be a false positive for ``xxonsh`` when the current
    session was started via ``python -m xonsh``.
    """
    if not path:
        return False
    try:
        if not os.path.isfile(path):
            return False
        if os.path.basename(path) == "__main__.py":
            return True
        return os.access(path, os.X_OK)
    except OSError:
        return False


def _resolve_one(value, resolve):
    """Resolve a single string path and report whether it's "bad".

    Returns ``(original, resolved, is_bad)``. ``original`` is the input
    value preserved verbatim — the caller renders it on the ``name:``
    row. ``resolved`` is the same value after the Windows PATHEXT lookup
    and, when ``resolve`` is True, ``os.path.realpath`` — the caller
    renders it on the optional ``name resolved:`` row when it differs
    from ``original``. A path is considered bad — and the caller renders
    its whole row in RED — if any of:

    * a symlink cycle is detected (both ``original`` and ``resolved``
      keep the input verbatim because the chain can't be followed),
    * the resolved path is not an accessible executable file (missing,
      not a regular file, or lacks the ``+x`` bit).

    If ``resolve`` is False, symlinks are NOT followed, but the
    accessibility / executable check still runs on the raw value, so
    ``--no-resolve`` still reports broken entries in red. The Windows
    PATHEXT fallback also still runs, so ``resolved`` can legitimately
    differ from ``original`` even in ``--no-resolve`` mode.
    """
    if not value:
        return value, value, False

    original = value

    if not os.path.exists(value):
        located = locate_relative_path(value, use_pathext=True, check_executable=True)
        if located:
            value = located

    if resolve:
        try:
            resolved = os.path.realpath(value, strict=True)
        except OSError as e:
            # POSIX reliably uses ELOOP for cycles.
            if getattr(e, "errno", None) == errno.ELOOP:
                return original, value, True
            # Windows / edge cases: walk the chain ourselves before giving up.
            if os.path.islink(value) and _has_symlink_cycle(value):
                return original, value, True
            # Non-cyclic failure (dangling link, missing target, permission
            # error, etc.) — fall back to the lenient ``realpath``, which
            # never raises but may return a non-existent path.
            resolved = os.path.realpath(value)
    else:
        resolved = value

    # ----- Accessibility / executable phase --------------------------------
    return original, resolved, not _is_executable_file(resolved)


def _resolve_path(value, resolve):
    """Return ``(original_value, resolved_value, is_bad)`` for an alias value.

    Accepts ``None`` (passed through), a string path (resolved via
    :func:`_resolve_one`), or a list whose first element is an executable
    path (only the first element is resolved — the remaining args are
    kept as-is on both the original and the resolved side). ``is_bad`` is
    True if the resolved (or, for lists, the first-element) path is a
    symlink loop or is missing / not executable.
    """
    if value is None:
        return None, None, False
    if isinstance(value, str):
        return _resolve_one(value, resolve)
    if isinstance(value, list) and value and isinstance(value[0], str):
        orig_head, resolved_head, bad = _resolve_one(value[0], resolve)
        tail = list(value[1:])
        return [orig_head] + tail, [resolved_head] + tail, bad
    return value, value, False


[docs] @dataclass(frozen=True) class Resolved: """A binary path probed by :class:`XContext`. ``path`` is the input value as discovered (whatever :func:`locate_executable`, :func:`get_current_xonsh`, :data:`sys.executable`, or the alias lookup returned) — preserved verbatim so the colored output and the JSON consumer can show the user the path they would actually type. ``resolved`` is the same value after PATHEXT lookup and (in default mode) ``os.path.realpath``; when symlinks weren't followed *and* no PATHEXT substitution happened, it equals ``path``. Both may be a string, a list (alias args where ``[0]`` is the executable), or ``None`` (not found / no such alias). ``bad`` is True when the entry is unusable: symlink loop, missing file, not a regular file, lacks ``+x``, or its ``--version`` probe failed (Windows Store ``python.exe`` alias). ``version`` is the trimmed ``--version`` output, populated only for python-family entries. :attr:`display` renders ``path`` for the colored row; list paths are space-joined the way the CLI shows them, plain strings pass through, and ``None`` becomes ``None`` so callers can detect "not found" without re-checking ``path``. :attr:`resolved_display` renders the same thing for ``resolved``. :attr:`differs` reports whether the resolved value is meaningfully different from the input — when it's False, the colored renderer suppresses the ``name resolved:`` row. """ path: object = None resolved: object = None bad: bool = False version: str = "" @property def display(self): return self._render(self.path) @property def resolved_display(self): return self._render(self.resolved) @property def differs(self): """True when ``resolved`` is set and is not identical to ``path``. Drives whether the colored renderer emits the secondary ``name resolved:`` row — same value on both sides would just duplicate the line. """ return self.resolved is not None and self.resolved != self.path @staticmethod def _render(value): if isinstance(value, list) and all(isinstance(x, str) for x in value): return " ".join(value) return str(value) if value else None
def _cached_method(method): """Per-instance, opt-in memoization for no-arg instance methods. Stores the result in ``self._cache`` keyed by the method name when caching is enabled, so the cache is bounded to the lifetime of the instance — a plain :func:`functools.cache` on bound methods would leak ``self`` references at the class level. When the holder instance has ``self._cache is None`` (the default constructor mode), every call re-runs the underlying probe so callers that hold onto an :class:`XContext` see fresh ``$PATH`` / alias state on each read. """ name = method.__name__ @functools.wraps(method) def wrapper(self): if self._cache is None: return method(self) if name not in self._cache: self._cache[name] = method(self) return self._cache[name] return wrapper
[docs] class XContext: """Lazy collector of every value displayed by ``xcontext``. Each ``get_*`` method computes its value and returns a :class:`Resolved` (or, for env getters, a plain string / ``None``). Construct with ``resolve=False`` to skip symlink resolution — the accessibility / ``+x`` check still runs, matching the ``--no-resolve`` CLI flag. Caching is **off by default** so a long-lived instance always reflects the current ``$PATH`` and alias state — callers that read a getter twice after mutating the environment get the new value, not a stale snapshot. Pass ``cache=True`` to enable per-instance memoization for the lifetime of the report; ``xcontext_main`` does this so each ``--version`` subprocess runs at most once per invocation. """ def __init__(self, resolve=True, cache=False): self._resolve = resolve self._cache: dict | None = {} if cache else None # ---- session: what the running xonsh process actually uses --------
[docs] @_cached_method def get_session_xxonsh(self) -> Resolved: """Return the path to the currently running xonsh interpreter.""" # Local import: ``xonsh.main`` pulls in heavy modules. from xonsh.main import get_current_xonsh original, resolved, bad = _resolve_path(get_current_xonsh(), self._resolve) return Resolved(path=original, resolved=resolved, bad=bad)
[docs] @_cached_method def get_session_xpython(self) -> Resolved: """Return the python interpreter that's running this xonsh. Inside an AppImage, ``sys.executable`` points at the AppImage bootstrap rather than the bundled python — fall back to ``$_`` so the displayed binary is something the user can actually invoke. """ appimage_python = XSH.env.get("_") if IN_APPIMAGE else None original, resolved, bad = _resolve_path( appimage_python if appimage_python else sys.executable, self._resolve ) # Probe the resolved binary — the original may be a symlink or # a non-existent shim (PATHEXT case) that can't be executed # directly. ver, ver_ok = _get_version(resolved) return Resolved( path=original, resolved=resolved, bad=bad or not ver_ok, version=ver )
[docs] @_cached_method def get_session_xpip(self) -> Resolved: """Return the ``xpip`` alias value (typically ``[python, -m, pip]``).""" original, resolved, bad = _resolve_path(XSH.aliases.get("xpip"), self._resolve) return Resolved(path=original, resolved=resolved, bad=bad)
# ---- commands: what ``$PATH`` lookup currently resolves to -------- def _get_command(self, name: str) -> Resolved: original, resolved, bad = _resolve_path(locate_executable(name), self._resolve) return Resolved(path=original, resolved=resolved, bad=bad)
[docs] @_cached_method def get_commands_xonsh(self) -> Resolved: """Return the ``xonsh`` binary that ``$PATH`` resolves to.""" return self._get_command("xonsh")
[docs] @_cached_method def get_commands_python(self) -> Resolved: """Return the ``python`` binary on ``$PATH``, with version probed. The version probe doubles as a spawn check — a Windows Store ``python.exe`` App Execution Alias can be located on ``$PATH`` but raises WinError 1920 on execution. Such entries get ``bad=True`` so the row renders red. """ base = self._get_command("python") if not base.path: return base ver, ver_ok = _get_version(base.resolved) return Resolved( path=base.path, resolved=base.resolved, bad=base.bad or not ver_ok, version=ver, )
[docs] @_cached_method def get_commands_pip(self) -> Resolved: """Return the ``pip`` binary that ``$PATH`` resolves to.""" return self._get_command("pip")
[docs] @_cached_method def get_commands_pytest(self) -> Resolved: """Return the ``pytest`` binary that ``$PATH`` resolves to.""" return self._get_command("pytest")
[docs] @_cached_method def get_commands_uv(self) -> Resolved: """Return the ``uv`` binary that ``$PATH`` resolves to.""" return self._get_command("uv")
# ---- env: virtualenv / conda flags --------------------------------
[docs] def get_env_conda_default_env(self): """Return ``$CONDA_DEFAULT_ENV`` or ``None`` if unset.""" return XSH.env.get("CONDA_DEFAULT_ENV")
[docs] def get_env_virtual_env(self): """Return ``$VIRTUAL_ENV`` or ``None`` if unset.""" return XSH.env.get("VIRTUAL_ENV")
[docs] def xcontext_main(no_resolve: bool = False, as_json: bool = False, _stdout=None): """Report information about the current xonsh environment. The colored text report shows the input path of each binary on a ``name:`` row and — when symlink resolution (or, on Windows, the PATHEXT lookup) changes it — the resolved path on a second ``name resolved:`` row. The label-color match check (GREEN/BLUE) uses the resolved paths so two entries that ultimately point to the same underlying file compare equal even when their input paths differ. Parameters ---------- no_resolve : -n, --no-resolve Show raw paths as-is without following symlinks (turns off the default resolution). The accessibility / executable check still runs, so broken entries are still flagged in red. as_json : -j, --json Emit the paths as a JSON object on stdout instead of the colored text report. Top-level keys mirror the text sections (``session``, ``commands``, ``env``). Each entry in ``session`` and ``commands`` carries the input path under its base key (e.g. ``xxonsh``) — and, **only when the resolved path differs from the input**, an additional sibling key with a ``_resolved`` suffix (e.g. ``xxonsh_resolved``). This mirrors the colored output's secondary row: with ``--no-resolve`` (or when paths already match their realpath) the ``_resolved`` keys are omitted entirely instead of duplicating the value. ``env`` only contains variables that are set. """ stdout = _stdout or sys.stdout # cache=True so each ``--version`` subprocess runs at most once # across the color-check + row-print + match comparisons. xc = XContext(resolve=not no_resolve, cache=True) session_xxonsh = xc.get_session_xxonsh() session_xpython = xc.get_session_xpython() session_xpip = xc.get_session_xpip() cmd_xonsh = xc.get_commands_xonsh() cmd_pip = xc.get_commands_pip() cmd_pytest = xc.get_commands_pytest() cmd_uv = xc.get_commands_uv() if as_json: # Skip the version probe — JSON consumers can call ``--version`` # themselves if they need it. ``commands`` always lists every # probed name (``null`` for not-on-PATH) so the base schema is # stable; the ``_resolved`` siblings, however, are only emitted # when ``r.differs`` is True — same rule as the colored output's # secondary row. That way ``--no-resolve`` (and the common case # where the input is already a realpath) produce a clean JSON # without echoing every path twice. cmd_python_raw = xc._get_command("python") def _section(items): out: dict = {} for key, r in items: out[key] = r.display if r.differs: out[f"{key}_resolved"] = r.resolved_display return out report = { "session": _section( [ ("xxonsh", session_xxonsh), ("xpython", session_xpython), ("xpip", session_xpip), ] ), "commands": _section( [ ("xonsh", cmd_xonsh), ("python", cmd_python_raw), ("pip", cmd_pip), ("pytest", cmd_pytest), ("uv", cmd_uv), ] ), "env": { ev: val for ev, val in ( ("VIRTUAL_ENV", xc.get_env_virtual_env()), ("CONDA_DEFAULT_ENV", xc.get_env_conda_default_env()), ) if val }, } print(json.dumps(report, indent=2), file=stdout) return 0 # The version-probing variant only runs in the colored path, where # the version string is actually displayed. cmd_python = xc.get_commands_python() # Color tokens: section headers are purple; within a family (xonsh/xxonsh, # python/xpython, pip/xpip) both labels go GREEN when the session binary # matches what ``$PATH`` resolves to, otherwise BLUE. The match check # compares the *resolved* paths so two entries that point at the same # underlying file via different symlinks still register as the same. # Labels outside any family (pytest, ``[Current environment]`` vars) # stay YELLOW. Any row whose path is "bad" — symlink loop, missing, # inaccessible, or not executable — is rendered entirely in RED, # overriding the row color. Printed via print_color, which dispatches # to the active shell's own color renderer. PURPLE = "{PURPLE}" GREEN = "{GREEN}" BLUE = "{BLUE}" YELLOW = "{YELLOW}" RED = "{RED}" RESET = "{RESET}" xonsh_color = GREEN if session_xxonsh.resolved == cmd_xonsh.resolved else BLUE python_color = GREEN if session_xpython.resolved == cmd_python.resolved else BLUE # ``xpip`` is a ``[python, -m, pip]`` list while the PATH-resolved ``pip`` # is a plain string, so a direct equality compare wouldn't match by # construction. Compare the executable head element instead. xpip_head = ( session_xpip.resolved[0] if isinstance(session_xpip.resolved, list) and session_xpip.resolved else None ) pip_color = GREEN if xpip_head == cmd_pip.resolved else BLUE label_color = { "xonsh": xonsh_color, "xxonsh": xonsh_color, "python": python_color, "xpython": python_color, "pip": pip_color, "xpip": pip_color, "pytest": YELLOW, "uv": YELLOW, } def _format_row(name, value, ver="", bad=False): """Build a ``print_color`` format string for one label/value row. If ``bad`` is True, the whole line (label + value + version) is wrapped in :data:`RED` to flag the problem (cycle, missing file, or not executable) — the label's normal color is overridden. Otherwise the label uses its family color and the value is rendered in the default terminal color. ``name`` may carry the trailing ``" resolved"`` suffix used by the secondary row; the family color is looked up against the base name so both rows share the same color. """ if bad: return f"{RED}{name}: {value}{ver}{RESET}" base = name.removesuffix(" resolved") color = label_color.get(base, YELLOW) return f"{color}{name}:{RESET} {value}{ver}" def _print_pair(name, r, show_not_found=True): """Print the row for ``r`` and, when its resolved path differs from the input, a second ``name resolved:`` row. The version line (``# Python 3.13.3``) is repeated on both rows — it describes the binary regardless of which spelling of its path the reader is looking at. """ ver = f" # {r.version}" if r.version else "" if r.display is None: if show_not_found: print_color(_format_row(name, "not found"), file=stdout) return print_color(_format_row(name, r.display, ver=ver, bad=r.bad), file=stdout) if r.differs: print_color( _format_row(f"{name} resolved", r.resolved_display, ver=ver, bad=r.bad), file=stdout, ) print_color(f"{PURPLE}[Current xonsh session]{RESET}", file=stdout) _print_pair("xxonsh", session_xxonsh) _print_pair("xpython", session_xpython) _print_pair("xpip", session_xpip) print("", file=stdout) print_color(f"{PURPLE}[Current commands environment]{RESET}", file=stdout) cmd_rows: list[tuple[str, Resolved]] = [ ("xonsh", cmd_xonsh), ("python", cmd_python), ("pip", cmd_pip), ] if cmd_pytest.path: cmd_rows.append(("pytest", cmd_pytest)) if cmd_uv.path: cmd_rows.append(("uv", cmd_uv)) for name, r in cmd_rows: _print_pair(name, r) env_rows = [ (ev, val) for ev, val in ( ("VIRTUAL_ENV", xc.get_env_virtual_env()), ("CONDA_DEFAULT_ENV", xc.get_env_conda_default_env()), ) if val ] if env_rows: print("", file=stdout) print_color(f"{PURPLE}[Current environment]{RESET}", file=stdout) for ev, val in env_rows: print_color(_format_row(ev, val), file=stdout) return 0
xcontext = ArgParserAlias( func=xcontext_main, has_args=True, prog="xcontext", threadable=False )