keymint 2.4.0__tar.gz → 2.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keymint
3
- Version: 2.4.0
3
+ Version: 2.6.0
4
4
  Summary: License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.
5
5
  Home-page: https://github.com/keymint-dev/keymint-python
6
6
  Author: KeyMint
@@ -16,18 +16,14 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Topic :: System :: Software Distribution
17
17
  Classifier: License :: OSI Approved :: MIT License
18
18
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.6
20
- Classifier: Programming Language :: Python :: 3.7
21
- Classifier: Programming Language :: Python :: 3.8
22
- Classifier: Programming Language :: Python :: 3.9
23
19
  Classifier: Programming Language :: Python :: 3.10
24
20
  Classifier: Programming Language :: Python :: 3.11
25
21
  Classifier: Programming Language :: Python :: 3.12
26
22
  Classifier: Operating System :: OS Independent
27
- Requires-Python: >=3.6
23
+ Requires-Python: >=3.10
28
24
  Description-Content-Type: text/markdown
29
25
  License-File: LICENSE
30
- Requires-Dist: requests>=2.25.0
26
+ Requires-Dist: requests>=2.32.4
31
27
  Provides-Extra: dev
32
28
  Requires-Dist: pytest>=6.0; extra == "dev"
33
29
  Requires-Dist: pytest-cov; extra == "dev"
@@ -91,6 +87,30 @@ if result and 'key' in result:
91
87
  print(f"Created Key: {result['key']}")
92
88
  ```
93
89
 
90
+ ## License Expiry
91
+
92
+ `activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.
93
+
94
+ ```python
95
+ from datetime import datetime, timezone
96
+ import math
97
+
98
+ result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
99
+
100
+ expiration = result.get('expirationDate')
101
+ if expiration is None:
102
+ print('License never expires')
103
+ else:
104
+ expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
105
+ seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
106
+ days_left = max(0, math.ceil(seconds_left / 86400))
107
+ print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')
108
+
109
+ # Store the last value you received (e.g. in your app's settings file).
110
+ ```
111
+
112
+ Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can tell the user the license expired on that date instead of calling the key invalid.
113
+
94
114
  ## Machine Identity
95
115
  Keymint provides utilities to uniquely identify machines for node-locking:
96
116
 
@@ -110,7 +130,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
110
130
  | `update_key` | Updates an existing license key. |
111
131
  | `block_key` | Blocks a license key. |
112
132
  | `unblock_key` | Unblocks a previously blocked license key. |
113
- | `sign_key` | Signs a key for offline (air-gapped) validation.|
133
+ | `sign_key` | Signs a key for offline validation using an admin API key.|
114
134
  | `floating_checkout` | Checks out a floating license seat. |
115
135
  | `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
116
136
  | `floating_checkin` | Checks in a session, releasing the seat. |
@@ -42,6 +42,30 @@ if result and 'key' in result:
42
42
  print(f"Created Key: {result['key']}")
43
43
  ```
44
44
 
45
+ ## License Expiry
46
+
47
+ `activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.
48
+
49
+ ```python
50
+ from datetime import datetime, timezone
51
+ import math
52
+
53
+ result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
54
+
55
+ expiration = result.get('expirationDate')
56
+ if expiration is None:
57
+ print('License never expires')
58
+ else:
59
+ expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
60
+ seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
61
+ days_left = max(0, math.ceil(seconds_left / 86400))
62
+ print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')
63
+
64
+ # Store the last value you received (e.g. in your app's settings file).
65
+ ```
66
+
67
+ Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can tell the user the license expired on that date instead of calling the key invalid.
68
+
45
69
  ## Machine Identity
46
70
  Keymint provides utilities to uniquely identify machines for node-locking:
47
71
 
@@ -61,7 +85,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
61
85
  | `update_key` | Updates an existing license key. |
62
86
  | `block_key` | Blocks a license key. |
63
87
  | `unblock_key` | Unblocks a previously blocked license key. |
64
- | `sign_key` | Signs a key for offline (air-gapped) validation.|
88
+ | `sign_key` | Signs a key for offline validation using an admin API key.|
65
89
  | `floating_checkout` | Checks out a floating license seat. |
66
90
  | `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
67
91
  | `floating_checkin` | Checks in a session, releasing the seat. |
@@ -17,11 +17,13 @@ class KeyMint:
17
17
  'Content-Type': 'application/json'
18
18
  }
19
19
 
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):
21
21
  url = f'{self.base_url}{endpoint}'
22
22
  headers = self.headers.copy()
23
23
  if idempotency_key:
24
24
  headers['Idempotency-Key'] = idempotency_key
25
+ if extra_headers:
26
+ headers.update(extra_headers)
25
27
 
26
28
  try:
27
29
  if method.upper() == 'GET':
@@ -42,8 +44,9 @@ class KeyMint:
42
44
  except requests.exceptions.HTTPError as http_err:
43
45
  try:
44
46
  error_data = http_err.response.json()
47
+ nested_error = error_data.get('error') if isinstance(error_data.get('error'), dict) else {}
45
48
  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',
47
50
  code=error_data.get('code', -1),
48
51
  status=http_err.response.status_code
49
52
  )
@@ -76,7 +79,10 @@ class KeyMint:
76
79
 
77
80
  :param params: Activation parameters including productId, licenseKey, and optional hostId.
78
81
  :param idempotency_key: Optional unique identifier to ensure request idempotency.
79
- :return: Activation success message and licensee information.
82
+ :return: Activation success message and licensee information. Includes
83
+ ``expirationDate``: the license expiry as an ISO 8601 string, or ``None``
84
+ when the license never expires. An expired license fails activation with
85
+ the same generic invalid-key error, so store the last value you received.
80
86
  """
81
87
  return self._handle_request('POST', '/key/activate', params, idempotency_key=idempotency_key)
82
88
 
@@ -95,6 +101,8 @@ class KeyMint:
95
101
  :param params: Parameters for checking out the license.
96
102
  :param idempotency_key: Optional unique identifier to ensure request idempotency.
97
103
  :returns: The checkout response containing sessionId and sessionSecret.
104
+ ``expiresAt`` is the seat lease; ``expirationDate`` is the license expiry
105
+ as an ISO 8601 string, or ``None`` when the license never expires.
98
106
  """
99
107
  return self._handle_request('POST', '/key/checkout', params, idempotency_key=idempotency_key)
100
108
 
@@ -123,10 +131,12 @@ class KeyMint:
123
131
  :returns: The license key details.
124
132
  """
125
133
  query_params = {
126
- 'productId': params['productId'],
127
- 'licenseKey': params['licenseKey']
134
+ 'productId': params['productId']
128
135
  }
129
- return self._handle_request('GET', '/key', query_params=query_params)
136
+ return self._handle_request(
137
+ 'GET', '/key', query_params=query_params,
138
+ extra_headers={'x-license-key': params['licenseKey']}
139
+ )
130
140
 
131
141
  def block_key(self, params: BlockKeyParams, idempotency_key: str | None = None) -> BlockKeyResponse:
132
142
  """
@@ -288,4 +298,3 @@ class KeyMint:
288
298
  except Exception:
289
299
  return False
290
300
 
291
-
@@ -1,6 +1,6 @@
1
1
  """KeyMint Python SDK version information."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.6.0"
4
4
  __author__ = "KeyMint"
5
5
  __email__ = "cliff@keymint.dev"
6
6
  __url__ = "https://github.com/keymint-dev/keymint-python"
@@ -30,9 +30,10 @@ class CreateKeyParams(TypedDict):
30
30
  heartbeatInterval: int | None
31
31
  sessionLeaseDuration: int | None
32
32
 
33
- class CreateKeyResponse(TypedDict):
33
+ class CreateKeyResponse(TypedDict, total=False):
34
34
  code: int
35
35
  key: str
36
+ keys: list[str]
36
37
 
37
38
  class KeyMintApiError(Exception):
38
39
  def __init__(self, message: str, code: int, status: int | None = None):
@@ -58,6 +59,9 @@ class ActivateKeyResponse(TypedDict):
58
59
  versionId: str | None
59
60
  version: dict[str, Any] | None
60
61
  allowedHosts: list[str] | None
62
+ # License expiry as an ISO 8601 string (e.g. "2027-03-01T00:00:00.000Z"),
63
+ # or None when the license never expires.
64
+ expirationDate: str | None
61
65
 
62
66
  class DeactivateKeyParams(TypedDict):
63
67
  productId: str
@@ -67,6 +71,7 @@ class DeactivateKeyParams(TypedDict):
67
71
  class DeactivateKeyResponse(TypedDict):
68
72
  message: str
69
73
  code: int
74
+ devicesRemoved: int
70
75
 
71
76
  class DeviceDetails(TypedDict):
72
77
  hostId: str
@@ -179,11 +184,13 @@ class DeleteCustomerResponse(TypedDict):
179
184
  class ToggleCustomerStatusParams(TypedDict):
180
185
  customerId: str
181
186
 
182
- class ToggleCustomerStatusResponse(TypedDict):
187
+ class ToggleCustomerStatusResponse(TypedDict, total=False):
183
188
  action: str
184
189
  status: bool
185
190
  message: str
186
191
  code: int
192
+ customerName: str
193
+ active: bool
187
194
 
188
195
  class CustomerLicenseKey(TypedDict):
189
196
  id: str
@@ -210,6 +217,8 @@ class FloatingCheckoutParams(TypedDict):
210
217
  deviceTag: str | None
211
218
  userIdentifier: str | None
212
219
  apiKey: str | None
220
+ timestamp: Any | None
221
+ signature: str | None
213
222
 
214
223
  class FloatingCheckoutResponse(TypedDict):
215
224
  code: int
@@ -217,9 +226,12 @@ class FloatingCheckoutResponse(TypedDict):
217
226
  sessionId: str
218
227
  sessionSecret: str
219
228
  nextNonce: str
220
- expiresAt: str
229
+ expiresAt: str # Floating seat lease expiry (not the license expiry)
221
230
  heartbeatInterval: int
222
231
  metadata: dict[str, Any] | None
232
+ # License expiry as an ISO 8601 string, or None when the license never
233
+ # expires. Separate from ``expiresAt``, which is the seat lease.
234
+ expirationDate: str | None
223
235
  currentSessions: int | None
224
236
  maxSessions: int | None
225
237
  licenseeName: str | None
@@ -278,5 +290,4 @@ class SignKeyParams(TypedDict):
278
290
  ttl: int | None
279
291
 
280
292
  class SignKeyResponse(TypedDict):
281
- code: int
282
- file: dict[str, Any] # { signedKey, keyId, publicKeyFingerprint }
293
+ file: str # Serialized signed license file returned by the API
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keymint
3
- Version: 2.4.0
3
+ Version: 2.6.0
4
4
  Summary: License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.
5
5
  Home-page: https://github.com/keymint-dev/keymint-python
6
6
  Author: KeyMint
@@ -16,18 +16,14 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Topic :: System :: Software Distribution
17
17
  Classifier: License :: OSI Approved :: MIT License
18
18
  Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.6
20
- Classifier: Programming Language :: Python :: 3.7
21
- Classifier: Programming Language :: Python :: 3.8
22
- Classifier: Programming Language :: Python :: 3.9
23
19
  Classifier: Programming Language :: Python :: 3.10
24
20
  Classifier: Programming Language :: Python :: 3.11
25
21
  Classifier: Programming Language :: Python :: 3.12
26
22
  Classifier: Operating System :: OS Independent
27
- Requires-Python: >=3.6
23
+ Requires-Python: >=3.10
28
24
  Description-Content-Type: text/markdown
29
25
  License-File: LICENSE
30
- Requires-Dist: requests>=2.25.0
26
+ Requires-Dist: requests>=2.32.4
31
27
  Provides-Extra: dev
32
28
  Requires-Dist: pytest>=6.0; extra == "dev"
33
29
  Requires-Dist: pytest-cov; extra == "dev"
@@ -91,6 +87,30 @@ if result and 'key' in result:
91
87
  print(f"Created Key: {result['key']}")
92
88
  ```
93
89
 
90
+ ## License Expiry
91
+
92
+ `activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.
93
+
94
+ ```python
95
+ from datetime import datetime, timezone
96
+ import math
97
+
98
+ result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
99
+
100
+ expiration = result.get('expirationDate')
101
+ if expiration is None:
102
+ print('License never expires')
103
+ else:
104
+ expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
105
+ seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
106
+ days_left = max(0, math.ceil(seconds_left / 86400))
107
+ print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')
108
+
109
+ # Store the last value you received (e.g. in your app's settings file).
110
+ ```
111
+
112
+ Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can tell the user the license expired on that date instead of calling the key invalid.
113
+
94
114
  ## Machine Identity
95
115
  Keymint provides utilities to uniquely identify machines for node-locking:
96
116
 
@@ -110,7 +130,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
110
130
  | `update_key` | Updates an existing license key. |
111
131
  | `block_key` | Blocks a license key. |
112
132
  | `unblock_key` | Unblocks a previously blocked license key. |
113
- | `sign_key` | Signs a key for offline (air-gapped) validation.|
133
+ | `sign_key` | Signs a key for offline validation using an admin API key.|
114
134
  | `floating_checkout` | Checks out a floating license seat. |
115
135
  | `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
116
136
  | `floating_checkin` | Checks in a session, releasing the seat. |
@@ -10,4 +10,5 @@ keymint.egg-info/PKG-INFO
10
10
  keymint.egg-info/SOURCES.txt
11
11
  keymint.egg-info/dependency_links.txt
12
12
  keymint.egg-info/requires.txt
13
- keymint.egg-info/top_level.txt
13
+ keymint.egg-info/top_level.txt
14
+ tests/test_contracts.py
@@ -1,4 +1,4 @@
1
- requests>=2.25.0
1
+ requests>=2.32.4
2
2
 
3
3
  [dev]
4
4
  pytest>=6.0
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="keymint",
8
- version="2.4.0",
8
+ version="2.6.0",
9
9
  author="KeyMint",
10
10
  author_email="cliff@keymint.dev",
11
11
  description="License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.",
@@ -26,19 +26,15 @@ setup(
26
26
  "Topic :: System :: Software Distribution",
27
27
  "License :: OSI Approved :: MIT License",
28
28
  "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",
33
29
  "Programming Language :: Python :: 3.10",
34
30
  "Programming Language :: Python :: 3.11",
35
31
  "Programming Language :: Python :: 3.12",
36
32
  "Operating System :: OS Independent",
37
33
  ],
38
34
  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',
40
36
  install_requires=[
41
- 'requests>=2.25.0',
37
+ 'requests>=2.32.4',
42
38
  ],
43
39
  extras_require={
44
40
  'dev': [
@@ -0,0 +1,85 @@
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"})
53
+
54
+
55
+ @pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None])
56
+ @patch("keymint.requests.post")
57
+ def test_activate_key_returns_expiration_date(mock_post, expiration_date):
58
+ mock_post.return_value = response({
59
+ "code": 0, "message": "License valid", "expirationDate": expiration_date,
60
+ })
61
+
62
+ result = KeyMint("client_test").activate_key({"productId": "product_123", "licenseKey": "license_123"})
63
+
64
+ assert mock_post.call_args.args[0].endswith("/key/activate")
65
+ assert "expirationDate" in result
66
+ assert result["expirationDate"] == expiration_date
67
+
68
+
69
+ @pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None])
70
+ @patch("keymint.requests.post")
71
+ def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post, expiration_date):
72
+ mock_post.return_value = response({
73
+ "code": 0, "message": "Checked out", "sessionId": "session_123",
74
+ "sessionSecret": "secret_123", "nextNonce": "nonce_123",
75
+ "expiresAt": "2026-10-26T12:05:00.000Z", "heartbeatInterval": 60,
76
+ "metadata": None, "expirationDate": expiration_date,
77
+ })
78
+
79
+ result = KeyMint("client_test").floating_checkout({
80
+ "productId": "product_123", "licenseKey": "license_123", "hostId": "host_123",
81
+ })
82
+
83
+ assert mock_post.call_args.args[0].endswith("/key/checkout")
84
+ assert result["expirationDate"] == expiration_date
85
+ assert result["expiresAt"] == "2026-10-26T12:05:00.000Z"
File without changes
File without changes
File without changes
File without changes