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.
- without_cli-0.0.7/PKG-INFO +65 -0
- without_cli-0.0.7/README.md +47 -0
- without_cli-0.0.7/pyproject.toml +26 -0
- without_cli-0.0.7/pyproject.toml.orig +25 -0
- without_cli-0.0.7/src/without_cli/__init__.py +105 -0
- without_cli-0.0.7/src/without_cli/binding.py +344 -0
- without_cli-0.0.7/src/without_cli/commands.py +1234 -0
- without_cli-0.0.7/src/without_cli/converters.py +89 -0
- without_cli-0.0.7/src/without_cli/runtime.py +110 -0
- without_cli-0.0.7/src/without_cli/sources.py +90 -0
- without_cli-0.0.7/src/without_cli/streams.py +153 -0
- without_cli-0.0.7/src/without_cli/tokens.py +764 -0
- without_cli-0.0.7/src/without_cli/usage.py +130 -0
- without_cli-0.0.0/PKG-INFO +0 -5
- without_cli-0.0.0/pyproject.toml +0 -9
- without_cli-0.0.0/pyproject.toml.orig +0 -9
- /without_cli-0.0.0/src/without_cli/__init__.py → /without_cli-0.0.7/src/without_cli/py.typed +0 -0
|
@@ -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"]
|