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.
- capito-0.1.2/LICENSE +21 -0
- capito-0.1.2/MANIFEST.in +2 -0
- capito-0.1.2/PKG-INFO +39 -0
- capito-0.1.2/README.md +19 -0
- capito-0.1.2/capito/__init__.py +18 -0
- capito-0.1.2/capito/__main__.py +113 -0
- capito-0.1.2/capito/accounts.py +354 -0
- capito-0.1.2/capito/auth.py +125 -0
- capito-0.1.2/capito/client.py +266 -0
- capito-0.1.2/capito/commands.py +11 -0
- capito-0.1.2/capito/config.py +138 -0
- capito-0.1.2/capito/contracts.py +275 -0
- capito-0.1.2/capito/core.py +179 -0
- capito-0.1.2/capito/docs/extending.md +301 -0
- capito-0.1.2/capito/docs/usage.md +435 -0
- capito-0.1.2/capito/files.py +59 -0
- capito-0.1.2/capito/registry.py +452 -0
- capito-0.1.2/capito/results.py +351 -0
- capito-0.1.2/capito/testing.py +39 -0
- capito-0.1.2/capito/transport.py +150 -0
- capito-0.1.2/capito/validation.py +123 -0
- capito-0.1.2/capito/view.py +309 -0
- capito-0.1.2/capito.egg-info/PKG-INFO +39 -0
- capito-0.1.2/capito.egg-info/SOURCES.txt +41 -0
- capito-0.1.2/capito.egg-info/dependency_links.txt +1 -0
- capito-0.1.2/capito.egg-info/requires.txt +8 -0
- capito-0.1.2/capito.egg-info/top_level.txt +1 -0
- capito-0.1.2/pyproject.toml +32 -0
- capito-0.1.2/setup.cfg +4 -0
- capito-0.1.2/tests/conftest.py +54 -0
- capito-0.1.2/tests/helpers.py +78 -0
- capito-0.1.2/tests/test_accounts.py +128 -0
- capito-0.1.2/tests/test_auth.py +85 -0
- capito-0.1.2/tests/test_cli.py +40 -0
- capito-0.1.2/tests/test_client.py +171 -0
- capito-0.1.2/tests/test_config.py +110 -0
- capito-0.1.2/tests/test_contracts.py +117 -0
- capito-0.1.2/tests/test_core.py +146 -0
- capito-0.1.2/tests/test_registry.py +330 -0
- capito-0.1.2/tests/test_results.py +177 -0
- capito-0.1.2/tests/test_transport.py +192 -0
- capito-0.1.2/tests/test_validation.py +92 -0
- 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.
|
capito-0.1.2/MANIFEST.in
ADDED
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))
|