Skip to content

Commit e4a4451

Browse files
authored
Create shortcut executables from registered entrypoints (#225)
Fixes #121
1 parent cbfce9c commit e4a4451

File tree

16 files changed

+1035
-480
lines changed

16 files changed

+1035
-480
lines changed

.github/workflows/build.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ jobs:
172172
PYTHON_MANAGER_CONFIG: .\test-config.json
173173
PYMANAGER_DEBUG: true
174174

175+
- name: 'Validate entrypoint script'
176+
run: |
177+
$env:PYTHON_MANAGER_CONFIG = (gi $env:PYTHON_MANAGER_CONFIG).FullName
178+
cd .\test_installs\_bin
179+
del pip* -Verbose
180+
pymanager install --refresh
181+
dir pip*
182+
Get-Item .\pip.exe
183+
Get-Item .\pip.exe.__target__
184+
Get-Content .\pip.exe.__target__
185+
Get-Item .\pip.exe.__script__.py
186+
Get-Content .\pip.exe.__script__.py
187+
.\pip.exe --version
188+
env:
189+
PYTHON_MANAGER_INCLUDE_UNMANAGED: false
190+
PYTHON_MANAGER_CONFIG: .\test-config.json
191+
PYMANAGER_DEBUG: true
192+
shell: powershell
193+
175194
- name: 'Offline bundle download and install'
176195
run: |
177196
pymanager list --online 3 3-32 3-64 3-arm64

src/manage/aliasutils.py

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import os
2+
3+
from .exceptions import FilesInUseError, NoLauncherTemplateError
4+
from .fsutils import atomic_unlink, ensure_tree, unlink
5+
from .logging import LOGGER
6+
from .pathutils import Path
7+
from .tagutils import install_matches_any
8+
9+
_EXE = ".exe".casefold()
10+
11+
DEFAULT_SITE_DIRS = ["Lib\\site-packages", "Scripts"]
12+
13+
SCRIPT_CODE = """import sys
14+
15+
# Clear sys.path[0] if it contains this script.
16+
# Be careful to use the most compatible Python code possible.
17+
try:
18+
if sys.path[0]:
19+
if sys.argv[0].startswith(sys.path[0]):
20+
sys.path[0] = ""
21+
else:
22+
open(sys.path[0] + "/" + sys.argv[0], "rb").close()
23+
sys.path[0] = ""
24+
except OSError:
25+
pass
26+
except AttributeError:
27+
pass
28+
except IndexError:
29+
pass
30+
31+
# Replace argv[0] with our executable instead of the script name.
32+
try:
33+
if sys.argv[0][-14:].upper() == ".__SCRIPT__.PY":
34+
sys.argv[0] = sys.argv[0][:-14]
35+
sys.orig_argv[0] = sys.argv[0]
36+
except AttributeError:
37+
pass
38+
except IndexError:
39+
pass
40+
41+
from {mod} import {func}
42+
sys.exit({func}())
43+
"""
44+
45+
46+
class AliasInfo:
47+
def __init__(self, **kwargs):
48+
self.install = kwargs.get("install")
49+
self.name = kwargs.get("name")
50+
self.windowed = kwargs.get("windowed", 0)
51+
self.target = kwargs.get("target")
52+
self.mod = kwargs.get("mod")
53+
self.func = kwargs.get("func")
54+
55+
def replace(self, **kwargs):
56+
return AliasInfo(**{
57+
"install": self.install,
58+
"name": self.name,
59+
"windowed": self.windowed,
60+
"target": self.target,
61+
"mod": self.mod,
62+
"func": self.func,
63+
**kwargs,
64+
})
65+
66+
@property
67+
def script_code(self):
68+
if self.mod and self.func:
69+
if not all(s.isidentifier() for s in self.mod.split(".")):
70+
LOGGER.warn("Alias %s has an entrypoint with invalid module "
71+
"%r.", self.name, self.mod)
72+
return None
73+
if not all(s.isidentifier() for s in self.func.split(".")):
74+
LOGGER.warn("Alias %s has an entrypoint with invalid function "
75+
"%r.", self.name, self.func)
76+
return None
77+
return SCRIPT_CODE.format(mod=self.mod, func=self.func)
78+
79+
80+
def _if_exists(launcher, plat):
81+
suffix = "." + launcher.suffix.lstrip(".")
82+
plat_launcher = launcher.parent / f"{launcher.stem}{plat}{suffix}"
83+
if plat_launcher.is_file():
84+
return plat_launcher
85+
return launcher
86+
87+
88+
def _create_alias(cmd, *, name, target, plat=None, windowed=0, script_code=None, _link=os.link):
89+
p = cmd.global_dir / name
90+
if not p.match("*.exe"):
91+
p = p.with_name(p.name + ".exe")
92+
if not isinstance(target, Path):
93+
target = Path(target)
94+
ensure_tree(p)
95+
launcher = cmd.launcher_exe
96+
if windowed:
97+
launcher = cmd.launcherw_exe or launcher
98+
99+
if plat:
100+
LOGGER.debug("Checking for launcher for platform -%s", plat)
101+
launcher = _if_exists(launcher, f"-{plat}")
102+
if not launcher.is_file():
103+
LOGGER.debug("Checking for launcher for default platform %s", cmd.default_platform)
104+
launcher = _if_exists(launcher, cmd.default_platform)
105+
if not launcher.is_file():
106+
LOGGER.debug("Checking for launcher for -64")
107+
launcher = _if_exists(launcher, "-64")
108+
LOGGER.debug("Create %s linking to %s using %s", name, target, launcher)
109+
if not launcher or not launcher.is_file():
110+
raise NoLauncherTemplateError()
111+
112+
try:
113+
launcher_bytes = launcher.read_bytes()
114+
except OSError:
115+
warnings_shown = cmd.scratch.setdefault("aliasutils.create_alias.warnings_shown", set())
116+
if str(launcher) not in warnings_shown:
117+
LOGGER.warn("Failed to read launcher template at %s.", launcher)
118+
warnings_shown.add(str(launcher))
119+
LOGGER.debug("Failed to read %s", launcher, exc_info=True)
120+
return
121+
122+
existing_bytes = b''
123+
try:
124+
with open(p, 'rb') as f:
125+
existing_bytes = f.read(len(launcher_bytes) + 1)
126+
except FileNotFoundError:
127+
pass
128+
except OSError:
129+
LOGGER.debug("Failed to read existing alias launcher.")
130+
131+
launcher_remap = cmd.scratch.setdefault("aliasutils.create_alias.launcher_remap", {})
132+
if existing_bytes == launcher_bytes:
133+
# Valid existing launcher, so save its path in case we need it later
134+
# for a hard link.
135+
launcher_remap.setdefault(launcher.name, p)
136+
else:
137+
# First try and create a hard link
138+
unlink(p)
139+
try:
140+
_link(launcher, p)
141+
LOGGER.debug("Created %s as hard link to %s", p.name, launcher.name)
142+
except OSError as ex:
143+
if ex.winerror != 17:
144+
# Report errors other than cross-drive links
145+
LOGGER.debug("Failed to create hard link for command.", exc_info=True)
146+
launcher2 = launcher_remap.get(launcher.name)
147+
if launcher2:
148+
try:
149+
_link(launcher2, p)
150+
LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name)
151+
except FileNotFoundError:
152+
raise
153+
except OSError:
154+
LOGGER.debug("Failed to create hard link to fallback launcher")
155+
launcher2 = None
156+
if not launcher2:
157+
try:
158+
p.write_bytes(launcher_bytes)
159+
LOGGER.debug("Created %s as copy of %s", p.name, launcher.name)
160+
launcher_remap[launcher.name] = p
161+
except OSError:
162+
LOGGER.error("Failed to create global command %s.", name)
163+
LOGGER.debug("TRACEBACK", exc_info=True)
164+
165+
p_target = p.with_name(p.name + ".__target__")
166+
do_update = True
167+
try:
168+
do_update = not target.match(p_target.read_text(encoding="utf-8"))
169+
except FileNotFoundError:
170+
pass
171+
except (OSError, UnicodeDecodeError):
172+
LOGGER.debug("Failed to read existing target path.", exc_info=True)
173+
174+
if do_update:
175+
p_target.write_text(str(target), encoding="utf-8")
176+
177+
p_script = p.with_name(p.name + ".__script__.py")
178+
if script_code:
179+
do_update = True
180+
try:
181+
do_update = p_script.read_text(encoding="utf-8") != script_code
182+
except FileNotFoundError:
183+
pass
184+
except (OSError, UnicodeDecodeError):
185+
LOGGER.debug("Failed to read existing script file.", exc_info=True)
186+
if do_update:
187+
p_script.write_text(script_code, encoding="utf-8")
188+
else:
189+
try:
190+
unlink(p_script)
191+
except OSError:
192+
LOGGER.error("Failed to clean up existing alias. Re-run with -v "
193+
"or check the install log for details.")
194+
LOGGER.info("Failed to remove %s.", p_script)
195+
LOGGER.debug("TRACEBACK", exc_info=True)
196+
197+
198+
def _parse_entrypoint_line(line):
199+
line = line.partition("#")[0]
200+
name, sep, rest = line.partition("=")
201+
name = name.strip()
202+
if name and name[0].isalnum() and sep and rest:
203+
mod, sep, rest = rest.partition(":")
204+
mod = mod.strip()
205+
if mod and sep and rest:
206+
func, sep, extra = rest.partition("[")
207+
func = func.strip()
208+
if func:
209+
return name, mod, func
210+
return None, None, None
211+
212+
213+
def _readlines(path):
214+
try:
215+
f = open(path, "r", encoding="utf-8", errors="strict")
216+
except OSError:
217+
LOGGER.debug("Failed to read %s", path, exc_info=True)
218+
return
219+
220+
with f:
221+
try:
222+
while True:
223+
yield next(f)
224+
except StopIteration:
225+
return
226+
except UnicodeDecodeError:
227+
LOGGER.debug("Failed to decode contents of %s", path, exc_info=True)
228+
return
229+
230+
231+
def _scan_one(install, root):
232+
# Scan d for dist-info directories with entry_points.txt
233+
dist_info = [d for d in root.glob("*.dist-info") if d.is_dir()]
234+
entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()]
235+
if len(entrypoints):
236+
LOGGER.debug("Found %i entry_points.txt files in %i dist-info in %s",
237+
len(entrypoints), len(dist_info), root)
238+
239+
# Filter down to [console_scripts] and [gui_scripts]
240+
for ep in entrypoints:
241+
alias = None
242+
for line in _readlines(ep):
243+
if line.strip() == "[console_scripts]":
244+
alias = dict(windowed=0)
245+
elif line.strip() == "[gui_scripts]":
246+
alias = dict(windowed=1)
247+
elif line.lstrip().startswith("["):
248+
alias = None
249+
elif alias is not None:
250+
name, mod, func = _parse_entrypoint_line(line)
251+
if name and mod and func:
252+
yield AliasInfo(install=install, name=name,
253+
mod=mod, func=func, **alias)
254+
255+
256+
def _scan(install, prefix, dirs):
257+
for dirname in dirs or ():
258+
root = prefix / dirname
259+
yield from _scan_one(install, root)
260+
261+
262+
def calculate_aliases(cmd, install, *, _scan=_scan):
263+
LOGGER.debug("Calculating aliases for %s", install["id"])
264+
265+
prefix = install["prefix"]
266+
267+
default_alias = None
268+
default_alias_w = None
269+
270+
for a in install.get("alias", ()):
271+
target = prefix / a["target"]
272+
if not target.is_file():
273+
LOGGER.warn("Skipping alias '%s' because target '%s' does not exist",
274+
a["name"], a["target"])
275+
continue
276+
ai = AliasInfo(install=install, **a)
277+
yield ai
278+
if a.get("windowed") and not default_alias_w:
279+
default_alias_w = ai
280+
if not default_alias:
281+
default_alias = ai
282+
283+
if not default_alias_w:
284+
default_alias_w = default_alias
285+
286+
if install.get("default"):
287+
if default_alias:
288+
yield default_alias.replace(name="python")
289+
if default_alias_w:
290+
yield default_alias_w.replace(name="pythonw", windowed=1)
291+
292+
site_dirs = DEFAULT_SITE_DIRS
293+
for s in install.get("shortcuts", ()):
294+
if s.get("kind") == "site-dirs":
295+
site_dirs = s.get("dirs", ())
296+
break
297+
298+
for ai in _scan(install, prefix, site_dirs):
299+
if ai.windowed and default_alias_w:
300+
yield ai.replace(target=default_alias_w.target)
301+
elif not ai.windowed and default_alias:
302+
yield ai.replace(target=default_alias.target)
303+
304+
305+
def create_aliases(cmd, aliases, *, _create_alias=_create_alias):
306+
if not cmd.global_dir:
307+
return
308+
309+
written = set()
310+
311+
LOGGER.debug("Creating aliases")
312+
313+
for alias in aliases:
314+
if not alias.name:
315+
LOGGER.debug("Invalid alias info provided with no name.")
316+
continue
317+
318+
n = alias.name.casefold().removesuffix(_EXE)
319+
if n in written:
320+
# We've already written this alias, so skip it.
321+
continue
322+
written.add(n)
323+
324+
if not alias.target:
325+
LOGGER.debug("No suitable alias found for %s. Skipping", alias.name)
326+
continue
327+
328+
target = alias.install["prefix"] / alias.target
329+
try:
330+
_create_alias(
331+
cmd,
332+
name=alias.name,
333+
plat=alias.install.get("tag", "").rpartition("-")[2],
334+
target=target,
335+
script_code=alias.script_code,
336+
windowed=alias.windowed,
337+
)
338+
except NoLauncherTemplateError:
339+
if install_matches_any(alias.install, getattr(cmd, "tags", None)):
340+
LOGGER.warn("Skipping %s alias because "
341+
"the launcher template was not found.", alias.name)
342+
else:
343+
LOGGER.debug("Skipping %s alias because "
344+
"the launcher template was not found.", alias.name)
345+
346+
347+
348+
def cleanup_aliases(cmd, *, preserve, _unlink_many=atomic_unlink):
349+
if not cmd.global_dir or not cmd.global_dir.is_dir():
350+
return
351+
352+
LOGGER.debug("Cleaning up aliases")
353+
expected = set()
354+
for alias in preserve:
355+
if alias.name:
356+
n = alias.name.casefold().removesuffix(_EXE) + _EXE
357+
expected.add(n)
358+
359+
LOGGER.debug("Retaining %d aliases", len(expected))
360+
for alias in cmd.global_dir.glob("*.exe"):
361+
if alias.name.casefold() in expected:
362+
continue
363+
target = alias.with_name(alias.name + ".__target__")
364+
script = alias.with_name(alias.name + ".__script__.py")
365+
LOGGER.debug("Unlink %s", alias)
366+
try:
367+
_unlink_many([alias, target, script])
368+
except (OSError, FilesInUseError):
369+
LOGGER.warn("Failed to remove %s. Ensure it is not in use and run "
370+
"py install --refresh to try again.", alias.name)
371+
LOGGER.debug("TRACEBACK", exc_info=True)

0 commit comments

Comments
 (0)