argly 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caprine Logic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include benchmarks/*.py
2
+ recursive-include examples *.py
3
+ recursive-include tests *.py
4
+ global-exclude __pycache__ *.py[cod] *.pyd *.so
argly-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: argly
3
+ Version: 0.1.0
4
+ Summary: A fast, module-oriented command-line framework with lazy handlers.
5
+ Author: depthbomb
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/depthbomb/argly#readme
8
+ Project-URL: Source, https://github.com/depthbomb/argly
9
+ Project-URL: Issues, https://github.com/depthbomb/argly/issues
10
+ Project-URL: Releases, https://github.com/depthbomb/argly/releases
11
+ Keywords: cli,command-line,parser,arguments,lazy-loading
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == "dev"
23
+ Requires-Dist: mypy>=1.15; extra == "dev"
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: pytest-cov>=6; extra == "dev"
26
+ Requires-Dist: ruff>=0.11; extra == "dev"
27
+ Requires-Dist: twine>=6; extra == "dev"
28
+ Provides-Extra: cython
29
+ Requires-Dist: Cython>=3.1; extra == "cython"
30
+ Requires-Dist: setuptools>=77; extra == "cython"
31
+ Requires-Dist: wheel>=0.45; extra == "cython"
32
+ Dynamic: license-file
33
+
34
+ # argly
35
+
36
+ Requires Python 3.14 or newer.
37
+
38
+ ```sh
39
+ python -m pip install argly
40
+ ```
41
+
42
+ From a checkout, with your virtual environment activated:
43
+
44
+ ```sh
45
+ python -m pip install -e ".[dev]"
46
+ python -m examples.remote_cli --help
47
+ python -m examples.remote_cli -v remote add origin --url=https://example.com -fvv
48
+ ```
49
+
50
+ The example just prints the parsed values. Its [remote commands](examples/remote_cli/commands/remote.py) live together in one file, with [global options](examples/remote_cli/commands/root.py) in another.
51
+
52
+ ## Commands are functions
53
+
54
+ Here's what a command module could look like. Put this in `mycli/commands/remote.py`, with an `__init__.py` in each package directory:
55
+
56
+ ```python
57
+ from typing import Annotated
58
+ from argly import Flag, Count, Option, Argument, Inherited, group, command
59
+
60
+
61
+ @group('remote', summary='Manage remotes.')
62
+ def remote(*, verbose: Annotated[int, Count('-v')] = 0) -> None:
63
+ pass
64
+
65
+
66
+ @command('remote add', summary='Add a remote.')
67
+ def add(
68
+ name: Annotated[str, Argument()],
69
+ *,
70
+ url: Annotated[str, Option('-u')],
71
+ verbose: Annotated[int, Inherited()],
72
+ force: Annotated[bool, Flag('-f')] = False,
73
+ ) -> int:
74
+ print(f'Adding {name}: {url}, force={force}, verbosity={verbose}')
75
+
76
+ return 0
77
+
78
+
79
+ @command('remote list', summary='List remotes.')
80
+ def list_remotes(*, verbose: Annotated[int, Inherited()]) -> int:
81
+ print(f'Listing remotes at verbosity {verbose}')
82
+
83
+ return 0
84
+ ```
85
+
86
+ Parameter names become long options, so `url` gives you `--url`, and `-u` is its alias. Value options and positional arguments without defaults are required. Commands return an integer exit code, and the decorators leave them callable as ordinary Python functions.
87
+
88
+ A group declares options for its descendants; its function doesn't run. `Inherited()` passes an ancestor's option into a handler without repeating its definition or default. In this example, `remote -vv add ...` and `remote add ... -vv` both work.
89
+
90
+ Use `@group("")` for global options that can appear anywhere before `--`. Other group options become available after entering that group. Command paths define the nesting, so related handlers can share a file without having to mirror the hierarchy in folders.
91
+
92
+ ## Familiar option syntax
93
+
94
+ Long names, short aliases, combined flags, and counters all work:
95
+
96
+ ```text
97
+ --url example.com --url=example.com
98
+ -u example.com -uexample.com -u=example.com
99
+ -abc -vvv
100
+ ```
101
+
102
+ In a short-option cluster, an option that takes a value consumes the rest of the token or the next argument. Use `--` when the remaining arguments should be treated literally.
103
+
104
+ Windows-style aliases are opt-in: declare something like `Option("-u", "/URL", "/U")` and enable `windows_options=True`, or use the generator's `--windows-options` switch. Matching is exact and case-sensitive, so an unregistered path like `/tmp` stays a value.
105
+
106
+ ## Generate help and load commands lazily
107
+
108
+ Generate a small Python module containing the command registry and preformatted help:
109
+
110
+ ```sh
111
+ argly gen --package mycli.commands --name mycli --output mycli/generated.py
112
+ ```
113
+
114
+ Then use it in `mycli/__main__.py`:
115
+
116
+ ```python
117
+ from argly import App
118
+ from mycli.generated import REGISTRY, get_help
119
+
120
+ raise SystemExit(App.from_registry(REGISTRY, help_lookup=get_help).run())
121
+ ```
122
+
123
+ Now you can run `python -m mycli remote add origin -u example.com`.
124
+
125
+ Generation imports your command modules to read their definitions. At runtime, only the selected command's module gets imported, and only its handler runs. Related commands in the same file share that import. Help comes straight from the generated text, without loading command modules.
126
+
127
+ Regenerate after changing command definitions. Add `--check` to the same generation command in CI to catch stale output without rewriting it. `python -m argly gen` works too.
128
+
129
+ While experimenting, you can skip generation and use `App.discover('mycli', 'mycli.commands').run()`. That imports the command modules up front.
130
+
131
+ ## Performance and development
132
+
133
+ Argly builds its parser tables once and reuses them. The [benchmark script](benchmarks/bench.py) compares equivalent invocations with `argparse` and measures help lookup and startup separately.
134
+
135
+ With the development dependencies installed:
136
+
137
+ ```sh
138
+ python -m pytest --cov=argly --cov-branch
139
+ python -m ruff check .
140
+ python -m ruff format --check .
141
+ python -m mypy
142
+ python -m build --outdir dist/release
143
+ python -m twine check --strict dist/release/*
144
+ python benchmarks/bench.py
145
+ ```
146
+
147
+ The release artifacts go in `dist/release` to keep them separate from local native builds.
148
+
149
+ There's also an optional Cython build of the same parser. To build a native wheel in PowerShell, with a C compiler installed:
150
+
151
+ ```powershell
152
+ python -m pip install -e ".[dev,cython]"
153
+ $env:ARGLY_CYTHON = "1"
154
+ python -m build --wheel --no-isolation
155
+ Remove-Item Env:\ARGLY_CYTHON
156
+ ```
157
+
158
+ The native wheel is written to `dist`; install it to use the compiled parser. Regular builds use pure Python.
argly-0.1.0/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # argly
2
+
3
+ Requires Python 3.14 or newer.
4
+
5
+ ```sh
6
+ python -m pip install argly
7
+ ```
8
+
9
+ From a checkout, with your virtual environment activated:
10
+
11
+ ```sh
12
+ python -m pip install -e ".[dev]"
13
+ python -m examples.remote_cli --help
14
+ python -m examples.remote_cli -v remote add origin --url=https://example.com -fvv
15
+ ```
16
+
17
+ The example just prints the parsed values. Its [remote commands](examples/remote_cli/commands/remote.py) live together in one file, with [global options](examples/remote_cli/commands/root.py) in another.
18
+
19
+ ## Commands are functions
20
+
21
+ Here's what a command module could look like. Put this in `mycli/commands/remote.py`, with an `__init__.py` in each package directory:
22
+
23
+ ```python
24
+ from typing import Annotated
25
+ from argly import Flag, Count, Option, Argument, Inherited, group, command
26
+
27
+
28
+ @group('remote', summary='Manage remotes.')
29
+ def remote(*, verbose: Annotated[int, Count('-v')] = 0) -> None:
30
+ pass
31
+
32
+
33
+ @command('remote add', summary='Add a remote.')
34
+ def add(
35
+ name: Annotated[str, Argument()],
36
+ *,
37
+ url: Annotated[str, Option('-u')],
38
+ verbose: Annotated[int, Inherited()],
39
+ force: Annotated[bool, Flag('-f')] = False,
40
+ ) -> int:
41
+ print(f'Adding {name}: {url}, force={force}, verbosity={verbose}')
42
+
43
+ return 0
44
+
45
+
46
+ @command('remote list', summary='List remotes.')
47
+ def list_remotes(*, verbose: Annotated[int, Inherited()]) -> int:
48
+ print(f'Listing remotes at verbosity {verbose}')
49
+
50
+ return 0
51
+ ```
52
+
53
+ Parameter names become long options, so `url` gives you `--url`, and `-u` is its alias. Value options and positional arguments without defaults are required. Commands return an integer exit code, and the decorators leave them callable as ordinary Python functions.
54
+
55
+ A group declares options for its descendants; its function doesn't run. `Inherited()` passes an ancestor's option into a handler without repeating its definition or default. In this example, `remote -vv add ...` and `remote add ... -vv` both work.
56
+
57
+ Use `@group("")` for global options that can appear anywhere before `--`. Other group options become available after entering that group. Command paths define the nesting, so related handlers can share a file without having to mirror the hierarchy in folders.
58
+
59
+ ## Familiar option syntax
60
+
61
+ Long names, short aliases, combined flags, and counters all work:
62
+
63
+ ```text
64
+ --url example.com --url=example.com
65
+ -u example.com -uexample.com -u=example.com
66
+ -abc -vvv
67
+ ```
68
+
69
+ In a short-option cluster, an option that takes a value consumes the rest of the token or the next argument. Use `--` when the remaining arguments should be treated literally.
70
+
71
+ Windows-style aliases are opt-in: declare something like `Option("-u", "/URL", "/U")` and enable `windows_options=True`, or use the generator's `--windows-options` switch. Matching is exact and case-sensitive, so an unregistered path like `/tmp` stays a value.
72
+
73
+ ## Generate help and load commands lazily
74
+
75
+ Generate a small Python module containing the command registry and preformatted help:
76
+
77
+ ```sh
78
+ argly gen --package mycli.commands --name mycli --output mycli/generated.py
79
+ ```
80
+
81
+ Then use it in `mycli/__main__.py`:
82
+
83
+ ```python
84
+ from argly import App
85
+ from mycli.generated import REGISTRY, get_help
86
+
87
+ raise SystemExit(App.from_registry(REGISTRY, help_lookup=get_help).run())
88
+ ```
89
+
90
+ Now you can run `python -m mycli remote add origin -u example.com`.
91
+
92
+ Generation imports your command modules to read their definitions. At runtime, only the selected command's module gets imported, and only its handler runs. Related commands in the same file share that import. Help comes straight from the generated text, without loading command modules.
93
+
94
+ Regenerate after changing command definitions. Add `--check` to the same generation command in CI to catch stale output without rewriting it. `python -m argly gen` works too.
95
+
96
+ While experimenting, you can skip generation and use `App.discover('mycli', 'mycli.commands').run()`. That imports the command modules up front.
97
+
98
+ ## Performance and development
99
+
100
+ Argly builds its parser tables once and reuses them. The [benchmark script](benchmarks/bench.py) compares equivalent invocations with `argparse` and measures help lookup and startup separately.
101
+
102
+ With the development dependencies installed:
103
+
104
+ ```sh
105
+ python -m pytest --cov=argly --cov-branch
106
+ python -m ruff check .
107
+ python -m ruff format --check .
108
+ python -m mypy
109
+ python -m build --outdir dist/release
110
+ python -m twine check --strict dist/release/*
111
+ python benchmarks/bench.py
112
+ ```
113
+
114
+ The release artifacts go in `dist/release` to keep them separate from local native builds.
115
+
116
+ There's also an optional Cython build of the same parser. To build a native wheel in PowerShell, with a C compiler installed:
117
+
118
+ ```powershell
119
+ python -m pip install -e ".[dev,cython]"
120
+ $env:ARGLY_CYTHON = "1"
121
+ python -m build --wheel --no-isolation
122
+ Remove-Item Env:\ARGLY_CYTHON
123
+ ```
124
+
125
+ The native wheel is written to `dist`; install it to use the compiled parser. Regular builds use pure Python.
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ from argly.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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)