Skip to content

Commit 82b0b5e

Browse files
committed
Rewrite argument parser
1 parent 80427c7 commit 82b0b5e

File tree

3 files changed

+272
-94
lines changed

3 files changed

+272
-94
lines changed

julia/pseudo_python_cli.py

Lines changed: 225 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,43 @@
77

88
from __future__ import print_function, absolute_import
99

10-
import argparse
10+
from collections import namedtuple
1111
import code
12+
import copy
1213
import runpy
1314
import sys
1415
import traceback
1516

17+
try:
18+
from types import SimpleNamespace
19+
except ImportError:
20+
from argparse import Namespace as SimpleNamespace
21+
22+
23+
ARGUMENT_HELP = """
24+
positional arguments:
25+
script path to file (default: None)
26+
args arguments passed to program in sys.argv[1:]
27+
28+
optional arguments:
29+
-h, --help show this help message and exit
30+
-i inspect interactively after running script.
31+
--version, -V Print the Python version number and exit.
32+
-VV is not supported.
33+
-c COMMAND Execute the Python code in COMMAND.
34+
-m MODULE Search sys.path for the named MODULE and execute its contents
35+
as the __main__ module.
36+
"""
37+
1638

1739
def python(module, command, script, args, interactive):
1840
if command:
1941
sys.argv[0] = "-c"
20-
elif script:
21-
sys.argv[0] = script
42+
43+
assert sys.argv
2244
sys.argv[1:] = args
45+
if script:
46+
sys.argv[0] = script
2347

2448
banner = ""
2549
try:
@@ -50,93 +74,221 @@ def python(module, command, script, args, interactive):
5074
if interactive:
5175
code.interact(banner=banner, local=scope)
5276

77+
ArgDest = namedtuple("ArgDest", "dest names default")
78+
Optional = namedtuple("Optional", "name is_long argdest nargs action terminal")
79+
Result = namedtuple("Result", "option values")
80+
81+
82+
class PyArgumentParser(object):
83+
84+
"""
85+
`ArgumentParser`-like parser with "terminal option" support.
86+
87+
Major differences:
88+
89+
* Formatted help has to be provided to `description`.
90+
* Many options for `.add_argument` are not supported.
91+
* Especially, there is no positional argument support: all positional
92+
arguments go into `ns.args`.
93+
* `.add_argument` can take boolean option `terminal` (default: `False`)
94+
to stop parsing after consuming the given option.
95+
"""
96+
97+
def __init__(self, prog=None, usage="%(prog)s [options] [args]",
98+
description=""):
99+
self.prog = sys.argv[0] if prog is None else prog
100+
self.usage = usage
101+
self.description = description
102+
103+
self._dests = ["args"]
104+
self._argdests = [ArgDest("args", (), [])]
105+
self._options = []
106+
107+
self.add_argument("--help", "-h", "-?", action="store_true")
108+
109+
def format_usage(self):
110+
return "usage: " + self.usage % {"prog": self.prog}
111+
112+
# Once we drop Python 2, we can do:
113+
"""
114+
def add_argument(self, name, *alt, dest=None, nargs=None, action=None,
115+
default=None, terminal=False):
116+
"""
117+
118+
def add_argument(self, name, *alt, **kwargs):
119+
return self._add_argument_impl(name, alt, **kwargs)
120+
121+
def _add_argument_impl(self, name, alt, dest=None, nargs=None, action=None,
122+
default=None, terminal=False):
123+
if dest is None:
124+
if name.startswith("--"):
125+
dest = name[2:]
126+
elif not name.startswith("-"):
127+
dest = name
128+
else:
129+
raise ValueError(name)
130+
131+
if not name.startswith("-"):
132+
raise NotImplementedError(
133+
"Positional arguments are not supported."
134+
" All positional arguments will be stored in `ns.args`.")
135+
if terminal and action is not None:
136+
raise NotImplementedError("Terminal option has to have argument.")
137+
138+
if nargs is not None and action is not None:
139+
raise TypeError("`nargs` and `action` are mutually exclusive")
140+
if action == "store_true":
141+
nargs = 0
142+
if nargs is None:
143+
nargs = 1
144+
assert isinstance(nargs, int)
145+
assert action in (None, "store_true")
53146

54-
class CustomFormatter(argparse.RawDescriptionHelpFormatter,
55-
argparse.ArgumentDefaultsHelpFormatter):
56-
pass
147+
assert dest not in self._dests
148+
self._dests.append(dest)
57149

150+
argdest = ArgDest(
151+
dest=dest,
152+
names=(name,) + alt,
153+
default=default,
154+
)
155+
self._argdests.append(argdest)
58156

59-
def make_parser(description=__doc__):
60-
parser = argparse.ArgumentParser(
157+
for arg in (name,) + alt:
158+
self._options.append(Optional(
159+
name=arg,
160+
is_long=arg.startswith("--"),
161+
argdest=argdest,
162+
nargs=nargs,
163+
action=action,
164+
terminal=terminal,
165+
))
166+
167+
def parse_args(self, args):
168+
ns = SimpleNamespace(**{
169+
argdest.dest: copy.copy(argdest.default)
170+
for argdest in self._argdests
171+
})
172+
args_iter = iter(args)
173+
self._parse_until_terminal(ns, args_iter)
174+
ns.args.extend(args_iter)
175+
176+
if ns.help:
177+
self.print_help()
178+
self.exit()
179+
del ns.help
180+
181+
return ns
182+
183+
def _parse_until_terminal(self, ns, args_iter):
184+
seen = set()
185+
for a in args_iter:
186+
187+
results = self._find_matches(a)
188+
if not results:
189+
ns.args.append(a)
190+
break
191+
192+
for i, res in enumerate(results):
193+
dest = res.option.argdest.dest
194+
if dest in seen:
195+
self._usage_and_error(
196+
"{} provided more than twice"
197+
.format(" ".join(res.option.argdest.names)))
198+
seen.add(dest)
199+
200+
while len(res.values) < res.option.nargs:
201+
try:
202+
res.values.append(next(args_iter))
203+
except StopIteration:
204+
self.error(self.format_usage())
205+
206+
if res.option.action == "store_true":
207+
setattr(ns, dest, True)
208+
else:
209+
value = res.values
210+
if res.option.nargs == 1:
211+
value, = value
212+
setattr(ns, dest, value)
213+
214+
if res.option.terminal:
215+
assert i == len(results) - 1
216+
return
217+
218+
def _find_matches(self, arg):
219+
for opt in self._options:
220+
if arg == opt.name:
221+
return [Result(opt, [])]
222+
elif arg.startswith(opt.name):
223+
# i.e., len(arg) > len(opt.name):
224+
if opt.is_long and arg[len(opt.name)] == "=":
225+
return [Result(opt, [arg[len(opt.name) + 1:]])]
226+
elif not opt.is_long:
227+
if opt.nargs > 0:
228+
return [Result(opt, [arg[len(opt.name):]])]
229+
else:
230+
results = [Result(opt, [])]
231+
rest = "-" + arg[len(opt.name):]
232+
results.extend(self._find_matches(rest))
233+
return results
234+
# arg="-ih" -> rest="-h"
235+
return []
236+
237+
def print_usage(self, file=None):
238+
print(self.format_usage(), file=file or sys.stdout)
239+
240+
def print_help(self):
241+
self.print_usage()
242+
print()
243+
print(self.description)
244+
245+
def exit(self, status=0):
246+
sys.exit(status)
247+
248+
def _usage_and_error(self, message):
249+
self.print_usage(sys.stderr)
250+
print(file=sys.stderr)
251+
self.error(message)
252+
253+
def error(self, message):
254+
print(message, file=sys.stderr)
255+
self.exit(2)
256+
257+
258+
def make_parser(description=__doc__ + ARGUMENT_HELP):
259+
parser = PyArgumentParser(
61260
prog=None if sys.argv[0] else "python",
62261
usage="%(prog)s [option] ... [-c cmd | -m mod | script | -] [args]",
63-
formatter_class=CustomFormatter,
64262
description=description)
65263

66-
parser.add_argument(
67-
"-i", dest="interactive", action="store_true",
68-
help="""
69-
inspect interactively after running script.
70-
""")
71-
parser.add_argument(
72-
"--version", "-V", action="version",
73-
version="Python {0}.{1}.{2}".format(*sys.version_info),
74-
help="""
75-
print the Python version number and exit.
76-
-VV is not supported.
77-
""")
78-
79-
group = parser.add_mutually_exclusive_group()
80-
group.add_argument(
81-
"-c", dest="command",
82-
help="""
83-
Execute the Python code in COMMAND.
84-
""")
85-
group.add_argument(
86-
"-m", dest="module",
87-
help="""
88-
Search sys.path for the named MODULE and execute its contents
89-
as the __main__ module.
90-
""")
91-
92-
parser.add_argument(
93-
"script", nargs="?",
94-
help="path to file")
95-
parser.add_argument(
96-
"args", nargs=argparse.REMAINDER,
97-
help="arguments passed to program in sys.argv[1:]")
264+
parser.add_argument("-i", dest="interactive", action="store_true")
265+
parser.add_argument("--version", "-V", action="store_true")
266+
parser.add_argument("-c", dest="command", terminal=True)
267+
parser.add_argument("-m", dest="module", terminal=True)
98268

99269
return parser
100270

101271

102272
def parse_args_with(parser, args):
103-
ns = parser.parse_args(list(preprocess_args(args)))
104-
if (ns.command or ns.module) and ns.script:
105-
ns.args = [ns.script] + ns.args
106-
ns.script = None
107-
return ns
273+
ns = parser.parse_args(args)
108274

275+
if ns.command and ns.module:
276+
parser.error("-c and -m are mutually exclusive")
277+
if ns.version:
278+
print("Python {0}.{1}.{2}".format(*sys.version_info))
279+
parser.exit()
280+
del ns.version
109281

110-
def parse_args(args):
111-
return parse_args_with(make_parser(), args)
282+
ns.script = None
283+
if (not (ns.command or ns.module)) and ns.args:
284+
ns.script = ns.args[0]
285+
ns.args = ns.args[1:]
112286

287+
return ns
113288

114-
def preprocess_args(args):
115-
"""
116-
Insert "--" after "[-c cmd | -m mod | script | -]"
117-
118-
This is required for the following to work:
119289

120-
>>> ns = parse_args(["-mjson.tool", "-h"])
121-
>>> ns.args
122-
['-h']
123-
"""
124-
it = iter(args)
125-
for a in it:
126-
yield a
127-
128-
if a in ("-m", "-c"):
129-
try:
130-
yield next(it)
131-
except StopIteration:
132-
return
133-
yield "--"
134-
elif a == "-":
135-
yield "--"
136-
elif a.startswith("-"):
137-
if a[1] in ("m", "c"):
138-
yield "--"
139-
# otherwise, it's some
290+
def parse_args(args):
291+
return parse_args_with(make_parser(), args)
140292

141293

142294
def main(args=None):

julia/python_jl.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828
import os
2929
import sys
3030

31-
from .pseudo_python_cli import make_parser, parse_args_with
31+
from .pseudo_python_cli import make_parser, parse_args_with, ARGUMENT_HELP
32+
33+
PYJL_ARGUMENT_HELP = ARGUMENT_HELP + """
34+
--julia JULIA Julia interpreter to be used. (default: julia)
35+
"""
3236

3337
script_jl = """
3438
import PyCall
@@ -99,12 +103,8 @@ def parse_pyjl_args(args):
99103
# parse error right now without initiating Julia interpreter and
100104
# importing PyCall.jl etc. to get an extra speedup for the
101105
# abnormal case (including -h/--help and -V/--version).
102-
parser = make_parser(description=__doc__)
103-
parser.add_argument(
104-
"--julia", default="julia",
105-
help="""
106-
Julia interpreter to be used.
107-
""")
106+
parser = make_parser(description=__doc__ + PYJL_ARGUMENT_HELP)
107+
parser.add_argument("--julia", default="julia")
108108

109109
ns = parse_args_with(parser, args)
110110
unused_args = list(remove_julia_options(args))

0 commit comments

Comments
 (0)