capito 0.1.2__tar.gz

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.
Files changed (43) hide show
  1. capito-0.1.2/LICENSE +21 -0
  2. capito-0.1.2/MANIFEST.in +2 -0
  3. capito-0.1.2/PKG-INFO +39 -0
  4. capito-0.1.2/README.md +19 -0
  5. capito-0.1.2/capito/__init__.py +18 -0
  6. capito-0.1.2/capito/__main__.py +113 -0
  7. capito-0.1.2/capito/accounts.py +354 -0
  8. capito-0.1.2/capito/auth.py +125 -0
  9. capito-0.1.2/capito/client.py +266 -0
  10. capito-0.1.2/capito/commands.py +11 -0
  11. capito-0.1.2/capito/config.py +138 -0
  12. capito-0.1.2/capito/contracts.py +275 -0
  13. capito-0.1.2/capito/core.py +179 -0
  14. capito-0.1.2/capito/docs/extending.md +301 -0
  15. capito-0.1.2/capito/docs/usage.md +435 -0
  16. capito-0.1.2/capito/files.py +59 -0
  17. capito-0.1.2/capito/registry.py +452 -0
  18. capito-0.1.2/capito/results.py +351 -0
  19. capito-0.1.2/capito/testing.py +39 -0
  20. capito-0.1.2/capito/transport.py +150 -0
  21. capito-0.1.2/capito/validation.py +123 -0
  22. capito-0.1.2/capito/view.py +309 -0
  23. capito-0.1.2/capito.egg-info/PKG-INFO +39 -0
  24. capito-0.1.2/capito.egg-info/SOURCES.txt +41 -0
  25. capito-0.1.2/capito.egg-info/dependency_links.txt +1 -0
  26. capito-0.1.2/capito.egg-info/requires.txt +8 -0
  27. capito-0.1.2/capito.egg-info/top_level.txt +1 -0
  28. capito-0.1.2/pyproject.toml +32 -0
  29. capito-0.1.2/setup.cfg +4 -0
  30. capito-0.1.2/tests/conftest.py +54 -0
  31. capito-0.1.2/tests/helpers.py +78 -0
  32. capito-0.1.2/tests/test_accounts.py +128 -0
  33. capito-0.1.2/tests/test_auth.py +85 -0
  34. capito-0.1.2/tests/test_cli.py +40 -0
  35. capito-0.1.2/tests/test_client.py +171 -0
  36. capito-0.1.2/tests/test_config.py +110 -0
  37. capito-0.1.2/tests/test_contracts.py +117 -0
  38. capito-0.1.2/tests/test_core.py +146 -0
  39. capito-0.1.2/tests/test_registry.py +330 -0
  40. capito-0.1.2/tests/test_results.py +177 -0
  41. capito-0.1.2/tests/test_transport.py +192 -0
  42. capito-0.1.2/tests/test_validation.py +92 -0
  43. capito-0.1.2/tests/test_view.py +112 -0
capito-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artur Artikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ graft tests
2
+ global-exclude __pycache__ *.py[cod]
capito-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: capito
3
+ Version: 0.1.2
4
+ Summary: Explore and call an HTTP API from Python: contract-based help, validation, accounts and project commands
5
+ Author-email: Artur Artikov <a.artikov@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/aartikov/capito
8
+ Project-URL: Issues, https://github.com/aartikov/capito/issues
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: PyYAML>=6
13
+ Requires-Dist: openapi-core<0.24,>=0.23.1
14
+ Requires-Dist: httpx<0.29,>=0.28.1
15
+ Requires-Dist: jsonschema>=4
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=8; extra == "test"
18
+ Requires-Dist: respx<0.23,>=0.22; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # capito
22
+
23
+ A ready tool for working with HTTP APIs from Python scripts and agents, extended by each project with its own commands: help from the OpenAPI contract, calls, accounts with Bearer sessions, contract checks, result viewing with secret masking. Not a testing framework: it knows nothing about plans, reports or requirements.
24
+
25
+ ## Installation
26
+
27
+ Python 3.12+:
28
+
29
+ ```
30
+ pip install capito
31
+ ```
32
+
33
+ In a project, pin the version, for example `capito==0.1.2` in `requirements.lock`.
34
+
35
+ ## Documentation
36
+
37
+ Served by the installed version: `python -m capito docs usage` for working with an API, `python -m capito docs extending` for connecting your own API.
38
+
39
+ Development goes through an agent; the rules are in [AGENTS.md](https://github.com/aartikov/capito/blob/main/AGENTS.md).
capito-0.1.2/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # capito
2
+
3
+ A ready tool for working with HTTP APIs from Python scripts and agents, extended by each project with its own commands: help from the OpenAPI contract, calls, accounts with Bearer sessions, contract checks, result viewing with secret masking. Not a testing framework: it knows nothing about plans, reports or requirements.
4
+
5
+ ## Installation
6
+
7
+ Python 3.12+:
8
+
9
+ ```
10
+ pip install capito
11
+ ```
12
+
13
+ In a project, pin the version, for example `capito==0.1.2` in `requirements.lock`.
14
+
15
+ ## Documentation
16
+
17
+ Served by the installed version: `python -m capito docs usage` for working with an API, `python -m capito docs extending` for connecting your own API.
18
+
19
+ Development goes through an agent; the rules are in [AGENTS.md](https://github.com/aartikov/capito/blob/main/AGENTS.md).
@@ -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']
@@ -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())
@@ -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))