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.
@@ -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.3
1
+ Metadata-Version: 2.4
2
2
  Name: keyrunes-python-sdk
3
- Version: 0.1.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
  [![Tests](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml)
36
- [![Coverage](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk/branch/main/graph/badge.svg)](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk)
37
38
  [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
38
39
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
  [![Tests](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Keyrunes/keyrunes-python-sdk/actions/workflows/ci.yml)
4
- [![Coverage](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk/branch/main/graph/badge.svg)](https://codecov.io/gh/Keyrunes/keyrunes-python-sdk)
5
4
  [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
5
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
 
@@ -1,6 +1,6 @@
1
1
  """Keyrunes SDK - Python client for Keyrunes Authorization System."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from keyrunes_sdk.client import KeyrunesClient
6
6
  from keyrunes_sdk.config import (
@@ -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(f"Request failed: {error_msg}")
150
+ raise NetworkError(
151
+ f"Request failed: {error_msg}",
152
+ status_code=response.status_code,
153
+ )
139
154
 
140
- result: Dict[str, Any] = response.json()
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
- "/api/register",
294
- data=registration.model_dump(),
370
+ ENDPOINT_REGISTER,
371
+ data=data,
295
372
  use_auth=False,
296
373
  )
297
374
 
298
- user_payload = (
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
- "/api/register",
423
+ ENDPOINT_REGISTER,
355
424
  data=registration.model_dump(),
356
425
  use_auth=False,
357
426
  )
358
427
 
359
- user_payload = (
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
- if token_user_id and str(user_id) == token_user_id:
413
- groups = (
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
- token_data = self._token_data
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
- try:
461
- response = self._make_request("GET", f"/api/users/{user_id}")
462
- return self._normalize_user(response)
463
- except UserNotFoundError:
464
- if (
465
- token_user_id
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
- token_data = self._token_data
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
- try:
509
- response = self._make_request("GET", "/api/users/me")
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.1.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
- include = ["LICENSE", "README.md", "CHANGELOG.md"]
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