Skip to content

Commit d7ee9e4

Browse files
authored
Move argument parsing to blurb._cli (#50)
1 parent eb3e9d7 commit d7ee9e4

File tree

6 files changed

+313
-306
lines changed

6 files changed

+313
-306
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ optional-dependencies.tests = [
3939
urls.Changelog = "https://github.com/python/blurb/blob/main/CHANGELOG.md"
4040
urls.Homepage = "https://github.com/python/blurb"
4141
urls.Source = "https://github.com/python/blurb"
42-
scripts.blurb = "blurb.blurb:main"
42+
scripts.blurb = "blurb._cli:main"
4343

4444
[tool.hatch]
4545
version.source = "vcs"

src/blurb/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Run blurb using ``python3 -m blurb``."""
2-
from blurb import blurb
2+
from blurb._cli import main
33

44

55
if __name__ == '__main__':
6-
blurb.main()
6+
main()

src/blurb/_cli.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import os
5+
import re
6+
import sys
7+
8+
import blurb
9+
10+
TYPE_CHECKING = False
11+
if TYPE_CHECKING:
12+
from collections.abc import Callable
13+
from typing import TypeAlias
14+
15+
CommandFunc: TypeAlias = Callable[..., None]
16+
17+
18+
subcommands: dict[str, CommandFunc] = {}
19+
readme_re = re.compile(r'This is \w+ version \d+\.\d+').match
20+
21+
22+
def error(msg: str, /):
23+
raise SystemExit(f'Error: {msg}')
24+
25+
26+
def subcommand(fn: CommandFunc):
27+
global subcommands
28+
subcommands[fn.__name__] = fn
29+
return fn
30+
31+
32+
def get_subcommand(subcommand: str, /) -> CommandFunc:
33+
fn = subcommands.get(subcommand)
34+
if not fn:
35+
error(f"Unknown subcommand: {subcommand}\nRun 'blurb help' for help.")
36+
return fn
37+
38+
39+
@subcommand
40+
def version() -> None:
41+
"""Print blurb version."""
42+
print('blurb version', blurb.__version__)
43+
44+
45+
@subcommand
46+
def help(subcommand: str | None = None) -> None:
47+
"""Print help for subcommands.
48+
49+
Prints the help text for the specified subcommand.
50+
If subcommand is not specified, prints one-line summaries for every command.
51+
"""
52+
53+
if not subcommand:
54+
_blurb_help()
55+
raise SystemExit(0)
56+
57+
fn = get_subcommand(subcommand)
58+
doc = fn.__doc__.strip()
59+
if not doc:
60+
error(f'help is broken, no docstring for {subcommand}')
61+
62+
options = []
63+
positionals = []
64+
65+
nesting = 0
66+
for name, p in inspect.signature(fn).parameters.items():
67+
if p.kind == inspect.Parameter.KEYWORD_ONLY:
68+
short_option = name[0]
69+
if isinstance(p.default, bool):
70+
options.append(f' [-{short_option}|--{name}]')
71+
else:
72+
if p.default is None:
73+
metavar = f'{name.upper()}'
74+
else:
75+
metavar = f'{name.upper()}[={p.default}]'
76+
options.append(f' [-{short_option}|--{name} {metavar}]')
77+
elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
78+
positionals.append(' ')
79+
has_default = (p.default != inspect._empty)
80+
if has_default:
81+
positionals.append('[')
82+
nesting += 1
83+
positionals.append(f'<{name}>')
84+
positionals.append(']' * nesting)
85+
86+
parameters = ''.join(options + positionals)
87+
print(f'blurb {subcommand}{parameters}')
88+
print()
89+
print(doc)
90+
raise SystemExit(0)
91+
92+
93+
# Make 'blurb --help/--version/-V' work.
94+
subcommands['--help'] = help
95+
subcommands['--version'] = version
96+
subcommands['-V'] = version
97+
98+
99+
def _blurb_help() -> None:
100+
"""Print default help for blurb."""
101+
102+
print('blurb version', blurb.__version__)
103+
print()
104+
print('Management tool for CPython Misc/NEWS and Misc/NEWS.d entries.')
105+
print()
106+
print('Usage:')
107+
print(' blurb [subcommand] [options...]')
108+
print()
109+
110+
# print list of subcommands
111+
summaries = []
112+
longest_name_len = -1
113+
for name, fn in subcommands.items():
114+
if name.startswith('-'):
115+
continue
116+
longest_name_len = max(longest_name_len, len(name))
117+
if not fn.__doc__:
118+
error(f'help is broken, no docstring for {fn.__name__}')
119+
fields = fn.__doc__.lstrip().split('\n')
120+
if not fields:
121+
first_line = '(no help available)'
122+
else:
123+
first_line = fields[0]
124+
summaries.append((name, first_line))
125+
summaries.sort()
126+
127+
print('Available subcommands:')
128+
print()
129+
for name, summary in summaries:
130+
print(' ', name.ljust(longest_name_len), ' ', summary)
131+
132+
print()
133+
print("If blurb is run without any arguments, this is equivalent to 'blurb add'.")
134+
135+
136+
def main() -> None:
137+
global original_dir
138+
139+
args = sys.argv[1:]
140+
141+
if not args:
142+
args = ['add']
143+
elif args[0] == '-h':
144+
# slight hack
145+
args[0] = 'help'
146+
147+
subcommand = args[0]
148+
args = args[1:]
149+
150+
fn = get_subcommand(subcommand)
151+
152+
# hack
153+
if fn in (help, version):
154+
raise SystemExit(fn(*args))
155+
156+
try:
157+
original_dir = os.getcwd()
158+
chdir_to_repo_root()
159+
160+
# map keyword arguments to options
161+
# we only handle boolean options
162+
# and they must have default values
163+
short_options = {}
164+
long_options = {}
165+
kwargs = {}
166+
for name, p in inspect.signature(fn).parameters.items():
167+
if p.kind == inspect.Parameter.KEYWORD_ONLY:
168+
if (p.default is not None
169+
and not isinstance(p.default, (bool, str))):
170+
raise SystemExit(
171+
'blurb command-line processing cannot handle '
172+
f'options of type {type(p.default).__qualname__}'
173+
)
174+
175+
kwargs[name] = p.default
176+
short_options[name[0]] = name
177+
long_options[name] = name
178+
179+
filtered_args = []
180+
done_with_options = False
181+
consume_after = None
182+
183+
def handle_option(s, dict):
184+
nonlocal consume_after
185+
name = dict.get(s, None)
186+
if not name:
187+
raise SystemExit(f'blurb: Unknown option for {subcommand}: "{s}"')
188+
189+
value = kwargs[name]
190+
if isinstance(value, bool):
191+
kwargs[name] = not value
192+
else:
193+
consume_after = name
194+
195+
for a in args:
196+
if consume_after:
197+
kwargs[consume_after] = a
198+
consume_after = None
199+
continue
200+
if done_with_options:
201+
filtered_args.append(a)
202+
continue
203+
if a.startswith('-'):
204+
if a == '--':
205+
done_with_options = True
206+
elif a.startswith('--'):
207+
handle_option(a[2:], long_options)
208+
else:
209+
for s in a[1:]:
210+
handle_option(s, short_options)
211+
continue
212+
filtered_args.append(a)
213+
214+
if consume_after:
215+
raise SystemExit(
216+
f'Error: blurb: {subcommand} {consume_after} '
217+
'must be followed by an option argument'
218+
)
219+
220+
raise SystemExit(fn(*filtered_args, **kwargs))
221+
except TypeError as e:
222+
# almost certainly wrong number of arguments.
223+
# count arguments of function and print appropriate error message.
224+
specified = len(args)
225+
required = optional = 0
226+
for p in inspect.signature(fn).parameters.values():
227+
if p.default == inspect._empty:
228+
required += 1
229+
else:
230+
optional += 1
231+
total = required + optional
232+
233+
if required <= specified <= total:
234+
# whoops, must be a real type error, reraise
235+
raise e
236+
237+
how_many = f'{specified} argument'
238+
if specified != 1:
239+
how_many += 's'
240+
241+
if total == 0:
242+
middle = 'accepts no arguments'
243+
else:
244+
if total == required:
245+
middle = 'requires'
246+
else:
247+
plural = '' if required == 1 else 's'
248+
middle = f'requires at least {required} argument{plural} and at most'
249+
middle += f' {total} argument'
250+
if total != 1:
251+
middle += 's'
252+
253+
print(f'Error: Wrong number of arguments!\n\nblurb {subcommand} {middle},\nand you specified {how_many}.')
254+
print()
255+
print('usage: ', end='')
256+
help(subcommand)
257+
258+
259+
def chdir_to_repo_root() -> str:
260+
# find the root of the local CPython repo
261+
# note that we can't ask git, because we might
262+
# be in an exported directory tree!
263+
264+
# we intentionally start in a (probably nonexistant) subtree
265+
# the first thing the while loop does is .., basically
266+
path = os.path.abspath('garglemox')
267+
while True:
268+
next_path = os.path.dirname(path)
269+
if next_path == path:
270+
raise SystemExit("You're not inside a CPython repo right now!")
271+
path = next_path
272+
273+
os.chdir(path)
274+
275+
def test_first_line(filename, test):
276+
if not os.path.exists(filename):
277+
return False
278+
with open(filename, encoding='utf-8') as file:
279+
lines = file.read().split('\n')
280+
if not (lines and test(lines[0])):
281+
return False
282+
return True
283+
284+
if not (test_first_line('README', readme_re)
285+
or test_first_line('README.rst', readme_re)):
286+
continue
287+
288+
if not test_first_line('LICENSE', 'A. HISTORY OF THE SOFTWARE'.__eq__):
289+
continue
290+
if not os.path.exists('Include/Python.h'):
291+
continue
292+
if not os.path.exists('Python/ceval.c'):
293+
continue
294+
295+
break
296+
297+
blurb.root = path
298+
return path

0 commit comments

Comments
 (0)