Skip to content

Commit 1e8f603

Browse files
committed
Improves help output and testability of commands.
#74
1 parent d2d8e2e commit 1e8f603

File tree

7 files changed

+193
-100
lines changed

7 files changed

+193
-100
lines changed

src/manage/commands.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,8 @@ def __init__(self, args, root=None):
353353
set_next = a.lstrip("-/").lower()
354354
try:
355355
key, value, *opts = cmd_args[set_next]
356-
except LookupError:
357-
raise ArgumentError(f"Unexpected argument: {a}")
356+
except KeyError:
357+
raise ArgumentError(f"Unexpected argument: {a}") from None
358358
if value is _NEXT:
359359
if sep:
360360
if opts:
@@ -868,6 +868,10 @@ class HelpCommand(BaseCommand):
868868

869869
_create_log_file = False
870870

871+
def __init__(self, args, root=None):
872+
super().__init__([self.CMD], root)
873+
self.args = [a for a in args[1:] if a.isalpha()]
874+
871875
def execute(self):
872876
LOGGER.print(COPYRIGHT)
873877
self.show_welcome(copyright=False)

src/manage/config.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,24 @@ def config_bool(v):
3535
return v.lower().startswith(("t", "y", "1"))
3636
return bool(v)
3737

38-
def _global_file():
38+
39+
def load_global_config(cfg, schema):
3940
try:
4041
from _native import package_get_root
4142
except ImportError:
42-
return Path(sys.executable).parent / DEFAULT_CONFIG_NAME
43-
return Path(package_get_root()) / DEFAULT_CONFIG_NAME
43+
file = Path(sys.executable).parent / DEFAULT_CONFIG_NAME
44+
else:
45+
file = Path(package_get_root()) / DEFAULT_CONFIG_NAME
46+
try:
47+
load_one_config(cfg, file, schema=schema)
48+
except FileNotFoundError:
49+
pass
50+
4451

4552
def load_config(root, override_file, schema):
4653
cfg = {}
4754

48-
global_file = _global_file()
49-
if global_file:
50-
try:
51-
load_one_config(cfg, global_file, schema=schema)
52-
except FileNotFoundError:
53-
pass
55+
load_global_config(cfg, schema=schema)
5456

5557
try:
5658
reg_cfg = load_registry_config(cfg["registry_override_key"], schema=schema)

src/manage/list_command.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,39 +167,39 @@ def format_csv(cmd, installs):
167167

168168

169169
def format_json(cmd, installs):
170-
print(json.dumps({"versions": installs}, default=str))
170+
LOGGER.print_raw(json.dumps({"versions": installs}, default=str))
171171

172172

173173
def format_json_lines(cmd, installs):
174174
for i in installs:
175-
print(json.dumps(i, default=str))
175+
LOGGER.print_raw(json.dumps(i, default=str))
176176

177177

178178
def format_bare_id(cmd, installs):
179179
for i in installs:
180180
# Don't print useless values (__active-virtual-env, __unmanaged-)
181181
if i["id"].startswith("__"):
182182
continue
183-
print(i["id"])
183+
LOGGER.print_raw(i["id"])
184184

185185

186186
def format_bare_exe(cmd, installs):
187187
for i in installs:
188-
print(i["executable"])
188+
LOGGER.print_raw(i["executable"])
189189

190190

191191
def format_bare_prefix(cmd, installs):
192192
for i in installs:
193193
try:
194-
print(i["prefix"])
194+
LOGGER.print_raw(i["prefix"])
195195
except KeyError:
196196
pass
197197

198198

199199
def format_bare_url(cmd, installs):
200200
for i in installs:
201201
try:
202-
print(i["url"])
202+
LOGGER.print_raw(i["url"])
203203
except KeyError:
204204
pass
205205

@@ -223,7 +223,7 @@ def format_legacy(cmd, installs, paths=False):
223223
if not seen_default and i.get("default"):
224224
tag = f"{tag} *"
225225
seen_default = True
226-
print(tag.ljust(17), i["executable"] if paths else i["display-name"])
226+
LOGGER.print_raw(tag.ljust(17), i["executable"] if paths else i["display-name"])
227227

228228

229229
FORMATTERS = {

src/manage/logging.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,19 @@ def supports_colour(stream):
7272

7373

7474
class Logger:
75-
def __init__(self):
76-
if os.getenv("PYMANAGER_DEBUG"):
75+
def __init__(self, level=None, console=sys.stderr, print_console=sys.stdout):
76+
if level is not None:
77+
self.level = level
78+
elif os.getenv("PYMANAGER_DEBUG"):
7779
self.level = DEBUG
7880
elif os.getenv("PYMANAGER_VERBOSE"):
7981
self.level = VERBOSE
8082
else:
8183
self.level = INFO
82-
self.console = sys.stderr
84+
self.console = console
8385
self.console_colour = supports_colour(self.console)
86+
self.print_console = print_console
87+
self.print_console_colour = supports_colour(self.print_console)
8488
self.file = None
8589
self._list = None
8690

@@ -158,13 +162,19 @@ def would_print(self, *args, always=False, level=INFO, **kwargs):
158162
return False
159163
return True
160164

161-
def print(self, msg=None, *args, always=False, level=INFO, **kwargs):
165+
def print(self, msg=None, *args, always=False, level=INFO, colours=True, **kwargs):
162166
if self._list is not None:
163-
self._list.append(((msg or "") % args, ()))
167+
if args:
168+
self._list.append(((msg or "") % args, ()))
169+
else:
170+
self._list.append((msg or "", ()))
164171
if not always and level < self.level:
165172
return
166173
if msg:
167-
if self.console_colour:
174+
if not colours:
175+
# Don't unescape or replace anything
176+
pass
177+
elif self.print_console_colour:
168178
for k in COLOURS:
169179
msg = msg.replace(k, COLOURS[k])
170180
else:
@@ -176,7 +186,14 @@ def print(self, msg=None, *args, always=False, level=INFO, **kwargs):
176186
msg = str(args[0])
177187
else:
178188
msg = ""
179-
print(msg, **kwargs, file=self.console)
189+
print(msg, **kwargs, file=self.print_console)
190+
191+
def print_raw(self, *msg, **kwargs):
192+
kwargs.pop("always", None)
193+
kwargs.pop("level", None)
194+
kwargs.pop("colours", None)
195+
return self.print(kwargs.pop("sep", " ").join(str(s) for s in msg),
196+
always=True, colours=False, **kwargs)
180197

181198

182199
LOGGER = Logger()

tests/conftest.py

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
import winreg
88

9-
from pathlib import Path
9+
from pathlib import Path, PurePath
1010

1111
TESTS = Path(__file__).absolute().parent
1212

@@ -18,41 +18,83 @@
1818
setattr(_native, k, getattr(_native_test, k))
1919

2020

21+
# Importing in order carefully to ensure the variables we override are handled
22+
# correctly by submodules.
2123
import manage
2224
manage.EXE_NAME = "pymanager-pytest"
2325

24-
2526
import manage.commands
2627
manage.commands.WELCOME = ""
2728

28-
29-
from manage.logging import LOGGER, DEBUG
29+
from manage.logging import LOGGER, DEBUG, ERROR
3030
LOGGER.level = DEBUG
3131

32+
import manage.config
33+
import manage.installs
34+
35+
36+
# Ensure we don't pick up any settings from configs or the registry
37+
38+
def _mock_load_global_config(cfg, schema):
39+
cfg.update({
40+
"base_config": "",
41+
"user_config": "",
42+
"additional_config": "",
43+
})
44+
45+
def _mock_load_registry_config(key, schema):
46+
return {}
47+
48+
manage.config.load_global_config = _mock_load_global_config
49+
manage.config.load_registry_config = _mock_load_registry_config
50+
51+
52+
@pytest.fixture
53+
def quiet_log():
54+
lvl = LOGGER.level
55+
LOGGER.level = ERROR
56+
try:
57+
yield
58+
finally:
59+
LOGGER.level = lvl
60+
61+
3262
class LogCaptureHandler(list):
3363
def skip_until(self, pattern, args=()):
3464
return ('until', pattern, args)
3565

66+
def not_logged(self, pattern, args=()):
67+
return ('not', pattern, args)
68+
3669
def __call__(self, *cmp):
37-
it1 = iter(self)
70+
i = 0
3871
for y in cmp:
3972
if not isinstance(y, tuple):
40-
op, pat, args = None, y, []
73+
op, pat, args = None, y, None
4174
elif len(y) == 3:
4275
op, pat, args = y
4376
elif len(y) == 2:
4477
op = None
4578
pat, args = y
4679

80+
if op == 'not':
81+
for j in range(i, len(self)):
82+
if re.match(pat, self[j][0], flags=re.S):
83+
pytest.fail(f"Should not have found {self[j][0]!r} matching {pat}")
84+
return
85+
continue
86+
4787
while True:
4888
try:
49-
x = next(it1)
50-
except StopIteration:
89+
x = self[i]
90+
i += 1
91+
except IndexError:
5192
pytest.fail(f"Not enough elements were logged looking for {pat}")
52-
if op == 'until' and not re.match(pat, x[0]):
93+
if op == 'until' and not re.match(pat, x[0], flags=re.S):
5394
continue
54-
assert re.match(pat, x[0])
55-
assert tuple(x[1]) == tuple(args)
95+
assert re.match(pat, x[0], flags=re.S)
96+
if args is not None:
97+
assert tuple(x[1]) == tuple(args)
5698
break
5799

58100

@@ -150,3 +192,67 @@ def setup(self, _subkey=None, **keys):
150192
def registry():
151193
with RegistryFixture(winreg.HKEY_CURRENT_USER, REG_TEST_ROOT) as key:
152194
yield key
195+
196+
197+
198+
def make_install(tag, **kwargs):
199+
run_for = []
200+
for t in kwargs.get("run_for", [tag]):
201+
run_for.append({"tag": t, "target": kwargs.get("target", "python.exe")})
202+
run_for.append({"tag": t, "target": kwargs.get("targetw", "pythonw.exe"), "windowed": 1})
203+
204+
return {
205+
"company": kwargs.get("company", "PythonCore"),
206+
"id": "{}-{}".format(kwargs.get("company", "PythonCore"), tag),
207+
"sort-version": kwargs.get("sort_version", tag),
208+
"display-name": "{} {}".format(kwargs.get("company", "Python"), tag),
209+
"tag": tag,
210+
"install-for": [tag],
211+
"run-for": run_for,
212+
"prefix": PurePath(kwargs.get("prefix", rf"C:\{tag}")),
213+
"executable": kwargs.get("executable", "python.exe"),
214+
}
215+
216+
217+
def fake_get_installs(install_dir):
218+
yield make_install("1.0")
219+
yield make_install("1.0-32", sort_version="1.0")
220+
yield make_install("1.0-64", sort_version="1.0")
221+
yield make_install("2.0-64", sort_version="2.0")
222+
yield make_install("2.0-arm64", sort_version="2.0")
223+
yield make_install("3.0a1-32", sort_version="3.0a1")
224+
yield make_install("3.0a1-64", sort_version="3.0a1")
225+
yield make_install("1.1", company="Company", target="company.exe", targetw="companyw.exe")
226+
yield make_install("1.1-64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe")
227+
yield make_install("1.1-arm64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe")
228+
yield make_install("2.1", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe")
229+
yield make_install("2.1-64", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe")
230+
231+
232+
def fake_get_installs2(install_dir):
233+
yield make_install("1.0-32", sort_version="1.0")
234+
yield make_install("3.0a1-32", sort_version="3.0a1", run_for=["3.0.1a1-32", "3.0-32", "3-32"])
235+
yield make_install("3.0a1-64", sort_version="3.0a1", run_for=["3.0.1a1-64", "3.0-64", "3-64"])
236+
yield make_install("3.0a1-arm64", sort_version="3.0a1", run_for=["3.0.1a1-arm64", "3.0-arm64", "3-arm64"])
237+
238+
239+
def fake_get_unmanaged_installs():
240+
return []
241+
242+
243+
def fake_get_venv_install(virtualenv):
244+
raise LookupError
245+
246+
247+
@pytest.fixture
248+
def patched_installs(monkeypatch):
249+
monkeypatch.setattr(manage.installs, "_get_installs", fake_get_installs)
250+
monkeypatch.setattr(manage.installs, "_get_unmanaged_installs", fake_get_unmanaged_installs)
251+
monkeypatch.setattr(manage.installs, "_get_venv_install", fake_get_venv_install)
252+
253+
254+
@pytest.fixture
255+
def patched_installs2(monkeypatch):
256+
monkeypatch.setattr(manage.installs, "_get_installs", fake_get_installs2)
257+
monkeypatch.setattr(manage.installs, "_get_unmanaged_installs", fake_get_unmanaged_installs)
258+
monkeypatch.setattr(manage.installs, "_get_venv_install", fake_get_venv_install)

tests/test_commands.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
import secrets
3-
from manage import commands
4-
from manage.exceptions import NoInstallsError
3+
from manage import commands, logging
4+
from manage.exceptions import ArgumentError, NoInstallsError
55

66

77
def test_pymanager_help_command(assert_log):
@@ -55,3 +55,29 @@ def test_exec_with_literal_default():
5555
except NoInstallsError:
5656
# This is also an okay result, if the default runtime isn't installed
5757
pass
58+
59+
60+
def test_legacy_list_command(assert_log, patched_installs):
61+
cmd = commands.ListLegacyCommand(["--list"])
62+
cmd.show_welcome()
63+
cmd.execute()
64+
assert_log(
65+
# Ensure welcome message is suppressed
66+
assert_log.not_logged(r"Python installation manager.+"),
67+
# Should have a range of values shown, we don't care too much
68+
assert_log.skip_until(r" -V:2\.0\[-64\]\s+Python.*"),
69+
assert_log.skip_until(r" -V:3\.0a1-32\s+Python.*"),
70+
)
71+
72+
73+
def test_legacy_list_command_args():
74+
with pytest.raises(ArgumentError):
75+
commands.ListLegacyCommand(["--list", "--help"])
76+
77+
78+
def test_legacy_listpaths_command_help(assert_log, patched_installs):
79+
cmd = commands.ListPathsLegacyCommand(["--list-paths"])
80+
cmd.help()
81+
assert_log(
82+
assert_log.skip_until(r".*List command.+py list.+"),
83+
)

0 commit comments

Comments
 (0)