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.
@@ -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__"]
@@ -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
+ }