Skip to content

Commit 3c057c9

Browse files
authored
Sync Python SDK with current licensing API (#1)
* fix(sdk): sync current API contracts * test(sdk): add API contract regression tests * ci: install SDK dependencies before tests
1 parent e9e3502 commit 3c057c9

8 files changed

Lines changed: 87 additions & 19 deletions

File tree

‎.github/workflows/ci-cd.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ jobs:
2323
- name: Install dependencies & tools
2424
run: |
2525
python -m pip install --upgrade pip
26-
pip install ruff build
26+
pip install -e . ruff build pytest
2727
2828
- name: Run Ruff (Linter & Formatter)
2929
run: ruff check .
3030

3131
- name: Verify Package Build
3232
run: python -m build
3333

34+
- name: Run Contract Tests
35+
run: pytest -q tests/test_contracts.py
36+
3437
publish:
3538
name: Publish to PyPI
3639
needs: validate

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog
2+
3+
## 2.5.0 - 2026-09-09
4+
5+
- Synchronize licensing, floating-session, offline-signing, error, and customer contracts with the current API.
6+
- Keep license credentials out of detailed-lookup URLs by using `x-license-key`.
7+
- Add deterministic request and response contract tests.
8+
- Correct the supported Python baseline to 3.10+, matching the syntax used by the package.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
6161
| `update_key` | Updates an existing license key. |
6262
| `block_key` | Blocks a license key. |
6363
| `unblock_key` | Unblocks a previously blocked license key. |
64-
| `sign_key` | Signs a key for offline (air-gapped) validation.|
64+
| `sign_key` | Signs a key for offline validation using an admin API key.|
6565
| `floating_checkout` | Checks out a floating license seat. |
6666
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
6767
| `floating_checkin` | Checks in a session, releasing the seat. |

‎keymint/__init__.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"):
1717
'Content-Type': 'application/json'
1818
}
1919

20-
def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None):
20+
def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None, extra_headers: dict | None = None):
2121
url = f'{self.base_url}{endpoint}'
2222
headers = self.headers.copy()
2323
if idempotency_key:
2424
headers['Idempotency-Key'] = idempotency_key
25+
if extra_headers:
26+
headers.update(extra_headers)
2527

2628
try:
2729
if method.upper() == 'GET':
@@ -42,8 +44,9 @@ def _handle_request(self, method: str, endpoint: str, params: dict | None = None
4244
except requests.exceptions.HTTPError as http_err:
4345
try:
4446
error_data = http_err.response.json()
47+
nested_error = error_data.get('error') if isinstance(error_data.get('error'), dict) else {}
4548
raise KeyMintApiError(
46-
message=error_data.get('message', 'An API error occurred'),
49+
message=error_data.get('message') or nested_error.get('message') or 'An API error occurred',
4750
code=error_data.get('code', -1),
4851
status=http_err.response.status_code
4952
)
@@ -123,10 +126,12 @@ def get_key(self, params: GetKeyParams) -> GetKeyResponse:
123126
:returns: The license key details.
124127
"""
125128
query_params = {
126-
'productId': params['productId'],
127-
'licenseKey': params['licenseKey']
129+
'productId': params['productId']
128130
}
129-
return self._handle_request('GET', '/key', query_params=query_params)
131+
return self._handle_request(
132+
'GET', '/key', query_params=query_params,
133+
extra_headers={'x-license-key': params['licenseKey']}
134+
)
130135

131136
def block_key(self, params: BlockKeyParams, idempotency_key: str | None = None) -> BlockKeyResponse:
132137
"""
@@ -288,4 +293,3 @@ def verify_webhook_signature(payload: str, header: str, secret: str, tolerance_s
288293
except Exception:
289294
return False
290295

291-

‎keymint/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""KeyMint Python SDK version information."""
22

3-
__version__ = "2.4.0"
3+
__version__ = "2.5.0"
44
__author__ = "KeyMint"
55
__email__ = "cliff@keymint.dev"
66
__url__ = "https://github.com/keymint-dev/keymint-python"

‎keymint/types.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ class CreateKeyParams(TypedDict):
3030
heartbeatInterval: int | None
3131
sessionLeaseDuration: int | None
3232

33-
class CreateKeyResponse(TypedDict):
33+
class CreateKeyResponse(TypedDict, total=False):
3434
code: int
3535
key: str
36+
keys: list[str]
3637

3738
class KeyMintApiError(Exception):
3839
def __init__(self, message: str, code: int, status: int | None = None):
@@ -67,6 +68,7 @@ class DeactivateKeyParams(TypedDict):
6768
class DeactivateKeyResponse(TypedDict):
6869
message: str
6970
code: int
71+
devicesRemoved: int
7072

7173
class DeviceDetails(TypedDict):
7274
hostId: str
@@ -179,11 +181,13 @@ class DeleteCustomerResponse(TypedDict):
179181
class ToggleCustomerStatusParams(TypedDict):
180182
customerId: str
181183

182-
class ToggleCustomerStatusResponse(TypedDict):
184+
class ToggleCustomerStatusResponse(TypedDict, total=False):
183185
action: str
184186
status: bool
185187
message: str
186188
code: int
189+
customerName: str
190+
active: bool
187191

188192
class CustomerLicenseKey(TypedDict):
189193
id: str
@@ -210,6 +214,8 @@ class FloatingCheckoutParams(TypedDict):
210214
deviceTag: str | None
211215
userIdentifier: str | None
212216
apiKey: str | None
217+
timestamp: Any | None
218+
signature: str | None
213219

214220
class FloatingCheckoutResponse(TypedDict):
215221
code: int
@@ -278,5 +284,4 @@ class SignKeyParams(TypedDict):
278284
ttl: int | None
279285

280286
class SignKeyResponse(TypedDict):
281-
code: int
282-
file: dict[str, Any] # { signedKey, keyId, publicKeyFingerprint }
287+
file: str # Serialized signed license file returned by the API

‎setup.py‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="keymint",
8-
version="2.4.0",
8+
version="2.5.0",
99
author="KeyMint",
1010
author_email="cliff@keymint.dev",
1111
description="License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.",
@@ -26,17 +26,13 @@
2626
"Topic :: System :: Software Distribution",
2727
"License :: OSI Approved :: MIT License",
2828
"Programming Language :: Python :: 3",
29-
"Programming Language :: Python :: 3.6",
30-
"Programming Language :: Python :: 3.7",
31-
"Programming Language :: Python :: 3.8",
32-
"Programming Language :: Python :: 3.9",
3329
"Programming Language :: Python :: 3.10",
3430
"Programming Language :: Python :: 3.11",
3531
"Programming Language :: Python :: 3.12",
3632
"Operating System :: OS Independent",
3733
],
3834
keywords="license-key validation activation node-lock hardware-id copy-protection product-key drm offline-licensing keymint license licensing api",
39-
python_requires='>=3.6',
35+
python_requires='>=3.10',
4036
install_requires=[
4137
'requests>=2.32.4',
4238
],

‎tests/test_contracts.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
import requests
5+
6+
from keymint import KeyMint, KeyMintApiError
7+
8+
9+
def response(payload, status=200):
10+
result = Mock()
11+
result.json.return_value = payload
12+
result.status_code = status
13+
if status >= 400:
14+
error = requests.exceptions.HTTPError()
15+
error.response = result
16+
result.raise_for_status.side_effect = error
17+
return result
18+
19+
20+
@patch("keymint.requests.get")
21+
def test_get_key_uses_header_not_query(mock_get):
22+
mock_get.return_value = response({"code": 0, "data": {"license": {"productId": "product 123"}}})
23+
24+
KeyMint("readonly_test").get_key({"productId": "product 123", "licenseKey": "secret/license+key"})
25+
26+
_, kwargs = mock_get.call_args
27+
assert kwargs["params"] == {"productId": "product 123"}
28+
assert kwargs["headers"]["x-license-key"] == "secret/license+key"
29+
assert "licenseKey" not in kwargs["params"]
30+
31+
32+
@patch("keymint.requests.post")
33+
def test_sign_key_returns_serialized_file(mock_post):
34+
mock_post.return_value = response({"file": '{"signedKey":"signature","keyId":"key_123"}'})
35+
36+
result = KeyMint("admin_test").sign_key({
37+
"productId": "product_123", "licenseKey": "license_123", "hostId": "host_123"
38+
})
39+
40+
assert isinstance(result["file"], str)
41+
42+
43+
@patch("keymint.requests.post")
44+
def test_nested_error_preserves_server_message(mock_post):
45+
mock_post.return_value = response({
46+
"success": False,
47+
"error": {"code": "CUSTOMER_EMAIL_EXISTS", "message": "Customer email already exists in team"},
48+
"code": 1,
49+
}, 409)
50+
51+
with pytest.raises(KeyMintApiError, match="Customer email already exists in team"):
52+
KeyMint("admin_test").create_key({"productId": "product_123"})

0 commit comments

Comments
 (0)