capito 0.1.2__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.
capito/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """capito: explore and call an HTTP API from Python. Start with client.operations() and client.describe().
2
+
3
+ Documentation of the installed version: python -m capito docs usage | extending.
4
+ """
5
+ from .accounts import ACCOUNT_ARGUMENTS, AccountRecord, AccountsCommands, Session, account_arguments
6
+ from .auth import Auth, BearerAuth, BearerTokens
7
+ from .client import Client
8
+ from .config import Config
9
+ from .core import Core
10
+ from .files import File, readable_file
11
+ from .registry import body_param, command, path_param, query_param
12
+ from .results import Account, CompoundResult, Result, Secrets, add_step, body_value, error_from, failed, strings
13
+ from .commands import Commands
14
+
15
+ __all__ = ['ACCOUNT_ARGUMENTS', 'Account', 'AccountRecord', 'AccountsCommands', 'Auth', 'BearerAuth', 'BearerTokens',
16
+ 'Client', 'Commands', 'CompoundResult', 'Config', 'Core', 'File', 'Result', 'Secrets', 'Session', 'account_arguments',
17
+ 'add_step', 'body_param', 'body_value', 'command', 'error_from', 'failed', 'path_param', 'query_param',
18
+ 'readable_file', 'strings']
capito/__main__.py ADDED
@@ -0,0 +1,113 @@
1
+ """Help from the shell: python -m capito operations|describe|docs. Read-only; requests are made from Python."""
2
+ import argparse
3
+ from importlib import resources
4
+ import json
5
+ import sys
6
+
7
+ from .client import client_class
8
+ from .config import Config
9
+
10
+ DOCS = {'usage': 'Working with an API: find an operation, call it, read the result, accounts, validation',
11
+ 'extending': 'Connecting your API: Client, commands, authorization, accounts, tests'}
12
+
13
+
14
+ def limit(value):
15
+ """A view limit for the command line: a non-negative number or 'all', passed to view() as is."""
16
+ if value == 'all':
17
+ return value
18
+ try:
19
+ number = int(value)
20
+ except ValueError:
21
+ number = -1
22
+ if number < 0:
23
+ raise argparse.ArgumentTypeError(f"expected a non-negative number or 'all', got {value!r}")
24
+ return number
25
+
26
+
27
+ def load_client(path=None, environment=None):
28
+ """The client named by the `client` key of capito.toml (given path or found upwards); capito.Client without it."""
29
+ config = Config.load(path, environment)
30
+ return client_class(config)(config)
31
+
32
+
33
+ def config_options(target, *, defaults=True):
34
+ """--config and --env, accepted before and after the subcommand; without defaults the subcommand never resets them."""
35
+ config, env = (None, None) if defaults else (argparse.SUPPRESS, argparse.SUPPRESS)
36
+ target.add_argument('--config', default=config, help='path to capito.toml; default: the nearest one upwards from the working directory')
37
+ target.add_argument('--env', default=env, help='environment; default: default_environment')
38
+
39
+
40
+ def parser():
41
+ common = argparse.ArgumentParser(add_help=False)
42
+ config_options(common, defaults=False)
43
+ limits = argparse.ArgumentParser(add_help=False)
44
+ for name, text in (('depth', 'levels under each selected node'), ('width', 'characters per value'), ('lines', 'lines of output')):
45
+ limits.add_argument(f'--{name}', type=limit, default=argparse.SUPPRESS, metavar='N|all',
46
+ help=f'{text}; all for no limit; default: [view] of the configuration')
47
+
48
+ root = argparse.ArgumentParser(prog='python -m capito',
49
+ description='Help on commands, contract operations and schemas, and the capito documentation. '
50
+ 'Makes no API requests and changes no state.')
51
+ config_options(root)
52
+ sub = root.add_subparsers(dest='action', required=True)
53
+ ops = sub.add_parser('operations', parents=[common, limits], help='list operations: client.operations(text, kind=).view()',
54
+ description='Commands and contract operations; --depth 1 adds signatures and effects.')
55
+ ops.add_argument('text', nargs='?', help='substring of id or summary, case-insensitive')
56
+ ops.add_argument('--kind', choices=('command', 'api'))
57
+ card = sub.add_parser('describe', parents=[common, limits], help='help cards: client.describe(id).view()',
58
+ description='Cards for command:<set>.<command>, "api:<METHOD> <route>" or schema:<Name>.')
59
+ card.add_argument('ids', nargs='+', metavar='id')
60
+ card.add_argument('--fields', nargs='+', default=(), metavar='FIELD', help='only these card fields, e.g. call returns')
61
+ card.add_argument('--items', type=limit, default=argparse.SUPPRESS, metavar='N|all', help='enum values; all for no limit')
62
+ card.add_argument('--json', action='store_true', help='JSON without limits; several ids give an array')
63
+ docs = sub.add_parser('docs', help='capito documentation; no configuration needed',
64
+ description='Documentation of the installed capito version. Without a topic: the list of topics.')
65
+ docs.add_argument('topic', nargs='?', choices=tuple(DOCS))
66
+ return root
67
+
68
+
69
+ def show_docs(topic):
70
+ if topic is None:
71
+ print('\n'.join(f'{name:10} {text}' for name, text in DOCS.items()))
72
+ print('\npython -m capito docs <topic>')
73
+ return 0
74
+ print(resources.files('capito').joinpath('docs', f'{topic}.md').read_text(encoding='utf-8'))
75
+ return 0
76
+
77
+
78
+ def main(argv=None, *, make_client=load_client):
79
+ args = parser().parse_args(argv)
80
+ if args.action == 'docs':
81
+ return show_docs(args.topic)
82
+ options = {k: getattr(args, k) for k in ('depth', 'items', 'width', 'lines') if hasattr(args, k)}
83
+ try:
84
+ client = make_client(args.config, environment=args.env)
85
+ except (OSError, ValueError, KeyError, ImportError, AttributeError) as exc:
86
+ print(f'capito: cannot load the client: {exc}', file=sys.stderr)
87
+ return 2
88
+ try:
89
+ if args.action == 'operations':
90
+ print(client.operations(args.text, kind=args.kind).view(**options))
91
+ return 0
92
+ except ValueError as exc: # the help contract cannot be read
93
+ print(f'capito: {exc}', file=sys.stderr)
94
+ return 2
95
+ cards, failed = [], False
96
+ for identifier in args.ids:
97
+ try:
98
+ help_ = client.describe(identifier)
99
+ cards.append(help_.view(*args.fields, **options, format='json' if args.json else 'text'))
100
+ except ValueError as exc:
101
+ print(f'capito: {exc}', file=sys.stderr)
102
+ failed = True
103
+ if args.json:
104
+ parsed = [json.loads(c) for c in cards]
105
+ if parsed:
106
+ print(json.dumps(parsed[0] if len(args.ids) == 1 else parsed, ensure_ascii=False, indent=2))
107
+ elif cards:
108
+ print('\n\n'.join(cards))
109
+ return 1 if failed else 0
110
+
111
+
112
+ if __name__ == '__main__':
113
+ sys.exit(main())
capito/accounts.py ADDED
@@ -0,0 +1,354 @@
1
+ """Local accounts: frozen records, the store with one reentrant lock per environment, and the accounts command set."""
2
+ from collections.abc import Mapping
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from types import MappingProxyType
7
+ from typing import Any, ClassVar, final
8
+ import json
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ import uuid
15
+
16
+ from .commands import Commands
17
+ from .registry import command
18
+ from .results import Account, Secrets, thaw
19
+ from .view import ALL, ViewList
20
+
21
+ if sys.platform == 'win32':
22
+ import msvcrt
23
+ else:
24
+ import fcntl
25
+
26
+
27
+ class StaleSessionError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def freeze(value):
32
+ """Read-only copy of JSON data: mappings become views that reject changes, lists become tuples."""
33
+ if isinstance(value, Mapping):
34
+ return MappingProxyType({k: freeze(v) for k, v in value.items()})
35
+ if isinstance(value, (list, tuple)):
36
+ return tuple(freeze(v) for v in value)
37
+ return value
38
+
39
+
40
+ @dataclass(frozen=True, repr=False)
41
+ class AccountRecord:
42
+ """A stored account with credentials. For commands and adapters only; never print it.
43
+
44
+ A snapshot: changing local state goes through client.core.save_session(), clear_session(),
45
+ update_account() and accounts.update_local(), which check `revision` against newer changes.
46
+ """
47
+ id: str
48
+ alias: str
49
+ identity: Mapping[str, Any] = field(default_factory=dict) # who it is; no secrets
50
+ session: Mapping[str, Any] = field(default_factory=dict) # credentials; their format belongs to the auth adapter
51
+ settings: Mapping[str, Any] = field(default_factory=dict) # local preferences
52
+ revision: int = 0 # session revision
53
+
54
+ def __post_init__(self):
55
+ for name in ('identity', 'session', 'settings'):
56
+ object.__setattr__(self, name, freeze(getattr(self, name)))
57
+
58
+ @classmethod
59
+ def from_json(cls, data):
60
+ return cls(data['id'], data['alias'], data['identity'], data['session'], data['settings'], data['session_revision'])
61
+
62
+ def to_json(self):
63
+ return dict(id=self.id, alias=self.alias, identity=thaw(self.identity), session=thaw(self.session),
64
+ settings=thaw(self.settings), session_revision=self.revision)
65
+
66
+ def __repr__(self):
67
+ return f'<AccountRecord {self.alias}: credentials hidden>'
68
+
69
+
70
+ @dataclass(frozen=True, repr=False)
71
+ class Session:
72
+ """An in-memory credential snapshot for call(session=...). Never refreshed or persisted by a call."""
73
+ record: AccountRecord = field(repr=False)
74
+
75
+ def __repr__(self):
76
+ return '<Session: credentials hidden>'
77
+
78
+
79
+ class _Lock:
80
+ def __init__(self):
81
+ self.thread = threading.RLock()
82
+ self.depth = 0
83
+ self.fd = None
84
+
85
+
86
+ _locks = {}
87
+ _locks_guard = threading.Lock()
88
+
89
+
90
+ def private_dir(path):
91
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
92
+ path.chmod(0o700) # Windows ignores it: there the user profile's permissions protect the files
93
+
94
+
95
+ def replace_file(source, target, attempts=50):
96
+ """os.replace; on Windows a reader holding the target open makes it fail for a moment, so retry briefly."""
97
+ for attempt in range(attempts):
98
+ try:
99
+ os.replace(source, target)
100
+ return
101
+ except PermissionError:
102
+ if attempt == attempts - 1:
103
+ raise
104
+ time.sleep(0.01)
105
+
106
+
107
+ def atomic_json(path, data):
108
+ private_dir(path.parent)
109
+ fd, temporary = tempfile.mkstemp(prefix='.tmp-', dir=path.parent)
110
+ try:
111
+ with os.fdopen(fd, 'w') as output:
112
+ json.dump(data, output, ensure_ascii=False, indent=2)
113
+ output.flush()
114
+ os.fsync(output.fileno())
115
+ replace_file(temporary, path)
116
+ finally:
117
+ Path(temporary).unlink(missing_ok=True)
118
+
119
+
120
+ def try_lock(fd):
121
+ """One non-blocking attempt to lock the environment's lock file across processes."""
122
+ try:
123
+ if sys.platform == 'win32':
124
+ os.lseek(fd, 0, os.SEEK_SET)
125
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
126
+ else:
127
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
128
+ return True
129
+ except (BlockingIOError, PermissionError, OSError) as exc:
130
+ if isinstance(exc, (BlockingIOError, PermissionError)) or getattr(exc, 'errno', None) in (11, 13, 35, 36):
131
+ return False
132
+ raise
133
+
134
+
135
+ def unlock(fd):
136
+ if sys.platform == 'win32':
137
+ os.lseek(fd, 0, os.SEEK_SET)
138
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
139
+ else:
140
+ fcntl.flock(fd, fcntl.LOCK_UN)
141
+
142
+
143
+ class AccountStore:
144
+ """Account files of one environment. Internal to the client; projects use client.core and accounts methods."""
145
+ def __init__(self, root, base_url, *, lock_timeout=30, policy=None, limits=ALL, summary_identity=(), lookup=()):
146
+ self.root = Path(root).resolve()
147
+ self.base_url = base_url
148
+ self.lock_timeout = lock_timeout
149
+ self.policy = policy or Secrets.http()
150
+ self.limits = limits
151
+ self.summary_identity = tuple(summary_identity)
152
+ self.lookup = tuple(lookup)
153
+ self._check_origin()
154
+
155
+ def _check_origin(self):
156
+ path = self.root / 'origin.json'
157
+ if path.exists() and json.loads(path.read_text())['base_url'] != self.base_url:
158
+ raise ValueError('State belongs to another server; choose a separate environment')
159
+
160
+ @contextmanager
161
+ def lock(self):
162
+ with _locks_guard:
163
+ state = _locks.setdefault((os.getpid(), str(self.root)), _Lock())
164
+ deadline = time.monotonic() + self.lock_timeout
165
+ if not state.thread.acquire(timeout=self.lock_timeout):
166
+ raise TimeoutError('Timed out waiting for environment state lock')
167
+ entered = False
168
+ try:
169
+ if state.depth == 0:
170
+ private_dir(self.root)
171
+ fd = os.open(self.root / '.lock', os.O_CREAT | os.O_RDWR, 0o600)
172
+ try:
173
+ while not try_lock(fd):
174
+ if time.monotonic() >= deadline:
175
+ raise TimeoutError('Timed out waiting for environment state lock')
176
+ time.sleep(0.01)
177
+ self._check_origin()
178
+ if not (self.root / 'origin.json').exists():
179
+ atomic_json(self.root / 'origin.json', {'base_url': self.base_url})
180
+ for parent in (self.root, self.root / 'accounts'):
181
+ for leftover in parent.glob('.tmp-*'):
182
+ leftover.unlink()
183
+ state.fd = fd
184
+ except BaseException:
185
+ os.close(fd)
186
+ raise
187
+ state.depth += 1
188
+ entered = True
189
+ yield
190
+ finally:
191
+ if entered:
192
+ state.depth -= 1
193
+ if state.depth == 0:
194
+ unlock(state.fd)
195
+ os.close(state.fd)
196
+ state.fd = None
197
+ state.thread.release()
198
+
199
+ def _path(self, account_id):
200
+ return self.root / 'accounts' / (str(uuid.UUID(account_id)) + '.json')
201
+
202
+ def _write(self, record):
203
+ atomic_json(self._path(record.id), record.to_json())
204
+
205
+ def records(self):
206
+ self._check_origin()
207
+ records = []
208
+ for path in sorted((self.root / 'accounts').glob('*.json')):
209
+ try:
210
+ records.append(AccountRecord.from_json(json.loads(path.read_text())))
211
+ except FileNotFoundError:
212
+ continue # Another process forgot this account after directory listing.
213
+ return records
214
+
215
+ def resolve(self, selector):
216
+ """The record whose ID, alias (any case) or one of the lookup identity fields equals selector."""
217
+ text = str(selector)
218
+ matches = [r for r in self.records()
219
+ if r.id == text or r.alias.casefold() == text.casefold()
220
+ or any(r.identity.get(name) == selector for name in self.lookup)]
221
+ if len(matches) != 1:
222
+ raise ValueError(f'Unknown or ambiguous account: {selector!r}')
223
+ return matches[0]
224
+
225
+ def _check_unique(self, alias, identity, skip=None):
226
+ for other in self.records():
227
+ if other.id == skip:
228
+ continue
229
+ if alias is not None and other.alias.casefold() == alias.casefold():
230
+ raise ValueError(f'Alias already exists: {alias}')
231
+ for name in self.lookup:
232
+ value = identity.get(name)
233
+ if value is not None and other.identity.get(name) == value:
234
+ raise ValueError(f'{name} {value!r} already belongs to account {other.alias!r}')
235
+
236
+ def create(self, alias, identity=None, settings=None):
237
+ if not isinstance(alias, str) or not alias.strip():
238
+ raise ValueError('A nonempty local alias is required')
239
+ with self.lock():
240
+ self._check_unique(alias, identity or {})
241
+ record = AccountRecord(str(uuid.uuid4()), alias, identity or {}, {}, settings or {}, 0)
242
+ self._write(record)
243
+ return record
244
+
245
+ def update(self, account, *, identity=None, settings=None, revision=None):
246
+ """Merge identity/settings changes; with revision, refuse if the session changed since that snapshot."""
247
+ with self.lock():
248
+ try:
249
+ current = self.resolve(account if isinstance(account, str) else account.id)
250
+ except ValueError:
251
+ raise StaleSessionError('Account was removed meanwhile') from None
252
+ if revision is not None and current.revision != revision:
253
+ raise StaleSessionError('Session changed meanwhile')
254
+ new_identity = {**thaw(current.identity), **(identity or {})}
255
+ if identity:
256
+ self._check_unique(None, {k: v for k, v in identity.items() if k in self.lookup}, skip=current.id)
257
+ updated = AccountRecord(current.id, current.alias, new_identity, current.session,
258
+ {**thaw(current.settings), **(settings or {})}, current.revision)
259
+ self._write(updated)
260
+ return updated
261
+
262
+ def replace_session(self, snapshot, session):
263
+ with self.lock():
264
+ try:
265
+ current = self.resolve(snapshot.id)
266
+ except ValueError:
267
+ raise StaleSessionError('Account was removed while request was running') from None
268
+ if current.revision != snapshot.revision:
269
+ raise StaleSessionError('Session changed while request was running')
270
+ updated = AccountRecord(current.id, current.alias, current.identity, session, current.settings,
271
+ current.revision + 1)
272
+ self._write(updated)
273
+ return updated
274
+
275
+ def forget(self, account_id):
276
+ with self.lock():
277
+ record = self.resolve(account_id)
278
+ self._path(record.id).unlink()
279
+
280
+ def public(self, record):
281
+ """The record without credentials, as shown to callers."""
282
+ return Account(record, policy=self.policy, limits=self.limits, summary_identity=self.summary_identity)
283
+
284
+
285
+ # Arguments of the local account commands; project commands reuse them explicitly.
286
+ ACCOUNT_ARGUMENTS = {
287
+ 'alias': {'type': 'str', 'description': 'Stable unique local alias; not the name of the server profile.', 'example': 'qa_new'},
288
+ 'selector': {'type': 'str', 'description': 'ID, alias or a project lookup field value of an existing local record.', 'example': 'alice'},
289
+ 'identity': {'type': 'dict | None', 'description': 'Account identifiers in the project model. Not a cache of the server profile.', 'example': {}},
290
+ 'settings': {'type': 'dict | None', 'description': 'Explicit local account settings, e.g. file bindings.', 'example': {}},
291
+ }
292
+
293
+
294
+ def account_arguments(*names, **extra):
295
+ """Help metadata for the named account arguments, plus command-specific ones."""
296
+ return {**{name: ACCOUNT_ARGUMENTS[name] for name in names}, **extra}
297
+
298
+
299
+ class AccountsCommands(Commands):
300
+ """The 'accounts' command set: local account records, and how as_user finds them.
301
+
302
+ Declared in Client.commands under the key 'accounts' (this class or a subclass); without it the client
303
+ has no accounts and as_user is unavailable.
304
+ lookup: identity fields accepted as selectors besides ID and alias; their values are unique.
305
+ summary_identity: identity fields shown by print(account).
306
+ A create() override with domain arguments must delegate to super().create(), which checks uniqueness.
307
+ """
308
+ lookup: ClassVar[tuple[str, ...]] = ()
309
+ summary_identity: ClassVar[tuple[str, ...]] = ()
310
+
311
+ @final
312
+ def resolve(self, selector) -> AccountRecord:
313
+ """The stored record, credentials included; for commands and adapters only — never print it."""
314
+ return self.client.core._store.resolve(selector)
315
+
316
+ @final
317
+ def update_local(self, selector, *, identity=None, settings=None) -> Account:
318
+ """Change identity/settings without a server request, e.g. local file bindings. Not a command."""
319
+ return self.client.core._store.public(self.client.core._store.update(self.resolve(selector), identity=identity, settings=settings))
320
+
321
+ @command('List local accounts without credentials; no server requests',
322
+ result='accounts',
323
+ effects='Reads local records; no server requests, no session changes.')
324
+ def list(self):
325
+ store = self.client.core._store
326
+ return ViewList((store.public(r) for r in store.records()), limits=self.client.core.config.view['list'])
327
+
328
+ @command('Read one local account without credentials',
329
+ result='account',
330
+ effects='Reads a local record; no server requests, no session changes.',
331
+ parameters=account_arguments('selector'))
332
+ def get(self, selector):
333
+ return self.client.core._store.public(self.resolve(selector))
334
+
335
+ @command('Create a local account record; does not register on the server',
336
+ result='account',
337
+ effects='Creates only a local record. Neither logs in nor registers on the server.',
338
+ parameters=account_arguments('alias', 'identity', 'settings'))
339
+ def create(self, alias, *, identity=None, settings=None):
340
+ return self.client.core._store.public(self.client.core._store.create(alias, identity, settings))
341
+
342
+ @command('Forget a local account; no logout or server deletion',
343
+ result='none',
344
+ effects='Removes the local record and its saved session. Nothing changes on the server.',
345
+ parameters=account_arguments('selector'))
346
+ def forget(self, selector):
347
+ self.client.core._store.forget(self.resolve(selector).id)
348
+
349
+ @command('Capture current session in memory for calls without refresh',
350
+ result='session',
351
+ effects='Reads a local record; no server requests, no session changes.',
352
+ parameters=account_arguments('selector'))
353
+ def session(self, selector):
354
+ return Session(self.resolve(selector))
capito/auth.py ADDED
@@ -0,0 +1,125 @@
1
+ """Authorization adapters: the interface the client calls, and the ready Bearer + refresh scheme.
2
+
3
+ Adapters are stateless and may be called from several threads; all state lives in the account store.
4
+ """
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any, ClassVar, final
9
+
10
+ if TYPE_CHECKING:
11
+ from .accounts import AccountRecord
12
+ from .client import Client
13
+ from .results import Result
14
+ from .transport import Request, Response
15
+
16
+
17
+ def header(headers, name):
18
+ """Case-insensitive header lookup; name is lowercase."""
19
+ return next((v for k, v in headers.items() if k.lower() == name), None)
20
+
21
+
22
+ def path_value(data, path):
23
+ """The value at a dotted path such as 'data.tokens.access', or None."""
24
+ for part in path.split('.'):
25
+ if not isinstance(data, Mapping) or part not in data:
26
+ return None
27
+ data = data[part]
28
+ return data
29
+
30
+
31
+ class Auth:
32
+ """No authorization. Base of all adapters: the client calls only these methods."""
33
+
34
+ def has_credentials(self, request: 'Request') -> bool:
35
+ """True if the caller attached authorization explicitly; the client then neither prepares nor refreshes."""
36
+ return False
37
+
38
+ def prepare(self, request: 'Request', account: 'AccountRecord') -> None:
39
+ """Attach the account's credentials to request.headers. Called before every attempt; no I/O, no state."""
40
+
41
+ def should_refresh(self, request: 'Request', response: 'Response') -> bool:
42
+ """Whether this response means 'credentials expired'. Pure: no I/O, no state."""
43
+ return False
44
+
45
+ def refresh(self, client: 'Client', account: 'AccountRecord', *, validate: bool | None = None) -> 'Result':
46
+ """Obtain and save a new session; the client calls it under the environment lock with a fresh record."""
47
+ return client.core.failure('This API has no session refresh')
48
+
49
+ def session_from(self, result: 'Result') -> Mapping[str, Any] | None:
50
+ """The session to store from a successful auth response; None if the response holds none."""
51
+ raise TypeError(f'{type(self).__name__} does not store sessions')
52
+
53
+
54
+ @dataclass(frozen=True, repr=False)
55
+ class BearerTokens:
56
+ access: str | None
57
+ refresh: str | None
58
+
59
+ def __repr__(self):
60
+ return '<BearerTokens: hidden>'
61
+
62
+
63
+ class BearerAuth(Auth, ABC):
64
+ """Authorization: Bearer <access token>; one refresh after 401. The stored session format is private.
65
+
66
+ A project declares where its auth responses keep the tokens and how to send its refresh request:
67
+
68
+ class MyAuth(BearerAuth):
69
+ access_token_path = 'accessToken'
70
+ refresh_token_path = 'refreshToken'
71
+
72
+ def send_refresh(self, client, refresh_token, account, *, validate):
73
+ return client.call('POST', '/auth/refresh', body={'refreshToken': refresh_token}, validate=validate)
74
+ """
75
+ access_token_path: ClassVar[str] # dotted path in the auth response body
76
+ refresh_token_path: ClassVar[str]
77
+
78
+ def __init__(self):
79
+ for name in ('access_token_path', 'refresh_token_path'):
80
+ value = getattr(type(self), name, None)
81
+ if not isinstance(value, str) or not value:
82
+ raise TypeError(f'{type(self).__name__} must declare {name}')
83
+
84
+ @abstractmethod
85
+ def send_refresh(self, client: 'Client', refresh_token: str, account: 'AccountRecord',
86
+ *, validate: bool | None) -> 'Result':
87
+ """The project's refresh request, without as_user. Return the raw result; tokens are extracted and saved here."""
88
+
89
+ def should_refresh(self, request, response):
90
+ """Override if the API reports expired tokens other than by 401."""
91
+ return response.status == 401
92
+
93
+ @final
94
+ def has_credentials(self, request):
95
+ return header(request.headers, 'authorization') is not None
96
+
97
+ @final
98
+ def prepare(self, request, account):
99
+ token = account.session.get('access_token')
100
+ if token:
101
+ request.headers['Authorization'] = 'Bearer ' + token
102
+
103
+ @final
104
+ def refresh(self, client, account, *, validate=None):
105
+ token = account.session.get('refresh_token')
106
+ if not token:
107
+ return client.core.failure('No refresh token; explicit login required')
108
+ result = self.send_refresh(client, token, account, validate=validate)
109
+ result.add_secrets(token)
110
+ return client.core.save_session(result, account)
111
+
112
+ @final
113
+ def session_from(self, result):
114
+ body = result.body
115
+ access, refresh = path_value(body, self.access_token_path), path_value(body, self.refresh_token_path)
116
+ if not (isinstance(access, str) and access and isinstance(refresh, str) and refresh):
117
+ return None
118
+ return {'access_token': access, 'refresh_token': refresh}
119
+
120
+ @final
121
+ def tokens(self, account: 'AccountRecord') -> BearerTokens | None:
122
+ """The account's tokens, e.g. for a logout body; None without a session."""
123
+ if not account.session:
124
+ return None
125
+ return BearerTokens(account.session.get('access_token'), account.session.get('refresh_token'))