Skip to content

Commit a64312f

Browse files
uplsh580j178
andauthored
Add support for python -m prek (#1686)
Related Issue: #1685 ## Summary prek can now be run with python -m prek in addition to the prek command, matching common Python CLI tools like `black`, `ruff`, and `uv`. ## Motivation - Consistency: Many Python tools support python -m <package> - Virtual environments: Makes it clear which Python interpreter (and venv) is used - Scripts & CI: Explicit execution environment - Tool integration: Some tools only support the `python -m` form ## Changes - Add `python/prek/` package with `__init__.py` and `__main__.py` - Set python-source = "python" in `pyproject.toml` so maturin includes the Python package - `__main__.`py invokes the prek binary next to the Python executable (e.g. `.venv/bin/prek with .venv/bin/python`), falling back to PATH if not found --------- Co-authored-by: Jo <10510431+j178@users.noreply.github.com>
1 parent 91a1fae commit a64312f

File tree

4 files changed

+148
-0
lines changed

4 files changed

+148
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ build-backend = "maturin"
3131
bindings = "bin"
3232
manifest-path = "crates/prek/Cargo.toml"
3333
strip = true
34+
python-source = "python"
3435
include = [{ path = "licenses/*", format = ["wheel", "sdist"] }]
3536

3637
[tool.rooster]

python/prek/__init__.py

Whitespace-only changes.

python/prek/__main__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import os
2+
import shutil
3+
import subprocess
4+
import sys
5+
6+
from ._find_prek import find_prek_bin
7+
8+
def _run() -> None:
9+
prek = find_prek_bin()
10+
11+
if sys.platform == "win32":
12+
import subprocess
13+
14+
# Avoid emitting a traceback on interrupt
15+
try:
16+
completed_process = subprocess.run([prek, *sys.argv[1:]], env=env)
17+
except KeyboardInterrupt:
18+
sys.exit(2)
19+
20+
sys.exit(completed_process.returncode)
21+
else:
22+
os.execvp(prek, [prek, *sys.argv[1:]])
23+
24+
25+
if __name__ == "__main__":
26+
_run()

python/prek/_find_prek.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# MIT License
2+
3+
# Copyright (c) 2025 Astral Software Inc.
4+
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
import os
24+
import sys
25+
import sysconfig
26+
27+
28+
class PrekNotFound(FileNotFoundError): ...
29+
30+
31+
def find_prek_bin() -> str:
32+
"""Return the prek binary path."""
33+
34+
prek_exe = "prek" + sysconfig.get_config_var("EXE")
35+
36+
targets = [
37+
# The scripts directory for the current Python
38+
sysconfig.get_path("scripts"),
39+
# The scripts directory for the base prefix
40+
sysconfig.get_path("scripts", vars={"base": sys.base_prefix}),
41+
# Above the package root, e.g., from `pip install --prefix` or `uv run --with`
42+
(
43+
# On Windows, with module path `<prefix>/Lib/site-packages/prek`
44+
_join(_matching_parents(_module_path(), "Lib/site-packages/prek"), "Scripts")
45+
if sys.platform == "win32"
46+
# On Unix, with module path `<prefix>/lib/python3.13/site-packages/prek`
47+
else _join(
48+
_matching_parents(_module_path(), "lib/python*/site-packages/prek"), "bin"
49+
)
50+
),
51+
# Adjacent to the package root, e.g., from `pip install --target`
52+
# with module path `<target>/prek`
53+
_join(_matching_parents(_module_path(), "prek"), "bin"),
54+
# The user scheme scripts directory, e.g., `~/.local/bin`
55+
sysconfig.get_path("scripts", scheme=_user_scheme()),
56+
]
57+
58+
seen = []
59+
for target in targets:
60+
if not target:
61+
continue
62+
if target in seen:
63+
continue
64+
seen.append(target)
65+
path = os.path.join(target, prek_exe)
66+
if os.path.isfile(path):
67+
return path
68+
69+
locations = "\n".join(f" - {target}" for target in seen)
70+
raise PrekNotFound(
71+
f"Could not find the prek binary in any of the following locations:\n{locations}\n"
72+
)
73+
74+
75+
def _module_path() -> str | None:
76+
path = os.path.dirname(__file__)
77+
return path
78+
79+
80+
def _matching_parents(path: str | None, match: str) -> str | None:
81+
"""
82+
Return the parent directory of `path` after trimming a `match` from the end.
83+
The match is expected to contain `/` as a path separator, while the `path`
84+
is expected to use the platform's path separator (e.g., `os.sep`). The path
85+
components are compared case-insensitively and a `*` wildcard can be used
86+
in the `match`.
87+
"""
88+
from fnmatch import fnmatch
89+
90+
if not path:
91+
return None
92+
parts = path.split(os.sep)
93+
match_parts = match.split("/")
94+
if len(parts) < len(match_parts):
95+
return None
96+
97+
if not all(
98+
fnmatch(part, match_part)
99+
for part, match_part in zip(reversed(parts), reversed(match_parts))
100+
):
101+
return None
102+
103+
return os.sep.join(parts[: -len(match_parts)])
104+
105+
106+
def _join(path: str | None, *parts: str) -> str | None:
107+
if not path:
108+
return None
109+
return os.path.join(path, *parts)
110+
111+
112+
def _user_scheme() -> str:
113+
if sys.version_info >= (3, 10):
114+
user_scheme = sysconfig.get_preferred_scheme("user")
115+
elif os.name == "nt":
116+
user_scheme = "nt_user"
117+
elif sys.platform == "darwin" and sys._framework: # ty: ignore[unresolved-attribute]
118+
user_scheme = "osx_framework_user"
119+
else:
120+
user_scheme = "posix_user"
121+
return user_scheme

0 commit comments

Comments
 (0)