diff --git a/CHANGELOG.md b/CHANGELOG.md index 5565688..628f1e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Add `expirationDate` (ISO 8601 string, or `None` when the license never expires) to the `activate_key` and `floating_checkout` response types. On checkout it is separate from `expiresAt`, the seat lease. Additive; no other fields changed. +- Document reading the expiry, computing days left, and storing the last value in the README. + ## 2.5.0 - 2026-09-09 - Synchronize licensing, floating-session, offline-signing, error, and customer contracts with the current API. diff --git a/README.md b/README.md index e1d9c3f..76a2223 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,30 @@ if result and 'key' in result: print(f"Created Key: {result['key']}") ``` +## License Expiry + +`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. + +```python +from datetime import datetime, timezone +import math + +result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id}) + +expiration = result.get('expirationDate') +if expiration is None: + print('License never expires') +else: + expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00')) + seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds() + days_left = max(0, math.ceil(seconds_left / 86400)) + print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)') + +# Store the last value you received (e.g. in your app's settings file). +``` + +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. + ## Machine Identity Keymint provides utilities to uniquely identify machines for node-locking: diff --git a/keymint/__init__.py b/keymint/__init__.py index 8e8e94a..e6b9a31 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -79,7 +79,10 @@ def activate_key(self, params: ActivateKeyParams, idempotency_key: str | None = :param params: Activation parameters including productId, licenseKey, and optional hostId. :param idempotency_key: Optional unique identifier to ensure request idempotency. - :return: Activation success message and licensee information. + :return: Activation success message and licensee information. Includes + ``expirationDate``: the license expiry as an ISO 8601 string, or ``None`` + when the license never expires. An expired license fails activation with + the same generic invalid-key error, so store the last value you received. """ return self._handle_request('POST', '/key/activate', params, idempotency_key=idempotency_key) @@ -98,6 +101,8 @@ def floating_checkout(self, params: FloatingCheckoutParams, idempotency_key: str :param params: Parameters for checking out the license. :param idempotency_key: Optional unique identifier to ensure request idempotency. :returns: The checkout response containing sessionId and sessionSecret. + ``expiresAt`` is the seat lease; ``expirationDate`` is the license expiry + as an ISO 8601 string, or ``None`` when the license never expires. """ return self._handle_request('POST', '/key/checkout', params, idempotency_key=idempotency_key) diff --git a/keymint/types.py b/keymint/types.py index eb1df5f..f3d0e9d 100644 --- a/keymint/types.py +++ b/keymint/types.py @@ -59,6 +59,9 @@ class ActivateKeyResponse(TypedDict): versionId: str | None version: dict[str, Any] | None allowedHosts: list[str] | None + # License expiry as an ISO 8601 string (e.g. "2027-03-01T00:00:00.000Z"), + # or None when the license never expires. + expirationDate: str | None class DeactivateKeyParams(TypedDict): productId: str @@ -223,9 +226,12 @@ class FloatingCheckoutResponse(TypedDict): sessionId: str sessionSecret: str nextNonce: str - expiresAt: str + expiresAt: str # Floating seat lease expiry (not the license expiry) heartbeatInterval: int metadata: dict[str, Any] | None + # License expiry as an ISO 8601 string, or None when the license never + # expires. Separate from ``expiresAt``, which is the seat lease. + expirationDate: str | None currentSessions: int | None maxSessions: int | None licenseeName: str | None diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 5b6f403..2b6275d 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -50,3 +50,36 @@ def test_nested_error_preserves_server_message(mock_post): with pytest.raises(KeyMintApiError, match="Customer email already exists in team"): KeyMint("admin_test").create_key({"productId": "product_123"}) + + +@pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None]) +@patch("keymint.requests.post") +def test_activate_key_returns_expiration_date(mock_post, expiration_date): + mock_post.return_value = response({ + "code": 0, "message": "License valid", "expirationDate": expiration_date, + }) + + result = KeyMint("client_test").activate_key({"productId": "product_123", "licenseKey": "license_123"}) + + assert mock_post.call_args.args[0].endswith("/key/activate") + assert "expirationDate" in result + assert result["expirationDate"] == expiration_date + + +@pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None]) +@patch("keymint.requests.post") +def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post, expiration_date): + mock_post.return_value = response({ + "code": 0, "message": "Checked out", "sessionId": "session_123", + "sessionSecret": "secret_123", "nextNonce": "nonce_123", + "expiresAt": "2026-10-26T12:05:00.000Z", "heartbeatInterval": 60, + "metadata": None, "expirationDate": expiration_date, + }) + + result = KeyMint("client_test").floating_checkout({ + "productId": "product_123", "licenseKey": "license_123", "hostId": "host_123", + }) + + assert mock_post.call_args.args[0].endswith("/key/checkout") + assert result["expirationDate"] == expiration_date + assert result["expiresAt"] == "2026-10-26T12:05:00.000Z"