w6w 0.2.1__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.
- w6w/__init__.py +131 -0
- w6w/_config.py +236 -0
- w6w/_env.py +84 -0
- w6w/_http.py +377 -0
- w6w/_vars.py +236 -0
- w6w/_version.py +15 -0
- w6w/client.py +210 -0
- w6w/connections.py +109 -0
- w6w/documents.py +303 -0
- w6w/errors.py +119 -0
- w6w/me.py +103 -0
- w6w/py.typed +1 -0
- w6w/run.py +138 -0
- w6w/types.py +912 -0
- w6w/workflows.py +260 -0
- w6w-0.2.1.dist-info/METADATA +350 -0
- w6w-0.2.1.dist-info/RECORD +20 -0
- w6w-0.2.1.dist-info/WHEEL +5 -0
- w6w-0.2.1.dist-info/licenses/LICENSE +21 -0
- w6w-0.2.1.dist-info/top_level.txt +1 -0
w6w/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""w6w — the official Python client for the w6w workflow API.
|
|
2
|
+
|
|
3
|
+
Typed, dependency-free, and stdlib-only: transport is `urllib.request`, tests
|
|
4
|
+
run under `unittest`. Nothing here imports a third-party module, and nothing
|
|
5
|
+
here ever will — see README "Zero runtime dependencies".
|
|
6
|
+
|
|
7
|
+
import w6w
|
|
8
|
+
print(w6w.__version__)
|
|
9
|
+
|
|
10
|
+
THE BARREL RULE
|
|
11
|
+
---------------
|
|
12
|
+
**Every public symbol of this package must be re-exported from this module and
|
|
13
|
+
listed in `__all__`.** `w6w.ApiError`, `w6w.W6wClient`, `w6w.Doc`, … are the
|
|
14
|
+
only import paths users are promised; anything reachable only as
|
|
15
|
+
`w6w._http.something` is private and may move without a major bump.
|
|
16
|
+
Modules whose names start with an underscore (`_version`, `_config`, `_vars`,
|
|
17
|
+
…) are implementation detail — the underscore is the signal, and `_vars` in
|
|
18
|
+
particular is spelled that way because the public attribute it backs is
|
|
19
|
+
`client.vars`, which would otherwise read like the `vars` builtin at module
|
|
20
|
+
scope.
|
|
21
|
+
|
|
22
|
+
The API surface itself (which operations exist, what each is called in each
|
|
23
|
+
language) is pinned by the wrappers' shared `endpoints.json` contract, not
|
|
24
|
+
decided here: three wrappers implement one surface, and they release together
|
|
25
|
+
at one version.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from ._config import BASE_PATH, ResolvedConfig, join_base_url
|
|
31
|
+
from ._http import HttpResponse, Transport, path
|
|
32
|
+
from ._vars import VarsApi, VarsRequest
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
from .client import W6wClient
|
|
35
|
+
from .connections import ConnectionsApi, ConnectionsRequest
|
|
36
|
+
from .documents import DocumentsApi, DocumentsHost
|
|
37
|
+
from .errors import ApiError, ConfigError
|
|
38
|
+
from .me import MeRequest, fetch_me
|
|
39
|
+
from .run import RunRequest, run_urn
|
|
40
|
+
from .types import (
|
|
41
|
+
UNSET,
|
|
42
|
+
ConnectionState,
|
|
43
|
+
ConnectionSummary,
|
|
44
|
+
Doc,
|
|
45
|
+
DocFormat,
|
|
46
|
+
Me,
|
|
47
|
+
PatchableStr,
|
|
48
|
+
RunEnvelope,
|
|
49
|
+
RunResult,
|
|
50
|
+
RunStatus,
|
|
51
|
+
StepError,
|
|
52
|
+
Unset,
|
|
53
|
+
Var,
|
|
54
|
+
VarType,
|
|
55
|
+
WorkflowRunResult,
|
|
56
|
+
WorkflowStatus,
|
|
57
|
+
WorkflowSummary,
|
|
58
|
+
is_action_run,
|
|
59
|
+
is_function_run,
|
|
60
|
+
is_terminal_run_status,
|
|
61
|
+
is_workflow_run,
|
|
62
|
+
)
|
|
63
|
+
from .workflows import WorkflowsApi, WorkflowsHost
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"ApiError",
|
|
67
|
+
"BASE_PATH",
|
|
68
|
+
"ConfigError",
|
|
69
|
+
# Identity and discovery (T2.3.4). `connections.list` and `workflows.list`
|
|
70
|
+
# are how a caller finds a URN to hand to `run` (D4); `me` is what makes a
|
|
71
|
+
# bug report actionable.
|
|
72
|
+
"ConnectionState",
|
|
73
|
+
"ConnectionSummary",
|
|
74
|
+
"ConnectionsApi",
|
|
75
|
+
"ConnectionsRequest",
|
|
76
|
+
# Asset operations (T2.3.3). The namespace classes are exported alongside
|
|
77
|
+
# their wire types and host protocols because a host that types a helper
|
|
78
|
+
# around `client.documents` needs to name them; they are reached through a
|
|
79
|
+
# client, never constructed directly.
|
|
80
|
+
"Doc",
|
|
81
|
+
"DocFormat",
|
|
82
|
+
"DocumentsApi",
|
|
83
|
+
"DocumentsHost",
|
|
84
|
+
"HttpResponse",
|
|
85
|
+
"Me",
|
|
86
|
+
"MeRequest",
|
|
87
|
+
"PatchableStr",
|
|
88
|
+
"ResolvedConfig",
|
|
89
|
+
# Execution (T2.3.5). `run` dispatches on a URN and hands back the
|
|
90
|
+
# kind-tagged envelope as it arrived — a plain dict, so a `kind` this
|
|
91
|
+
# release has never heard of keeps every sibling field. The predicates are
|
|
92
|
+
# how a caller discriminates the three known arms.
|
|
93
|
+
"RunEnvelope",
|
|
94
|
+
"RunRequest",
|
|
95
|
+
"RunResult",
|
|
96
|
+
"RunStatus",
|
|
97
|
+
"StepError",
|
|
98
|
+
"Transport",
|
|
99
|
+
# The omit-vs-null sentinel and its type. Public because a caller assembling
|
|
100
|
+
# a patch programmatically has to be able to say "not this field" — `None`
|
|
101
|
+
# already means "set this field to null", and the two must stay distinct.
|
|
102
|
+
"UNSET",
|
|
103
|
+
"Unset",
|
|
104
|
+
"Var",
|
|
105
|
+
"VarType",
|
|
106
|
+
"VarsApi",
|
|
107
|
+
"VarsRequest",
|
|
108
|
+
"W6wClient",
|
|
109
|
+
"WorkflowRunResult",
|
|
110
|
+
"WorkflowStatus",
|
|
111
|
+
"WorkflowSummary",
|
|
112
|
+
"WorkflowsApi",
|
|
113
|
+
"WorkflowsHost",
|
|
114
|
+
"__version__",
|
|
115
|
+
# `client.me()` and `client.run()` are methods rather than namespaces, so
|
|
116
|
+
# the functions they delegate to are the exported symbols — same layering
|
|
117
|
+
# as the namespaces.
|
|
118
|
+
"fetch_me",
|
|
119
|
+
"is_action_run",
|
|
120
|
+
"is_function_run",
|
|
121
|
+
"is_terminal_run_status",
|
|
122
|
+
"is_workflow_run",
|
|
123
|
+
"join_base_url",
|
|
124
|
+
# Exported because `W6wClient.request` is public: a host reaching an
|
|
125
|
+
# endpoint this version does not model yet must have the same
|
|
126
|
+
# encoding-at-interpolation primitive the operation modules use, or it will
|
|
127
|
+
# concatenate a caller value into a path by hand — the one bug the encoding
|
|
128
|
+
# pin exists to prevent.
|
|
129
|
+
"path",
|
|
130
|
+
"run_urn",
|
|
131
|
+
]
|
w6w/_config.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Configuration resolution: what server, what credential, what project.
|
|
2
|
+
|
|
3
|
+
This module turns whatever the caller supplied — plus the environment, read
|
|
4
|
+
through the single seam in `_env.py` — into one plain :class:`ResolvedConfig`
|
|
5
|
+
value. It is resolved **once, at client construction**, and handed to the
|
|
6
|
+
transport; nothing downstream re-reads the environment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Mapping, Optional
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from ._env import ENV_BASE_URL, ENV_TOKEN, read_env
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
#: The API's base path, from the shared contract's `basePath`
|
|
20
|
+
#: (`packages/wrappers/endpoints.json`).
|
|
21
|
+
#:
|
|
22
|
+
#: **Empty since v0.2.0.** The server serves its routes at the ROOT of its own
|
|
23
|
+
#: host (``https://api.w6w.io/vars``, not ``.../api/vars`` — the host already
|
|
24
|
+
#: says "api"), so there is no prefix to append and :func:`join_base_url`
|
|
25
|
+
#: appends nothing. Kept exported, and kept mirroring the contract, so the
|
|
26
|
+
#: constant still answers "what does this client prepend?" — now "nothing".
|
|
27
|
+
BASE_PATH: str = ""
|
|
28
|
+
|
|
29
|
+
#: The schemes a w6w base URL may use. Anything else is a configuration
|
|
30
|
+
#: mistake — most often a bare `host:port` that `urlsplit` reads as a scheme.
|
|
31
|
+
ALLOWED_SCHEMES = ("http", "https")
|
|
32
|
+
|
|
33
|
+
#: Characters that may never appear in an HTTP header value. CR and LF are
|
|
34
|
+
#: header injection; NUL and the rest are rejected with them rather than left to
|
|
35
|
+
#: fail somewhere deeper. HTAB is technically legal in a field-value and is
|
|
36
|
+
#: rejected too — it cannot appear in a bearer token for any good reason.
|
|
37
|
+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ResolvedConfig:
|
|
42
|
+
"""Configuration after resolution — the value the transport runs on.
|
|
43
|
+
|
|
44
|
+
Held as **instance** state on the client, never in a module-level variable:
|
|
45
|
+
two clients in one process must be able to hold different credentials and
|
|
46
|
+
point at different servers with no interference
|
|
47
|
+
(`docs/implementation.md` §2). Frozen, so nothing downstream can mutate a
|
|
48
|
+
client's identity in place.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
#: The normalized base, e.g. `https://api.example.com`. Never
|
|
52
|
+
#: trailing-slashed, never relative.
|
|
53
|
+
base_url: str
|
|
54
|
+
#: The bearer token, or `None` when none was configured.
|
|
55
|
+
token: Optional[str] = None
|
|
56
|
+
#: Default project id for the project-scoped operations, or `None`.
|
|
57
|
+
project: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def join_base_url(origin: str) -> str:
|
|
61
|
+
"""Validate a configured origin and join it with the API base path.
|
|
62
|
+
|
|
63
|
+
**This is the one and only base-URL code path.** `resolve_config` calls this
|
|
64
|
+
function and does no normalising of its own, so a caller who imports the
|
|
65
|
+
helper gets exactly the answer their client will use — including the same
|
|
66
|
+
errors. A helper that were merely *similar* to the client's path (skipping
|
|
67
|
+
the whitespace trim, say) would answer differently for the same input, which
|
|
68
|
+
is worse than having no helper at all.
|
|
69
|
+
|
|
70
|
+
The join rule is pinned by `docs/implementation.md` §2 and mirrors the house
|
|
71
|
+
helper the operator console already uses against this same API, so every
|
|
72
|
+
client agrees on what `W6W_BASE_URL` means:
|
|
73
|
+
|
|
74
|
+
1. strip surrounding whitespace, then **all** trailing slashes;
|
|
75
|
+
2. reject anything that is not an absolute `http`/`https` URL;
|
|
76
|
+
3. append nothing — the API is served at the root of its own host.
|
|
77
|
+
|
|
78
|
+
::
|
|
79
|
+
|
|
80
|
+
https://api.example.com -> https://api.example.com
|
|
81
|
+
https://api.example.com/ -> https://api.example.com
|
|
82
|
+
https://api.example.com/// -> https://api.example.com
|
|
83
|
+
|
|
84
|
+
Any path in the configured value is preserved verbatim, so a deployment
|
|
85
|
+
that genuinely sits behind a gateway prefix can still be addressed by
|
|
86
|
+
configuring that prefix. This is also why a stale
|
|
87
|
+
``https://api.example.com/api`` is NOT silently rewritten: it is
|
|
88
|
+
indistinguishable from such a prefix, and quietly stripping it would break
|
|
89
|
+
the deployments that mean it. It will 404 — the announced break for the
|
|
90
|
+
v0.1.x base path.
|
|
91
|
+
|
|
92
|
+
Step 2 exists because a **relative** base URL must fail *here*, as
|
|
93
|
+
configuration, with a message about configuration. Without an absoluteness
|
|
94
|
+
check, ``W6W_BASE_URL=/foo`` would walk straight past blank-is-absent
|
|
95
|
+
(`_env.read_env`) and then fail on the first request as "the server may be
|
|
96
|
+
down" — the same hole, reopened by another route, and misdiagnosed.
|
|
97
|
+
|
|
98
|
+
:param origin: The configured origin. Surrounding whitespace and trailing
|
|
99
|
+
slashes are tolerated.
|
|
100
|
+
:returns: The normalized base URL.
|
|
101
|
+
:raises ConfigError: When the origin is blank, relative, or not http(s).
|
|
102
|
+
"""
|
|
103
|
+
trimmed = origin.strip().rstrip("/")
|
|
104
|
+
if not trimmed:
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
"No w6w base URL is configured. Pass one to the client "
|
|
107
|
+
'(W6wClient(base_url="https://api.example.com")) or set the '
|
|
108
|
+
"{env} environment variable. It holds the server's origin, e.g. "
|
|
109
|
+
'"https://api.example.com" — the API is served at its root, so no '
|
|
110
|
+
"path is appended.".format(env=ENV_BASE_URL),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
split = urlsplit(trimmed)
|
|
114
|
+
if split.scheme not in ALLOWED_SCHEMES or not split.netloc:
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
"The w6w base URL {value!r} is not an absolute http(s) URL. "
|
|
117
|
+
"{env} holds an ORIGIN, e.g. \"https://api.example.com\" — scheme "
|
|
118
|
+
"and host. A relative or host-only value cannot be requested and "
|
|
119
|
+
"would fail later as a connection problem rather than as the "
|
|
120
|
+
"configuration mistake it is.".format(value=trimmed, env=ENV_BASE_URL),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return trimmed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_config(
|
|
127
|
+
base_url: Optional[str] = None,
|
|
128
|
+
token: Optional[str] = None,
|
|
129
|
+
project: Optional[str] = None,
|
|
130
|
+
environ: Optional[Mapping[str, str]] = None,
|
|
131
|
+
) -> ResolvedConfig:
|
|
132
|
+
"""Resolve explicit arguments and the environment into one config value.
|
|
133
|
+
|
|
134
|
+
Precedence is **explicit argument > environment variable**, always; there is
|
|
135
|
+
no default base URL. The two sides of that chain treat emptiness
|
|
136
|
+
*asymmetrically*, on purpose (`docs/implementation.md` §2, Precedence):
|
|
137
|
+
|
|
138
|
+
- An explicitly passed empty string is an **explicit value**, not "unset".
|
|
139
|
+
It does not fall through to the environment — a caller who wrote
|
|
140
|
+
``base_url=""`` gets the configuration error, not a value the wrapper went
|
|
141
|
+
looking for behind their back. (It is still not a usable base URL, so the
|
|
142
|
+
error is the same one either way.)
|
|
143
|
+
- An **empty or whitespace-only environment variable is ABSENT** and falls
|
|
144
|
+
through (see `_env.read_env`), so ``W6W_BASE_URL=``, ``W6W_BASE_URL=" "``
|
|
145
|
+
and an unset ``W6W_BASE_URL`` all end here, in the same configuration
|
|
146
|
+
error — never in a relative base.
|
|
147
|
+
|
|
148
|
+
The difference is the difference between a value a *caller* chose and a
|
|
149
|
+
value a *shell* produced.
|
|
150
|
+
|
|
151
|
+
:param base_url: Explicit origin; `None` consults `W6W_BASE_URL`.
|
|
152
|
+
:param token: Explicit bearer token; `None` consults `W6W_TOKEN`.
|
|
153
|
+
:param project: Default project id for the `documents.*` operations. There
|
|
154
|
+
is no environment variable for it, and no `vars.*` operation takes one —
|
|
155
|
+
vars are not project-scoped (`docs/implementation.md` §7).
|
|
156
|
+
:param environ: Environment mapping override, for tests.
|
|
157
|
+
:returns: The resolved configuration.
|
|
158
|
+
:raises ConfigError: When no usable base URL was supplied, naming
|
|
159
|
+
`W6W_BASE_URL`.
|
|
160
|
+
"""
|
|
161
|
+
raw_base_url = base_url if base_url is not None else read_env(ENV_BASE_URL, environ)
|
|
162
|
+
return ResolvedConfig(
|
|
163
|
+
# Every normalisation, emptiness test and validation lives in
|
|
164
|
+
# `join_base_url` — one code path, so the exported helper and the client
|
|
165
|
+
# can never answer differently for the same input.
|
|
166
|
+
base_url=join_base_url(raw_base_url or ""),
|
|
167
|
+
token=token if token is not None else read_env(ENV_TOKEN, environ),
|
|
168
|
+
project=project,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def require_token(config: ResolvedConfig) -> str:
|
|
173
|
+
"""Return the configured token, or raise a :class:`ConfigError` naming `W6W_TOKEN`.
|
|
174
|
+
|
|
175
|
+
A client may be *constructed* without a token so that a CLI's `--help` and
|
|
176
|
+
`--version` work offline; the error surfaces on the first request instead.
|
|
177
|
+
There are no anonymous operations in this surface
|
|
178
|
+
(`docs/implementation.md` §2).
|
|
179
|
+
|
|
180
|
+
A blank token counts as no token, for the same reason a blank base URL does:
|
|
181
|
+
``W6W_TOKEN=`` is how a shell or a Dockerfile spells "I meant to set this and
|
|
182
|
+
did not", and ``Authorization: Bearer `` would turn that into an opaque 401
|
|
183
|
+
instead of the one message that explains it.
|
|
184
|
+
|
|
185
|
+
**The token is also validated as a header value, here, by this package.**
|
|
186
|
+
A token carrying CR or LF is HTTP header injection — everything after the
|
|
187
|
+
newline becomes an attacker-chosen header or request line — and it arrives
|
|
188
|
+
the way secrets usually do: `$(cat token.txt)` with a trailing newline, a
|
|
189
|
+
copy-paste out of a UI, a CI variable spanning two lines. This check is not
|
|
190
|
+
delegated to the runtime on purpose. `http.client` does reject CR/LF, but
|
|
191
|
+
only when the request is actually *sent*, and it raises a bare `ValueError`
|
|
192
|
+
from outside this package's error model — so an injected token would sail
|
|
193
|
+
through every unit test in this suite (which never sends), and in production
|
|
194
|
+
would surface as an exception a caller has no reason to be catching. NUL and
|
|
195
|
+
the other control characters are rejected alongside them.
|
|
196
|
+
|
|
197
|
+
The offending character is reported by codepoint and position; **the token
|
|
198
|
+
itself is never echoed**, because this message lands in logs.
|
|
199
|
+
|
|
200
|
+
:param config: The resolved configuration.
|
|
201
|
+
:returns: The configured token, verbatim.
|
|
202
|
+
:raises ConfigError: When no non-blank token is configured, or when the
|
|
203
|
+
token cannot be sent as a header value.
|
|
204
|
+
"""
|
|
205
|
+
if config.token is None or not config.token.strip():
|
|
206
|
+
raise ConfigError(
|
|
207
|
+
"No w6w API token is configured. Pass one to the client "
|
|
208
|
+
'(W6wClient(token="...")) or set the {env} environment variable. '
|
|
209
|
+
"Every w6w API operation is authenticated.".format(env=ENV_TOKEN),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
control = _CONTROL_CHARS_RE.search(config.token)
|
|
213
|
+
if control is not None:
|
|
214
|
+
raise ConfigError(
|
|
215
|
+
"The configured w6w API token contains a control character "
|
|
216
|
+
"(U+{code:04X}) at position {index}, so it cannot be sent as an "
|
|
217
|
+
"Authorization header — a carriage return or newline in a header "
|
|
218
|
+
"value is header injection. Check {env} for a stray newline (a "
|
|
219
|
+
"token read from a file usually has one).".format(
|
|
220
|
+
code=ord(control.group()),
|
|
221
|
+
index=control.start(),
|
|
222
|
+
env=ENV_TOKEN,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
# http.client encodes header values as latin-1; anything else would
|
|
227
|
+
# raise a UnicodeEncodeError from deep inside the send path.
|
|
228
|
+
config.token.encode("latin-1")
|
|
229
|
+
except UnicodeEncodeError as err:
|
|
230
|
+
raise ConfigError(
|
|
231
|
+
"The configured w6w API token contains a non-latin-1 character at "
|
|
232
|
+
"position {index}, so it cannot be sent as an Authorization header. "
|
|
233
|
+
"Check {env}.".format(index=err.start, env=ENV_TOKEN),
|
|
234
|
+
) from None
|
|
235
|
+
|
|
236
|
+
return config.token
|
w6w/_env.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The **only** module in this package that reads the environment.
|
|
2
|
+
|
|
3
|
+
`docs/implementation.md` §2 (MECHANISM PIN — instance state, never globals) pins
|
|
4
|
+
exactly one environment-reading module per wrapper; everything else receives
|
|
5
|
+
resolved configuration as a plain value. That is not a style preference. It is
|
|
6
|
+
what keeps `os.environ` out of the operation modules, so no operation can
|
|
7
|
+
silently acquire a dependency on ambient process state, and so every env-var
|
|
8
|
+
behaviour has exactly **one** seam to exercise in tests (§9).
|
|
9
|
+
|
|
10
|
+
The spec names that module `w6w/_config.py`; this package splits the *reading*
|
|
11
|
+
(here) from the *resolving* (`_config.py`, which imports this file and is its
|
|
12
|
+
only caller). The invariant the pin is about — one place touches the
|
|
13
|
+
environment — is unchanged, and it is enforced mechanically: the repo gate greps
|
|
14
|
+
the whole `src/w6w` tree for `os.environ|os.getenv` and requires exactly one
|
|
15
|
+
matching file. The `node` wrapper made the same split for the same reason
|
|
16
|
+
(`src/env.ts` beside `src/config.ts`).
|
|
17
|
+
|
|
18
|
+
The mapping is **injectable** (`environ=`) so the whole precedence chain can be
|
|
19
|
+
tested without mutating the process environment: a module-patched global cannot
|
|
20
|
+
be exercised two different ways in one process, which is the same argument that
|
|
21
|
+
makes the credential and the transport constructor-injected.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Mapping, Optional
|
|
28
|
+
|
|
29
|
+
#: Environment variable holding the server **origin** (see `_config.py`).
|
|
30
|
+
ENV_BASE_URL: str = "W6W_BASE_URL"
|
|
31
|
+
|
|
32
|
+
#: Environment variable holding the bearer token.
|
|
33
|
+
ENV_TOKEN: str = "W6W_TOKEN"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def process_environ() -> Mapping[str, str]:
|
|
37
|
+
"""Return the live process environment.
|
|
38
|
+
|
|
39
|
+
Factored out as a function so `read_env`'s default source is a named,
|
|
40
|
+
assertable thing rather than a value captured at import time — and so this
|
|
41
|
+
file stays the single grep-able point where `os.environ` appears.
|
|
42
|
+
|
|
43
|
+
:returns: `os.environ` itself (not a copy — reads must see later changes).
|
|
44
|
+
"""
|
|
45
|
+
return os.environ
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_env(name: str, environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
|
49
|
+
"""Read one environment variable, or `None` when it carries no usable value.
|
|
50
|
+
|
|
51
|
+
**A variable that is set but empty — or whitespace-only — reads as ABSENT.**
|
|
52
|
+
`W6W_BASE_URL=`, `W6W_BASE_URL=" "`, `W6W_BASE_URL=$'\\n'` and an unset
|
|
53
|
+
`W6W_BASE_URL` are all the same thing here, and the precedence chain falls
|
|
54
|
+
through all four to the next source (and ultimately to a `ConfigError`).
|
|
55
|
+
|
|
56
|
+
The guard is therefore a **truthiness/strip** check and deliberately *not*
|
|
57
|
+
an `is None` test. `os.environ.get` returns `""` for a set-but-empty
|
|
58
|
+
variable, never `None`, so `is None` is the Python spelling of the same
|
|
59
|
+
nullish-coalescing mistake that `docs/implementation.md` §2 forbids in the
|
|
60
|
+
TypeScript wrappers: an empty `W6W_BASE_URL` would survive as a value and
|
|
61
|
+
short-circuit the precedence chain, leaving the client on an empty base and
|
|
62
|
+
issuing every request against a relative URL — exactly the browser
|
|
63
|
+
same-origin default a library must not have. Do not "tidy" this into
|
|
64
|
+
`if value is None`.
|
|
65
|
+
|
|
66
|
+
An empty environment variable is not a hypothetical. `export W6W_BASE_URL=`,
|
|
67
|
+
a Dockerfile `ENV W6W_BASE_URL=` with no build arg, and a CI variable whose
|
|
68
|
+
template did not interpolate all produce exactly this, and it *looks* absent
|
|
69
|
+
in every log line while behaving as present in every null check.
|
|
70
|
+
|
|
71
|
+
The value is returned **verbatim**, not stripped: trimming applies to the
|
|
72
|
+
emptiness *test*, not to the stored value. Normalising the shape of a base
|
|
73
|
+
URL is the join rule's job (`_config.join_base_url`).
|
|
74
|
+
|
|
75
|
+
:param name: The variable to read, e.g. :data:`ENV_BASE_URL`.
|
|
76
|
+
:param environ: Mapping to read from. Defaults to the process environment;
|
|
77
|
+
pass one to test the precedence chain without touching `os.environ`.
|
|
78
|
+
:returns: The value, or `None` if unset, empty or whitespace-only.
|
|
79
|
+
"""
|
|
80
|
+
source = process_environ() if environ is None else environ
|
|
81
|
+
value = source.get(name)
|
|
82
|
+
if value is None or not value.strip():
|
|
83
|
+
return None
|
|
84
|
+
return value
|