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 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
@@ -0,0 +1,3 @@
1
+ from argly.cli import main
2
+
3
+ raise SystemExit(main())
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')