diff --git a/docs/changelog/128.doc.rst b/docs/changelog/128.doc.rst
new file mode 100644
index 0000000..ad7dd8a
--- /dev/null
+++ b/docs/changelog/128.doc.rst
@@ -0,0 +1,4 @@
+Document :attr:`~python_discovery.PythonInfo.system_exe` across the tutorial, the how-to guide and the explanation of
+how resolution reaches a base interpreter. The class diagram in the how-to guide had
+:attr:`~python_discovery.PythonInfo.system_executable` typed ``str`` rather than ``str | None`` - by
+:user:`gaborbernat`.
diff --git a/docs/explanation.rst b/docs/explanation.rst
index d32ffd6..72dfe2f 100644
--- a/docs/explanation.rst
+++ b/docs/explanation.rst
@@ -186,7 +186,7 @@ APIs whenever a spec is given.
**Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
-deduplicates by the resolved real path of each candidate's ``system_executable`` (falling back to ``executable``).
+deduplicates by the resolved real path of each candidate's :attr:`~python_discovery.PythonInfo.system_exe`.
That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python``
symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install,"
which is what callers building choosers or version-range pickers usually want.
@@ -198,6 +198,40 @@ version first, smallest install root, etc.), wrap the call in :func:`sorted` --
include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who
want it.
+Resolving a virtual environment to its base
+--------------------------------------------
+
+A :class:`~python_discovery.PythonInfo` reaches you in one of two states, and the difference shows up in a single
+field.
+
+Collection fills in :attr:`~python_discovery.PythonInfo.system_executable` by inspection alone, which works whenever
+the interpreter is not in a virtual environment or names its base outright. When inspection comes up short the field
+holds ``None``, and :meth:`~python_discovery.PythonInfo.resolve_to_system` walks the prefix chain a layer at a time,
+interrogating each prefix until it reaches a real install. Every discovery entry point runs that walk, so an
+interpreter you got from :func:`~python_discovery.get_interpreter`,
+:func:`~python_discovery.iter_interpreters` or :meth:`~python_discovery.PythonInfo.from_exe` is resolved before you
+see it.
+
+.. mermaid::
+
+ flowchart TD
+ Collect["collection
system_executable = None"] --> Walk["resolve_to_system()
walk prefix chain"]
+ Walk --> Done["system_executable set"]
+ Collect -- "base named outright" --> Done
+ Done --> Read["system_exe"]
+
+ style Collect fill:#ffe3a3,stroke:#d29200,color:#3a2c00
+ style Walk fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Done fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style Read fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+
+The annotation cannot describe that ordering, so ``system_executable`` stays ``str | None`` and a type checker asks
+every reader to narrow a value resolution has settled.
+:attr:`~python_discovery.PythonInfo.system_exe` closes that gap by falling back to
+:attr:`~python_discovery.PythonInfo.executable`, which is the value ``resolve_to_system`` writes anyway when a prefix
+links back to its own interpreter. Reading the raw field still tells the two states apart, which is what
+:meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False`` leaves you holding.
+
How caching works
-------------------
diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst
index 0715d24..0e2e996 100644
--- a/docs/how-to/standalone-usage.rst
+++ b/docs/how-to/standalone-usage.rst
@@ -145,7 +145,8 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
classDiagram
class PythonInfo {
+executable: str
- +system_executable: str
+ +system_exe: str
+ +system_executable: str | None
+implementation: str
+version_info: VersionInfo
+architecture: int
@@ -167,7 +168,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
info = get_interpreter("python3.12", cache=cache)
info.executable # Resolved path to the binary.
- info.system_executable # The underlying system interpreter (outside any venv).
+ info.system_exe # The underlying system interpreter (outside any venv).
info.implementation # "CPython", "PyPy", "GraalPy", etc.
info.version_info # VersionInfo(major, minor, micro, releaselevel, serial).
info.architecture # 64 or 32.
@@ -178,6 +179,13 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
info.sysconfig_vars # All sysconfig.get_config_vars() values.
info.sysconfig_paths # All sysconfig.get_paths() values.
+Prefer :attr:`~python_discovery.PythonInfo.system_exe` over the raw
+:attr:`~python_discovery.PythonInfo.system_executable` field it reads. The field is typed ``str | None`` because it
+holds nothing until resolution runs, so a type checker makes you narrow it at every use even though discovery has
+filled it in by the time you hold a :class:`~python_discovery.PythonInfo`. Read the raw field only when you build a
+:class:`~python_discovery.PythonInfo` yourself, or call
+:meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False``, and want to tell the two states apart.
+
Implement a custom cache backend
-----------------------------------
diff --git a/docs/tutorial/getting-started.rst b/docs/tutorial/getting-started.rst
index 3ced1d4..712fc49 100644
--- a/docs/tutorial/getting-started.rst
+++ b/docs/tutorial/getting-started.rst
@@ -17,6 +17,7 @@ Before diving into code, here are the key ideas:
- **Spec** -- a short string describing what you are looking for (e.g., ``python3.12``, ``pypy3.9``, ``>=3.11``).
- **Discovery** -- the process of searching your system for an interpreter that matches a spec.
- **Cache** -- a disk store that remembers previously discovered interpreters so the next lookup is instant.
+- **Resolution** -- following a virtual environment back to the base interpreter it was built from.
Inspecting the current interpreter
------------------------------------
@@ -45,6 +46,7 @@ The simplest use case: get information about the Python that is running right no
info = PythonInfo.current_system(cache)
print(info.executable) # /usr/bin/python3.12
+ print(info.system_exe) # /usr/bin/python3.12
print(info.version_info[:3]) # (3, 12, 1)
print(info.implementation) # CPython (or PyPy, GraalPy, etc.)
print(info.architecture) # 64 (or 32)
@@ -52,6 +54,11 @@ The simplest use case: get information about the Python that is running right no
The returned :class:`~python_discovery.PythonInfo` object contains everything the library knows about that interpreter:
paths, version numbers, sysconfig variables, platform details, and more.
+Run the same snippet from inside a virtual environment and the two paths part company.
+:attr:`~python_discovery.PythonInfo.executable` stays the environment's own ``python``, while
+:attr:`~python_discovery.PythonInfo.system_exe` names the base interpreter that environment was built from. Reach for
+``system_exe`` whenever you want the real install rather than a link to it.
+
Finding a different interpreter
--------------------------------