keyrunes-python-sdk 0.0.1__tar.gz → 0.2.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,52 @@ 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.2.0] - 2026-09-03
9
+
10
+ ### Added
11
+
12
+ - Property-based test suite (`tests/test_property_based.py`, 24 tests) built on
13
+ Hypothesis, covering base URL normalization, `_normalize_user` id precedence
14
+ and `is_admin` derivation, JWT claim parsing, request URL construction, HTTP
15
+ status to exception mapping, and model validation bounds.
16
+ - Input fuzzing ("spider") suite (`tests/test_fuzz.py`, 19 tests) that crawls the
17
+ public surface with hostile payloads and asserts that only `KeyrunesError` and
18
+ pydantic `ValidationError` ever escape, and that nothing panics or leaks a raw
19
+ `ValueError`/`TypeError`.
20
+ - Request contract suite (`tests/test_request_contract.py`, 33 tests) that mocks
21
+ only the httpx transport, so the method, URL, headers and JSON body actually
22
+ put on the wire are asserted for every endpoint.
23
+ - Hypothesis profiles in `tests/conftest.py` (`fast`, `dev`, `ci`) selected via
24
+ the `HYPOTHESIS_PROFILE` environment variable. `fast` is derandomized and
25
+ database-free so mutation runs judge every mutant against identical examples.
26
+ - `[tool.mutmut]` configuration in `pyproject.toml` for mutation testing.
27
+ - `hypothesis` added as a development dependency.
28
+
29
+ ### Fixed
30
+
31
+ - A 2xx response carrying a non-JSON body raised a raw `ValueError` out of
32
+ `KeyrunesClient._make_request`. It is now wrapped in `NetworkError`, so the
33
+ documented exception contract holds for malformed responses.
34
+
35
+ ### Changed
36
+
37
+ - Extracted the duplicated "build a `User` from JWT claims" block into
38
+ `KeyrunesClient._user_from_token_claims()`.
39
+
40
+ ### Removed
41
+
42
+ - Unreachable `except UserNotFoundError` fallbacks in `get_user`,
43
+ `get_current_user` and `has_group`. Each re-tested a condition that had
44
+ already forced an early return, so a 404 from the server was being swallowed
45
+ instead of propagated.
46
+
47
+ ### Testing
48
+
49
+ - Test count raised from 83 to 159.
50
+ - Mutation score (mutmut) raised from 44% (294/669 mutants killed) to 72%
51
+ (387/537). The remaining survivors are predominantly equivalent mutants that
52
+ only alter error message prose.
53
+
8
54
  ## [0.1.0] - 2025-12-03
9
55
 
10
56
  ### Adicionado
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: keyrunes-python-sdk
3
- Version: 0.0.1
3
+ Version: 0.2.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.2.0"
4
4
 
5
5
  from keyrunes_sdk.client import KeyrunesClient
6
6
  from keyrunes_sdk.config import (
@@ -1,5 +1,6 @@
1
1
  """Keyrunes API Client."""
2
2
 
3
+ import os
3
4
  from typing import Any, Dict, List, Optional
4
5
  from urllib.parse import urljoin
5
6
 
@@ -38,6 +39,8 @@ class KeyrunesClient:
38
39
  base_url: Base URL of the Keyrunes API
39
40
  (e.g., "https://keyrunes.example.com")
40
41
  api_key: Optional API key for authentication
42
+ organization_key: Optional Organization Key (required for v0.2.0+)
43
+ If not provided, looks for KEYRUNES_ORG_KEY env var
41
44
  timeout: Request timeout in seconds (default: 30)
42
45
 
43
46
  Example:
@@ -50,11 +53,16 @@ class KeyrunesClient:
50
53
  self,
51
54
  base_url: str,
52
55
  api_key: Optional[str] = None,
56
+ organization_key: Optional[str] = None,
53
57
  timeout: int = 30,
54
58
  ) -> None:
55
59
  """Initialize Keyrunes client."""
56
60
  self.base_url = base_url.rstrip("/")
57
61
  self.api_key = api_key
62
+ # Prioritize explicit argument, then env var
63
+ self.organization_key = organization_key or os.getenv(
64
+ "KEYRUNES_ORG_KEY"
65
+ )
58
66
  self.timeout = timeout
59
67
  self._token: Optional[str] = None
60
68
  self._token_data: Optional[Dict[str, Any]] = None
@@ -63,6 +71,11 @@ class KeyrunesClient:
63
71
  if api_key:
64
72
  self._client.headers.update({"X-API-Key": api_key})
65
73
 
74
+ if self.organization_key:
75
+ self._client.headers.update(
76
+ {"X-Organization-Key": self.organization_key}
77
+ )
78
+
66
79
  def _make_request(
67
80
  self,
68
81
  method: str,
@@ -124,7 +137,12 @@ class KeyrunesClient:
124
137
  )
125
138
  raise NetworkError(f"Request failed: {error_msg}")
126
139
 
127
- result: Dict[str, Any] = response.json()
140
+ try:
141
+ result: Dict[str, Any] = response.json()
142
+ except (ValueError, TypeError) as e:
143
+ raise NetworkError(
144
+ f"Malformed JSON in response from {url}: {str(e)}"
145
+ )
128
146
  return result
129
147
 
130
148
  except httpx.RequestError as e:
@@ -154,6 +172,21 @@ class KeyrunesClient:
154
172
  )
155
173
  return User(**normalized)
156
174
 
175
+ def _user_from_token_claims(self) -> User:
176
+ """Build a :class:`User` out of the claims carried by the JWT.
177
+
178
+ Only called once ``self._token_data`` is known to be populated.
179
+ """
180
+ token_data = self._token_data or {}
181
+ return self._normalize_user(
182
+ {
183
+ "id": str(token_data.get("sub", "")),
184
+ "username": token_data.get("username", ""),
185
+ "email": token_data.get("email", ""),
186
+ "groups": token_data.get("groups", []),
187
+ }
188
+ )
189
+
157
190
  def _parse_token_response(self, payload: Dict[str, Any]) -> Token:
158
191
  """
159
192
  Accept both legacy and current API token responses.
@@ -190,13 +223,16 @@ class KeyrunesClient:
190
223
  user=user_model,
191
224
  )
192
225
 
193
- def login(self, username: str, password: str) -> Token:
226
+ def login(
227
+ self, username: str, password: str, namespace: str = "public"
228
+ ) -> Token:
194
229
  """
195
230
  Authenticate user and obtain access token.
196
231
 
197
232
  Args:
198
233
  username: Username or email
199
234
  password: User password
235
+ namespace: User namespace (default: "public")
200
236
 
201
237
  Returns:
202
238
  Token object containing access token and user info
@@ -206,10 +242,14 @@ class KeyrunesClient:
206
242
 
207
243
  Example:
208
244
  >>> client = KeyrunesClient("https://keyrunes.example.com")
209
- >>> token = client.login("user@example.com", "password123")
245
+ >>> token = client.login(
246
+ ... "user@example.com", "password123", namespace="public"
247
+ ... )
210
248
  >>> client.set_token(token.access_token)
211
249
  """
212
- credentials = LoginCredentials(identity=username, password=password)
250
+ credentials = LoginCredentials(
251
+ identity=username, password=password, namespace=namespace
252
+ )
213
253
  response = self._make_request(
214
254
  "POST",
215
255
  "/api/login",
@@ -228,7 +268,12 @@ class KeyrunesClient:
228
268
  return token
229
269
 
230
270
  def register_user(
231
- self, username: str, email: str, password: str, **attributes: Any
271
+ self,
272
+ username: str,
273
+ email: str,
274
+ password: str,
275
+ namespace: str = "public",
276
+ **attributes: Any,
232
277
  ) -> User:
233
278
  """
234
279
  Register a new user.
@@ -237,6 +282,7 @@ class KeyrunesClient:
237
282
  username: Username (3-50 characters)
238
283
  email: User email address
239
284
  password: Password (minimum 8 characters)
285
+ namespace: User namespace (default: "public")
240
286
  **attributes: Additional user attributes
241
287
 
242
288
  Returns:
@@ -251,6 +297,7 @@ class KeyrunesClient:
251
297
  ... username="newuser",
252
298
  ... email="newuser@example.com",
253
299
  ... password="securepass123",
300
+ ... namespace="my-app",
254
301
  ... department="Engineering"
255
302
  ... )
256
303
  """
@@ -258,6 +305,7 @@ class KeyrunesClient:
258
305
  username=username,
259
306
  email=email,
260
307
  password=password,
308
+ namespace=namespace,
261
309
  attributes=attributes,
262
310
  )
263
311
  response = self._make_request(
@@ -283,6 +331,7 @@ class KeyrunesClient:
283
331
  email: str,
284
332
  password: str,
285
333
  admin_key: str,
334
+ namespace: str = "public",
286
335
  **attributes: Any,
287
336
  ) -> User:
288
337
  """
@@ -293,6 +342,7 @@ class KeyrunesClient:
293
342
  email: Admin email address
294
343
  password: Password (minimum 8 characters)
295
344
  admin_key: Admin registration key
345
+ namespace: User namespace (default: "public")
296
346
  **attributes: Additional user attributes
297
347
 
298
348
  Returns:
@@ -316,6 +366,7 @@ class KeyrunesClient:
316
366
  email=email,
317
367
  password=password,
318
368
  admin_key=admin_key,
369
+ namespace=namespace,
319
370
  attributes=attributes,
320
371
  )
321
372
  response = self._make_request(
@@ -378,13 +429,8 @@ class KeyrunesClient:
378
429
  check = GroupCheck(**response)
379
430
  return check.has_access
380
431
  except UserNotFoundError:
381
- if token_user_id and str(user_id) == token_user_id:
382
- groups = (
383
- self._token_data.get("groups", [])
384
- if self._token_data
385
- else []
386
- )
387
- return group_id in groups
432
+ # The self-lookup above already returned for the authenticated
433
+ # user, so reaching here always means a genuine miss.
388
434
  raise GroupNotFoundError(
389
435
  f"Group '{group_id}' not found or user not in group"
390
436
  )
@@ -417,33 +463,12 @@ class KeyrunesClient:
417
463
  )
418
464
 
419
465
  if token_user_id and str(user_id) == token_user_id and self._token_data:
420
- token_data = self._token_data
421
- user_data = {
422
- "id": str(token_data.get("sub", "")),
423
- "username": token_data.get("username", ""),
424
- "email": token_data.get("email", ""),
425
- "groups": token_data.get("groups", []),
426
- }
427
- return self._normalize_user(user_data)
466
+ return self._user_from_token_claims()
428
467
 
429
- try:
430
- response = self._make_request("GET", f"/api/users/{user_id}")
431
- return self._normalize_user(response)
432
- except UserNotFoundError:
433
- if (
434
- token_user_id
435
- and str(user_id) == token_user_id
436
- and self._token_data
437
- ):
438
- token_data = self._token_data
439
- user_data = {
440
- "id": str(token_data.get("sub", "")),
441
- "username": token_data.get("username", ""),
442
- "email": token_data.get("email", ""),
443
- "groups": token_data.get("groups", []),
444
- }
445
- return self._normalize_user(user_data)
446
- raise
468
+ # The claims shortcut above already handled the authenticated user, so
469
+ # a 404 here is always a genuine miss and is propagated as such.
470
+ response = self._make_request("GET", f"/api/users/{user_id}")
471
+ return self._normalize_user(response)
447
472
 
448
473
  def get_current_user(self) -> User:
449
474
  """
@@ -465,29 +490,12 @@ class KeyrunesClient:
465
490
  raise AuthenticationError("Not authenticated. Please login first.")
466
491
 
467
492
  if self._token_data:
468
- token_data = self._token_data
469
- user_data = {
470
- "id": str(token_data.get("sub", "")),
471
- "username": token_data.get("username", ""),
472
- "email": token_data.get("email", ""),
473
- "groups": token_data.get("groups", []),
474
- }
475
- return self._normalize_user(user_data)
493
+ return self._user_from_token_claims()
476
494
 
477
- try:
478
- response = self._make_request("GET", "/api/users/me")
479
- return self._normalize_user(response)
480
- except UserNotFoundError:
481
- if self._token_data:
482
- token_data = self._token_data
483
- user_data = {
484
- "id": str(token_data.get("sub", "")),
485
- "username": token_data.get("username", ""),
486
- "email": token_data.get("email", ""),
487
- "groups": token_data.get("groups", []),
488
- }
489
- return self._normalize_user(user_data)
490
- raise
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")
498
+ return self._normalize_user(response)
491
499
 
492
500
  def get_user_groups(self, user_id: Optional[str] = None) -> List[str]:
493
501
  """
@@ -69,6 +69,7 @@ class _GlobalConfig:
69
69
  self,
70
70
  base_url: str,
71
71
  api_key: Optional[str] = None,
72
+ organization_key: Optional[str] = None,
72
73
  timeout: int = 30,
73
74
  ) -> KeyrunesClient:
74
75
  """
@@ -77,6 +78,7 @@ class _GlobalConfig:
77
78
  Args:
78
79
  base_url: Base URL of the Keyrunes API
79
80
  api_key: Optional API key for authentication
81
+ organization_key: Optional Organization Key (required for v0.2.0+)
80
82
  timeout: Request timeout in seconds (default: 30)
81
83
 
82
84
  Returns:
@@ -91,7 +93,10 @@ class _GlobalConfig:
91
93
  ... )
92
94
  """
93
95
  client = KeyrunesClient(
94
- base_url=base_url, api_key=api_key, timeout=timeout
96
+ base_url=base_url,
97
+ api_key=api_key,
98
+ organization_key=organization_key,
99
+ timeout=timeout,
95
100
  )
96
101
  self.set_client(client)
97
102
  return client
@@ -133,6 +138,7 @@ def get_config() -> _GlobalConfig:
133
138
  def configure(
134
139
  base_url: str,
135
140
  api_key: Optional[str] = None,
141
+ organization_key: Optional[str] = None,
136
142
  timeout: int = 30,
137
143
  ) -> KeyrunesClient:
138
144
  """
@@ -145,6 +151,7 @@ def configure(
145
151
  Args:
146
152
  base_url: Base URL of the Keyrunes API
147
153
  api_key: Optional API key for authentication
154
+ organization_key: Optional Organization Key (required for v0.2.0+)
148
155
  timeout: Request timeout in seconds (default: 30)
149
156
 
150
157
  Returns:
@@ -167,6 +174,7 @@ def configure(
167
174
  return _config.configure(
168
175
  base_url=base_url,
169
176
  api_key=api_key,
177
+ organization_key=organization_key,
170
178
  timeout=timeout,
171
179
  )
172
180
 
@@ -64,6 +64,7 @@ class UserRegistration(BaseModel):
64
64
  )
65
65
  email: EmailStr = Field(..., description="User email")
66
66
  password: str = Field(..., min_length=8, description="Password")
67
+ namespace: str = Field("default", description="User namespace")
67
68
  attributes: Dict[str, Any] = Field(
68
69
  default_factory=dict, description="Additional attributes"
69
70
  )
@@ -80,11 +81,14 @@ class LoginCredentials(BaseModel):
80
81
 
81
82
  identity: str = Field(..., description="Username or email")
82
83
  password: str = Field(..., description="Password")
84
+ namespace: str = Field("default", description="User namespace")
83
85
 
84
86
  @classmethod
85
- def from_username(cls, username: str, password: str) -> "LoginCredentials":
87
+ def from_username(
88
+ cls, username: str, password: str, namespace: str = "default"
89
+ ) -> "LoginCredentials":
86
90
  """Create from username parameter (for backward compatibility)."""
87
- return cls(identity=username, password=password)
91
+ return cls(identity=username, password=password, namespace=namespace)
88
92
 
89
93
 
90
94
  class GroupCheck(BaseModel):
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "keyrunes-python-sdk"
3
- version = "0.0.1"
3
+ version = "0.2.0"
4
4
  description = "Python SDK for Keyrunes Authorization System"
5
5
  authors = ["keyrunes <contact@singularjourney.host>"]
6
6
  maintainers = ["jonatasoli <contact@jonatasoli.dev>"]
@@ -33,6 +33,7 @@ python = ">=3.10.1,<4.0"
33
33
  pydantic = {extras = ["email"], version = "^2.0.0"}
34
34
  pyjwt = "^2.9.0"
35
35
  httpx = "^0.28.1"
36
+ mutmut = "^3.7.0"
36
37
 
37
38
  [tool.poetry.group.dev.dependencies]
38
39
  pytest = "^7.4.0"
@@ -40,12 +41,15 @@ pytest-cov = "^4.1.0"
40
41
  pytest-mock = "^3.11.1"
41
42
  factory-boy = "^3.3.0"
42
43
  faker = "^19.0.0"
43
- black = "^23.7.0"
44
+ black = "^25.12.0"
44
45
  isort = "^5.12.0"
45
46
  flake8 = "^6.1.0"
46
47
  mypy = "^1.5.0"
47
48
  taskipy = "^1.12.0"
48
49
  towncrier = "^25.8.0"
50
+ pre-commit = "^4.5.1"
51
+ safety = "^3.7.0"
52
+ hypothesis = "^6.0"
49
53
 
50
54
  [build-system]
51
55
  requires = ["poetry-core>=1.0.0"]
@@ -64,6 +68,17 @@ addopts = [
64
68
  "-v",
65
69
  ]
66
70
 
71
+ [tool.mutmut]
72
+ # Mutation testing. Only the library is mutated; the test suite is the oracle.
73
+ source_paths = ["keyrunes_sdk"]
74
+ do_not_mutate = [
75
+ "keyrunes_sdk/__init__.py",
76
+ ]
77
+ # The project-wide addopts turn on coverage, which roughly doubles the runtime
78
+ # of every one of the hundreds of per-mutant test runs.
79
+ pytest_add_cli_args = ["--no-cov", "-p", "no:cacheprovider", "-x", "-q"]
80
+ pytest_add_cli_args_test_selection = ["tests/"]
81
+
67
82
  [tool.black]
68
83
  line-length = 80
69
84
  target-version = ['py312']