From 1eae7520fb938eb938723587a682e5d4e66f4d20 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 00:42:50 +0000 Subject: [PATCH] feat: expose expirationDate on activate and floating checkout Add expirationDate (ISO 8601 string, or None when the license never expires) to ActivateKeyResponse and FloatingCheckoutResponse, document it in the method docstrings, README and CHANGELOG, and add contract tests for a date value and None. --- CHANGELOG.md | 5 +++++ README.md | 24 ++++++++++++++++++++++++ keymint/__init__.py | 7 ++++++- keymint/types.py | 8 +++++++- tests/test_contracts.py | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) 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"