without-cli 0.0.0__tar.gz → 0.0.7__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.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: without-cli
3
+ Version: 0.0.7
4
+ Summary: Command-line parsing as values: typed tokens that are the parse, the help, and the read at once.
5
+ Author: Josh Karpel
6
+ Author-email: Josh Karpel <josh.karpel@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.14
17
+ Description-Content-Type: text/markdown
18
+
19
+ # without-cli
20
+
21
+ Command-line parsing as values. A token is one declaration that is the parse, the
22
+ help entry, and the typed read at once; a command is a value you can pass around
23
+ rather than a decorator's side effect; and parsing is a pure function from argv to
24
+ an outcome, so nothing is registered anywhere and nothing exits out from under you.
25
+
26
+ ```python
27
+ @dataclass(frozen=True, slots=True)
28
+ class Session(Streams):
29
+ client: Client
30
+
31
+
32
+ @command("add", argument("text", once(STR)), option(("-t", "--tag"), many(STR)), summary="Add a todo.")
33
+ async def add(session: Session, text: str, tags: tuple[str, ...]) -> int:
34
+ todo = await session.client.create(text, tags)
35
+ session.stdout.write(f"{todo.id}\n")
36
+ return 0
37
+
38
+
39
+ app = group("todos", verbose, endpoint, state=session, commands=(add, show, db))
40
+
41
+ if __name__ == "__main__":
42
+ raise SystemExit(run(app))
43
+ ```
44
+
45
+ `state` is an async context manager, entered only when something beneath it is
46
+ selected, so every command receives a live client it did not open and does not
47
+ close, typed and checked, with no ambient context object. There is no separate
48
+ root concept: the top of a tree is an ordinary group whose parent is the shell,
49
+ which supplies the `Streams` it derives from, so a CLI with no shared resource
50
+ declares no `state` and its commands take that `Streams` directly. An option can name
51
+ where else its value may come from (`sources=(FromFile(...), FromEnv(...))`),
52
+ which covers environment variables and Kubernetes or Docker secret mounts through
53
+ the same validation the command line goes through.
54
+
55
+ Help is a `Usage` *value* that plain text is one rendering of, so no styling
56
+ library sits on the path every program crosses and this package depends on
57
+ nothing. The streams are injected, so a test asserts on output by passing
58
+ `Streams.captured()`, with no subprocess and no runner.
59
+
60
+ See the
61
+ [`without-cli` guide](https://without.help/without-cli/)
62
+ (with the [API reference](https://without.help/reference/without_cli/))
63
+ for the full surface, including what is deliberately absent, and
64
+ [alternatives](https://without.help/without-cli/alternatives/) for the same
65
+ program written against argparse, click, and typer.
@@ -0,0 +1,47 @@
1
+ # without-cli
2
+
3
+ Command-line parsing as values. A token is one declaration that is the parse, the
4
+ help entry, and the typed read at once; a command is a value you can pass around
5
+ rather than a decorator's side effect; and parsing is a pure function from argv to
6
+ an outcome, so nothing is registered anywhere and nothing exits out from under you.
7
+
8
+ ```python
9
+ @dataclass(frozen=True, slots=True)
10
+ class Session(Streams):
11
+ client: Client
12
+
13
+
14
+ @command("add", argument("text", once(STR)), option(("-t", "--tag"), many(STR)), summary="Add a todo.")
15
+ async def add(session: Session, text: str, tags: tuple[str, ...]) -> int:
16
+ todo = await session.client.create(text, tags)
17
+ session.stdout.write(f"{todo.id}\n")
18
+ return 0
19
+
20
+
21
+ app = group("todos", verbose, endpoint, state=session, commands=(add, show, db))
22
+
23
+ if __name__ == "__main__":
24
+ raise SystemExit(run(app))
25
+ ```
26
+
27
+ `state` is an async context manager, entered only when something beneath it is
28
+ selected, so every command receives a live client it did not open and does not
29
+ close, typed and checked, with no ambient context object. There is no separate
30
+ root concept: the top of a tree is an ordinary group whose parent is the shell,
31
+ which supplies the `Streams` it derives from, so a CLI with no shared resource
32
+ declares no `state` and its commands take that `Streams` directly. An option can name
33
+ where else its value may come from (`sources=(FromFile(...), FromEnv(...))`),
34
+ which covers environment variables and Kubernetes or Docker secret mounts through
35
+ the same validation the command line goes through.
36
+
37
+ Help is a `Usage` *value* that plain text is one rendering of, so no styling
38
+ library sits on the path every program crosses and this package depends on
39
+ nothing. The streams are injected, so a test asserts on output by passing
40
+ `Streams.captured()`, with no subprocess and no runner.
41
+
42
+ See the
43
+ [`without-cli` guide](https://without.help/without-cli/)
44
+ (with the [API reference](https://without.help/reference/without_cli/))
45
+ for the full surface, including what is deliberately absent, and
46
+ [alternatives](https://without.help/without-cli/alternatives/) for the same
47
+ program written against argparse, click, and typer.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.3,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "without-cli"
7
+ version = "0.0.7"
8
+ description = "Command-line parsing as values: typed tokens that are the parse, the help, and the read at once."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.14"
12
+ classifiers = [
13
+ "Development Status :: 2 - Pre-Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [[project.authors]]
25
+ name = "Josh Karpel"
26
+ email = "josh.karpel@gmail.com"
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.3,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "without-cli"
7
+ version = "0.0.7"
8
+ description = "Command-line parsing as values: typed tokens that are the parse, the help, and the read at once."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Josh Karpel", email = "josh.karpel@gmail.com" },
13
+ ]
14
+ requires-python = ">=3.14"
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
@@ -0,0 +1,105 @@
1
+ from without_cli.binding import Answered
2
+ from without_cli.binding import Bound
3
+ from without_cli.binding import Outcome
4
+ from without_cli.binding import Rejected
5
+ from without_cli.binding import parse_argv
6
+ from without_cli.binding import render_rejection
7
+ from without_cli.commands import Action
8
+ from without_cli.commands import Arm
9
+ from without_cli.commands import DeclarationError
10
+ from without_cli.commands import Level
11
+ from without_cli.commands import Node
12
+ from without_cli.commands import command
13
+ from without_cli.commands import group
14
+ from without_cli.commands import source_paths
15
+ from without_cli.converters import BOOL
16
+ from without_cli.converters import FLOAT
17
+ from without_cli.converters import INT
18
+ from without_cli.converters import PATH
19
+ from without_cli.converters import STR
20
+ from without_cli.converters import UUID
21
+ from without_cli.converters import Converter
22
+ from without_cli.converters import choice
23
+ from without_cli.runtime import ANSWERED
24
+ from without_cli.runtime import run
25
+ from without_cli.sources import FromEnv
26
+ from without_cli.sources import FromFile
27
+ from without_cli.sources import Source
28
+ from without_cli.sources import read_files
29
+ from without_cli.streams import Capture
30
+ from without_cli.streams import Streams
31
+ from without_cli.streams import Writer
32
+ from without_cli.streams import lines
33
+ from without_cli.tokens import Args
34
+ from without_cli.tokens import Cardinality
35
+ from without_cli.tokens import ExtractionError
36
+ from without_cli.tokens import Extractor
37
+ from without_cli.tokens import Option
38
+ from without_cli.tokens import Parameter
39
+ from without_cli.tokens import Positional
40
+ from without_cli.tokens import argument
41
+ from without_cli.tokens import count
42
+ from without_cli.tokens import default
43
+ from without_cli.tokens import flag
44
+ from without_cli.tokens import into
45
+ from without_cli.tokens import many
46
+ from without_cli.tokens import once
47
+ from without_cli.tokens import option
48
+ from without_cli.tokens import optional
49
+ from without_cli.usage import Usage
50
+ from without_cli.usage import render
51
+ from without_cli.usage import usage
52
+
53
+ __all__ = [
54
+ "ANSWERED",
55
+ "BOOL",
56
+ "FLOAT",
57
+ "INT",
58
+ "PATH",
59
+ "STR",
60
+ "UUID",
61
+ "Action",
62
+ "Answered",
63
+ "Args",
64
+ "Arm",
65
+ "Bound",
66
+ "Capture",
67
+ "Cardinality",
68
+ "Converter",
69
+ "DeclarationError",
70
+ "ExtractionError",
71
+ "Extractor",
72
+ "FromEnv",
73
+ "FromFile",
74
+ "Level",
75
+ "Node",
76
+ "Option",
77
+ "Outcome",
78
+ "Parameter",
79
+ "Positional",
80
+ "Rejected",
81
+ "Source",
82
+ "Streams",
83
+ "Usage",
84
+ "Writer",
85
+ "argument",
86
+ "choice",
87
+ "command",
88
+ "count",
89
+ "default",
90
+ "flag",
91
+ "group",
92
+ "into",
93
+ "lines",
94
+ "many",
95
+ "once",
96
+ "option",
97
+ "optional",
98
+ "parse_argv",
99
+ "read_files",
100
+ "render",
101
+ "render_rejection",
102
+ "run",
103
+ "source_paths",
104
+ "usage",
105
+ ]
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from dataclasses import field
7
+ from pathlib import Path
8
+ from types import MappingProxyType
9
+ from typing import Generic
10
+ from typing import TypeVar
11
+
12
+ from without_cli.commands import Action
13
+ from without_cli.commands import Arm
14
+ from without_cli.commands import Level
15
+ from without_cli.commands import Node
16
+ from without_cli.sources import from_sources
17
+ from without_cli.tokens import PRESENT
18
+ from without_cli.tokens import Args
19
+ from without_cli.tokens import ExtractionError
20
+ from without_cli.tokens import Option
21
+ from without_cli.usage import Usage
22
+ from without_cli.usage import usage
23
+
24
+ # Shared empty defaults, so `parse_argv` can be called with only an argv while
25
+ # keeping its signature free of a mutable default.
26
+ _NO_ENV: Mapping[str, str] = MappingProxyType({})
27
+ _NO_FILES: Mapping[Path, str] = MappingProxyType({})
28
+
29
+ # Contravariant, matching `Action`: `Bound[Never]` is the type of "a bound
30
+ # invocation, whatever state it wants". The legacy `TypeVar` is needed because
31
+ # PEP 695's inferred variance treats a (frozen) dataclass field as invariant.
32
+ _T_contra = TypeVar("_T_contra", contravariant=True)
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Bound(Generic[_T_contra]): # noqa: UP046 - PEP 695 infers a frozen dataclass field as invariant; the contravariant TypeVar is deliberate
37
+ """
38
+ A valid invocation: every value parsed, nothing run.
39
+
40
+ Reaching this proves the command line was good, because all extraction has
41
+ already happened. That is what lets a program open its resources only for
42
+ invocations that were going to work, and it is why `run` never has to report
43
+ a usage error out of the middle of a command.
44
+ """
45
+
46
+ action: Action[_T_contra] = field(compare=False)
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class Answered:
51
+ """
52
+ The scan met one of the caller's `answered` spellings, so nothing was bound.
53
+
54
+ This layer holds no opinion about what any spelling *means*: it reports which
55
+ one it met and which level it was addressed to, and the shell decides whether
56
+ `--help` prints usage, `--version` reads `node.version`, or `--license` prints
57
+ something else entirely. That is why there is one outcome here rather than one
58
+ per flag, and why adding a flag needs no change below `run`.
59
+
60
+ Stopping has to happen in the scan even though deciding does not, because only
61
+ the scan knows whether a token is a flag or the value of the option before it,
62
+ and because a level's required options have not been checked yet: that is what
63
+ lets `prog db migrate --help` answer instead of complaining about the `--dsn`
64
+ you were asking how to spell.
65
+ """
66
+
67
+ spelling: str
68
+ path: tuple[Node, ...]
69
+
70
+ @property
71
+ def node(self) -> Node:
72
+ """The level the spelling was addressed to, whose `version` a shell may read."""
73
+ return self.path[-1]
74
+
75
+ @property
76
+ def usage(self) -> Usage:
77
+ """That level's usage, for a shell answering with help."""
78
+ return usage(self.path)
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Rejected:
83
+ """
84
+ The invocation was not valid, with the usage of the level that refused it.
85
+
86
+ Carrying the `Usage` rather than a formatted string is what lets a program
87
+ decide how much to show and where: `run`'s default is the synopsis plus a
88
+ pointer to `--help`, and an application that wants the whole help text on a
89
+ mistake renders `usage` in full instead.
90
+ """
91
+
92
+ message: str
93
+ usage: Usage
94
+
95
+
96
+ type Outcome[T] = Bound[T] | Answered | Rejected
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class _Scanned:
101
+ """
102
+ One level's argv, split into option occurrences and the bare tokens left over.
103
+
104
+ What `bare` holds is the caller's `stop_at_positional`: a level with children
105
+ stops at the first bare token, so `bare` is that token and everything after it
106
+ (the subcommand and its own argv); a level without them consumes the whole
107
+ line, so `bare` is its positionals.
108
+ """
109
+
110
+ options: dict[str, list[str]]
111
+ bare: list[str]
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class _Refused:
116
+ message: str
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class _Answered:
121
+ """The scan met one of the caller's `answered` spellings."""
122
+
123
+ spelling: str
124
+
125
+
126
+ type _Scan = _Scanned | _Refused | _Answered
127
+
128
+
129
+ def _scan(
130
+ node: Node,
131
+ argv: Sequence[str],
132
+ *,
133
+ stop_at_positional: bool,
134
+ answered: Sequence[str],
135
+ ) -> _Scan:
136
+ """
137
+ Split one level's argv into option occurrences and bare tokens.
138
+
139
+ A spelling in `answered` short-circuits the scan, but only where this level
140
+ has not declared an option by that name: shadowing is how a program that
141
+ wants `--version` to mean something of its own takes it back. The scan runs
142
+ left to right and stops at the first one it meets, so no spelling has a
143
+ standing precedence over another.
144
+
145
+ A level with subcommands stops at the first bare token (that token is the
146
+ subcommand's name and everything after it belongs to the child), which is what
147
+ keeps `prog --verbose sub --flag` unambiguous without either level knowing
148
+ about the other's options.
149
+ """
150
+ by_name = {name: option for option in node.options for name in option.names}
151
+ options: dict[str, list[str]] = {}
152
+ bare: list[str] = []
153
+ index = 0
154
+ literal = False
155
+
156
+ def record(option: Option, value: str) -> None:
157
+ options.setdefault(option.canonical, []).append(value)
158
+
159
+ while index < len(argv):
160
+ token = argv[index]
161
+ if literal or token == "-" or not token.startswith("-"):
162
+ if stop_at_positional:
163
+ return _Scanned(options, list(argv[index:]))
164
+ bare.append(token)
165
+ index += 1
166
+ elif token == "--":
167
+ literal = True
168
+ index += 1
169
+ elif token in answered and token not in by_name:
170
+ return _Answered(token)
171
+ elif token.startswith("--"):
172
+ name, separator, inline = token.partition("=")
173
+ option = by_name.get(name)
174
+ if option is None:
175
+ return _Refused(f"unknown option {name}")
176
+ if option.metavar is None:
177
+ if separator:
178
+ return _Refused(f"option {name} takes no value")
179
+ record(option, PRESENT)
180
+ index += 1
181
+ elif separator:
182
+ record(option, inline)
183
+ index += 1
184
+ elif index + 1 < len(argv):
185
+ record(option, argv[index + 1])
186
+ index += 2
187
+ else:
188
+ return _Refused(f"option {name} expects a value")
189
+ else:
190
+ # A short cluster: `-abc` is three flags, or one flag and a value, so
191
+ # it is walked character by character rather than looked up whole.
192
+ cluster = token[1:]
193
+ position = 0
194
+ while position < len(cluster):
195
+ name = f"-{cluster[position]}"
196
+ option = by_name.get(name)
197
+ if option is None:
198
+ return _Refused(f"unknown option {name}")
199
+ if option.metavar is None:
200
+ record(option, PRESENT)
201
+ position += 1
202
+ elif position + 1 < len(cluster):
203
+ record(option, cluster[position + 1 :])
204
+ position = len(cluster)
205
+ elif index + 1 < len(argv):
206
+ record(option, argv[index + 1])
207
+ index += 1
208
+ position = len(cluster)
209
+ else:
210
+ return _Refused(f"option {name} expects a value")
211
+ index += 1
212
+ return _Scanned(options, bare)
213
+
214
+
215
+ def _merged(
216
+ node: Node,
217
+ scanned: Mapping[str, list[str]],
218
+ env: Mapping[str, str],
219
+ files: Mapping[Path, str],
220
+ ) -> dict[str, tuple[str, ...]]:
221
+ """
222
+ Settle each option's raw values: the command line if it said anything, else the first source that did.
223
+
224
+ Command line beats sources outright rather than merging with them, so a
225
+ repeated option cannot be half-overridden by an environment variable that
226
+ supplied a different count.
227
+ """
228
+ values: dict[str, tuple[str, ...]] = {}
229
+ for option in node.options:
230
+ occurrences = scanned.get(option.canonical)
231
+ supplied = tuple(occurrences) if occurrences else from_sources(option.sources, env, files)
232
+ if supplied:
233
+ values[option.canonical] = supplied
234
+ return values
235
+
236
+
237
+ def _assigned(node: Node, tokens: Sequence[str]) -> dict[str, tuple[str, ...]] | _Refused:
238
+ """
239
+ Hand the bare tokens to the level's positionals, in declaration order.
240
+
241
+ A positional with nothing left for it is simply absent, so its own token
242
+ decides whether that is a rejection; only a token with no positional left to
243
+ take it is refused here.
244
+ """
245
+ assigned: dict[str, tuple[str, ...]] = {}
246
+ index = 0
247
+ for spec in node.positionals:
248
+ if spec.variadic:
249
+ assigned[spec.name] = tuple(tokens[index:])
250
+ index = len(tokens)
251
+ elif index < len(tokens):
252
+ assigned[spec.name] = (tokens[index],)
253
+ index += 1
254
+ if index < len(tokens):
255
+ return _Refused(f"unexpected argument {tokens[index]!r}")
256
+ return assigned
257
+
258
+
259
+ def parse_argv[T](
260
+ arm: Arm[T],
261
+ *,
262
+ argv: Sequence[str],
263
+ env: Mapping[str, str] = _NO_ENV,
264
+ files: Mapping[Path, str] = _NO_FILES,
265
+ answered: Sequence[str] = (),
266
+ ) -> Outcome[T]:
267
+ """
268
+ Turn a command line into a valid invocation, a rejection, or a stop the caller
269
+ asked for.
270
+
271
+ A pure, total function of its values, which is what makes the whole parser
272
+ testable without a process: no `sys.argv`, no `os.environ`, no filesystem, no
273
+ exit, no output. `env` and `files` are the already-read contents of an
274
+ option's fallback sources (see `run`, which reads them).
275
+
276
+ Nothing here is magic by default. `answered` is the caller's list of spellings
277
+ that should stop the scan and come back as an `Answered` rather than being
278
+ parsed, and it is empty unless asked for, so `--help` means nothing to this
279
+ function on its own. `run` passes the conventional set and decides what each
280
+ one does; a program wanting `-?`, a `help` subcommand, or nothing at all
281
+ passes its own list and this function does not change.
282
+
283
+ Every value is extracted here, so a `Bound` proves the invocation is good and
284
+ nothing has been opened yet when a `Rejected` comes back.
285
+ """
286
+ node = arm.node
287
+ path = [node]
288
+ levels: list[Level] = []
289
+ remaining = list(argv)
290
+
291
+ while True:
292
+ scanned = _scan(node, remaining, stop_at_positional=bool(node.children), answered=answered)
293
+ if isinstance(scanned, _Answered):
294
+ return Answered(scanned.spelling, tuple(path))
295
+ if isinstance(scanned, _Refused):
296
+ return Rejected(scanned.message, usage(tuple(path)))
297
+ options = _merged(node, scanned.options, env, files)
298
+
299
+ if not node.children:
300
+ assigned = _assigned(node, scanned.bare)
301
+ if isinstance(assigned, _Refused):
302
+ return Rejected(assigned.message, usage(tuple(path)))
303
+ levels.append(Level(node.name, Args(options=options, arguments=assigned)))
304
+ break
305
+
306
+ if not scanned.bare:
307
+ return Rejected("expected a command", usage(tuple(path)))
308
+ child = node.child(scanned.bare[0])
309
+ if child is None:
310
+ known = ", ".join(sorted(entry.name for entry in node.children))
311
+ return Rejected(f"unknown command {scanned.bare[0]!r} (expected one of: {known})", usage(tuple(path)))
312
+ levels.append(Level(node.name, Args(options=options, arguments={})))
313
+ node = child
314
+ path.append(child)
315
+ remaining = scanned.bare[1:]
316
+
317
+ try:
318
+ return Bound(arm.resolve(tuple(levels)))
319
+ except ExtractionError as exc:
320
+ return Rejected(f"{exc.parameter}: {exc}", usage(tuple(path)))
321
+
322
+
323
+ def render_rejection(rejected: Rejected) -> str:
324
+ """
325
+ The default rendering of a bad command line: what went wrong, then where to look.
326
+
327
+ Deliberately not the whole help text. A rejection usually means one thing was
328
+ wrong, and burying that line under fifty lines of options is how a CLI
329
+ trains people to stop reading its errors.
330
+ """
331
+ program = " ".join(rejected.usage.path)
332
+ return (
333
+ "\n".join(
334
+ [
335
+ f"{program}: {rejected.message}",
336
+ f"usage: {rejected.usage.invocation}",
337
+ f"try '{program} --help' for more information",
338
+ ]
339
+ )
340
+ + "\n"
341
+ )
342
+
343
+
344
+ __all__ = ["Answered", "Bound", "Outcome", "Rejected", "parse_argv", "render_rejection"]