axi-toolkit 0.2.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.
- axi_toolkit/__init__.py +28 -0
- axi_toolkit/envconfig.py +243 -0
- axi_toolkit/errors.py +458 -0
- axi_toolkit/redact.py +173 -0
- axi_toolkit/render/__init__.py +7 -0
- axi_toolkit/render/cli.py +191 -0
- axi_toolkit/render/prose.py +100 -0
- axi_toolkit/toon.py +349 -0
- axi_toolkit/toon_spec/LICENSE +21 -0
- axi_toolkit/toon_spec/PROVENANCE.md +69 -0
- axi_toolkit/toon_spec/__init__.py +224 -0
- axi_toolkit/toon_spec/checksums.txt +9 -0
- axi_toolkit/toon_spec/encode/arrays-nested.json +132 -0
- axi_toolkit/toon_spec/encode/arrays-objects.json +191 -0
- axi_toolkit/toon_spec/encode/arrays-primitive.json +111 -0
- axi_toolkit/toon_spec/encode/arrays-tabular.json +182 -0
- axi_toolkit/toon_spec/encode/delimiters.json +253 -0
- axi_toolkit/toon_spec/encode/objects-keyed.json +155 -0
- axi_toolkit/toon_spec/encode/objects.json +297 -0
- axi_toolkit/toon_spec/encode/primitives.json +278 -0
- axi_toolkit/toon_spec/encode/whitespace.json +51 -0
- axi_toolkit-0.2.0.dist-info/METADATA +162 -0
- axi_toolkit-0.2.0.dist-info/RECORD +25 -0
- axi_toolkit-0.2.0.dist-info/WHEEL +4 -0
- axi_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
axi_toolkit/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared toolkit for Agent eXperience Interface (AXI) command-line tools.
|
|
2
|
+
|
|
3
|
+
Two AXI CLIs measured 1 378 identical lines of toolkit between them, and the
|
|
4
|
+
duplication had already cost: a TOON specification violation was fixed in one copy and
|
|
5
|
+
not the other, and the divergence stayed invisible until somebody ran both encoders
|
|
6
|
+
against the same fixtures. This package is the one copy.
|
|
7
|
+
|
|
8
|
+
What is here is the toolkit tier and nothing else:
|
|
9
|
+
|
|
10
|
+
- :mod:`axi_toolkit.toon` -- a strict TOON encoder, and :mod:`axi_toolkit.toon_spec`, the
|
|
11
|
+
specification's own conformance fixtures vendored beside it so a tool asserts its
|
|
12
|
+
score rather than claiming one.
|
|
13
|
+
- :mod:`axi_toolkit.errors` -- the error contract, with recovery carried as data.
|
|
14
|
+
- :mod:`axi_toolkit.render` -- that data as a shell line, or as a sentence.
|
|
15
|
+
- :mod:`axi_toolkit.redact` -- the credential boundary.
|
|
16
|
+
- :mod:`axi_toolkit.envconfig` -- environment-only credentials.
|
|
17
|
+
|
|
18
|
+
What is deliberately not here: an agent package, framework adapters, an MCP server, a
|
|
19
|
+
dual sync/async API, and any client class wrapping an HTTP library. The agent surface
|
|
20
|
+
of a pure function is its own signature; every framework derives a schema from
|
|
21
|
+
annotations, so there is no adapter worth writing. Nothing in this package imports an
|
|
22
|
+
HTTP or WebSocket client, the distribution declares no runtime dependency, and
|
|
23
|
+
``tests/test_purity.py`` is what keeps both true.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
|
|
28
|
+
__all__ = ["__version__"]
|
axi_toolkit/envconfig.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Connection settings, read from the environment and never from a file.
|
|
2
|
+
|
|
3
|
+
Both source tools open their configuration module with the same paragraph, and it is
|
|
4
|
+
the right one: a credential passed on a command line leaks into shell history and the
|
|
5
|
+
process table, and a credential in a file leaks into commits. The environment is the
|
|
6
|
+
only channel, and there is deliberately no ``--token`` flag anywhere.
|
|
7
|
+
|
|
8
|
+
The two copies were not identical, and the differences are real rather than
|
|
9
|
+
accidental: different variable names and aliases, one defaulting a bare host to
|
|
10
|
+
``https`` and the other to ``http``, one adding a default port and the other stripping
|
|
11
|
+
a mistakenly-pasted ``/api`` suffix. Every one of those is a property of the system
|
|
12
|
+
behind the tool, so none of them can be decided here. They live on a
|
|
13
|
+
:class:`CredentialSpec` the tool declares once, and this module reproduces each tool's
|
|
14
|
+
present behaviour under its own spec -- messages, codes and recovery included, byte
|
|
15
|
+
for byte, which ``tests/conformance`` asserts against both.
|
|
16
|
+
|
|
17
|
+
Three mechanisms are genuinely shared and are the reason this module exists at all:
|
|
18
|
+
the environment is the only source, a token that cannot be an HTTP header value is
|
|
19
|
+
rejected before anything sends it, and ``user:password@`` in a URL is separated off
|
|
20
|
+
and registered as a secret rather than carried into anything printable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
29
|
+
|
|
30
|
+
from .errors import ConfigError, Recovery
|
|
31
|
+
from .redact import register_secret
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_TIMEOUT",
|
|
35
|
+
"ILLEGAL_TOKEN",
|
|
36
|
+
"CredentialSpec",
|
|
37
|
+
"Credentials",
|
|
38
|
+
"describe_environment",
|
|
39
|
+
"first_env",
|
|
40
|
+
"load",
|
|
41
|
+
"missing_vars",
|
|
42
|
+
"normalize_base_url",
|
|
43
|
+
"setup_recovery",
|
|
44
|
+
"split_userinfo",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
DEFAULT_TIMEOUT = 30.0
|
|
48
|
+
|
|
49
|
+
#: Anything a token must not contain. A header value cannot carry a line break, and an
|
|
50
|
+
#: HTTP client raises a ``ValueError`` embedding the whole ``Bearer ...`` header when it
|
|
51
|
+
#: finds one -- which is a credential inside a traceback. So the check happens at the
|
|
52
|
+
#: point the value is read, not at the point it is encoded.
|
|
53
|
+
ILLEGAL_TOKEN = re.compile(r"[\s\x00-\x1f\x7f]")
|
|
54
|
+
|
|
55
|
+
_SCHEMES = ("http", "https")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class CredentialSpec:
|
|
60
|
+
"""How one tool reads its credentials from the environment.
|
|
61
|
+
|
|
62
|
+
Everything here is a fact about the system the tool talks to, which is why it is
|
|
63
|
+
declared by the tool rather than defaulted by this module. The one exception is
|
|
64
|
+
``default_scheme``: it defaults to ``https`` because a bare host that silently
|
|
65
|
+
became ``http://`` would send the credential in the clear, and a tool that wants
|
|
66
|
+
the other answer has to say so and say why.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
#: Variable names for the base URL, most-preferred first. Later names are aliases
|
|
70
|
+
#: accepted so an existing shell environment keeps working.
|
|
71
|
+
url_vars: tuple
|
|
72
|
+
#: Variable names for the credential, most-preferred first.
|
|
73
|
+
token_vars: tuple
|
|
74
|
+
#: What to set each variable to. One per variable, in the same order.
|
|
75
|
+
setup: tuple = ()
|
|
76
|
+
#: How to confirm the settings work once both are present -- typically the tool's
|
|
77
|
+
#: own ``doctor``. Kept apart from ``setup`` so a caller already inside ``doctor``
|
|
78
|
+
#: can leave it out rather than telling the user to run what they are running.
|
|
79
|
+
verify: Recovery | None = None
|
|
80
|
+
#: What to check when the credential carries a character a header cannot.
|
|
81
|
+
token_recovery: tuple = ()
|
|
82
|
+
#: The scheme a bare host gets. ``https`` unless the system genuinely serves plain
|
|
83
|
+
#: HTTP on a local network, in which case defaulting to TLS fails every first run.
|
|
84
|
+
default_scheme: str = "https"
|
|
85
|
+
#: A port appended to a host that names none. ``None`` where a missing port means
|
|
86
|
+
#: the default for the scheme.
|
|
87
|
+
default_port: int | None = None
|
|
88
|
+
#: A path suffix stripped from the base URL -- a common paste mistake where a tool's
|
|
89
|
+
#: API root is a well-known subpath of the instance root.
|
|
90
|
+
strip_path_suffix: str = ""
|
|
91
|
+
default_timeout: float = DEFAULT_TIMEOUT
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
if not self.url_vars or not self.token_vars:
|
|
95
|
+
raise ValueError("a credential spec names at least one URL and one token variable")
|
|
96
|
+
if self.default_scheme not in _SCHEMES:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"default_scheme must be one of {_SCHEMES}, got {self.default_scheme!r}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class Credentials:
|
|
104
|
+
"""A resolved, ready-to-use configuration.
|
|
105
|
+
|
|
106
|
+
Carries the variable names it was resolved from, which the source tools threw away
|
|
107
|
+
and then re-read the environment to recover. A caller reporting on its own
|
|
108
|
+
configuration should not have to ask twice.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
base_url: str
|
|
112
|
+
token: str
|
|
113
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
114
|
+
url_var: str = ""
|
|
115
|
+
token_var: str = ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def first_env(names: tuple, environ) -> tuple:
|
|
119
|
+
"""The first of ``names`` set to something other than whitespace, and its value."""
|
|
120
|
+
for name in names:
|
|
121
|
+
value = environ.get(name)
|
|
122
|
+
if value and value.strip():
|
|
123
|
+
return name, value.strip()
|
|
124
|
+
return None, None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def split_userinfo(netloc: str) -> tuple:
|
|
128
|
+
"""Separate any ``user:password@`` prefix from a network location.
|
|
129
|
+
|
|
130
|
+
Such credentials are never sent -- these tools authenticate with their own token --
|
|
131
|
+
but they must not survive into the base URL either, because a tool's own status
|
|
132
|
+
view prints that URL and those views end up in agent transcripts.
|
|
133
|
+
"""
|
|
134
|
+
if "@" not in netloc:
|
|
135
|
+
return "", netloc
|
|
136
|
+
userinfo, _, host = netloc.rpartition("@")
|
|
137
|
+
return userinfo, host
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def normalize_base_url(raw: str, spec: CredentialSpec) -> str:
|
|
141
|
+
"""Accept a bare host, apply ``spec``'s defaults, and drop any trailing path noise."""
|
|
142
|
+
value = raw.strip().rstrip("/")
|
|
143
|
+
if "://" not in value:
|
|
144
|
+
value = f"{spec.default_scheme}://{value}"
|
|
145
|
+
parts = urlsplit(value)
|
|
146
|
+
if not parts.netloc:
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
f"{spec.url_vars[0]} is not a usable URL: {value!r}",
|
|
149
|
+
recovery=spec.setup[:1],
|
|
150
|
+
code="BAD_URL",
|
|
151
|
+
)
|
|
152
|
+
if parts.scheme not in _SCHEMES:
|
|
153
|
+
raise ConfigError(
|
|
154
|
+
f"{spec.url_vars[0]} must use http or https, got {parts.scheme!r}",
|
|
155
|
+
recovery=spec.setup[:1],
|
|
156
|
+
code="BAD_URL",
|
|
157
|
+
)
|
|
158
|
+
path = parts.path.rstrip("/")
|
|
159
|
+
if spec.strip_path_suffix and path.endswith(spec.strip_path_suffix):
|
|
160
|
+
path = path[: -len(spec.strip_path_suffix)]
|
|
161
|
+
userinfo, host = split_userinfo(parts.netloc)
|
|
162
|
+
if userinfo:
|
|
163
|
+
# Registered before returning: from here on the value can only be printed
|
|
164
|
+
# through the redacting output boundary.
|
|
165
|
+
register_secret(userinfo, min_length=4)
|
|
166
|
+
_, _, password = userinfo.partition(":")
|
|
167
|
+
register_secret(password, min_length=4)
|
|
168
|
+
if spec.default_port and ":" not in host.rsplit("]", 1)[-1]:
|
|
169
|
+
# ``rsplit("]")`` so a bracketed IPv6 literal's own colons are not read as a
|
|
170
|
+
# port that is already there.
|
|
171
|
+
host = f"{host}:{spec.default_port}"
|
|
172
|
+
return urlunsplit((parts.scheme, host, path, "", ""))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load(spec: CredentialSpec, environ=None, *, timeout: float | None = None) -> Credentials:
|
|
176
|
+
"""Resolve credentials, or raise :class:`ConfigError` naming what is absent."""
|
|
177
|
+
environ = os.environ if environ is None else environ
|
|
178
|
+
url_var, raw_url = first_env(spec.url_vars, environ)
|
|
179
|
+
token_var, token = first_env(spec.token_vars, environ)
|
|
180
|
+
|
|
181
|
+
missing = []
|
|
182
|
+
if not raw_url:
|
|
183
|
+
missing.append(spec.url_vars[0])
|
|
184
|
+
if not token:
|
|
185
|
+
missing.append(spec.token_vars[0])
|
|
186
|
+
if missing:
|
|
187
|
+
names = " and ".join(missing)
|
|
188
|
+
plural = "are" if len(missing) > 1 else "is"
|
|
189
|
+
raise ConfigError(
|
|
190
|
+
f"{names} {plural} not set in the environment",
|
|
191
|
+
recovery=setup_recovery(spec),
|
|
192
|
+
code="NOT_CONFIGURED",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if ILLEGAL_TOKEN.search(token):
|
|
196
|
+
# The message names the variable and never the value: a rejected credential is
|
|
197
|
+
# still a credential, and an error message is printed.
|
|
198
|
+
raise ConfigError(
|
|
199
|
+
f"{spec.token_vars[0]} contains whitespace or a control character",
|
|
200
|
+
recovery=spec.token_recovery,
|
|
201
|
+
code="BAD_TOKEN",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Registered at the moment it is read, so no later code path can print it.
|
|
205
|
+
register_secret(token)
|
|
206
|
+
return Credentials(
|
|
207
|
+
base_url=normalize_base_url(raw_url, spec),
|
|
208
|
+
token=token,
|
|
209
|
+
timeout=spec.default_timeout if timeout is None else timeout,
|
|
210
|
+
url_var=url_var or "",
|
|
211
|
+
token_var=token_var or "",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def setup_recovery(spec: CredentialSpec, *, include_verify: bool = True) -> tuple:
|
|
216
|
+
"""The guidance offered wherever configuration is found to be absent."""
|
|
217
|
+
if include_verify and spec.verify is not None:
|
|
218
|
+
return (*spec.setup, spec.verify)
|
|
219
|
+
return tuple(spec.setup)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def missing_vars(spec: CredentialSpec, environ=None) -> list:
|
|
223
|
+
"""The primary variable names that are absent, in the order to report them."""
|
|
224
|
+
described = describe_environment(spec, environ)
|
|
225
|
+
missing = []
|
|
226
|
+
if not described["url_set"]:
|
|
227
|
+
missing.append(spec.url_vars[0])
|
|
228
|
+
if not described["token_set"]:
|
|
229
|
+
missing.append(spec.token_vars[0])
|
|
230
|
+
return missing
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def describe_environment(spec: CredentialSpec, environ=None) -> dict:
|
|
234
|
+
"""Report which variables are set without ever revealing the credential."""
|
|
235
|
+
environ = os.environ if environ is None else environ
|
|
236
|
+
url_var, raw_url = first_env(spec.url_vars, environ)
|
|
237
|
+
token_var, token = first_env(spec.token_vars, environ)
|
|
238
|
+
return {
|
|
239
|
+
"url_var": url_var or "",
|
|
240
|
+
"url_set": bool(raw_url),
|
|
241
|
+
"token_var": token_var or "",
|
|
242
|
+
"token_set": bool(token),
|
|
243
|
+
}
|