Skip to content

Commit 96a136e

Browse files
committed
📝 docs: rewrite docs and fix 3.12 tkinter coverage
Remove virtualenv references, add mermaid diagrams for discovery flow, shim resolution, cache design, and spec format. Cover all features including PEP 440 specifiers, free-threaded builds, ISA matching, and custom cache backends. Use skipif marker for tkinter test so the function body is excluded from coverage when tkinter is unavailable (3.12 CI).
1 parent e716ac2 commit 96a136e

7 files changed

Lines changed: 276 additions & 53 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"sphinx.ext.extlinks",
1717
"sphinx.ext.intersphinx",
1818
"sphinx_autodoc_typehints",
19+
"sphinxcontrib.mermaid",
1920
]
2021

2122
intersphinx_mapping = {

docs/explanation.rst

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,123 @@ Discovery strategy
66

77
The :class:`~py_discovery.Builtin` discover searches for Python interpreters in the following order:
88

9-
1. **Absolute paths** -- if the spec is an absolute path, try it directly.
10-
2. **``try_first_with``** -- explicit paths to try before anything else.
11-
3. **Relative paths** -- resolve relative to the current directory.
12-
4. **Current interpreter** -- the running Python itself.
13-
5. **Windows registry** (PEP 514) -- on Windows, enumerate registered interpreters.
14-
6. **PATH search** -- walk ``$PATH`` entries, matching filenames against the spec pattern.
15-
7. **Version-manager shims** -- resolve pyenv, mise, and asdf shims to real binaries.
16-
8. **uv-managed Pythons** -- check ``UV_PYTHON_INSTALL_DIR``, ``XDG_DATA_HOME/uv/python``, or the platform default.
17-
18-
Cache sharing
19-
-------------
20-
21-
``py-discovery`` uses the same on-disk cache layout as virtualenv (``py_info/4/<sha256>.json``), so they share
22-
cached interpreter metadata. The :class:`~py_discovery.DiskCache` stores results under a configurable root directory
23-
with file locking via ``filelock``.
24-
25-
Protocol design
9+
.. mermaid::
10+
11+
flowchart TD
12+
Start["Builtin.run()"] --> AbsPath{Is spec an\nabsolute path?}
13+
AbsPath -->|Yes| TryAbs["Try path directly"]
14+
AbsPath -->|No| TryFirst["try_first_with paths"]
15+
TryFirst --> RelPath{Is spec a\nrelative path?}
16+
RelPath -->|Yes| TryRel["Resolve relative to cwd"]
17+
RelPath -->|No| Current["Current interpreter"]
18+
Current --> Win{Windows?}
19+
Win -->|Yes| PEP514["PEP 514 registry"]
20+
Win -->|No| PATH
21+
PEP514 --> PATH["PATH search"]
22+
PATH --> Shims["Version-manager shims\n(pyenv / mise / asdf)"]
23+
Shims --> UV["uv-managed Pythons"]
24+
25+
TryAbs --> Verify
26+
TryRel --> Verify
27+
UV --> Verify
28+
29+
Verify{{"Verify candidate\n(subprocess call)"}}
30+
Verify -->|Satisfies spec| Cache["Cache & return"]
31+
Verify -->|No match| Next["Try next candidate"]
32+
33+
Each candidate is verified by running it as a subprocess to collect its metadata (version, architecture, platform,
34+
sysconfig paths, etc.). Verified interpreters are cached to avoid repeating this subprocess call.
35+
36+
Shim resolution
2637
---------------
2738

28-
The cache layer is defined as a :class:`typing.Protocol` (:class:`~py_discovery.PyInfoCache`), allowing custom
29-
backends without inheriting from any base class. The built-in :class:`~py_discovery.DiskCache` implements this protocol
30-
using JSON files and ``filelock``.
39+
When a spec like ``python3.12`` resolves to a version-manager shim (e.g., ``~/.pyenv/shims/python3.12``),
40+
py-discovery detects this and resolves it to the real binary:
41+
42+
.. mermaid::
43+
44+
flowchart LR
45+
Shim["Shim detected"] --> EnvVar{"PYENV_VERSION\nset?"}
46+
EnvVar -->|Yes| Use["Use that version"]
47+
EnvVar -->|No| File{".python-version\nfile exists?"}
48+
File -->|Yes| Use
49+
File -->|No| Global{"$(PYENV_ROOT)/version\nexists?"}
50+
Global -->|Yes| Use
51+
Global -->|No| Skip["Skip shim"]
52+
53+
For mise and asdf, the ``MISE_DATA_DIR`` / ``ASDF_DATA_DIR`` directories are searched. The shim directory is
54+
identified via ``PYENV_ROOT``, ``MISE_DATA_DIR``, or ``ASDF_DATA_DIR`` environment variables.
55+
56+
Cache design
57+
------------
58+
59+
The :class:`~py_discovery.DiskCache` stores interpreter metadata as JSON files under
60+
``<root>/py_info/4/<sha256>.json``, where the hash is derived from the interpreter path. File locking via ``filelock``
61+
ensures safe concurrent access.
62+
63+
.. mermaid::
64+
65+
flowchart LR
66+
Lookup["py_info(path)"] --> Exists{Cache hit?}
67+
Exists -->|Yes| Read["Read JSON"]
68+
Exists -->|No| Run["Run subprocess"]
69+
Run --> Write["Write JSON\n(with filelock)"]
70+
Write --> Return["Return PythonInfo"]
71+
Read --> Return
72+
73+
The cache layer uses a :class:`typing.Protocol` (:class:`~py_discovery.PyInfoCache`), so any object with the right
74+
method signatures works as a cache backend -- no inheritance required. Two built-in implementations are provided:
75+
76+
- :class:`~py_discovery.DiskCache` -- persistent JSON + filelock storage
77+
- :class:`~py_discovery.NoOpCache` -- no-op, useful for one-shot scripts or testing
78+
79+
Spec format
80+
-----------
81+
82+
A spec string follows the pattern ``[impl][version][t][-arch][-machine]``:
83+
84+
.. mermaid::
85+
86+
flowchart LR
87+
Spec["Spec string"] --> Impl["impl\n(optional)"]
88+
Impl --> Version["version\n(optional)"]
89+
Version --> T["t\n(optional)"]
90+
T --> Arch["-arch\n(optional)"]
91+
Arch --> Machine["-machine\n(optional)"]
92+
93+
style Impl fill:#e0f0ff,stroke:#4a90d9
94+
style Version fill:#e0ffe0,stroke:#4a9f4a
95+
style T fill:#fff0e0,stroke:#d9904a
96+
style Arch fill:#ffe0e0,stroke:#d94a4a
97+
style Machine fill:#f0e0ff,stroke:#904ad9
98+
99+
+------------------------------+-----------------------------------------------+
100+
| Spec | Meaning |
101+
+==============================+===============================================+
102+
| ``python3.12`` | CPython 3.12 (any architecture) |
103+
+------------------------------+-----------------------------------------------+
104+
| ``cpython3.12`` | Explicitly CPython 3.12 |
105+
+------------------------------+-----------------------------------------------+
106+
| ``pypy3.9`` | PyPy 3.9 |
107+
+------------------------------+-----------------------------------------------+
108+
| ``3.12`` | Any implementation, version 3.12 |
109+
+------------------------------+-----------------------------------------------+
110+
| ``python3.12t`` | Free-threaded (no-GIL) CPython 3.12 |
111+
+------------------------------+-----------------------------------------------+
112+
| ``python3.12-64`` | 64-bit CPython 3.12 |
113+
+------------------------------+-----------------------------------------------+
114+
| ``python3.12-64-arm64`` | 64-bit CPython 3.12 on ARM64 |
115+
+------------------------------+-----------------------------------------------+
116+
| ``/usr/bin/python3`` | Absolute path, used directly |
117+
+------------------------------+-----------------------------------------------+
118+
| ``>=3.11,<3.13`` | PEP 440 version specifier |
119+
+------------------------------+-----------------------------------------------+
120+
| ``cpython>=3.11`` | PEP 440 specifier with implementation filter |
121+
+------------------------------+-----------------------------------------------+
122+
123+
The ``impl`` prefix is optional; ``python`` and ``py`` are treated as "any implementation". The ``t`` suffix matches
124+
free-threaded (no-GIL) builds. Architecture (``-32`` or ``-64``) and ISA (``-arm64``, ``-x86_64``, etc.) are optional
125+
suffixes separated by dashes.
126+
127+
PEP 440 specifiers (``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported for flexible version matching.
128+
Multiple specifiers can be comma-separated: ``>=3.11,<3.13``.

docs/how-to/standalone-usage.rst

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,109 @@
1-
Standalone usage
2-
================
1+
Advanced usage
2+
==============
33

4-
``py-discovery`` works independently of virtualenv. It has only two runtime dependencies: ``filelock`` and
5-
``platformdirs``.
6-
7-
Using the ``Builtin`` discover
8-
------------------------------
4+
Custom cache backends
5+
---------------------
96

10-
The :class:`~py_discovery.Builtin` class searches PATH, version managers (pyenv, mise, asdf), the Windows registry
11-
(PEP 514), and uv-managed installations:
7+
Implement the :class:`~py_discovery.PyInfoCache` protocol to provide your own storage. The protocol requires two
8+
methods:
129

1310
.. code-block:: python
1411
1512
from pathlib import Path
1613
14+
from py_discovery import ContentStore, PyInfoCache
15+
16+
17+
class MyContentStore:
18+
def __init__(self, path: Path) -> None:
19+
self._path = path
20+
21+
def exists(self) -> bool: ...
22+
23+
def read(self) -> dict | None: ...
24+
25+
def write(self, content: dict) -> None: ...
26+
27+
def remove(self) -> None: ...
28+
29+
def locked(self): ...
30+
31+
32+
class MyCache:
33+
def py_info(self, path: Path) -> MyContentStore: ...
34+
35+
def py_info_clear(self) -> None: ...
36+
37+
The built-in :class:`~py_discovery.DiskCache` stores JSON files under ``<root>/py_info/4/<sha256>.json`` with
38+
``filelock``-based locking. :class:`~py_discovery.NoOpCache` disables caching entirely.
39+
40+
Using ``get_interpreter`` directly
41+
-----------------------------------
42+
43+
For lower-level control, use :func:`~py_discovery.get_interpreter` instead of the :class:`~py_discovery.Builtin` class:
44+
45+
.. code-block:: python
46+
47+
from py_discovery import get_interpreter
48+
49+
info = get_interpreter("python3.12", try_first_with=["/opt/python/bin"])
50+
if info is not None:
51+
print(info.executable)
52+
53+
The ``try_first_with`` parameter takes paths to check before the normal discovery strategy.
54+
55+
Controlling the environment
56+
---------------------------
57+
58+
Pass a custom environment mapping to isolate discovery from the system environment:
59+
60+
.. code-block:: python
61+
62+
import os
63+
64+
from py_discovery import Builtin
65+
66+
env = {**os.environ, "PATH": "/usr/local/bin:/usr/bin"}
67+
discover = Builtin(python_spec=["python3.12"], env=env)
68+
69+
This controls which ``PATH``, ``PYENV_ROOT``, ``UV_PYTHON_INSTALL_DIR``, and other environment variables are used
70+
during discovery.
71+
72+
Inspecting interpreter metadata
73+
--------------------------------
74+
75+
A :class:`~py_discovery.PythonInfo` object exposes detailed interpreter metadata:
76+
77+
.. code-block:: python
78+
1779
from py_discovery import Builtin, DiskCache
80+
from pathlib import Path
1881
1982
cache = DiskCache(root=Path("~/.cache/py-discovery").expanduser())
20-
discover = Builtin(python_spec=["python3.12"], cache=cache)
21-
interpreter = discover.interpreter # cached property, calls run() once
83+
info = Builtin(python_spec=["python3.12"], cache=cache).run()
2284
23-
Custom cache backends
24-
---------------------
85+
info.executable # resolved path to the binary
86+
info.system_executable # the system (non-venv) executable
87+
info.implementation # "CPython", "PyPy", etc.
88+
info.version_info # VersionInfo(major, minor, micro, releaselevel, serial)
89+
info.architecture # 64 or 32
90+
info.platform # sys.platform value ("linux", "darwin", "win32")
91+
info.sysconfig_vars # dict of sysconfig variables
92+
info.sysconfig_paths # dict of sysconfig paths
2593
26-
Implement the :class:`~py_discovery.PyInfoCache` protocol to provide your own storage:
94+
Parsing specs without discovery
95+
-------------------------------
2796

28-
.. code-block:: python
97+
Use :class:`~py_discovery.PythonSpec` to parse a spec string without running discovery:
2998

30-
from py_discovery import PyInfoCache
99+
.. code-block:: python
31100
32-
class MyCache:
33-
def py_info(self, path):
34-
...
101+
from py_discovery import PythonSpec
35102
36-
def py_info_clear(self):
37-
...
103+
spec = PythonSpec.from_string_spec("cpython3.12t-64-arm64")
104+
print(spec.implementation) # cpython
105+
print(spec.major) # 3
106+
print(spec.minor) # 12
107+
print(spec.free_threaded) # True
108+
print(spec.architecture) # 64
109+
print(spec.machine) # arm64

docs/index.rst

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
py-discovery
22
============
33

4-
Python interpreter discovery library. Extracted from `virtualenv <https://virtualenv.pypa.io>`_, ``py-discovery``
5-
provides a self-contained API for finding Python interpreters on the system.
4+
A Python interpreter discovery library. ``py-discovery`` finds Python interpreters on your system by searching PATH,
5+
version managers (pyenv, mise, asdf), the Windows registry (PEP 514), and uv-managed installations. Results are cached
6+
to disk for fast repeated lookups.
67

78
Tutorial
89
--------
@@ -13,7 +14,7 @@ Tutorial
1314
tutorial/getting-started
1415

1516
:doc:`tutorial/getting-started`
16-
Create a cache and discover interpreters.
17+
Install py-discovery, find interpreters, and inspect their metadata.
1718

1819
How-to guides
1920
-------------
@@ -24,7 +25,7 @@ How-to guides
2425
how-to/standalone-usage
2526

2627
:doc:`how-to/standalone-usage`
27-
Using py-discovery without virtualenv.
28+
Advanced usage: custom cache backends, environment isolation, and the ``get_interpreter`` function.
2829

2930
Reference
3031
---------
@@ -46,4 +47,4 @@ Explanation
4647
explanation
4748

4849
:doc:`explanation`
49-
How discovery works, cache sharing, and the Protocol design.
50+
How the discovery strategy works, spec format syntax, and the cache design.

docs/tutorial/getting-started.rst

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,72 @@
11
Getting started
22
===============
33

4-
Install py-discovery:
4+
Installation
5+
------------
56

67
.. code-block:: console
78
89
pip install py-discovery
910
10-
Discover the current interpreter:
11+
Finding the current interpreter
12+
--------------------------------
1113

1214
.. code-block:: python
1315
16+
from pathlib import Path
17+
1418
from py_discovery import DiskCache, PythonInfo
1519
1620
cache = DiskCache(root=Path("~/.cache/py-discovery").expanduser())
1721
info = PythonInfo.current_system(cache)
18-
print(info.spec)
22+
print(info.executable) # /usr/bin/python3.12
23+
print(info.version_info[:3]) # (3, 12, 1)
24+
print(info.implementation) # CPython
25+
print(info.architecture) # 64
26+
27+
Discovering an interpreter by spec
28+
-----------------------------------
1929

20-
Find an interpreter by spec:
30+
Use :class:`~py_discovery.Builtin` to find an interpreter matching a specification string:
2131

2232
.. code-block:: python
2333
34+
from pathlib import Path
35+
2436
from py_discovery import Builtin, DiskCache
2537
2638
cache = DiskCache(root=Path("~/.cache/py-discovery").expanduser())
27-
builtin = Builtin(python_spec=["python3.12"], cache=cache)
28-
result = builtin.run()
39+
discover = Builtin(python_spec=["python3.12"], cache=cache)
40+
result = discover.run()
2941
if result is not None:
3042
print(result.executable)
43+
44+
The ``python_spec`` parameter accepts a list of spec strings to try in order. The first match wins.
45+
46+
Spec syntax
47+
-----------
48+
49+
Specs follow the pattern ``[impl][version][t][-arch][-machine]``:
50+
51+
- ``python3.12`` -- CPython 3.12
52+
- ``cpython3.12`` -- explicitly CPython 3.12
53+
- ``pypy3.9`` -- PyPy 3.9
54+
- ``3.12`` -- any implementation, version 3.12
55+
- ``python3.12t`` -- free-threaded CPython 3.12
56+
- ``python3.12-64`` -- 64-bit CPython 3.12
57+
- ``python3.12-64-arm64`` -- 64-bit CPython 3.12 on ARM64
58+
- ``/usr/bin/python3`` -- absolute path, used directly
59+
- ``>=3.11,<3.13`` -- PEP 440 version specifier
60+
- ``cpython>=3.11`` -- PEP 440 specifier with implementation filter
61+
62+
Without caching
63+
---------------
64+
65+
Pass ``cache=None`` to skip caching entirely. Each discovery will invoke the interpreter subprocess:
66+
67+
.. code-block:: python
68+
69+
from py_discovery import Builtin
70+
71+
discover = Builtin(python_spec=["python3.12"], cache=None)
72+
result = discover.run()

0 commit comments

Comments
 (0)