argly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argly/__init__.py +17 -0
- argly/__main__.py +3 -0
- argly/_parser.py +271 -0
- argly/app.py +151 -0
- argly/cli.py +16 -0
- argly/compiler.py +275 -0
- argly/declarations.py +81 -0
- argly/helpgen.py +187 -0
- argly/py.typed +0 -0
- argly/schema.py +166 -0
- argly-0.1.0.dist-info/METADATA +158 -0
- argly-0.1.0.dist-info/RECORD +16 -0
- argly-0.1.0.dist-info/WHEEL +5 -0
- argly-0.1.0.dist-info/entry_points.txt +2 -0
- argly-0.1.0.dist-info/licenses/LICENSE +21 -0
- argly-0.1.0.dist-info/top_level.txt +1 -0
argly/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from argly._parser import UsageError
|
|
3
|
+
from argly.app import App, Invocation
|
|
4
|
+
from argly.declarations import Count, Flag, Option, Argument, Inherited, command, group
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'App',
|
|
8
|
+
'Argument',
|
|
9
|
+
'Count',
|
|
10
|
+
'Flag',
|
|
11
|
+
'Inherited',
|
|
12
|
+
'Invocation',
|
|
13
|
+
'Option',
|
|
14
|
+
'UsageError',
|
|
15
|
+
'command',
|
|
16
|
+
'group',
|
|
17
|
+
]
|
argly/__main__.py
ADDED
argly/_parser.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UsageError(ValueError):
|
|
6
|
+
"""An invalid invocation, reported by App.run with exit status 2."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParseResult:
|
|
10
|
+
__slots__ = ('node', 'values', 'help_requested')
|
|
11
|
+
|
|
12
|
+
def __init__(self, node: Node, values: dict[str, Any], help_requested: bool) -> None:
|
|
13
|
+
self.node = node
|
|
14
|
+
self.values = values
|
|
15
|
+
self.help_requested = help_requested
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Node:
|
|
19
|
+
__slots__ = (
|
|
20
|
+
'path',
|
|
21
|
+
'summary',
|
|
22
|
+
'handler',
|
|
23
|
+
'bindings',
|
|
24
|
+
'options',
|
|
25
|
+
'arguments',
|
|
26
|
+
'children',
|
|
27
|
+
'lookup',
|
|
28
|
+
'defaults',
|
|
29
|
+
'required',
|
|
30
|
+
'mutable_defaults',
|
|
31
|
+
'windows_options',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(self, data: dict[str, Any], parent: Optional[Node], windows: bool) -> None:
|
|
35
|
+
self.path: str = data['path']
|
|
36
|
+
self.summary: str = data['summary']
|
|
37
|
+
self.handler: Optional[str] = data['handler']
|
|
38
|
+
self.bindings: dict[str, str] = data['bindings']
|
|
39
|
+
self.arguments: list[dict[str, Any]] = [spec.copy() for spec in data['arguments']]
|
|
40
|
+
self.children: dict[str, Node] = {}
|
|
41
|
+
self.windows_options = windows
|
|
42
|
+
self.options: list[dict[str, Any]] = ([] if parent is None else parent.options) + data[
|
|
43
|
+
'options'
|
|
44
|
+
]
|
|
45
|
+
self.lookup: dict[str, dict[str, Any]] = {}
|
|
46
|
+
self.defaults: dict[str, Any] = {}
|
|
47
|
+
self.required: list[str] = []
|
|
48
|
+
for option in self.options:
|
|
49
|
+
for spelling in option['names']:
|
|
50
|
+
if windows or not spelling.startswith('/'):
|
|
51
|
+
self.lookup[spelling] = option
|
|
52
|
+
|
|
53
|
+
self.defaults[option['dest']] = _default_value(option)
|
|
54
|
+
if option['required']:
|
|
55
|
+
self.required.append(option['dest'])
|
|
56
|
+
|
|
57
|
+
for argument in self.arguments:
|
|
58
|
+
argument['default'] = _default_value(argument)
|
|
59
|
+
|
|
60
|
+
self.mutable_defaults = tuple(
|
|
61
|
+
dest for dest, default in self.defaults.items() if isinstance(default, list)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _default_value(spec: dict[str, Any]) -> Any:
|
|
66
|
+
default = spec['default']
|
|
67
|
+
if default is not None and spec['type'] == 'path':
|
|
68
|
+
if spec['multiple']:
|
|
69
|
+
return [_convert(item, spec) for item in default]
|
|
70
|
+
|
|
71
|
+
return _convert(default, spec)
|
|
72
|
+
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _convert(value: str, spec: dict[str, Any]) -> Any:
|
|
77
|
+
kind = spec['type']
|
|
78
|
+
try:
|
|
79
|
+
if kind == 'str':
|
|
80
|
+
result: Any = value
|
|
81
|
+
elif kind == 'int':
|
|
82
|
+
result = int(value)
|
|
83
|
+
elif kind == 'float':
|
|
84
|
+
result = float(value)
|
|
85
|
+
elif kind == 'path':
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
|
|
88
|
+
result = Path(value)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f'unsupported value type: {kind}')
|
|
91
|
+
except (ValueError, OverflowError) as error:
|
|
92
|
+
raise UsageError(f'{spec["dest"]}: invalid {kind} value {value!r}') from error
|
|
93
|
+
|
|
94
|
+
choices = spec['choices']
|
|
95
|
+
choice_value = str(result) if kind == 'path' else result
|
|
96
|
+
if choices is not None and choice_value not in choices:
|
|
97
|
+
raise UsageError(f'{spec["dest"]}: choose from {", ".join(map(str, choices))}')
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _store(values: dict[str, Any], spec: dict[str, Any], value: Optional[str]) -> None:
|
|
103
|
+
dest = spec['dest']
|
|
104
|
+
action = spec['action']
|
|
105
|
+
if action == 'flag':
|
|
106
|
+
values[dest] = not spec['default']
|
|
107
|
+
elif action == 'count':
|
|
108
|
+
values[dest] = values.get(dest, spec['default']) + 1
|
|
109
|
+
else:
|
|
110
|
+
assert value is not None
|
|
111
|
+
converted = _convert(value, spec)
|
|
112
|
+
if spec['multiple']:
|
|
113
|
+
if dest in values:
|
|
114
|
+
values[dest].append(converted)
|
|
115
|
+
else:
|
|
116
|
+
values[dest] = [converted]
|
|
117
|
+
else:
|
|
118
|
+
values[dest] = converted
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _looks_like_option(value: str, node: Node) -> bool:
|
|
122
|
+
if value == '--' or value in ('--help', '-h'):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
if value.startswith('-') and value != '-':
|
|
126
|
+
try:
|
|
127
|
+
float(value)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return node.windows_options and value.partition('=')[0] in node.lookup
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(root: Node, argv: list[str]) -> ParseResult:
|
|
135
|
+
node = root
|
|
136
|
+
values: dict[str, Any] = {}
|
|
137
|
+
positionals: list[str] = []
|
|
138
|
+
help_requested = False
|
|
139
|
+
literal = False
|
|
140
|
+
index = 0
|
|
141
|
+
size = len(argv)
|
|
142
|
+
while index < size:
|
|
143
|
+
token = argv[index]
|
|
144
|
+
index += 1
|
|
145
|
+
if literal:
|
|
146
|
+
positionals.append(token)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if token == '--':
|
|
150
|
+
literal = True
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if token in ('--help', '-h'):
|
|
154
|
+
help_requested = True
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
name, equal, attached = token.partition('=')
|
|
158
|
+
spec = node.lookup.get(name)
|
|
159
|
+
if spec is not None:
|
|
160
|
+
if spec['action'] != 'value':
|
|
161
|
+
if equal:
|
|
162
|
+
raise UsageError(f'{name} does not take a value')
|
|
163
|
+
|
|
164
|
+
_store(values, spec, None)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if not equal:
|
|
168
|
+
if index == size or _looks_like_option(argv[index], node):
|
|
169
|
+
raise UsageError(
|
|
170
|
+
f"{name} requires a value (use {name}=VALUE for a value starting with '-')"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
attached = argv[index]
|
|
174
|
+
index += 1
|
|
175
|
+
|
|
176
|
+
_store(values, spec, attached)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if token.startswith('--'):
|
|
180
|
+
raise UsageError(f'unknown option {name!r}')
|
|
181
|
+
|
|
182
|
+
negative_number = False
|
|
183
|
+
if (
|
|
184
|
+
token.startswith('-')
|
|
185
|
+
and len(token) > 1
|
|
186
|
+
and (token[1].isdigit() or token[1] == '.')
|
|
187
|
+
and node.arguments
|
|
188
|
+
):
|
|
189
|
+
try:
|
|
190
|
+
float(token)
|
|
191
|
+
negative_number = True
|
|
192
|
+
except ValueError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
if token.startswith('-') and token != '-' and not negative_number:
|
|
196
|
+
offset = 1
|
|
197
|
+
while offset < len(token):
|
|
198
|
+
short = '-' + token[offset]
|
|
199
|
+
if short == '-h':
|
|
200
|
+
help_requested = True
|
|
201
|
+
offset += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
spec = node.lookup.get(short)
|
|
205
|
+
if spec is None:
|
|
206
|
+
raise UsageError(f'unknown option {short!r}')
|
|
207
|
+
|
|
208
|
+
offset += 1
|
|
209
|
+
if spec['action'] != 'value':
|
|
210
|
+
_store(values, spec, None)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
attached = token[offset:]
|
|
214
|
+
if attached.startswith('='):
|
|
215
|
+
attached = attached[1:]
|
|
216
|
+
elif not attached:
|
|
217
|
+
if index == size or _looks_like_option(argv[index], node):
|
|
218
|
+
raise UsageError(f'{short} requires a value')
|
|
219
|
+
|
|
220
|
+
attached = argv[index]
|
|
221
|
+
index += 1
|
|
222
|
+
|
|
223
|
+
_store(values, spec, attached)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if node.children and not positionals:
|
|
229
|
+
child = node.children.get(token)
|
|
230
|
+
if child is None:
|
|
231
|
+
raise UsageError(f'unknown command {token!r} under {node.path or "<root>"}')
|
|
232
|
+
|
|
233
|
+
node = child
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
positionals.append(token)
|
|
237
|
+
|
|
238
|
+
if help_requested:
|
|
239
|
+
return ParseResult(node, values, True)
|
|
240
|
+
|
|
241
|
+
for dest in node.required:
|
|
242
|
+
if dest not in values:
|
|
243
|
+
raise UsageError(f'missing required option --{dest.replace("_", "-")}')
|
|
244
|
+
|
|
245
|
+
result = node.defaults.copy()
|
|
246
|
+
for dest in node.mutable_defaults:
|
|
247
|
+
result[dest] = result[dest].copy()
|
|
248
|
+
|
|
249
|
+
result.update(values)
|
|
250
|
+
offset = 0
|
|
251
|
+
for spec in node.arguments:
|
|
252
|
+
dest = spec['dest']
|
|
253
|
+
if spec['multiple']:
|
|
254
|
+
rest = positionals[offset:]
|
|
255
|
+
if spec['required'] and not rest:
|
|
256
|
+
raise UsageError(f'missing required argument {dest}')
|
|
257
|
+
|
|
258
|
+
result[dest] = [_convert(value, spec) for value in rest] if rest else spec['default'][:]
|
|
259
|
+
offset = len(positionals)
|
|
260
|
+
elif offset < len(positionals):
|
|
261
|
+
result[dest] = _convert(positionals[offset], spec)
|
|
262
|
+
offset += 1
|
|
263
|
+
elif spec['required']:
|
|
264
|
+
raise UsageError(f'missing required argument {dest}')
|
|
265
|
+
else:
|
|
266
|
+
result[dest] = spec['default']
|
|
267
|
+
|
|
268
|
+
if offset < len(positionals):
|
|
269
|
+
raise UsageError(f'unexpected argument {positionals[offset]!r}')
|
|
270
|
+
|
|
271
|
+
return ParseResult(node, result, False)
|
argly/app.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import Any, TextIO, Optional
|
|
5
|
+
from argly.schema import validate_registry
|
|
6
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
7
|
+
from argly._parser import Node, UsageError, ParseResult, parse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Invocation:
|
|
11
|
+
"""A parsed invocation. Parsing does not import the selected handler."""
|
|
12
|
+
|
|
13
|
+
__slots__ = ('path', 'values', 'kwargs', 'help_requested', '_node')
|
|
14
|
+
|
|
15
|
+
def __init__(self, result: ParseResult) -> None:
|
|
16
|
+
self.path = result.node.path
|
|
17
|
+
self.values = result.values
|
|
18
|
+
self.help_requested = result.help_requested
|
|
19
|
+
self.kwargs = (
|
|
20
|
+
{}
|
|
21
|
+
if result.help_requested
|
|
22
|
+
else {
|
|
23
|
+
parameter: result.values[source]
|
|
24
|
+
for parameter, source in result.node.bindings.items()
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
self._node = result.node
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class App:
|
|
31
|
+
"""A compiled command tree. Reuse it to avoid rebuilding parser tables."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ('name', 'registry', '_nodes', '_root', '_help_lookup', '_handlers')
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
commands: Iterable[Callable[..., Any]] = (),
|
|
39
|
+
*,
|
|
40
|
+
windows_options: bool = False,
|
|
41
|
+
help_lookup: Optional[Callable[[str], Optional[str]]] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
from argly.compiler import build_registry
|
|
44
|
+
|
|
45
|
+
functions = tuple(commands)
|
|
46
|
+
registry = build_registry(name, functions, windows_options=windows_options)
|
|
47
|
+
self._initialize(registry, help_lookup)
|
|
48
|
+
for function in functions:
|
|
49
|
+
path = getattr(function, '__argly__')[0] # noqa: B009
|
|
50
|
+
handler = self._nodes[path].handler
|
|
51
|
+
if handler is not None:
|
|
52
|
+
self._handlers[handler] = function
|
|
53
|
+
|
|
54
|
+
def parse(self, args: Sequence[str]) -> Invocation:
|
|
55
|
+
"""Parse explicit arguments without calling handlers or writing output."""
|
|
56
|
+
return Invocation(parse(self._root, list(args)))
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
self,
|
|
60
|
+
args: Optional[Sequence[str]] = None,
|
|
61
|
+
*,
|
|
62
|
+
out: Optional[TextIO] = None,
|
|
63
|
+
err: Optional[TextIO] = None,
|
|
64
|
+
) -> int:
|
|
65
|
+
"""Return a handler's exit status, 0 for help, or 2 for usage errors."""
|
|
66
|
+
output = sys.stdout if out is None else out
|
|
67
|
+
errors = sys.stderr if err is None else err
|
|
68
|
+
try:
|
|
69
|
+
invocation = self.parse(sys.argv[1:] if args is None else args)
|
|
70
|
+
node = invocation._node
|
|
71
|
+
if invocation.help_requested or node.handler is None:
|
|
72
|
+
output.write(self.format_help(node.path))
|
|
73
|
+
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
handler = self._handlers.get(node.handler)
|
|
77
|
+
if handler is None:
|
|
78
|
+
module_name, _, attribute = node.handler.partition(':')
|
|
79
|
+
target: Any = import_module(module_name)
|
|
80
|
+
for part in attribute.split('.'):
|
|
81
|
+
target = getattr(target, part)
|
|
82
|
+
|
|
83
|
+
if not callable(target):
|
|
84
|
+
raise TypeError(f'handler {node.handler!r} is not callable')
|
|
85
|
+
|
|
86
|
+
handler = target
|
|
87
|
+
self._handlers[node.handler] = handler
|
|
88
|
+
|
|
89
|
+
code = handler(**invocation.kwargs)
|
|
90
|
+
if type(code) is not int:
|
|
91
|
+
raise TypeError(f'command {node.path or self.name!r} must return an int')
|
|
92
|
+
|
|
93
|
+
return code
|
|
94
|
+
except UsageError as error:
|
|
95
|
+
errors.write(f'{self.name}: error: {error}\n')
|
|
96
|
+
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
def format_help(self, path: str = '') -> str:
|
|
100
|
+
"""Use generated help when available, otherwise render one page on demand."""
|
|
101
|
+
if path not in self._nodes:
|
|
102
|
+
raise ValueError(f'unknown command path {path!r}')
|
|
103
|
+
|
|
104
|
+
if self._help_lookup is not None:
|
|
105
|
+
text = self._help_lookup(path)
|
|
106
|
+
if text is not None:
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
from argly.helpgen import render
|
|
110
|
+
|
|
111
|
+
return render(self.name, self._nodes[path])
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_registry(
|
|
115
|
+
cls,
|
|
116
|
+
registry: dict[str, Any],
|
|
117
|
+
*,
|
|
118
|
+
help_lookup: Optional[Callable[[str], Optional[str]]] = None,
|
|
119
|
+
) -> App:
|
|
120
|
+
"""Load generated metadata without discovering or importing command modules."""
|
|
121
|
+
app = cls.__new__(cls)
|
|
122
|
+
app._initialize(registry, help_lookup)
|
|
123
|
+
|
|
124
|
+
return app
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def discover(cls, name: str, package: str, *, windows_options: bool = False) -> App:
|
|
128
|
+
"""Import a package's declarations for development or help generation."""
|
|
129
|
+
from argly.compiler import discover
|
|
130
|
+
|
|
131
|
+
return cls(name, discover(package), windows_options=windows_options)
|
|
132
|
+
|
|
133
|
+
def _initialize(
|
|
134
|
+
self,
|
|
135
|
+
registry: dict[str, Any],
|
|
136
|
+
help_lookup: Optional[Callable[[str], Optional[str]]],
|
|
137
|
+
) -> None:
|
|
138
|
+
self.registry = validate_registry(registry)
|
|
139
|
+
self.name: str = self.registry['name']
|
|
140
|
+
self._help_lookup = help_lookup
|
|
141
|
+
self._handlers: dict[str, Callable[..., Any]] = {}
|
|
142
|
+
self._nodes: dict[str, Node] = {}
|
|
143
|
+
for entry in self.registry['commands']:
|
|
144
|
+
path = entry['path']
|
|
145
|
+
parent = self._nodes.get(path.rpartition(' ')[0]) if path else None
|
|
146
|
+
node = Node(entry, parent, self.registry['windows_options'])
|
|
147
|
+
self._nodes[path] = node
|
|
148
|
+
if parent is not None:
|
|
149
|
+
parent.children[path.rpartition(' ')[2]] = node
|
|
150
|
+
|
|
151
|
+
self._root = self._nodes['']
|
argly/cli.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sys import argv
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from argparse import ArgumentParser
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from argly.helpgen import main as generate_main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(args: Optional[Sequence[str]] = None) -> int:
|
|
9
|
+
arguments = list(argv[1:] if args is None else args)
|
|
10
|
+
parser = ArgumentParser(prog='argly', description='Tools for building argly applications.')
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
'command', choices=['gen'], help='Generate static help and a command registry'
|
|
13
|
+
)
|
|
14
|
+
parser.parse_args(arguments[:1])
|
|
15
|
+
|
|
16
|
+
return generate_main(arguments[1:], prog='argly gen')
|