diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 65f31ff..055b5cc 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies & tools run: | python -m pip install --upgrade pip - pip install ruff build + pip install -e . ruff build pytest - name: Run Ruff (Linter & Formatter) run: ruff check . @@ -31,6 +31,9 @@ jobs: - name: Verify Package Build run: python -m build + - name: Run Contract Tests + run: pytest -q tests/test_contracts.py + publish: name: Publish to PyPI needs: validate diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5565688 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 2.5.0 - 2026-09-09 + +- Synchronize licensing, floating-session, offline-signing, error, and customer contracts with the current API. +- Keep license credentials out of detailed-lookup URLs by using `x-license-key`. +- Add deterministic request and response contract tests. +- Correct the supported Python baseline to 3.10+, matching the syntax used by the package. diff --git a/README.md b/README.md index 344f0a2..e1d9c3f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `update_key` | Updates an existing license key. | | `block_key` | Blocks a license key. | | `unblock_key` | Unblocks a previously blocked license key. | -| `sign_key` | Signs a key for offline (air-gapped) validation.| +| `sign_key` | Signs a key for offline validation using an admin API key.| | `floating_checkout` | Checks out a floating license seat. | | `floating_heartbeat`| Sends a heartbeat to keep a session alive. | | `floating_checkin` | Checks in a session, releasing the seat. | diff --git a/keymint/__init__.py b/keymint/__init__.py index c774fc6..8e8e94a 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -17,11 +17,13 @@ def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"): 'Content-Type': 'application/json' } - def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None): + 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): url = f'{self.base_url}{endpoint}' headers = self.headers.copy() if idempotency_key: headers['Idempotency-Key'] = idempotency_key + if extra_headers: + headers.update(extra_headers) try: if method.upper() == 'GET': @@ -42,8 +44,9 @@ def _handle_request(self, method: str, endpoint: str, params: dict | None = None except requests.exceptions.HTTPError as http_err: try: error_data = http_err.response.json() + nested_error = error_data.get('error') if isinstance(error_data.get('error'), dict) else {} raise KeyMintApiError( - message=error_data.get('message', 'An API error occurred'), + message=error_data.get('message') or nested_error.get('message') or 'An API error occurred', code=error_data.get('code', -1), status=http_err.response.status_code ) @@ -123,10 +126,12 @@ def get_key(self, params: GetKeyParams) -> GetKeyResponse: :returns: The license key details. """ query_params = { - 'productId': params['productId'], - 'licenseKey': params['licenseKey'] + 'productId': params['productId'] } - return self._handle_request('GET', '/key', query_params=query_params) + return self._handle_request( + 'GET', '/key', query_params=query_params, + extra_headers={'x-license-key': params['licenseKey']} + ) def block_key(self, params: BlockKeyParams, idempotency_key: str | None = None) -> BlockKeyResponse: """ @@ -288,4 +293,3 @@ def verify_webhook_signature(payload: str, header: str, secret: str, tolerance_s except Exception: return False - diff --git a/keymint/_version.py b/keymint/_version.py index ad25fd1..11779a2 100644 --- a/keymint/_version.py +++ b/keymint/_version.py @@ -1,6 +1,6 @@ """KeyMint Python SDK version information.""" -__version__ = "2.4.0" +__version__ = "2.5.0" __author__ = "KeyMint" __email__ = "cliff@keymint.dev" __url__ = "https://github.com/keymint-dev/keymint-python" diff --git a/keymint/types.py b/keymint/types.py index bef8a2b..eb1df5f 100644 --- a/keymint/types.py +++ b/keymint/types.py @@ -30,9 +30,10 @@ class CreateKeyParams(TypedDict): heartbeatInterval: int | None sessionLeaseDuration: int | None -class CreateKeyResponse(TypedDict): +class CreateKeyResponse(TypedDict, total=False): code: int key: str + keys: list[str] class KeyMintApiError(Exception): def __init__(self, message: str, code: int, status: int | None = None): @@ -67,6 +68,7 @@ class DeactivateKeyParams(TypedDict): class DeactivateKeyResponse(TypedDict): message: str code: int + devicesRemoved: int class DeviceDetails(TypedDict): hostId: str @@ -179,11 +181,13 @@ class DeleteCustomerResponse(TypedDict): class ToggleCustomerStatusParams(TypedDict): customerId: str -class ToggleCustomerStatusResponse(TypedDict): +class ToggleCustomerStatusResponse(TypedDict, total=False): action: str status: bool message: str code: int + customerName: str + active: bool class CustomerLicenseKey(TypedDict): id: str @@ -210,6 +214,8 @@ class FloatingCheckoutParams(TypedDict): deviceTag: str | None userIdentifier: str | None apiKey: str | None + timestamp: Any | None + signature: str | None class FloatingCheckoutResponse(TypedDict): code: int @@ -278,5 +284,4 @@ class SignKeyParams(TypedDict): ttl: int | None class SignKeyResponse(TypedDict): - code: int - file: dict[str, Any] # { signedKey, keyId, publicKeyFingerprint } + file: str # Serialized signed license file returned by the API diff --git a/setup.py b/setup.py index ad467d2..0afbc97 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="keymint", - version="2.4.0", + version="2.5.0", author="KeyMint", author_email="cliff@keymint.dev", description="License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.", @@ -26,17 +26,13 @@ "Topic :: System :: Software Distribution", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", ], keywords="license-key validation activation node-lock hardware-id copy-protection product-key drm offline-licensing keymint license licensing api", - python_requires='>=3.6', + python_requires='>=3.10', install_requires=[ 'requests>=2.32.4', ], diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..5b6f403 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,52 @@ +from unittest.mock import Mock, patch + +import pytest +import requests + +from keymint import KeyMint, KeyMintApiError + + +def response(payload, status=200): + result = Mock() + result.json.return_value = payload + result.status_code = status + if status >= 400: + error = requests.exceptions.HTTPError() + error.response = result + result.raise_for_status.side_effect = error + return result + + +@patch("keymint.requests.get") +def test_get_key_uses_header_not_query(mock_get): + mock_get.return_value = response({"code": 0, "data": {"license": {"productId": "product 123"}}}) + + KeyMint("readonly_test").get_key({"productId": "product 123", "licenseKey": "secret/license+key"}) + + _, kwargs = mock_get.call_args + assert kwargs["params"] == {"productId": "product 123"} + assert kwargs["headers"]["x-license-key"] == "secret/license+key" + assert "licenseKey" not in kwargs["params"] + + +@patch("keymint.requests.post") +def test_sign_key_returns_serialized_file(mock_post): + mock_post.return_value = response({"file": '{"signedKey":"signature","keyId":"key_123"}'}) + + result = KeyMint("admin_test").sign_key({ + "productId": "product_123", "licenseKey": "license_123", "hostId": "host_123" + }) + + assert isinstance(result["file"], str) + + +@patch("keymint.requests.post") +def test_nested_error_preserves_server_message(mock_post): + mock_post.return_value = response({ + "success": False, + "error": {"code": "CUSTOMER_EMAIL_EXISTS", "message": "Customer email already exists in team"}, + "code": 1, + }, 409) + + with pytest.raises(KeyMintApiError, match="Customer email already exists in team"): + KeyMint("admin_test").create_key({"productId": "product_123"})