keyrunes-python-sdk 0.1.0__tar.gz → 0.3.0__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.
- keyrunes_python_sdk-0.3.0/CHANGELOG.md +206 -0
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/PKG-INFO +7 -4
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/README.md +2 -1
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/keyrunes_sdk/__init__.py +1 -1
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/keyrunes_sdk/client.py +143 -81
- keyrunes_python_sdk-0.3.0/keyrunes_sdk/exceptions.py +62 -0
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/keyrunes_sdk/models.py +21 -0
- keyrunes_python_sdk-0.3.0/keyrunes_sdk/py.typed +0 -0
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/pyproject.toml +17 -2
- keyrunes_python_sdk-0.1.0/CHANGELOG.md +0 -105
- keyrunes_python_sdk-0.1.0/keyrunes_sdk/exceptions.py +0 -43
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/LICENSE +0 -0
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/keyrunes_sdk/config.py +0 -0
- {keyrunes_python_sdk-0.1.0 → keyrunes_python_sdk-0.3.0}/keyrunes_sdk/decorators.py +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
|
|
4
|
+
|
|
5
|
+
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
|
|
6
|
+
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-09-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `User.user_id`, carrying the internal identifier Keyrunes puts in the JWT
|
|
13
|
+
`sub` claim. It is a different value from `User.id` (the external UUID), and
|
|
14
|
+
a consumer that keys its own records off `sub` needs it; before this release
|
|
15
|
+
it was discarded during normalization and could only be recovered by
|
|
16
|
+
re-parsing the token.
|
|
17
|
+
- `User.namespace`, `User.organization_id` and `User.first_login`, all of which
|
|
18
|
+
the server returns and normalization used to drop.
|
|
19
|
+
- `Token.requires_password_change`, so a login that must be followed by a
|
|
20
|
+
password change can be detected without reading the raw response.
|
|
21
|
+
- `KeyrunesClient.refresh_token()`, wrapping `POST /api/refresh-token`. It
|
|
22
|
+
accepts an explicit token or falls back to the client's current one, raises
|
|
23
|
+
`InvalidTokenError` when neither is available, and adopts the refreshed
|
|
24
|
+
token the way `login()` does.
|
|
25
|
+
- `get_current_user(force_refresh=True)`, which always asks the server. The
|
|
26
|
+
claims shortcut reads a token the SDK never verified, so it cannot answer
|
|
27
|
+
"is this token still accepted"; a caller validating a token needs the round
|
|
28
|
+
trip.
|
|
29
|
+
- `KeyrunesError.status_code`, carrying the HTTP status when the error came
|
|
30
|
+
from a response rather than from the transport. Without it a caller cannot
|
|
31
|
+
tell "the server refused this request" (4xx) from "the server or the network
|
|
32
|
+
failed" (5xx, or no response at all), and both are raised as `NetworkError`.
|
|
33
|
+
- `register_user(group=...)`, sent as a top-level field.
|
|
34
|
+
- A `py.typed` marker (PEP 561). The package was already fully typed, but
|
|
35
|
+
without the marker a consumer running mypy saw every SDK import as
|
|
36
|
+
untyped.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `get_current_user()` asked for `/api/users/me`, which the Keyrunes router
|
|
41
|
+
does not expose — the real route is `/api/me`, so the call answered 404
|
|
42
|
+
against a live server whenever the claims shortcut did not cover it. The
|
|
43
|
+
endpoint is now a named constant, `ENDPOINT_ME`.
|
|
44
|
+
- `register_user()` and `register_admin()` required the response to be
|
|
45
|
+
`{"user": {...}}`, but `POST /api/register` answers with the bare user
|
|
46
|
+
object. Every registration against a real server failed with "Unexpected
|
|
47
|
+
response format". Both shapes are now accepted.
|
|
48
|
+
- Extra keyword arguments to `register_user()` were nested under `attributes`,
|
|
49
|
+
so a `group` never reached the server, which reads it as a top-level field.
|
|
50
|
+
`group` is now an explicit parameter placed at the top level; other keyword
|
|
51
|
+
attributes keep nesting under `attributes`.
|
|
52
|
+
|
|
53
|
+
### Testing
|
|
54
|
+
|
|
55
|
+
- Test count raised from 159 to 189.
|
|
56
|
+
|
|
57
|
+
### Notes
|
|
58
|
+
|
|
59
|
+
- Every added field is optional with a backwards-compatible default, and
|
|
60
|
+
`User.id` keeps its existing precedence (`id` → `user_id` → `external_id`).
|
|
61
|
+
Code written against 0.2.0 keeps working unchanged.
|
|
62
|
+
|
|
63
|
+
## [0.2.0] - 2026-09-03
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- Property-based test suite (`tests/test_property_based.py`, 24 tests) built on
|
|
68
|
+
Hypothesis, covering base URL normalization, `_normalize_user` id precedence
|
|
69
|
+
and `is_admin` derivation, JWT claim parsing, request URL construction, HTTP
|
|
70
|
+
status to exception mapping, and model validation bounds.
|
|
71
|
+
- Input fuzzing ("spider") suite (`tests/test_fuzz.py`, 19 tests) that crawls the
|
|
72
|
+
public surface with hostile payloads and asserts that only `KeyrunesError` and
|
|
73
|
+
pydantic `ValidationError` ever escape, and that nothing panics or leaks a raw
|
|
74
|
+
`ValueError`/`TypeError`.
|
|
75
|
+
- Request contract suite (`tests/test_request_contract.py`, 33 tests) that mocks
|
|
76
|
+
only the httpx transport, so the method, URL, headers and JSON body actually
|
|
77
|
+
put on the wire are asserted for every endpoint.
|
|
78
|
+
- Hypothesis profiles in `tests/conftest.py` (`fast`, `dev`, `ci`) selected via
|
|
79
|
+
the `HYPOTHESIS_PROFILE` environment variable. `fast` is derandomized and
|
|
80
|
+
database-free so mutation runs judge every mutant against identical examples.
|
|
81
|
+
- `[tool.mutmut]` configuration in `pyproject.toml` for mutation testing.
|
|
82
|
+
- `hypothesis` added as a development dependency.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- A 2xx response carrying a non-JSON body raised a raw `ValueError` out of
|
|
87
|
+
`KeyrunesClient._make_request`. It is now wrapped in `NetworkError`, so the
|
|
88
|
+
documented exception contract holds for malformed responses.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
|
|
92
|
+
- Extracted the duplicated "build a `User` from JWT claims" block into
|
|
93
|
+
`KeyrunesClient._user_from_token_claims()`.
|
|
94
|
+
|
|
95
|
+
### Removed
|
|
96
|
+
|
|
97
|
+
- Unreachable `except UserNotFoundError` fallbacks in `get_user`,
|
|
98
|
+
`get_current_user` and `has_group`. Each re-tested a condition that had
|
|
99
|
+
already forced an early return, so a 404 from the server was being swallowed
|
|
100
|
+
instead of propagated.
|
|
101
|
+
|
|
102
|
+
### Testing
|
|
103
|
+
|
|
104
|
+
- Test count raised from 83 to 159.
|
|
105
|
+
- Mutation score (mutmut) raised from 44% (294/669 mutants killed) to 72%
|
|
106
|
+
(387/537). The remaining survivors are predominantly equivalent mutants that
|
|
107
|
+
only alter error message prose.
|
|
108
|
+
|
|
109
|
+
## [0.1.0] - 2025-12-03
|
|
110
|
+
|
|
111
|
+
### Adicionado
|
|
112
|
+
|
|
113
|
+
#### Funcionalidades Core
|
|
114
|
+
- Cliente `KeyrunesClient` completo para interação com Keyrunes API
|
|
115
|
+
- Autenticação com login de usuário e admin
|
|
116
|
+
- Registro de usuário e admin com validação
|
|
117
|
+
- Verificação de pertencimento a grupos
|
|
118
|
+
- Obtenção de informações de usuários
|
|
119
|
+
|
|
120
|
+
#### Decorators
|
|
121
|
+
- `@require_group()` - Decorator para verificar grupos de usuários
|
|
122
|
+
- `@require_admin()` - Decorator para verificar privilégios de admin
|
|
123
|
+
- Suporte para múltiplos grupos (ANY ou ALL)
|
|
124
|
+
- Sistema de client global para uso sem passar client explicitamente
|
|
125
|
+
|
|
126
|
+
#### Modelos Pydantic
|
|
127
|
+
- `User` - Modelo de usuário com validação
|
|
128
|
+
- `Token` - Modelo de token JWT
|
|
129
|
+
- `Group` - Modelo de grupo
|
|
130
|
+
- `UserRegistration` - Dados de registro de usuário
|
|
131
|
+
- `AdminRegistration` - Dados de registro de admin
|
|
132
|
+
- `LoginCredentials` - Credenciais de login
|
|
133
|
+
- `GroupCheck` - Resultado de verificação de grupo
|
|
134
|
+
|
|
135
|
+
#### Exceções Customizadas
|
|
136
|
+
- `KeyrunesError` - Exceção base
|
|
137
|
+
- `AuthenticationError` - Erro de autenticação
|
|
138
|
+
- `AuthorizationError` - Erro de autorização
|
|
139
|
+
- `GroupNotFoundError` - Grupo não encontrado
|
|
140
|
+
- `UserNotFoundError` - Usuário não encontrado
|
|
141
|
+
- `InvalidTokenError` - Token inválido
|
|
142
|
+
- `NetworkError` - Erro de rede
|
|
143
|
+
|
|
144
|
+
#### Sistema de Configuração Global
|
|
145
|
+
- `configure()` - Configura client global
|
|
146
|
+
- `get_global_client()` - Obtém client global
|
|
147
|
+
- `clear_global_client()` - Limpa client global
|
|
148
|
+
- Thread-safe com Lock
|
|
149
|
+
|
|
150
|
+
#### Desenvolvimento e Testes
|
|
151
|
+
- Docker Compose completo com Keyrunes, PostgreSQL e Redis
|
|
152
|
+
- 78 testes com 99% de cobertura
|
|
153
|
+
- Testes usando pytest, factory-boy e faker
|
|
154
|
+
- Exemplos práticos de uso
|
|
155
|
+
- Makefile com comandos úteis
|
|
156
|
+
- Configuração completa de CI/CD
|
|
157
|
+
|
|
158
|
+
#### Documentação
|
|
159
|
+
- README.md completo com exemplos
|
|
160
|
+
- TESTING.md com guia de testes
|
|
161
|
+
- Docstrings em todas as funções
|
|
162
|
+
- Type hints completos
|
|
163
|
+
- Exemplos práticos em `examples/`
|
|
164
|
+
|
|
165
|
+
### Detalhes Técnicos
|
|
166
|
+
|
|
167
|
+
- Python 3.8.1+ compatível
|
|
168
|
+
- Gerenciamento com Poetry
|
|
169
|
+
- Validação com Pydantic 2.0
|
|
170
|
+
- Type hints completos
|
|
171
|
+
- Thread-safe
|
|
172
|
+
- Context manager support
|
|
173
|
+
|
|
174
|
+
### Testes
|
|
175
|
+
|
|
176
|
+
- 78 testes implementados
|
|
177
|
+
- 99% de cobertura de código
|
|
178
|
+
- Testes unitários e de integração
|
|
179
|
+
- Factories com factory-boy
|
|
180
|
+
- Dados fake com Faker
|
|
181
|
+
|
|
182
|
+
### Ferramentas de Desenvolvimento
|
|
183
|
+
|
|
184
|
+
- Black para formatação
|
|
185
|
+
- isort para organização de imports
|
|
186
|
+
- flake8 para linting
|
|
187
|
+
- mypy para type checking
|
|
188
|
+
- pytest para testes
|
|
189
|
+
|
|
190
|
+
## [Unreleased]
|
|
191
|
+
|
|
192
|
+
### Planejado
|
|
193
|
+
|
|
194
|
+
- Suporte para refresh token automático
|
|
195
|
+
- Cache de verificações de grupo
|
|
196
|
+
- Suporte para OIDC
|
|
197
|
+
- Integração com FastAPI
|
|
198
|
+
- Integração com Flask
|
|
199
|
+
- Integração com Django
|
|
200
|
+
- Mais exemplos práticos
|
|
201
|
+
- Documentação com Sphinx
|
|
202
|
+
- Publicação no PyPI
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
Para mais detalhes sobre cada versão, veja os [releases no GitHub](https://github.com/jonatasoli/keyurnes-sdk-python-dark/releases).
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: keyrunes-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for Keyrunes Authorization System
|
|
5
5
|
License: AGPL
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: keyrunes,authorization,rbac,abac,security,authentication,permissions
|
|
7
8
|
Author: keyrunes
|
|
8
9
|
Author-email: contact@singularjourney.host
|
|
@@ -17,12 +18,13 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
23
|
Classifier: Topic :: Security
|
|
23
24
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
25
|
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
25
26
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
27
|
+
Requires-Dist: mutmut (>=3.7.0,<4.0.0)
|
|
26
28
|
Requires-Dist: pydantic[email] (>=2.0.0,<3.0.0)
|
|
27
29
|
Requires-Dist: pyjwt (>=2.9.0,<3.0.0)
|
|
28
30
|
Project-URL: Documentation, https://github.com/jonatasoli/keyrunes-python-sdk#readme
|
|
@@ -33,11 +35,12 @@ Description-Content-Type: text/markdown
|
|
|
33
35
|
# Keyrunes SDK Python Client
|
|
34
36
|
|
|
35
37
|
[](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml)
|
|
36
|
-
[](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk)
|
|
37
38
|
[](https://www.python.org/downloads/)
|
|
38
39
|
[](LICENSE)
|
|
39
40
|
|
|
41
|
+
|
|
40
42
|
Python SDK for integration with the [Keyrunes Authorization System](https://github.com/Keyrunes/keyrunes), a modern high-performance authorization system built in Rust.
|
|
43
|
+
[Pypi](https://pypi.org/project/keyrunes-python-sdk/)
|
|
41
44
|
|
|
42
45
|
## Features
|
|
43
46
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Keyrunes SDK Python Client
|
|
2
2
|
|
|
3
3
|
[](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml)
|
|
4
|
-
[](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk)
|
|
5
4
|
[](https://www.python.org/downloads/)
|
|
6
5
|
[](LICENSE)
|
|
7
6
|
|
|
7
|
+
|
|
8
8
|
Python SDK for integration with the [Keyrunes Authorization System](https://github.com/Keyrunes/keyrunes), a modern high-performance authorization system built in Rust.
|
|
9
|
+
[Pypi](https://pypi.org/project/keyrunes-python-sdk/)
|
|
9
10
|
|
|
10
11
|
## Features
|
|
11
12
|
|
|
@@ -11,6 +11,7 @@ from keyrunes_sdk.exceptions import (
|
|
|
11
11
|
AuthenticationError,
|
|
12
12
|
AuthorizationError,
|
|
13
13
|
GroupNotFoundError,
|
|
14
|
+
InvalidTokenError,
|
|
14
15
|
NetworkError,
|
|
15
16
|
UserNotFoundError,
|
|
16
17
|
)
|
|
@@ -23,6 +24,17 @@ from keyrunes_sdk.models import (
|
|
|
23
24
|
UserRegistration,
|
|
24
25
|
)
|
|
25
26
|
|
|
27
|
+
#: Where the server answers "who is this token". The Keyrunes router exposes
|
|
28
|
+
#: this as ``/api/me``; there is no ``/api/users/me``.
|
|
29
|
+
#: Creates a user; answers with the bare user object.
|
|
30
|
+
ENDPOINT_REGISTER = "/api/register"
|
|
31
|
+
|
|
32
|
+
#: Where the server answers "who is this token".
|
|
33
|
+
ENDPOINT_ME = "/api/me"
|
|
34
|
+
|
|
35
|
+
#: Exchanges a still-valid token for a fresh one.
|
|
36
|
+
ENDPOINT_REFRESH_TOKEN = "/api/refresh-token"
|
|
37
|
+
|
|
26
38
|
|
|
27
39
|
class KeyrunesClient:
|
|
28
40
|
"""
|
|
@@ -135,9 +147,17 @@ class KeyrunesClient:
|
|
|
135
147
|
error_msg = (
|
|
136
148
|
response.text or f"HTTP {response.status_code} error"
|
|
137
149
|
)
|
|
138
|
-
raise NetworkError(
|
|
150
|
+
raise NetworkError(
|
|
151
|
+
f"Request failed: {error_msg}",
|
|
152
|
+
status_code=response.status_code,
|
|
153
|
+
)
|
|
139
154
|
|
|
140
|
-
|
|
155
|
+
try:
|
|
156
|
+
result: Dict[str, Any] = response.json()
|
|
157
|
+
except (ValueError, TypeError) as e:
|
|
158
|
+
raise NetworkError(
|
|
159
|
+
f"Malformed JSON in response from {url}: {str(e)}"
|
|
160
|
+
)
|
|
141
161
|
return result
|
|
142
162
|
|
|
143
163
|
except httpx.RequestError as e:
|
|
@@ -165,8 +185,32 @@ class KeyrunesClient:
|
|
|
165
185
|
normalized["is_admin"] = (
|
|
166
186
|
is_admin_flag or has_admin_group or has_admin_in_name
|
|
167
187
|
)
|
|
188
|
+
# Carried through untouched so a caller can key its own records off the
|
|
189
|
+
# same identifier the JWT ``sub`` uses, rather than off ``id``.
|
|
190
|
+
raw_user_id = data.get("user_id")
|
|
191
|
+
normalized["user_id"] = (
|
|
192
|
+
str(raw_user_id) if raw_user_id is not None else None
|
|
193
|
+
)
|
|
194
|
+
normalized["namespace"] = data.get("namespace")
|
|
195
|
+
normalized["organization_id"] = data.get("organization_id")
|
|
196
|
+
normalized["first_login"] = bool(data.get("first_login", False))
|
|
168
197
|
return User(**normalized)
|
|
169
198
|
|
|
199
|
+
def _user_from_token_claims(self) -> User:
|
|
200
|
+
"""Build a :class:`User` out of the claims carried by the JWT.
|
|
201
|
+
|
|
202
|
+
Only called once ``self._token_data`` is known to be populated.
|
|
203
|
+
"""
|
|
204
|
+
token_data = self._token_data or {}
|
|
205
|
+
return self._normalize_user(
|
|
206
|
+
{
|
|
207
|
+
"id": str(token_data.get("sub", "")),
|
|
208
|
+
"username": token_data.get("username", ""),
|
|
209
|
+
"email": token_data.get("email", ""),
|
|
210
|
+
"groups": token_data.get("groups", []),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
170
214
|
def _parse_token_response(self, payload: Dict[str, Any]) -> Token:
|
|
171
215
|
"""
|
|
172
216
|
Accept both legacy and current API token responses.
|
|
@@ -201,6 +245,9 @@ class KeyrunesClient:
|
|
|
201
245
|
expires_in=payload.get("expires_in"),
|
|
202
246
|
refresh_token=payload.get("refresh_token"),
|
|
203
247
|
user=user_model,
|
|
248
|
+
requires_password_change=bool(
|
|
249
|
+
payload.get("requires_password_change", False)
|
|
250
|
+
),
|
|
204
251
|
)
|
|
205
252
|
|
|
206
253
|
def login(
|
|
@@ -247,12 +294,36 @@ class KeyrunesClient:
|
|
|
247
294
|
self._token_data = None
|
|
248
295
|
return token
|
|
249
296
|
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _registration_payload(response: Any) -> Dict[str, Any]:
|
|
299
|
+
"""Pull the user object out of a registration response.
|
|
300
|
+
|
|
301
|
+
``POST /api/register`` answers with the bare user object. Some
|
|
302
|
+
deployments wrap it as ``{"user": {...}}``, so both are accepted.
|
|
303
|
+
"""
|
|
304
|
+
if not isinstance(response, dict):
|
|
305
|
+
raise NetworkError(
|
|
306
|
+
"Unexpected response format for user registration."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
wrapped = response.get("user")
|
|
310
|
+
if isinstance(wrapped, dict) and wrapped:
|
|
311
|
+
return wrapped
|
|
312
|
+
|
|
313
|
+
# A bare user object is identified by carrying an identifier; anything
|
|
314
|
+
# else is a response shape this SDK does not understand.
|
|
315
|
+
if response.get("id") or response.get("user_id"):
|
|
316
|
+
return response
|
|
317
|
+
|
|
318
|
+
raise NetworkError("Unexpected response format for user registration.")
|
|
319
|
+
|
|
250
320
|
def register_user(
|
|
251
321
|
self,
|
|
252
322
|
username: str,
|
|
253
323
|
email: str,
|
|
254
324
|
password: str,
|
|
255
325
|
namespace: str = "public",
|
|
326
|
+
group: Optional[str] = None,
|
|
256
327
|
**attributes: Any,
|
|
257
328
|
) -> User:
|
|
258
329
|
"""
|
|
@@ -263,6 +334,8 @@ class KeyrunesClient:
|
|
|
263
334
|
email: User email address
|
|
264
335
|
password: Password (minimum 8 characters)
|
|
265
336
|
namespace: User namespace (default: "public")
|
|
337
|
+
group: Group to place the new user in. Sent as a top-level field,
|
|
338
|
+
which is where the server reads it from.
|
|
266
339
|
**attributes: Additional user attributes
|
|
267
340
|
|
|
268
341
|
Returns:
|
|
@@ -288,22 +361,18 @@ class KeyrunesClient:
|
|
|
288
361
|
namespace=namespace,
|
|
289
362
|
attributes=attributes,
|
|
290
363
|
)
|
|
364
|
+
data = registration.model_dump()
|
|
365
|
+
if group is not None:
|
|
366
|
+
data["group"] = group
|
|
367
|
+
|
|
291
368
|
response = self._make_request(
|
|
292
369
|
"POST",
|
|
293
|
-
|
|
294
|
-
data=
|
|
370
|
+
ENDPOINT_REGISTER,
|
|
371
|
+
data=data,
|
|
295
372
|
use_auth=False,
|
|
296
373
|
)
|
|
297
374
|
|
|
298
|
-
|
|
299
|
-
response.get("user") if isinstance(response, dict) else None
|
|
300
|
-
)
|
|
301
|
-
if not user_payload:
|
|
302
|
-
raise NetworkError(
|
|
303
|
-
"Unexpected response format for user registration."
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
return self._normalize_user(user_payload)
|
|
375
|
+
return self._normalize_user(self._registration_payload(response))
|
|
307
376
|
|
|
308
377
|
def register_admin(
|
|
309
378
|
self,
|
|
@@ -351,20 +420,12 @@ class KeyrunesClient:
|
|
|
351
420
|
)
|
|
352
421
|
response = self._make_request(
|
|
353
422
|
"POST",
|
|
354
|
-
|
|
423
|
+
ENDPOINT_REGISTER,
|
|
355
424
|
data=registration.model_dump(),
|
|
356
425
|
use_auth=False,
|
|
357
426
|
)
|
|
358
427
|
|
|
359
|
-
|
|
360
|
-
response.get("user") if isinstance(response, dict) else None
|
|
361
|
-
)
|
|
362
|
-
if not user_payload:
|
|
363
|
-
raise NetworkError(
|
|
364
|
-
"Unexpected response format for admin registration."
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
return self._normalize_user(user_payload)
|
|
428
|
+
return self._normalize_user(self._registration_payload(response))
|
|
368
429
|
|
|
369
430
|
def has_group(self, user_id: str, group_id: str) -> bool:
|
|
370
431
|
"""
|
|
@@ -409,13 +470,8 @@ class KeyrunesClient:
|
|
|
409
470
|
check = GroupCheck(**response)
|
|
410
471
|
return check.has_access
|
|
411
472
|
except UserNotFoundError:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
self._token_data.get("groups", [])
|
|
415
|
-
if self._token_data
|
|
416
|
-
else []
|
|
417
|
-
)
|
|
418
|
-
return group_id in groups
|
|
473
|
+
# The self-lookup above already returned for the authenticated
|
|
474
|
+
# user, so reaching here always means a genuine miss.
|
|
419
475
|
raise GroupNotFoundError(
|
|
420
476
|
f"Group '{group_id}' not found or user not in group"
|
|
421
477
|
)
|
|
@@ -448,38 +504,25 @@ class KeyrunesClient:
|
|
|
448
504
|
)
|
|
449
505
|
|
|
450
506
|
if token_user_id and str(user_id) == token_user_id and self._token_data:
|
|
451
|
-
|
|
452
|
-
user_data = {
|
|
453
|
-
"id": str(token_data.get("sub", "")),
|
|
454
|
-
"username": token_data.get("username", ""),
|
|
455
|
-
"email": token_data.get("email", ""),
|
|
456
|
-
"groups": token_data.get("groups", []),
|
|
457
|
-
}
|
|
458
|
-
return self._normalize_user(user_data)
|
|
507
|
+
return self._user_from_token_claims()
|
|
459
508
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
and str(user_id) == token_user_id
|
|
467
|
-
and self._token_data
|
|
468
|
-
):
|
|
469
|
-
token_data = self._token_data
|
|
470
|
-
user_data = {
|
|
471
|
-
"id": str(token_data.get("sub", "")),
|
|
472
|
-
"username": token_data.get("username", ""),
|
|
473
|
-
"email": token_data.get("email", ""),
|
|
474
|
-
"groups": token_data.get("groups", []),
|
|
475
|
-
}
|
|
476
|
-
return self._normalize_user(user_data)
|
|
477
|
-
raise
|
|
478
|
-
|
|
479
|
-
def get_current_user(self) -> User:
|
|
509
|
+
# The claims shortcut above already handled the authenticated user, so
|
|
510
|
+
# a 404 here is always a genuine miss and is propagated as such.
|
|
511
|
+
response = self._make_request("GET", f"/api/users/{user_id}")
|
|
512
|
+
return self._normalize_user(response)
|
|
513
|
+
|
|
514
|
+
def get_current_user(self, force_refresh: bool = False) -> User:
|
|
480
515
|
"""
|
|
481
516
|
Get currently authenticated user information.
|
|
482
517
|
|
|
518
|
+
Args:
|
|
519
|
+
force_refresh: Ask the server even when the token's own claims
|
|
520
|
+
could answer. Required whenever the answer is used to decide
|
|
521
|
+
whether the token is still good: the claims shortcut reads a
|
|
522
|
+
token the SDK never verified, so it says nothing about whether
|
|
523
|
+
the server still accepts it (revoked, expired, or signed with
|
|
524
|
+
another key).
|
|
525
|
+
|
|
483
526
|
Returns:
|
|
484
527
|
User object for authenticated user
|
|
485
528
|
|
|
@@ -495,30 +538,11 @@ class KeyrunesClient:
|
|
|
495
538
|
if not self._token:
|
|
496
539
|
raise AuthenticationError("Not authenticated. Please login first.")
|
|
497
540
|
|
|
498
|
-
if self._token_data:
|
|
499
|
-
|
|
500
|
-
user_data = {
|
|
501
|
-
"id": str(token_data.get("sub", "")),
|
|
502
|
-
"username": token_data.get("username", ""),
|
|
503
|
-
"email": token_data.get("email", ""),
|
|
504
|
-
"groups": token_data.get("groups", []),
|
|
505
|
-
}
|
|
506
|
-
return self._normalize_user(user_data)
|
|
541
|
+
if self._token_data and not force_refresh:
|
|
542
|
+
return self._user_from_token_claims()
|
|
507
543
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
return self._normalize_user(response)
|
|
511
|
-
except UserNotFoundError:
|
|
512
|
-
if self._token_data:
|
|
513
|
-
token_data = self._token_data
|
|
514
|
-
user_data = {
|
|
515
|
-
"id": str(token_data.get("sub", "")),
|
|
516
|
-
"username": token_data.get("username", ""),
|
|
517
|
-
"email": token_data.get("email", ""),
|
|
518
|
-
"groups": token_data.get("groups", []),
|
|
519
|
-
}
|
|
520
|
-
return self._normalize_user(user_data)
|
|
521
|
-
raise
|
|
544
|
+
response = self._make_request("GET", ENDPOINT_ME)
|
|
545
|
+
return self._normalize_user(response)
|
|
522
546
|
|
|
523
547
|
def get_user_groups(self, user_id: Optional[str] = None) -> List[str]:
|
|
524
548
|
"""
|
|
@@ -565,6 +589,44 @@ class KeyrunesClient:
|
|
|
565
589
|
except Exception:
|
|
566
590
|
self._token_data = None
|
|
567
591
|
|
|
592
|
+
def refresh_token(self, token: Optional[str] = None) -> Token:
|
|
593
|
+
"""
|
|
594
|
+
Exchange a still-valid token for a fresh one.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
token: Token to exchange. Defaults to the client's current token.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
Token object carrying the new access token.
|
|
601
|
+
|
|
602
|
+
Raises:
|
|
603
|
+
InvalidTokenError: If no token is available to exchange
|
|
604
|
+
AuthenticationError: If the server rejects the token
|
|
605
|
+
NetworkError: If the request fails
|
|
606
|
+
|
|
607
|
+
Example:
|
|
608
|
+
>>> client = KeyrunesClient("https://keyrunes.example.com")
|
|
609
|
+
>>> client.set_token("eyJhbGciOiJIUzI1NiIs...")
|
|
610
|
+
>>> refreshed = client.refresh_token()
|
|
611
|
+
>>> client.set_token(refreshed.access_token)
|
|
612
|
+
"""
|
|
613
|
+
current = token or self._token
|
|
614
|
+
if not current:
|
|
615
|
+
raise InvalidTokenError("No token available to refresh.")
|
|
616
|
+
|
|
617
|
+
response = self._make_request(
|
|
618
|
+
"POST",
|
|
619
|
+
ENDPOINT_REFRESH_TOKEN,
|
|
620
|
+
data={"token": current},
|
|
621
|
+
use_auth=False,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
refreshed = self._parse_token_response(response)
|
|
625
|
+
# Refreshing is only ever useful if the client keeps using the result,
|
|
626
|
+
# so adopt it the way login() does.
|
|
627
|
+
self.set_token(refreshed.access_token)
|
|
628
|
+
return refreshed
|
|
629
|
+
|
|
568
630
|
def clear_token(self) -> None:
|
|
569
631
|
"""
|
|
570
632
|
Clear authentication token.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Custom exceptions for Keyrunes SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KeyrunesError(Exception):
|
|
7
|
+
"""Base exception for all Keyrunes SDK errors.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
message: Human-readable description.
|
|
11
|
+
status_code: HTTP status the server answered with, when the error came
|
|
12
|
+
from a response rather than from the transport. Callers use it to
|
|
13
|
+
tell "the server refused this request" (4xx, the caller's problem)
|
|
14
|
+
from "the server or the network failed" (5xx or no response at
|
|
15
|
+
all), which otherwise look identical.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, message: str = "", status_code: Optional[int] = None
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthenticationError(KeyrunesError):
|
|
26
|
+
"""Raised when authentication fails."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthorizationError(KeyrunesError):
|
|
32
|
+
"""Raised when authorization fails."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GroupNotFoundError(KeyrunesError):
|
|
38
|
+
"""Raised when a group is not found."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UserNotFoundError(KeyrunesError):
|
|
44
|
+
"""Raised when a user is not found."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidTokenError(KeyrunesError):
|
|
50
|
+
"""Raised when token is invalid or expired."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NetworkError(KeyrunesError):
|
|
56
|
+
"""Raised when a request fails.
|
|
57
|
+
|
|
58
|
+
``status_code`` is set when the server answered and the response was an
|
|
59
|
+
error; it is ``None`` when the request never got a response.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
pass
|
|
@@ -28,6 +28,23 @@ class User(BaseModel):
|
|
|
28
28
|
is_admin: bool = Field(
|
|
29
29
|
False, description="Whether user has admin privileges"
|
|
30
30
|
)
|
|
31
|
+
# Keyrunes answers with two identifiers: ``id`` is the external UUID and
|
|
32
|
+
# ``user_id`` is the internal integer the JWT carries as ``sub``. ``id``
|
|
33
|
+
# above keeps its historical precedence; this field is kept alongside it so
|
|
34
|
+
# a caller that keys its own records off ``sub`` can reach that value
|
|
35
|
+
# without re-parsing the token.
|
|
36
|
+
user_id: Optional[str] = Field(
|
|
37
|
+
None, description="Internal user identifier, as carried by the JWT sub"
|
|
38
|
+
)
|
|
39
|
+
namespace: Optional[str] = Field(
|
|
40
|
+
None, description="Namespace (tenant) the user belongs to"
|
|
41
|
+
)
|
|
42
|
+
organization_id: Optional[Any] = Field(
|
|
43
|
+
None, description="Organization the user belongs to"
|
|
44
|
+
)
|
|
45
|
+
first_login: bool = Field(
|
|
46
|
+
False, description="Whether the user has yet to complete a first login"
|
|
47
|
+
)
|
|
31
48
|
|
|
32
49
|
|
|
33
50
|
class Group(BaseModel):
|
|
@@ -54,6 +71,10 @@ class Token(BaseModel):
|
|
|
54
71
|
)
|
|
55
72
|
refresh_token: Optional[str] = Field(None, description="Refresh token")
|
|
56
73
|
user: Optional[User] = Field(None, description="User information")
|
|
74
|
+
requires_password_change: bool = Field(
|
|
75
|
+
False,
|
|
76
|
+
description="Whether the server wants the password changed before use",
|
|
77
|
+
)
|
|
57
78
|
|
|
58
79
|
|
|
59
80
|
class UserRegistration(BaseModel):
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "keyrunes-python-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Python SDK for Keyrunes Authorization System"
|
|
5
5
|
authors = ["keyrunes <contact@singularjourney.host>"]
|
|
6
6
|
maintainers = ["jonatasoli <contact@jonatasoli.dev>"]
|
|
@@ -25,7 +25,9 @@ classifiers = [
|
|
|
25
25
|
"Topic :: System :: Systems Administration :: Authentication/Directory",
|
|
26
26
|
]
|
|
27
27
|
packages = [{include = "keyrunes_sdk"}]
|
|
28
|
-
|
|
28
|
+
# `py.typed` marks the package as typed (PEP 561); without it a consumer
|
|
29
|
+
# running mypy sees every SDK import as untyped.
|
|
30
|
+
include = ["LICENSE", "README.md", "CHANGELOG.md", "keyrunes_sdk/py.typed"]
|
|
29
31
|
exclude = ["tests", "examples", "docs", "*.pyc", "__pycache__"]
|
|
30
32
|
|
|
31
33
|
[tool.poetry.dependencies]
|
|
@@ -33,6 +35,7 @@ python = ">=3.10.1,<4.0"
|
|
|
33
35
|
pydantic = {extras = ["email"], version = "^2.0.0"}
|
|
34
36
|
pyjwt = "^2.9.0"
|
|
35
37
|
httpx = "^0.28.1"
|
|
38
|
+
mutmut = "^3.7.0"
|
|
36
39
|
|
|
37
40
|
[tool.poetry.group.dev.dependencies]
|
|
38
41
|
pytest = "^7.4.0"
|
|
@@ -48,6 +51,7 @@ taskipy = "^1.12.0"
|
|
|
48
51
|
towncrier = "^25.8.0"
|
|
49
52
|
pre-commit = "^4.5.1"
|
|
50
53
|
safety = "^3.7.0"
|
|
54
|
+
hypothesis = "^6.0"
|
|
51
55
|
|
|
52
56
|
[build-system]
|
|
53
57
|
requires = ["poetry-core>=1.0.0"]
|
|
@@ -66,6 +70,17 @@ addopts = [
|
|
|
66
70
|
"-v",
|
|
67
71
|
]
|
|
68
72
|
|
|
73
|
+
[tool.mutmut]
|
|
74
|
+
# Mutation testing. Only the library is mutated; the test suite is the oracle.
|
|
75
|
+
source_paths = ["keyrunes_sdk"]
|
|
76
|
+
do_not_mutate = [
|
|
77
|
+
"keyrunes_sdk/__init__.py",
|
|
78
|
+
]
|
|
79
|
+
# The project-wide addopts turn on coverage, which roughly doubles the runtime
|
|
80
|
+
# of every one of the hundreds of per-mutant test runs.
|
|
81
|
+
pytest_add_cli_args = ["--no-cov", "-p", "no:cacheprovider", "-x", "-q"]
|
|
82
|
+
pytest_add_cli_args_test_selection = ["tests/"]
|
|
83
|
+
|
|
69
84
|
[tool.black]
|
|
70
85
|
line-length = 80
|
|
71
86
|
target-version = ['py312']
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
|
|
4
|
-
|
|
5
|
-
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
|
|
6
|
-
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
|
-
|
|
8
|
-
## [0.1.0] - 2025-12-03
|
|
9
|
-
|
|
10
|
-
### Adicionado
|
|
11
|
-
|
|
12
|
-
#### Funcionalidades Core
|
|
13
|
-
- Cliente `KeyrunesClient` completo para interação com Keyrunes API
|
|
14
|
-
- Autenticação com login de usuário e admin
|
|
15
|
-
- Registro de usuário e admin com validação
|
|
16
|
-
- Verificação de pertencimento a grupos
|
|
17
|
-
- Obtenção de informações de usuários
|
|
18
|
-
|
|
19
|
-
#### Decorators
|
|
20
|
-
- `@require_group()` - Decorator para verificar grupos de usuários
|
|
21
|
-
- `@require_admin()` - Decorator para verificar privilégios de admin
|
|
22
|
-
- Suporte para múltiplos grupos (ANY ou ALL)
|
|
23
|
-
- Sistema de client global para uso sem passar client explicitamente
|
|
24
|
-
|
|
25
|
-
#### Modelos Pydantic
|
|
26
|
-
- `User` - Modelo de usuário com validação
|
|
27
|
-
- `Token` - Modelo de token JWT
|
|
28
|
-
- `Group` - Modelo de grupo
|
|
29
|
-
- `UserRegistration` - Dados de registro de usuário
|
|
30
|
-
- `AdminRegistration` - Dados de registro de admin
|
|
31
|
-
- `LoginCredentials` - Credenciais de login
|
|
32
|
-
- `GroupCheck` - Resultado de verificação de grupo
|
|
33
|
-
|
|
34
|
-
#### Exceções Customizadas
|
|
35
|
-
- `KeyrunesError` - Exceção base
|
|
36
|
-
- `AuthenticationError` - Erro de autenticação
|
|
37
|
-
- `AuthorizationError` - Erro de autorização
|
|
38
|
-
- `GroupNotFoundError` - Grupo não encontrado
|
|
39
|
-
- `UserNotFoundError` - Usuário não encontrado
|
|
40
|
-
- `InvalidTokenError` - Token inválido
|
|
41
|
-
- `NetworkError` - Erro de rede
|
|
42
|
-
|
|
43
|
-
#### Sistema de Configuração Global
|
|
44
|
-
- `configure()` - Configura client global
|
|
45
|
-
- `get_global_client()` - Obtém client global
|
|
46
|
-
- `clear_global_client()` - Limpa client global
|
|
47
|
-
- Thread-safe com Lock
|
|
48
|
-
|
|
49
|
-
#### Desenvolvimento e Testes
|
|
50
|
-
- Docker Compose completo com Keyrunes, PostgreSQL e Redis
|
|
51
|
-
- 78 testes com 99% de cobertura
|
|
52
|
-
- Testes usando pytest, factory-boy e faker
|
|
53
|
-
- Exemplos práticos de uso
|
|
54
|
-
- Makefile com comandos úteis
|
|
55
|
-
- Configuração completa de CI/CD
|
|
56
|
-
|
|
57
|
-
#### Documentação
|
|
58
|
-
- README.md completo com exemplos
|
|
59
|
-
- TESTING.md com guia de testes
|
|
60
|
-
- Docstrings em todas as funções
|
|
61
|
-
- Type hints completos
|
|
62
|
-
- Exemplos práticos em `examples/`
|
|
63
|
-
|
|
64
|
-
### Detalhes Técnicos
|
|
65
|
-
|
|
66
|
-
- Python 3.8.1+ compatível
|
|
67
|
-
- Gerenciamento com Poetry
|
|
68
|
-
- Validação com Pydantic 2.0
|
|
69
|
-
- Type hints completos
|
|
70
|
-
- Thread-safe
|
|
71
|
-
- Context manager support
|
|
72
|
-
|
|
73
|
-
### Testes
|
|
74
|
-
|
|
75
|
-
- 78 testes implementados
|
|
76
|
-
- 99% de cobertura de código
|
|
77
|
-
- Testes unitários e de integração
|
|
78
|
-
- Factories com factory-boy
|
|
79
|
-
- Dados fake com Faker
|
|
80
|
-
|
|
81
|
-
### Ferramentas de Desenvolvimento
|
|
82
|
-
|
|
83
|
-
- Black para formatação
|
|
84
|
-
- isort para organização de imports
|
|
85
|
-
- flake8 para linting
|
|
86
|
-
- mypy para type checking
|
|
87
|
-
- pytest para testes
|
|
88
|
-
|
|
89
|
-
## [Unreleased]
|
|
90
|
-
|
|
91
|
-
### Planejado
|
|
92
|
-
|
|
93
|
-
- Suporte para refresh token automático
|
|
94
|
-
- Cache de verificações de grupo
|
|
95
|
-
- Suporte para OIDC
|
|
96
|
-
- Integração com FastAPI
|
|
97
|
-
- Integração com Flask
|
|
98
|
-
- Integração com Django
|
|
99
|
-
- Mais exemplos práticos
|
|
100
|
-
- Documentação com Sphinx
|
|
101
|
-
- Publicação no PyPI
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
Para mais detalhes sobre cada versão, veja os [releases no GitHub](https://github.com/jonatasoli/keyurnes-sdk-python-dark/releases).
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Custom exceptions for Keyrunes SDK."""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class KeyrunesError(Exception):
|
|
5
|
-
"""Base exception for all Keyrunes SDK errors."""
|
|
6
|
-
|
|
7
|
-
pass
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class AuthenticationError(KeyrunesError):
|
|
11
|
-
"""Raised when authentication fails."""
|
|
12
|
-
|
|
13
|
-
pass
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class AuthorizationError(KeyrunesError):
|
|
17
|
-
"""Raised when authorization fails."""
|
|
18
|
-
|
|
19
|
-
pass
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class GroupNotFoundError(KeyrunesError):
|
|
23
|
-
"""Raised when a group is not found."""
|
|
24
|
-
|
|
25
|
-
pass
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class UserNotFoundError(KeyrunesError):
|
|
29
|
-
"""Raised when a user is not found."""
|
|
30
|
-
|
|
31
|
-
pass
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class InvalidTokenError(KeyrunesError):
|
|
35
|
-
"""Raised when token is invalid or expired."""
|
|
36
|
-
|
|
37
|
-
pass
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class NetworkError(KeyrunesError):
|
|
41
|
-
"""Raised when network request fails."""
|
|
42
|
-
|
|
43
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|