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 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