The prose of each docstring and comment in the core package now follows ASD-STE100 Simplified Technical English, with Canadian spelling: short sentences, simple verb tenses, no dashes or semicolons, and one word for one concept (docs/development/writing-style.md has the rules and the glossary). The meaning and each reason stay. The code does not change: the AST of each file, without its docstrings, is the same as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
247 lines
9.6 KiB
Python
247 lines
9.6 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The host that MeshTerm runs on, read one time, so that a bug report already has the answers.
|
|
|
|
Each value here answers a question that otherwise costs one more exchange with the person
|
|
who reported the problem: which build of MeshTerm, on which OS, in which terminal, how
|
|
wide, and with which colour depth. No value is about the mesh, and no value is private.
|
|
This module intentionally knows nothing about contacts, keys, positions, or messages.
|
|
Thus the user can paste the block that it fills in public, and nobody has to read the
|
|
block first.
|
|
|
|
Two probes are **ladders over environment variables**, not protocol queries. This is a
|
|
compromise, and we state it here: a terminal has no portable way to tell what it is. The
|
|
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
|
|
arrive, and a timeout budget on a path that must never hang. Thus MeshTerm identifies
|
|
each terminal by the variable that the terminal sets about itself. This is the same
|
|
ladder that :func:`~meshterm.ui.termfont.detect_terminal_font` already uses for the font.
|
|
If MeshTerm does not recognize the terminal, it uses ``TERM``, not a guess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
|
|
#: each documented API. Thus the build number is the only thing that separates the two.
|
|
_WINDOWS_11_BUILD = 22000
|
|
|
|
#: The terminals that identify themselves, by the variable that each one sets. The order
|
|
#: is from the most specific to the least specific. An emulator inside another emulator
|
|
#: (the terminal of VS Code, a multiplexer) sets its own marker, and also inherits the
|
|
#: outer marker. Thus the inner identity must win.
|
|
#:
|
|
#: MeshTerm checks ``TERM_PROGRAM`` separately, between these variables and the ``TERM``
|
|
#: fallback, because the value of ``TERM_PROGRAM`` is the name. Thus it identifies
|
|
#: terminals that this table never has to list.
|
|
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
|
|
("WT_SESSION", "Windows Terminal"),
|
|
("ConEmuPID", "ConEmu"),
|
|
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
|
|
("KITTY_WINDOW_ID", "kitty"),
|
|
("ALACRITTY_WINDOW_ID", "Alacritty"),
|
|
("KONSOLE_VERSION", "Konsole"),
|
|
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
|
|
("VTE_VERSION", "VTE-based"),
|
|
)
|
|
|
|
#: The names that ``TERM_PROGRAM`` gives to terminals whose own name is not the name that
|
|
#: a person uses. The table has only the names that are really different. Each other
|
|
#: value passes through as it is. Thus this table does not grow into a catalogue of all
|
|
#: the terminals that exist.
|
|
_TERM_PROGRAM_NAMES: dict[str, str] = {
|
|
"Apple_Terminal": "Terminal.app",
|
|
"vscode": "VS Code",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Host:
|
|
"""The machine and the Python that run MeshTerm.
|
|
|
|
Attributes:
|
|
install: How this copy was installed. ``"frozen"``: a one-file build, which has
|
|
its own bootloader, its own relaunch rules, and no source tree to read.
|
|
``"source"``: a checkout, so MeshTerm can ask for a commit. ``"package"``: a
|
|
wheel from an index. The three fail in different ways, and the person who
|
|
reports a problem usually does not know which one they have.
|
|
os: The OS and its version, as a person writes it: ``Windows 11``,
|
|
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
|
|
os_build: The exact build under that name: a Windows build number, or the kernel
|
|
release on all other systems. This value separates two hosts that have the
|
|
same name.
|
|
arch: The processor architecture. It tells an arm64 build from an x86_64 build
|
|
when a binary does not operate correctly.
|
|
python: The interpreter version, in three parts.
|
|
"""
|
|
|
|
install: str
|
|
os: str
|
|
os_build: str
|
|
arch: str
|
|
python: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Terminal:
|
|
"""The terminal in which MeshTerm draws, and what it can draw.
|
|
|
|
Attributes:
|
|
program: The name of the emulator. If nothing identified itself, the value of
|
|
``TERM``. ``None`` when ``TERM`` is not set either.
|
|
term: The ``TERM`` value, exactly. It is kept next to ``program``, not merged
|
|
into it, because a terminal that gives a false ``TERM`` is itself a frequent
|
|
cause of a rendering report.
|
|
colorterm: The ``COLORTERM`` value. A terminal uses it to claim truecolor. It is
|
|
the first thing to check when a screen comes in the wrong colours.
|
|
cols: The width of the terminal in cells.
|
|
rows: The height of the terminal in cells.
|
|
over_ssh: Whether this is an ssh session. It changes what MeshTerm can know (the
|
|
font is on the remote client). It is also the reason that several other
|
|
results here show ``unknown`` instead of a wrong value.
|
|
"""
|
|
|
|
program: str | None
|
|
term: str | None
|
|
colorterm: str | None
|
|
cols: int
|
|
rows: int
|
|
over_ssh: bool
|
|
|
|
|
|
def install_kind() -> str:
|
|
"""How this copy of MeshTerm was installed: ``frozen``, ``source``, or ``package``.
|
|
|
|
A frozen build is clear, because the bootloader sets the flag. After that, the
|
|
question is whether there is a repository above the package. MeshTerm can ask a
|
|
checkout for its commit, and a checkout can have uncommitted work. A wheel can do
|
|
neither.
|
|
|
|
Returns:
|
|
One of ``"frozen"``, ``"source"``, or ``"package"``.
|
|
"""
|
|
if getattr(sys, "frozen", False):
|
|
return "frozen"
|
|
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
|
|
if (Path(__file__).resolve().parents[2] / ".git").exists():
|
|
return "source"
|
|
return "package"
|
|
|
|
|
|
def host() -> Host:
|
|
"""Read the facts of the machine.
|
|
|
|
Returns:
|
|
The :class:`Host` block.
|
|
"""
|
|
name, build = _os_name_and_build()
|
|
return Host(
|
|
install=install_kind(),
|
|
os=name,
|
|
os_build=build,
|
|
arch=platform.machine() or "unknown",
|
|
python=platform.python_version(),
|
|
)
|
|
|
|
|
|
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
|
|
"""Read the facts of the terminal.
|
|
|
|
Args:
|
|
environ: The environment to examine (the default is ``os.environ``). A test can
|
|
inject it, so that the ladder can be tested without a terminal of the correct
|
|
type.
|
|
|
|
Returns:
|
|
The :class:`Terminal` block.
|
|
"""
|
|
env = os.environ if environ is None else environ
|
|
size = shutil.get_terminal_size()
|
|
return Terminal(
|
|
program=terminal_program(env),
|
|
term=env.get("TERM") or None,
|
|
colorterm=env.get("COLORTERM") or None,
|
|
cols=size.columns,
|
|
rows=size.lines,
|
|
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
|
|
)
|
|
|
|
|
|
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
|
|
"""Name the terminal emulator from what it tells about itself.
|
|
|
|
The ladder: first, the terminals in :data:`_TERMINAL_VARS` that set a marker
|
|
variable. Then ``TERM_PROGRAM``, whose value is the name, so that it covers terminals
|
|
that this module does not know. Last, ``TERM``, which is the last honest answer that
|
|
there is.
|
|
|
|
Args:
|
|
environ: The environment to examine (the default is ``os.environ``).
|
|
|
|
Returns:
|
|
The name of the terminal, or ``None`` when nothing in the environment names one.
|
|
"""
|
|
env = os.environ if environ is None else environ
|
|
for var, name in _TERMINAL_VARS:
|
|
value = env.get(var)
|
|
if value:
|
|
return name or value
|
|
program = env.get("TERM_PROGRAM")
|
|
if program:
|
|
return _TERM_PROGRAM_NAMES.get(program, program)
|
|
return env.get("TERM") or None
|
|
|
|
|
|
def _os_name_and_build() -> tuple[str, str]:
|
|
"""The OS as a person writes it, and the exact build under that name."""
|
|
system = platform.system()
|
|
if system == "Windows":
|
|
return _windows()
|
|
if system == "Darwin":
|
|
version = platform.mac_ver()[0]
|
|
return (f"macOS {version}" if version else "macOS"), platform.release()
|
|
if system == "Linux":
|
|
return (_pretty_name() or "Linux"), platform.release()
|
|
return (system or "unknown"), platform.release()
|
|
|
|
|
|
def _windows() -> tuple[str, str]:
|
|
"""Windows by the name under which it is sold, and its build number.
|
|
|
|
``platform.win32_ver`` reports release ``"10"`` also on Windows 11, because the new
|
|
name never got into the version APIs. Thus the build number decides which Windows
|
|
this is. The build is also the useful half of the pair, because a fix is shipped for
|
|
a build.
|
|
"""
|
|
release, version, _service_pack, _kind = platform.win32_ver()
|
|
try:
|
|
build = int(version.rsplit(".", 1)[-1])
|
|
except ValueError:
|
|
build = 0
|
|
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
|
|
return name, version or platform.version()
|
|
|
|
|
|
def _pretty_name() -> str | None:
|
|
"""The name that the distribution gives itself, from ``/etc/os-release``.
|
|
|
|
This is the only place where the system itself states its Linux variant, so that
|
|
MeshTerm does not have to guess. It is the reason that a report can show "Calculinux"
|
|
or "Raspberry Pi OS" instead of ``Linux``. A host without the file (or without
|
|
permission to read it) has no answer.
|
|
"""
|
|
try:
|
|
text = Path("/etc/os-release").read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
for line in text.splitlines():
|
|
key, _, value = line.partition("=")
|
|
if key == "PRETTY_NAME":
|
|
return value.strip().strip('"') or None
|
|
return None
|