keyrunes-python-sdk 0.2.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.
@@ -5,6 +5,61 @@ Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
5
5
  O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
6
6
  e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
7
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
+
8
63
  ## [0.2.0] - 2026-09-03
9
64
 
10
65
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keyrunes-python-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for Keyrunes Authorization System
5
5
  License: AGPL
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  """Keyrunes SDK - Python client for Keyrunes Authorization System."""
2
2
 
3
- __version__ = "0.2.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,7 +147,10 @@ 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
155
  try:
141
156
  result: Dict[str, Any] = response.json()
@@ -170,6 +185,15 @@ class KeyrunesClient:
170
185
  normalized["is_admin"] = (
171
186
  is_admin_flag or has_admin_group or has_admin_in_name
172
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))
173
197
  return User(**normalized)
174
198
 
175
199
  def _user_from_token_claims(self) -> User:
@@ -221,6 +245,9 @@ class KeyrunesClient:
221
245
  expires_in=payload.get("expires_in"),
222
246
  refresh_token=payload.get("refresh_token"),
223
247
  user=user_model,
248
+ requires_password_change=bool(
249
+ payload.get("requires_password_change", False)
250
+ ),
224
251
  )
225
252
 
226
253
  def login(
@@ -267,12 +294,36 @@ class KeyrunesClient:
267
294
  self._token_data = None
268
295
  return token
269
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
+
270
320
  def register_user(
271
321
  self,
272
322
  username: str,
273
323
  email: str,
274
324
  password: str,
275
325
  namespace: str = "public",
326
+ group: Optional[str] = None,
276
327
  **attributes: Any,
277
328
  ) -> User:
278
329
  """
@@ -283,6 +334,8 @@ class KeyrunesClient:
283
334
  email: User email address
284
335
  password: Password (minimum 8 characters)
285
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.
286
339
  **attributes: Additional user attributes
287
340
 
288
341
  Returns:
@@ -308,22 +361,18 @@ class KeyrunesClient:
308
361
  namespace=namespace,
309
362
  attributes=attributes,
310
363
  )
364
+ data = registration.model_dump()
365
+ if group is not None:
366
+ data["group"] = group
367
+
311
368
  response = self._make_request(
312
369
  "POST",
313
- "/api/register",
314
- data=registration.model_dump(),
370
+ ENDPOINT_REGISTER,
371
+ data=data,
315
372
  use_auth=False,
316
373
  )
317
374
 
318
- user_payload = (
319
- response.get("user") if isinstance(response, dict) else None
320
- )
321
- if not user_payload:
322
- raise NetworkError(
323
- "Unexpected response format for user registration."
324
- )
325
-
326
- return self._normalize_user(user_payload)
375
+ return self._normalize_user(self._registration_payload(response))
327
376
 
328
377
  def register_admin(
329
378
  self,
@@ -371,20 +420,12 @@ class KeyrunesClient:
371
420
  )
372
421
  response = self._make_request(
373
422
  "POST",
374
- "/api/register",
423
+ ENDPOINT_REGISTER,
375
424
  data=registration.model_dump(),
376
425
  use_auth=False,
377
426
  )
378
427
 
379
- user_payload = (
380
- response.get("user") if isinstance(response, dict) else None
381
- )
382
- if not user_payload:
383
- raise NetworkError(
384
- "Unexpected response format for admin registration."
385
- )
386
-
387
- return self._normalize_user(user_payload)
428
+ return self._normalize_user(self._registration_payload(response))
388
429
 
389
430
  def has_group(self, user_id: str, group_id: str) -> bool:
390
431
  """
@@ -470,10 +511,18 @@ class KeyrunesClient:
470
511
  response = self._make_request("GET", f"/api/users/{user_id}")
471
512
  return self._normalize_user(response)
472
513
 
473
- def get_current_user(self) -> User:
514
+ def get_current_user(self, force_refresh: bool = False) -> User:
474
515
  """
475
516
  Get currently authenticated user information.
476
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
+
477
526
  Returns:
478
527
  User object for authenticated user
479
528
 
@@ -489,12 +538,10 @@ class KeyrunesClient:
489
538
  if not self._token:
490
539
  raise AuthenticationError("Not authenticated. Please login first.")
491
540
 
492
- if self._token_data:
541
+ if self._token_data and not force_refresh:
493
542
  return self._user_from_token_claims()
494
543
 
495
- # The claims shortcut above already handled every case where the token
496
- # could answer, so a 404 here is a genuine miss and is propagated.
497
- response = self._make_request("GET", "/api/users/me")
544
+ response = self._make_request("GET", ENDPOINT_ME)
498
545
  return self._normalize_user(response)
499
546
 
500
547
  def get_user_groups(self, user_id: Optional[str] = None) -> List[str]:
@@ -542,6 +589,44 @@ class KeyrunesClient:
542
589
  except Exception:
543
590
  self._token_data = None
544
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
+
545
630
  def clear_token(self) -> None:
546
631
  """
547
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.2.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]
@@ -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