Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ 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 .

- 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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
16 changes: 10 additions & 6 deletions keymint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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
)
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -288,4 +293,3 @@ def verify_webhook_signature(payload: str, header: str, secret: str, tolerance_s
except Exception:
return False


2 changes: 1 addition & 1 deletion keymint/_version.py
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 9 additions & 4 deletions keymint/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -67,6 +68,7 @@ class DeactivateKeyParams(TypedDict):
class DeactivateKeyResponse(TypedDict):
message: str
code: int
devicesRemoved: int

class DeviceDetails(TypedDict):
hostId: str
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
8 changes: 2 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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',
],
Expand Down
52 changes: 52 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
@@ -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"})
Loading