From 73ae51dfe9379bc3ae28c0cdc8e57b1ed3bc9448 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 03:08:22 +0000 Subject: [PATCH 1/2] feat: delete_key for DELETE /key --- CHANGELOG.md | 1 + README.md | 1 + keymint/__init__.py | 18 ++++++++++++++++-- keymint/types.py | 8 ++++++++ tests/test_contracts.py | 41 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561c606..a85f870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `product_id` is required: one signing key covers every product in an organization, so a verifier that skips the product check would let a license for one product unlock another. - New required dependency: `cryptography>=41`, for Ed25519. - CI now runs every test under `tests/`, including the shared offline-license test vectors. +- Add `delete_key` (`DELETE /key`), which permanently deletes a license key. Use `block_key` to stop a key reversibly. ## 2.6.0 - 2026-10-26 diff --git a/README.md b/README.md index abebbdf..5037b20 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,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. | +| `delete_key` | Permanently deletes a license key. | | `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. | diff --git a/keymint/__init__.py b/keymint/__init__.py index 10db559..7fdbfa5 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -31,7 +31,8 @@ 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, extra_headers: dict | 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, delete_body: dict | None = None): + # DELETE sends a JSON body only when delete_body is given (delete_key). url = f'{self.base_url}{endpoint}' headers = self.headers.copy() if idempotency_key: @@ -49,7 +50,10 @@ def _handle_request(self, method: str, endpoint: str, params: dict | None = None elif method.upper() == 'PATCH': response = requests.patch(url, json=params, params=query_params, headers=headers) elif method.upper() == 'DELETE': - response = requests.delete(url, params=query_params, headers=headers) + if delete_body is not None: + response = requests.delete(url, json=delete_body, params=query_params, headers=headers) + else: + response = requests.delete(url, params=query_params, headers=headers) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -170,6 +174,16 @@ def unblock_key(self, params: UnblockKeyParams, idempotency_key: str | None = No """ return self._handle_request('POST', '/key/unblock', params, idempotency_key=idempotency_key) + def delete_key(self, params: DeleteKeyParams, idempotency_key: str | None = None) -> DeleteKeyResponse: + """ + Permanently deletes a license key. Use block_key to stop a key reversibly. + :param params: Parameters for deleting the key. + :param idempotency_key: Optional unique identifier to ensure request idempotency. + :returns: The deletion confirmation. + """ + body = {'productId': params['productId'], 'licenseKey': params['licenseKey']} + return self._handle_request('DELETE', '/key', idempotency_key=idempotency_key, delete_body=body) + def update_key(self, params: 'UpdateKeyParams', idempotency_key: str | None = None) -> 'UpdateKeyResponse': """ Updates an existing license key. diff --git a/keymint/types.py b/keymint/types.py index f3d0e9d..b5d0335 100644 --- a/keymint/types.py +++ b/keymint/types.py @@ -123,6 +123,14 @@ class UnblockKeyResponse(TypedDict): message: str code: int +class DeleteKeyParams(TypedDict): + productId: str + licenseKey: str + +class DeleteKeyResponse(TypedDict): + message: str + code: int + # Customer Management Types class CreateCustomerParams(TypedDict): diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 2b6275d..e1c5330 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -83,3 +83,44 @@ def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post assert mock_post.call_args.args[0].endswith("/key/checkout") assert result["expirationDate"] == expiration_date assert result["expiresAt"] == "2026-10-26T12:05:00.000Z" + + +@patch("keymint.requests.delete") +def test_delete_key_sends_json_body_not_url(mock_delete): + mock_delete.return_value = response({"message": "Key deleted", "code": 0}) + + result = KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "secret/license+key"}) + + args, kwargs = mock_delete.call_args + url = args[0] + assert url == "https://api.keymint.dev/key" + assert kwargs["json"] == {"productId": "product_123", "licenseKey": "secret/license+key"} + assert kwargs["params"] is None + assert "secret" not in url and "license" not in url + assert "x-license-key" not in kwargs["headers"] + assert kwargs["headers"]["Authorization"] == "Bearer admin_test" + assert kwargs["headers"]["Content-Type"] == "application/json" + assert result == {"message": "Key deleted", "code": 0} + + +@patch("keymint.requests.delete") +def test_delete_key_raises_on_404(mock_delete): + mock_delete.return_value = response({"message": "Invalid license key", "code": 1}, 404) + + with pytest.raises(KeyMintApiError, match="Invalid license key") as excinfo: + KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "license_123"}) + + assert excinfo.value.status == 404 + assert excinfo.value.code == 1 + + +@patch("keymint.requests.delete") +def test_delete_customer_still_uses_query_params_and_no_body(mock_delete): + mock_delete.return_value = response({"action": "deleteCustomer", "status": True, "code": 0}) + + KeyMint("admin_test").delete_customer({"customerId": "cust_123"}) + + args, kwargs = mock_delete.call_args + assert args[0] == "https://api.keymint.dev/customer/by-id" + assert kwargs["params"] == {"customerId": "cust_123"} + assert set(kwargs) == {"params", "headers"} From 30373f426a7d5f49e5f40e86b30b60acb28f3c20 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 03:09:45 +0000 Subject: [PATCH 2/2] docs: delete_customer keeps the customer's license keys --- README.md | 2 +- keymint/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5037b20..d5b5803 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `get_customer_with_keys`| Gets a customer along with their license keys. | | `update_customer` | Updates customer information. | | `toggle_customer_status`| Toggles customer active status. | -| `delete_customer` | Permanently deletes a customer and their keys. | +| `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. | ### Webhook Verification diff --git a/keymint/__init__.py b/keymint/__init__.py index 7fdbfa5..386fce4 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -243,7 +243,7 @@ def update_customer(self, params: UpdateCustomerParams, idempotency_key: str | N def delete_customer(self, params: DeleteCustomerParams, idempotency_key: str | None = None) -> DeleteCustomerResponse: """ - Permanently deletes a customer and all associated license keys. + Deletes a customer. Their license keys are kept; use delete_key to remove them. :param params: Parameters containing the customer ID. :param idempotency_key: Optional unique identifier to ensure request idempotency. :returns: The deletion confirmation.