Skip to content

Commit 10648f4

Browse files
authored
Avoid recreating unchanged global aliases, and use hard links where possible (#194)
Fixes #184
1 parent a542a7f commit 10648f4

File tree

3 files changed

+198
-12
lines changed

3 files changed

+198
-12
lines changed

src/manage/commands.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,10 @@ class BaseCommand:
351351
show_help = False
352352

353353
def __init__(self, args, root=None):
354+
# Storage for command-specific data per-command execution.
355+
# All data should use a unique key.
356+
self.scratch = {}
357+
354358
cmd_args = {
355359
k: v for k, v in
356360
[*CLI_SCHEMA.items(), *CLI_SCHEMA.get(self.CMD, {}).items()]

src/manage/install_command.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,14 @@ def _if_exists(launcher, plat):
223223
return launcher
224224

225225

226-
def _write_alias(cmd, install, alias, target):
226+
def _write_alias(cmd, install, alias, target, _link=os.link):
227227
p = (cmd.global_dir / alias["name"])
228+
target = Path(target)
228229
ensure_tree(p)
229-
unlink(p)
230230
launcher = cmd.launcher_exe
231231
if alias.get("windowed"):
232232
launcher = cmd.launcherw_exe or launcher
233+
233234
plat = install["tag"].rpartition("-")[-1]
234235
if plat:
235236
LOGGER.debug("Checking for launcher for platform -%s", plat)
@@ -247,8 +248,71 @@ def _write_alias(cmd, install, alias, target):
247248
else:
248249
LOGGER.debug("Skipping %s alias because the launcher template was not found.", alias["name"])
249250
return
250-
p.write_bytes(launcher.read_bytes())
251-
p.with_name(p.name + ".__target__").write_text(str(target), encoding="utf-8")
251+
252+
try:
253+
launcher_bytes = launcher.read_bytes()
254+
except OSError:
255+
warnings_shown = cmd.scratch.setdefault("install_command._write_alias.warnings_shown", set())
256+
if str(launcher) not in warnings_shown:
257+
LOGGER.warn("Failed to read launcher template at %s.", launcher)
258+
warnings_shown.add(str(launcher))
259+
LOGGER.debug("Failed to read %s", launcher, exc_info=True)
260+
return
261+
262+
existing_bytes = b''
263+
try:
264+
with open(p, 'rb') as f:
265+
existing_bytes = f.read(len(launcher_bytes) + 1)
266+
except FileNotFoundError:
267+
pass
268+
except OSError:
269+
LOGGER.debug("Failed to read existing alias launcher.")
270+
271+
launcher_remap = cmd.scratch.setdefault("install_command._write_alias.launcher_remap", {})
272+
273+
if existing_bytes == launcher_bytes:
274+
# Valid existing launcher, so save its path in case we need it later
275+
# for a hard link.
276+
launcher_remap.setdefault(launcher.name, p)
277+
else:
278+
# First try and create a hard link
279+
unlink(p)
280+
try:
281+
_link(launcher, p)
282+
LOGGER.debug("Created %s as hard link to %s", p.name, launcher.name)
283+
except OSError as ex:
284+
if ex.winerror != 17:
285+
# Report errors other than cross-drive links
286+
LOGGER.debug("Failed to create hard link for command.", exc_info=True)
287+
launcher2 = launcher_remap.get(launcher.name)
288+
if launcher2:
289+
try:
290+
_link(launcher2, p)
291+
LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name)
292+
except FileNotFoundError:
293+
raise
294+
except OSError:
295+
LOGGER.debug("Failed to create hard link to fallback launcher")
296+
launcher2 = None
297+
if not launcher2:
298+
try:
299+
p.write_bytes(launcher_bytes)
300+
LOGGER.debug("Created %s as copy of %s", p.name, launcher.name)
301+
launcher_remap[launcher.name] = p
302+
except OSError:
303+
LOGGER.error("Failed to create global command %s.", alias["name"])
304+
LOGGER.debug(exc_info=True)
305+
306+
p_target = p.with_name(p.name + ".__target__")
307+
try:
308+
if target.match(p_target.read_text(encoding="utf-8")):
309+
return
310+
except FileNotFoundError:
311+
pass
312+
except (OSError, UnicodeDecodeError):
313+
LOGGER.debug("Failed to read existing target path.", exc_info=True)
314+
315+
p_target.write_text(str(target), encoding="utf-8")
252316

253317

254318
def _create_shortcut_pep514(cmd, install, shortcut):

tests/test_install_command.py

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Cmd:
2222
default_platform = "-64"
2323

2424
def __init__(self, platform=None):
25+
self.scratch = {}
2526
if platform:
2627
self.default_platform = platform
2728

@@ -81,19 +82,19 @@ def check_warm64(self, cmd, tag, name):
8182

8283

8384
def test_write_alias_tag_with_platform(alias_checker):
84-
alias_checker.check_32(alias_checker.Cmd, "1.0-32", "testA")
85-
alias_checker.check_w32(alias_checker.Cmd, "1.0-32", "testB")
86-
alias_checker.check_64(alias_checker.Cmd, "1.0-64", "testC")
87-
alias_checker.check_w64(alias_checker.Cmd, "1.0-64", "testD")
88-
alias_checker.check_arm64(alias_checker.Cmd, "1.0-arm64", "testE")
89-
alias_checker.check_warm64(alias_checker.Cmd, "1.0-arm64", "testF")
85+
alias_checker.check_32(alias_checker.Cmd(), "1.0-32", "testA")
86+
alias_checker.check_w32(alias_checker.Cmd(), "1.0-32", "testB")
87+
alias_checker.check_64(alias_checker.Cmd(), "1.0-64", "testC")
88+
alias_checker.check_w64(alias_checker.Cmd(), "1.0-64", "testD")
89+
alias_checker.check_arm64(alias_checker.Cmd(), "1.0-arm64", "testE")
90+
alias_checker.check_warm64(alias_checker.Cmd(), "1.0-arm64", "testF")
9091

9192

9293
def test_write_alias_default_platform(alias_checker):
9394
alias_checker.check_32(alias_checker.Cmd("-32"), "1.0", "testA")
9495
alias_checker.check_w32(alias_checker.Cmd("-32"), "1.0", "testB")
95-
alias_checker.check_64(alias_checker.Cmd, "1.0", "testC")
96-
alias_checker.check_w64(alias_checker.Cmd, "1.0", "testD")
96+
alias_checker.check_64(alias_checker.Cmd(), "1.0", "testC")
97+
alias_checker.check_w64(alias_checker.Cmd(), "1.0", "testD")
9798
alias_checker.check_arm64(alias_checker.Cmd("-arm64"), "1.0", "testE")
9899
alias_checker.check_warm64(alias_checker.Cmd("-arm64"), "1.0", "testF")
99100

@@ -103,13 +104,128 @@ def test_write_alias_fallback_platform(alias_checker):
103104
alias_checker.check_w64(alias_checker.Cmd("-spam"), "1.0", "testB")
104105

105106

107+
def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path):
108+
fake_config.launcher_exe = tmp_path / "non-existent.exe"
109+
fake_config.default_platform = '-32'
110+
fake_config.global_dir = tmp_path / "bin"
111+
IC._write_alias(
112+
fake_config,
113+
{"tag": "test"},
114+
{"name": "test.exe"},
115+
tmp_path / "target.exe",
116+
)
117+
assert_log(
118+
"Checking for launcher.*",
119+
"Checking for launcher.*",
120+
"Checking for launcher.*",
121+
"Create %s linking to %s",
122+
"Skipping %s alias because the launcher template was not found.",
123+
assert_log.end_of_log(),
124+
)
125+
126+
127+
def test_write_alias_launcher_unreadable(fake_config, assert_log, tmp_path):
128+
class FakeLauncherPath:
129+
stem = "test"
130+
suffix = ".exe"
131+
parent = tmp_path
132+
133+
@staticmethod
134+
def is_file():
135+
return True
136+
137+
@staticmethod
138+
def read_bytes():
139+
raise OSError("no reading for the test")
140+
141+
fake_config.scratch = {}
142+
fake_config.launcher_exe = FakeLauncherPath
143+
fake_config.default_platform = '-32'
144+
fake_config.global_dir = tmp_path / "bin"
145+
IC._write_alias(
146+
fake_config,
147+
{"tag": "test"},
148+
{"name": "test.exe"},
149+
tmp_path / "target.exe",
150+
)
151+
assert_log(
152+
"Checking for launcher.*",
153+
"Create %s linking to %s",
154+
"Failed to read launcher template at %s\\.",
155+
"Failed to read %s",
156+
assert_log.end_of_log(),
157+
)
158+
159+
160+
def test_write_alias_launcher_unlinkable(fake_config, assert_log, tmp_path):
161+
def fake_link(x, y):
162+
raise OSError("Error for testing")
163+
164+
fake_config.scratch = {}
165+
fake_config.launcher_exe = tmp_path / "launcher.txt"
166+
fake_config.launcher_exe.write_bytes(b'Arbitrary contents')
167+
fake_config.default_platform = '-32'
168+
fake_config.global_dir = tmp_path / "bin"
169+
IC._write_alias(
170+
fake_config,
171+
{"tag": "test"},
172+
{"name": "test.exe"},
173+
tmp_path / "target.exe",
174+
_link=fake_link
175+
)
176+
assert_log(
177+
"Checking for launcher.*",
178+
"Create %s linking to %s",
179+
"Failed to create hard link.+",
180+
"Created %s as copy of %s",
181+
assert_log.end_of_log(),
182+
)
183+
184+
185+
def test_write_alias_launcher_unlinkable_remap(fake_config, assert_log, tmp_path):
186+
# This is for the fairly expected case of the PyManager install being on one
187+
# drive, but the global commands directory being on another. In this
188+
# situation, we can't hard link directly into the app files, and will need
189+
# to copy. But we only need to copy once, so if a launcher_remap has been
190+
# set (in the current process), then we have an available copy already and
191+
# can link to that.
192+
193+
def fake_link(x, y):
194+
if x.match("launcher.txt"):
195+
raise OSError(17, "Error for testing")
196+
197+
fake_config.scratch = {
198+
"install_command._write_alias.launcher_remap": {"launcher.txt": tmp_path / "actual_launcher.txt"},
199+
}
200+
fake_config.launcher_exe = tmp_path / "launcher.txt"
201+
fake_config.launcher_exe.write_bytes(b'Arbitrary contents')
202+
(tmp_path / "actual_launcher.txt").write_bytes(b'Arbitrary contents')
203+
fake_config.default_platform = '-32'
204+
fake_config.global_dir = tmp_path / "bin"
205+
IC._write_alias(
206+
fake_config,
207+
{"tag": "test"},
208+
{"name": "test.exe"},
209+
tmp_path / "target.exe",
210+
_link=fake_link
211+
)
212+
assert_log(
213+
"Checking for launcher.*",
214+
"Create %s linking to %s",
215+
"Failed to create hard link.+",
216+
("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")),
217+
assert_log.end_of_log(),
218+
)
219+
220+
106221
@pytest.mark.parametrize("default", [1, 0])
107222
def test_write_alias_default(alias_checker, monkeypatch, tmp_path, default):
108223
prefix = Path(tmp_path) / "runtime"
109224

110225
class Cmd:
111226
global_dir = Path(tmp_path) / "bin"
112227
launcher_exe = None
228+
scratch = {}
113229
def get_installs(self):
114230
return [
115231
{
@@ -146,6 +262,7 @@ def write_alias(*a):
146262

147263
def test_print_cli_shortcuts(patched_installs, assert_log, monkeypatch, tmp_path):
148264
class Cmd:
265+
scratch = {}
149266
global_dir = Path(tmp_path)
150267
def get_installs(self):
151268
return installs.get_installs(None)
@@ -163,6 +280,7 @@ def get_installs(self):
163280

164281
def test_print_path_warning(patched_installs, assert_log, tmp_path):
165282
class Cmd:
283+
scratch = {}
166284
global_dir = Path(tmp_path)
167285
def get_installs(self):
168286
return installs.get_installs(None)

0 commit comments

Comments
 (0)