authlix 0.3.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.
authlix-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: authlix
3
+ Version: 0.3.0
4
+ Summary: Client and manager helpers for Authlix
5
+ Author-email: Authlix <me@showdown.boo>
6
+ Project-URL: Homepage, https://authlix.io/
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+
11
+ # Authlix Python SDK
12
+
13
+ > Client and manager helpers for integrating public validation flows and project operations with the Authlix API.
14
+
15
+ **License client** – validates license keys, collects environment metadata and lets end-users update `client_editable` fields.
16
+ **Manager client** – handles authentication plus CRUD operations for projects, metadata schemas and licenses.
17
+ **Environment helpers** – gather HWID, local/public IPs and other contextual information in one call.
18
+
19
+ The SDK targets Python 3.9+ and is published to PyPI as `authlix`.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install authlix
25
+ ```
26
+
27
+
28
+
29
+ ## Quick start
30
+
31
+ ### Validate a license key
32
+
33
+ ```python
34
+ from authlix import LicenseClient
35
+
36
+ client = LicenseClient(
37
+ base_url="https://authlix.io",
38
+ project_id=42,
39
+ )
40
+
41
+ # Automatically collects HWID, LAN/public IP, etc.
42
+ result = client.validate_with_environment("CL-XXXX-XXXX")
43
+ print(result)
44
+ ```
45
+
46
+ To control the payload manually, call `validate_license` and pass `hwid`, `ip`, and `metadata` yourself. The metadata must only contain fields that are marked `client_editable` in your project schema.
47
+
48
+ ### Manage projects and licenses
49
+
50
+ ```python
51
+ from authlix import ManagerClient
52
+
53
+ manager = ManagerClient(base_url="https://authlix.io")
54
+ manager.authenticate("automation_bot", api_key="sk_live_XXXXXXXXXXXXXXXX")
55
+
56
+ # Create project and create license
57
+ project = manager.create_project(
58
+ name="My Awesome App",
59
+ description="Tooling pour la communauté",
60
+ )
61
+ license_data = manager.create_license(
62
+ project_id=project["id"],
63
+ days_valid=30,
64
+ metadata={"plan": "pro"},
65
+ )
66
+
67
+ # Revoke a license
68
+ manager.update_license(
69
+ license_id=license_data["id"],
70
+ is_active=False,
71
+ metadata={"plan": "revoked", "notes": "Chargeback"},
72
+ )
73
+
74
+ # Update a license
75
+ manager.update_license(
76
+ license_id=license_data["id"],
77
+ metadata={"plan": "enterprise", "notes": "Upgraded"},
78
+ )
79
+
80
+ # Delegate day-to-day operations to another teammate
81
+ manager.add_project_manager(project_id=project["id"], username="support_team")
82
+ ```
83
+
84
+ More helpers are exposed for listing projects/licenses, extending expirations, resetting HWIDs, or deleting keys entirely.
85
+
86
+ ### Look up a license by key
87
+
88
+ For efficiency, use `get_license_by_key` when you only need to look up a single license instead of fetching all licenses:
89
+
90
+ ```python
91
+ try:
92
+ license_data = manager.get_license_by_key(
93
+ project_id=42,
94
+ license_key="CL-XXXX-XXXX"
95
+ )
96
+ print(f"License ID: {license_data['id']}, Active: {license_data['is_active']}")
97
+ except NotFoundError:
98
+ print("License key not found in this project")
99
+ except ForbiddenError:
100
+ print("Insufficient permissions for this project")
101
+ ```
102
+
103
+ The key is automatically normalized to uppercase to avoid case mismatches. This method is ideal for dashboards and support tools that need to quickly retrieve license details by key.
104
+
105
+ ### API key management
106
+
107
+ Automations or SDKs can provision scoped API keys and rotate them without touching end-user credentials.
108
+
109
+ ```python
110
+ manager = ManagerClient(base_url="https://authlix.io")
111
+ manager.authenticate("admin", password="password")
112
+
113
+ # Create a key limited to a project (secret is returned once)
114
+ api_key = manager.create_api_key(
115
+ label="CI Runner",
116
+ project_ids=[project["id"]],
117
+ allow_all_projects=False,
118
+ )
119
+
120
+ # Update its scopes or friendly label
121
+ manager.update_api_key(
122
+ api_key_id=api_key["id"],
123
+ project_ids=[project["id"], 7],
124
+ )
125
+
126
+ # Dashboard helper: only show keys relevant to a project
127
+ manager.list_project_api_keys(project_id=project["id"])
128
+
129
+ # Cleanup when the automation is retired
130
+ manager.delete_api_key(api_key_id=api_key["id"])
131
+ ```
132
+
133
+ Use `manager.list_api_keys()` to render an operator-wide view (active + revoked keys).
134
+
135
+ ## Client-editable metadata workflow
136
+
137
+ Certain metadata fields can be safely mutated by the end-user thanks to the `client_editable` flag defined in the project schema.
138
+
139
+ 1. **Manager** defines the schema:
140
+
141
+ ```python
142
+ from authlix import ManagerClient
143
+
144
+ manager = ManagerClient(base_url="https://authlix.io")
145
+ manager.authenticate("admin", "password")
146
+
147
+ manager.update_metadata_schema(
148
+ project_id=42,
149
+ fields=[
150
+ {"name": "notes", "type": "string", "client_editable": True},
151
+ {"name": "plan", "type": "string", "client_editable": False},
152
+ ],
153
+ )
154
+ ```
155
+
156
+ 2. **Client** updates the editable fields:
157
+
158
+ ```python
159
+ from authlix import LicenseClient
160
+
161
+ client = LicenseClient(base_url="https://authlix.io", project_id=42)
162
+
163
+ client.update_client_metadata(
164
+ key="CL-XXXX-XXXX",
165
+ metadata={"notes": "Nouvelle machine"},
166
+ )
167
+ ```
168
+
169
+ `update_client_metadata` validates locally that `project_id` is an `int` and the `metadata` dictionary is non-empty before calling `POST /api/client_metadata`. Server responses bubble up any JSON `msg` errors through `ApiError` for easier debugging (e.g., non editable field, inactive key, etc.).
170
+
171
+ ## Environment helpers
172
+
173
+ ```python
174
+ from authlix import collect_environment_metadata
175
+
176
+ print(collect_environment_metadata())
177
+ ```
178
+
179
+ The helper returns HWID, hostname, LAN IP, optional public IP, and other useful attributes. Use it to enrich `validate_license` calls or to store telemetry on validated machines.
180
+
181
+ ## Error handling
182
+
183
+ The SDK provides specific exception classes for common HTTP errors:
184
+
185
+ - `BadRequestError` – raised for HTTP 400 (Bad Request, e.g., missing or invalid parameters)
186
+ - `ForbiddenError` – raised for HTTP 403 (Forbidden, e.g., insufficient project scope)
187
+ - `NotFoundError` – raised for HTTP 404 (Not Found, e.g., unknown license key)
188
+ - `ApiError(status_code, message, payload)` – generic error for other HTTP ≥ 400 responses
189
+ - `AuthenticationError` – surfaces authentication/authorization issues in manager flows
190
+ - `AuthlixError` – base class for all SDK exceptions for easy broad exception handling
191
+
192
+ All error classes include a `status_code` and `payload` for detailed debugging:
193
+
194
+ ```python
195
+ from authlix import ApiError, BadRequestError, ForbiddenError, NotFoundError
196
+
197
+ try:
198
+ license_data = manager.get_license_by_key(project_id=42, license_key="CL-XXXX")
199
+ except BadRequestError as exc:
200
+ print(f"Invalid request: {exc}")
201
+ except ForbiddenError as exc:
202
+ print(f"Access denied to project: {exc}")
203
+ except NotFoundError as exc:
204
+ print(f"License not found: {exc}")
205
+ except ApiError as exc:
206
+ print(f"API error {exc.status_code}: {exc}")
207
+ print(exc.payload)
208
+ ```
209
+
210
+ ## Local development
211
+
212
+ ```bash
213
+ python -m venv .venv
214
+ .venv\Scripts\activate
215
+ pip install -e .
216
+ python -m build
217
+ ```
218
+
219
+ Additional helpers:
220
+
221
+ - `list_licenses(project_id)` – list all licenses (keys + live metadata and usage counters) for a project (prefer `get_license_by_key` for single lookups)
222
+ - `get_license_by_key(project_id, license_key)` – efficiently retrieve a single license by its key (automatically normalized to uppercase)
223
+ - `create_license(project_id, days_valid=None, metadata=None)` – generate a new license key
224
+ - `update_license(license_id, is_active=None, expires_at=None, reset_hwid=False, metadata=None)` – ban/disable, move expiration, reset HWID, or overwrite metadata
225
+ - `extend_license(project_id, license_id, days)` – convenience method to push expiration forward
226
+ - `delete_license(license_id)` – hard-delete a key
227
+ - `add_project_manager(project_id, username)` – delegate back-office access to teammates
228
+ - `create_api_key(label, project_ids=None, allow_all_projects=False)` – mint scoped API keys from the dashboard
229
+ - `update_api_key(api_key_id, ...)`/`delete_api_key(api_key_id)` – rotate or revoke keys instantly
230
+ - `list_api_keys()`/`list_project_api_keys(project_id)` – audit keys globally or per project
231
+
232
+ ## Environment helpers
233
+
234
+ ```python
235
+ from authlix import collect_environment_metadata
236
+
237
+ print(collect_environment_metadata())
238
+ ```
239
+
240
+ Returns HWID, hostname, LAN IP, and (if available) public IP.
@@ -0,0 +1,230 @@
1
+ # Authlix Python SDK
2
+
3
+ > Client and manager helpers for integrating public validation flows and project operations with the Authlix API.
4
+
5
+ **License client** – validates license keys, collects environment metadata and lets end-users update `client_editable` fields.
6
+ **Manager client** – handles authentication plus CRUD operations for projects, metadata schemas and licenses.
7
+ **Environment helpers** – gather HWID, local/public IPs and other contextual information in one call.
8
+
9
+ The SDK targets Python 3.9+ and is published to PyPI as `authlix`.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install authlix
15
+ ```
16
+
17
+
18
+
19
+ ## Quick start
20
+
21
+ ### Validate a license key
22
+
23
+ ```python
24
+ from authlix import LicenseClient
25
+
26
+ client = LicenseClient(
27
+ base_url="https://authlix.io",
28
+ project_id=42,
29
+ )
30
+
31
+ # Automatically collects HWID, LAN/public IP, etc.
32
+ result = client.validate_with_environment("CL-XXXX-XXXX")
33
+ print(result)
34
+ ```
35
+
36
+ To control the payload manually, call `validate_license` and pass `hwid`, `ip`, and `metadata` yourself. The metadata must only contain fields that are marked `client_editable` in your project schema.
37
+
38
+ ### Manage projects and licenses
39
+
40
+ ```python
41
+ from authlix import ManagerClient
42
+
43
+ manager = ManagerClient(base_url="https://authlix.io")
44
+ manager.authenticate("automation_bot", api_key="sk_live_XXXXXXXXXXXXXXXX")
45
+
46
+ # Create project and create license
47
+ project = manager.create_project(
48
+ name="My Awesome App",
49
+ description="Tooling pour la communauté",
50
+ )
51
+ license_data = manager.create_license(
52
+ project_id=project["id"],
53
+ days_valid=30,
54
+ metadata={"plan": "pro"},
55
+ )
56
+
57
+ # Revoke a license
58
+ manager.update_license(
59
+ license_id=license_data["id"],
60
+ is_active=False,
61
+ metadata={"plan": "revoked", "notes": "Chargeback"},
62
+ )
63
+
64
+ # Update a license
65
+ manager.update_license(
66
+ license_id=license_data["id"],
67
+ metadata={"plan": "enterprise", "notes": "Upgraded"},
68
+ )
69
+
70
+ # Delegate day-to-day operations to another teammate
71
+ manager.add_project_manager(project_id=project["id"], username="support_team")
72
+ ```
73
+
74
+ More helpers are exposed for listing projects/licenses, extending expirations, resetting HWIDs, or deleting keys entirely.
75
+
76
+ ### Look up a license by key
77
+
78
+ For efficiency, use `get_license_by_key` when you only need to look up a single license instead of fetching all licenses:
79
+
80
+ ```python
81
+ try:
82
+ license_data = manager.get_license_by_key(
83
+ project_id=42,
84
+ license_key="CL-XXXX-XXXX"
85
+ )
86
+ print(f"License ID: {license_data['id']}, Active: {license_data['is_active']}")
87
+ except NotFoundError:
88
+ print("License key not found in this project")
89
+ except ForbiddenError:
90
+ print("Insufficient permissions for this project")
91
+ ```
92
+
93
+ The key is automatically normalized to uppercase to avoid case mismatches. This method is ideal for dashboards and support tools that need to quickly retrieve license details by key.
94
+
95
+ ### API key management
96
+
97
+ Automations or SDKs can provision scoped API keys and rotate them without touching end-user credentials.
98
+
99
+ ```python
100
+ manager = ManagerClient(base_url="https://authlix.io")
101
+ manager.authenticate("admin", password="password")
102
+
103
+ # Create a key limited to a project (secret is returned once)
104
+ api_key = manager.create_api_key(
105
+ label="CI Runner",
106
+ project_ids=[project["id"]],
107
+ allow_all_projects=False,
108
+ )
109
+
110
+ # Update its scopes or friendly label
111
+ manager.update_api_key(
112
+ api_key_id=api_key["id"],
113
+ project_ids=[project["id"], 7],
114
+ )
115
+
116
+ # Dashboard helper: only show keys relevant to a project
117
+ manager.list_project_api_keys(project_id=project["id"])
118
+
119
+ # Cleanup when the automation is retired
120
+ manager.delete_api_key(api_key_id=api_key["id"])
121
+ ```
122
+
123
+ Use `manager.list_api_keys()` to render an operator-wide view (active + revoked keys).
124
+
125
+ ## Client-editable metadata workflow
126
+
127
+ Certain metadata fields can be safely mutated by the end-user thanks to the `client_editable` flag defined in the project schema.
128
+
129
+ 1. **Manager** defines the schema:
130
+
131
+ ```python
132
+ from authlix import ManagerClient
133
+
134
+ manager = ManagerClient(base_url="https://authlix.io")
135
+ manager.authenticate("admin", "password")
136
+
137
+ manager.update_metadata_schema(
138
+ project_id=42,
139
+ fields=[
140
+ {"name": "notes", "type": "string", "client_editable": True},
141
+ {"name": "plan", "type": "string", "client_editable": False},
142
+ ],
143
+ )
144
+ ```
145
+
146
+ 2. **Client** updates the editable fields:
147
+
148
+ ```python
149
+ from authlix import LicenseClient
150
+
151
+ client = LicenseClient(base_url="https://authlix.io", project_id=42)
152
+
153
+ client.update_client_metadata(
154
+ key="CL-XXXX-XXXX",
155
+ metadata={"notes": "Nouvelle machine"},
156
+ )
157
+ ```
158
+
159
+ `update_client_metadata` validates locally that `project_id` is an `int` and the `metadata` dictionary is non-empty before calling `POST /api/client_metadata`. Server responses bubble up any JSON `msg` errors through `ApiError` for easier debugging (e.g., non editable field, inactive key, etc.).
160
+
161
+ ## Environment helpers
162
+
163
+ ```python
164
+ from authlix import collect_environment_metadata
165
+
166
+ print(collect_environment_metadata())
167
+ ```
168
+
169
+ The helper returns HWID, hostname, LAN IP, optional public IP, and other useful attributes. Use it to enrich `validate_license` calls or to store telemetry on validated machines.
170
+
171
+ ## Error handling
172
+
173
+ The SDK provides specific exception classes for common HTTP errors:
174
+
175
+ - `BadRequestError` – raised for HTTP 400 (Bad Request, e.g., missing or invalid parameters)
176
+ - `ForbiddenError` – raised for HTTP 403 (Forbidden, e.g., insufficient project scope)
177
+ - `NotFoundError` – raised for HTTP 404 (Not Found, e.g., unknown license key)
178
+ - `ApiError(status_code, message, payload)` – generic error for other HTTP ≥ 400 responses
179
+ - `AuthenticationError` – surfaces authentication/authorization issues in manager flows
180
+ - `AuthlixError` – base class for all SDK exceptions for easy broad exception handling
181
+
182
+ All error classes include a `status_code` and `payload` for detailed debugging:
183
+
184
+ ```python
185
+ from authlix import ApiError, BadRequestError, ForbiddenError, NotFoundError
186
+
187
+ try:
188
+ license_data = manager.get_license_by_key(project_id=42, license_key="CL-XXXX")
189
+ except BadRequestError as exc:
190
+ print(f"Invalid request: {exc}")
191
+ except ForbiddenError as exc:
192
+ print(f"Access denied to project: {exc}")
193
+ except NotFoundError as exc:
194
+ print(f"License not found: {exc}")
195
+ except ApiError as exc:
196
+ print(f"API error {exc.status_code}: {exc}")
197
+ print(exc.payload)
198
+ ```
199
+
200
+ ## Local development
201
+
202
+ ```bash
203
+ python -m venv .venv
204
+ .venv\Scripts\activate
205
+ pip install -e .
206
+ python -m build
207
+ ```
208
+
209
+ Additional helpers:
210
+
211
+ - `list_licenses(project_id)` – list all licenses (keys + live metadata and usage counters) for a project (prefer `get_license_by_key` for single lookups)
212
+ - `get_license_by_key(project_id, license_key)` – efficiently retrieve a single license by its key (automatically normalized to uppercase)
213
+ - `create_license(project_id, days_valid=None, metadata=None)` – generate a new license key
214
+ - `update_license(license_id, is_active=None, expires_at=None, reset_hwid=False, metadata=None)` – ban/disable, move expiration, reset HWID, or overwrite metadata
215
+ - `extend_license(project_id, license_id, days)` – convenience method to push expiration forward
216
+ - `delete_license(license_id)` – hard-delete a key
217
+ - `add_project_manager(project_id, username)` – delegate back-office access to teammates
218
+ - `create_api_key(label, project_ids=None, allow_all_projects=False)` – mint scoped API keys from the dashboard
219
+ - `update_api_key(api_key_id, ...)`/`delete_api_key(api_key_id)` – rotate or revoke keys instantly
220
+ - `list_api_keys()`/`list_project_api_keys(project_id)` – audit keys globally or per project
221
+
222
+ ## Environment helpers
223
+
224
+ ```python
225
+ from authlix import collect_environment_metadata
226
+
227
+ print(collect_environment_metadata())
228
+ ```
229
+
230
+ Returns HWID, hostname, LAN IP, and (if available) public IP.
@@ -0,0 +1,39 @@
1
+ """Official Authlix helper library.
2
+
3
+ This package exposes two primary interfaces:
4
+
5
+ - :class:`authlix.client.LicenseClient` for end-users validating licenses
6
+ - :class:`authlix.manager.ManagerClient` for operators managing projects
7
+
8
+ Utility helpers live in :mod:`authlix.environment` for gathering HWID/IP metadata.
9
+ """
10
+
11
+ from .client import LicenseClient
12
+ from .manager import ManagerClient
13
+ from .environment import (
14
+ get_machine_fingerprint,
15
+ get_public_ip,
16
+ collect_environment_metadata,
17
+ )
18
+ from .exceptions import (
19
+ AuthlixError,
20
+ AuthenticationError,
21
+ ApiError,
22
+ BadRequestError,
23
+ ForbiddenError,
24
+ NotFoundError,
25
+ )
26
+
27
+ __all__ = [
28
+ "LicenseClient",
29
+ "ManagerClient",
30
+ "get_machine_fingerprint",
31
+ "get_public_ip",
32
+ "collect_environment_metadata",
33
+ "AuthlixError",
34
+ "AuthenticationError",
35
+ "ApiError",
36
+ "BadRequestError",
37
+ "ForbiddenError",
38
+ "NotFoundError",
39
+ ]
@@ -0,0 +1,142 @@
1
+ """Client-facing helpers for validating Authlix keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ import requests
8
+
9
+ from .environment import collect_environment_metadata, get_machine_fingerprint, get_public_ip
10
+ from .exceptions import ApiError
11
+
12
+
13
+ class LicenseClient:
14
+ """Simple wrapper for the public validation endpoint.
15
+
16
+ Parameters
17
+ ----------
18
+ base_url:
19
+ Base URL of the CyberLicensing API (e.g. ``"https://licensing.example.com"``).
20
+ project_id:
21
+ Owning project id, required by ``POST /api/validate_license`` to scope
22
+ licenses per product.
23
+ timeout:
24
+ Optional HTTP timeout in seconds.
25
+ """
26
+
27
+ def __init__(self, base_url: str, project_id: int, timeout: float = 5.0):
28
+ self.base_url = base_url.rstrip('/')
29
+ self.project_id = project_id
30
+ self.timeout = timeout
31
+
32
+ def _handle_response(self, response: requests.Response) -> Dict:
33
+ if response.status_code >= 400:
34
+ payload: Optional[Dict[str, Any]] = None
35
+ if response.content:
36
+ try:
37
+ payload = response.json()
38
+ except ValueError:
39
+ payload = None
40
+ message = (
41
+ payload.get("msg")
42
+ if isinstance(payload, dict) and payload.get("msg")
43
+ else response.text
44
+ )
45
+ raise ApiError(response.status_code, message, payload)
46
+ if response.content:
47
+ try:
48
+ return response.json()
49
+ except ValueError as exc:
50
+ raise ApiError(response.status_code, f"Invalid JSON response: {exc}", None)
51
+ return {}
52
+
53
+ def validate_license(
54
+ self,
55
+ key: str,
56
+ hwid: Optional[str] = None,
57
+ ip: Optional[str] = None,
58
+ metadata: Optional[Dict[str, Any]] = None,
59
+ ) -> Dict:
60
+ """Validate a license key against the public endpoint.
61
+
62
+ The request payload matches the backend contract::
63
+
64
+ {
65
+ "project_id": 1,
66
+ "key": "XXXX-XXXX-XXXX-XXXX",
67
+ "hwid": "unique-hardware-id"
68
+ }
69
+
70
+ ``metadata`` can be supplied to share additional key-value pairs, but it
71
+ must only contain fields flagged as ``client_editable`` in the project
72
+ metadata schema configured by the project manager.
73
+ """
74
+
75
+ payload: Dict[str, Any] = {
76
+ "project_id": self.project_id,
77
+ "key": key,
78
+ }
79
+ if hwid:
80
+ payload["hwid"] = hwid
81
+ if ip:
82
+ payload["ip"] = ip
83
+ if metadata:
84
+ payload["metadata"] = metadata
85
+
86
+ response = requests.post(
87
+ f"{self.base_url}/api/validate_license",
88
+ json=payload,
89
+ timeout=self.timeout,
90
+ )
91
+ return self._handle_response(response)
92
+
93
+ def validate_with_environment(self, key: str, extra_metadata: Optional[Dict[str, Any]] = None) -> Dict:
94
+ """Validate a key while auto-populating the machine fingerprint as HWID.
95
+
96
+ The public IP is captured automatically server-side, no need to send it.
97
+ Only extra_metadata with client_editable fields can be provided.
98
+ """
99
+
100
+ hwid = get_machine_fingerprint()
101
+
102
+ return self.validate_license(
103
+ key=key,
104
+ hwid=hwid,
105
+ metadata=extra_metadata if extra_metadata else None,
106
+ )
107
+
108
+ def update_client_metadata(self, key: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
109
+ """Update client-editable metadata fields for a specific key.
110
+
111
+ Parameters
112
+ ----------
113
+ key:
114
+ License key to mutate.
115
+ metadata:
116
+ Non-empty dictionary of ``client_editable`` fields to update.
117
+ """
118
+
119
+ if not isinstance(self.project_id, int):
120
+ raise ValueError("project_id must be an integer")
121
+ if not isinstance(metadata, dict) or not metadata:
122
+ raise ValueError("metadata must be a non-empty dictionary")
123
+
124
+ payload = {
125
+ "project_id": self.project_id,
126
+ "key": key,
127
+ "metadata": metadata,
128
+ }
129
+ response = requests.post(
130
+ f"{self.base_url}/api/client_metadata",
131
+ json=payload,
132
+ timeout=self.timeout,
133
+ )
134
+ return self._handle_response(response)
135
+
136
+ @staticmethod
137
+ def get_default_hwid() -> str:
138
+ return get_machine_fingerprint()
139
+
140
+ @staticmethod
141
+ def get_default_ip() -> Optional[str]:
142
+ return get_public_ip()
@@ -0,0 +1,53 @@
1
+ """Environment helpers for Authlix SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import platform
7
+ import socket
8
+ import uuid
9
+ from typing import Dict, Optional
10
+
11
+ import requests
12
+
13
+ PUBLIC_IP_ENDPOINT = "https://api.ipify.org?format=json"
14
+
15
+
16
+ def get_public_ip(timeout: float = 3.0) -> Optional[str]:
17
+ """Return the machine's public IP using a lightweight HTTPS call."""
18
+
19
+ try:
20
+ response = requests.get(PUBLIC_IP_ENDPOINT, timeout=timeout)
21
+ response.raise_for_status()
22
+ return response.json().get("ip")
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def get_machine_fingerprint() -> str:
28
+ """Compute a stable machine fingerprint using MAC + platform info."""
29
+
30
+ mac = uuid.getnode()
31
+ platform_bits = platform.platform()
32
+ processor = platform.processor()
33
+ base_string = f"{mac}-{platform_bits}-{processor}"
34
+ digest = hashlib.sha256(base_string.encode("utf-8")).hexdigest()
35
+ return digest
36
+
37
+
38
+ def collect_environment_metadata(include_public_ip: bool = False) -> Dict[str, Optional[str]]:
39
+ """Gather useful metadata (HWID only by default).
40
+
41
+ Note: Public IP is captured automatically by the server.
42
+ """
43
+
44
+ metadata: Dict[str, Optional[str]] = {
45
+ "hwid": get_machine_fingerprint(),
46
+ }
47
+
48
+ # Ces champs ne sont inclus que si explicitement demandé
49
+ # et nécessitent une configuration client_editable côté projet
50
+ if include_public_ip:
51
+ metadata["ip"] = get_public_ip()
52
+
53
+ return metadata
@@ -0,0 +1,39 @@
1
+ class AuthlixError(Exception):
2
+ """Base class for SDK-specific exceptions."""
3
+
4
+
5
+ class AuthenticationError(AuthlixError):
6
+ """Raised when authentication fails or a token is missing."""
7
+
8
+
9
+ class ApiError(CyberLicensingError):
10
+ """Raised when the API returns an error response."""
11
+
12
+ def __init__(self, status_code: int, message: str, payload=None):
13
+ super().__init__(message)
14
+ self.status_code = status_code
15
+ self.payload = payload or {}
16
+
17
+ def __str__(self) -> str:
18
+ return f"API Error {self.status_code}: {super().__str__()}"
19
+
20
+
21
+ class BadRequestError(ApiError):
22
+ """Raised when the API returns HTTP 400 (Bad Request)."""
23
+
24
+ def __init__(self, message: str, payload=None):
25
+ super().__init__(400, message, payload)
26
+
27
+
28
+ class ForbiddenError(ApiError):
29
+ """Raised when the API returns HTTP 403 (Forbidden - insufficient project scope)."""
30
+
31
+ def __init__(self, message: str, payload=None):
32
+ super().__init__(403, message, payload)
33
+
34
+
35
+ class NotFoundError(ApiError):
36
+ """Raised when the API returns HTTP 404 (Not Found)."""
37
+
38
+ def __init__(self, message: str, payload=None):
39
+ super().__init__(404, message, payload)
@@ -0,0 +1,248 @@
1
+ """Manager-side client for Authlix operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import requests
9
+
10
+ from .exceptions import ApiError, AuthenticationError, BadRequestError, ForbiddenError, NotFoundError
11
+
12
+
13
+ class ManagerClient:
14
+ def __init__(self, base_url: str, token: Optional[str] = None, timeout: float = 5.0):
15
+ self.base_url = base_url.rstrip('/')
16
+ self.timeout = timeout
17
+ self.token = token
18
+
19
+ # --- Auth helpers ---
20
+ def authenticate(
21
+ self,
22
+ username: str,
23
+ *,
24
+ password: Optional[str] = None,
25
+ api_key: Optional[str] = None,
26
+ ) -> str:
27
+ if not username:
28
+ raise AuthenticationError("username is required")
29
+ if (password is None and api_key is None) or (password is not None and api_key is not None):
30
+ raise AuthenticationError("Provide exactly one of password or api_key")
31
+
32
+ payload: Dict[str, Any] = {"username": username}
33
+ if password is not None:
34
+ payload["password"] = password
35
+ else:
36
+ payload["api_key"] = api_key
37
+
38
+ response = requests.post(
39
+ f"{self.base_url}/api/login",
40
+ json=payload,
41
+ timeout=self.timeout,
42
+ )
43
+ if response.status_code != 200:
44
+ message = "Invalid credentials"
45
+ try:
46
+ data = response.json()
47
+ except ValueError:
48
+ data = None
49
+ if isinstance(data, dict) and data.get("msg"):
50
+ message = data["msg"]
51
+ raise AuthenticationError(message)
52
+ token = response.json().get("access_token")
53
+ if not token:
54
+ raise AuthenticationError("Unable to retrieve access token")
55
+ self.token = token
56
+ return token
57
+
58
+ def _headers(self) -> Dict[str, str]:
59
+ if not self.token:
60
+ raise AuthenticationError("JWT token missing. Call authenticate() first or set token.")
61
+ return {"Authorization": f"Bearer {self.token}"}
62
+
63
+ def _request(self, method: str, path: str, require_auth: bool = True, **kwargs):
64
+ headers = kwargs.pop('headers', {})
65
+ if require_auth:
66
+ headers.update(self._headers())
67
+ response = requests.request(
68
+ method,
69
+ f"{self.base_url}{path}",
70
+ headers=headers,
71
+ timeout=self.timeout,
72
+ **kwargs,
73
+ )
74
+ if response.status_code >= 400:
75
+ payload = None
76
+ if response.content:
77
+ try:
78
+ payload = response.json()
79
+ except ValueError:
80
+ payload = None
81
+ message = (
82
+ payload.get("msg")
83
+ if isinstance(payload, dict) and payload.get("msg")
84
+ else response.text
85
+ )
86
+ # Raise specific exceptions for common error codes
87
+ if response.status_code == 400:
88
+ raise BadRequestError(message, payload)
89
+ elif response.status_code == 403:
90
+ raise ForbiddenError(message, payload)
91
+ elif response.status_code == 404:
92
+ raise NotFoundError(message, payload)
93
+ else:
94
+ raise ApiError(response.status_code, message, payload)
95
+ if response.content:
96
+ return response.json()
97
+ return None
98
+
99
+ # --- Project helpers ---
100
+ def list_projects(self) -> List[Dict]:
101
+ return self._request('GET', '/api/projects')
102
+
103
+ def create_project(self, name: str, description: Optional[str] = None) -> Dict:
104
+ payload: Dict[str, Any] = {"name": name}
105
+ if description is not None:
106
+ payload["description"] = description
107
+ return self._request('POST', '/api/projects', json=payload)
108
+
109
+ def add_project_manager(self, project_id: int, username: str) -> Dict:
110
+ payload = {"username": username}
111
+ return self._request('POST', f'/api/projects/{project_id}/add_manager', json=payload)
112
+
113
+ def update_metadata_schema(self, project_id: int, fields: List[Dict[str, Any]]) -> Dict:
114
+ return self._request('PUT', f'/api/projects/{project_id}/metadata_fields', json={"fields": fields})
115
+
116
+ def list_licenses(self, project_id: int) -> List[Dict]:
117
+ return self._request('GET', f'/api/projects/{project_id}/licenses')
118
+
119
+ def get_license_by_key(self, project_id: int, license_key: str) -> Dict:
120
+ """Retrieve a single license by its key.
121
+
122
+ This is more efficient than fetching all licenses when you only need
123
+ to look up one license by its key. The key is automatically converted
124
+ to uppercase to avoid case mismatches.
125
+
126
+ Parameters
127
+ ----------
128
+ project_id:
129
+ The project ID that owns the license.
130
+ license_key:
131
+ The license key to look up (e.g., "CL-XXXX-XXXX").
132
+
133
+ Returns
134
+ -------
135
+ Dict containing the license data.
136
+
137
+ Raises
138
+ ------
139
+ BadRequestError:
140
+ If the key parameter is missing or invalid (HTTP 400).
141
+ ForbiddenError:
142
+ If the token doesn't have access to this project (HTTP 403).
143
+ NotFoundError:
144
+ If the license key doesn't exist in the project (HTTP 404).
145
+ """
146
+ # Convert key to uppercase to avoid case mismatches
147
+ normalized_key = license_key.upper()
148
+ return self._request('GET', f'/api/projects/{project_id}/licenses/by_key?key={normalized_key}')
149
+
150
+ def create_license(
151
+ self,
152
+ project_id: int,
153
+ days_valid: Optional[int] = None,
154
+ metadata: Optional[Dict[str, Any]] = None,
155
+ ) -> Dict:
156
+ payload: Dict[str, object] = {}
157
+ if days_valid is not None:
158
+ payload['days_valid'] = days_valid
159
+ if metadata is not None:
160
+ payload['metadata'] = metadata
161
+ return self._request('POST', f'/api/projects/{project_id}/licenses', json=payload)
162
+
163
+ def update_license(
164
+ self,
165
+ license_id: int,
166
+ *,
167
+ is_active: Optional[bool] = None,
168
+ expires_at: Optional[str] = None,
169
+ reset_hwid: bool = False,
170
+ metadata: Optional[Dict[str, Any]] = None,
171
+ ) -> Dict:
172
+ payload: Dict[str, object] = {}
173
+ if is_active is not None:
174
+ payload['is_active'] = is_active
175
+ if expires_at is not None:
176
+ payload['expires_at'] = expires_at
177
+ if reset_hwid:
178
+ payload['reset_hwid'] = True
179
+ if metadata is not None:
180
+ payload['metadata'] = metadata
181
+ return self._request('PUT', f'/api/licenses/{license_id}', json=payload)
182
+
183
+ def extend_license(self, project_id: int, license_id: int, days: int) -> Dict:
184
+ """Convenience helper to push expiration forward by *days*."""
185
+
186
+ licenses = self.list_licenses(project_id)
187
+ license_data = next((lic for lic in licenses if lic['id'] == license_id), None)
188
+ if not license_data:
189
+ raise ApiError(404, f"License {license_id} not found in project {project_id}", {})
190
+
191
+ expires_at = license_data.get('expires_at')
192
+ if not expires_at:
193
+ raise ApiError(400, "License is lifetime; set an explicit expiration first", {})
194
+
195
+ try:
196
+ current_exp = datetime.fromisoformat(expires_at)
197
+ except ValueError as exc:
198
+ raise ApiError(500, f"Invalid expiration format: {expires_at}", {"error": str(exc)})
199
+
200
+ new_expiration = (current_exp + timedelta(days=days)).date().isoformat()
201
+ return self.update_license(license_id, expires_at=new_expiration)
202
+
203
+ def delete_license(self, license_id: int) -> Dict:
204
+ return self._request('DELETE', f'/api/licenses/{license_id}')
205
+
206
+ # --- API key helpers ---
207
+ def list_api_keys(self) -> List[Dict[str, Any]]:
208
+ return self._request('GET', '/api/api_keys')
209
+
210
+ def create_api_key(
211
+ self,
212
+ label: str,
213
+ *,
214
+ project_ids: Optional[List[int]] = None,
215
+ allow_all_projects: bool = False,
216
+ ) -> Dict[str, Any]:
217
+ payload: Dict[str, Any] = {
218
+ "label": label,
219
+ "allow_all_projects": allow_all_projects,
220
+ }
221
+ if project_ids is not None:
222
+ payload["project_ids"] = project_ids
223
+ return self._request('POST', '/api/api_keys', json=payload)
224
+
225
+ def update_api_key(
226
+ self,
227
+ api_key_id: int,
228
+ *,
229
+ label: Optional[str] = None,
230
+ project_ids: Optional[List[int]] = None,
231
+ allow_all_projects: Optional[bool] = None,
232
+ ) -> Dict[str, Any]:
233
+ payload: Dict[str, Any] = {}
234
+ if label is not None:
235
+ payload["label"] = label
236
+ if project_ids is not None:
237
+ payload["project_ids"] = project_ids
238
+ if allow_all_projects is not None:
239
+ payload["allow_all_projects"] = allow_all_projects
240
+ if not payload:
241
+ raise ValueError("At least one field must be provided to update an API key")
242
+ return self._request('PUT', f'/api/api_keys/{api_key_id}', json=payload)
243
+
244
+ def delete_api_key(self, api_key_id: int) -> Dict[str, Any]:
245
+ return self._request('DELETE', f'/api/api_keys/{api_key_id}')
246
+
247
+ def list_project_api_keys(self, project_id: int) -> List[Dict[str, Any]]:
248
+ return self._request('GET', f'/api/projects/{project_id}/api_keys')
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: authlix
3
+ Version: 0.3.0
4
+ Summary: Client and manager helpers for Authlix
5
+ Author-email: Authlix <me@showdown.boo>
6
+ Project-URL: Homepage, https://authlix.io/
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+
11
+ # Authlix Python SDK
12
+
13
+ > Client and manager helpers for integrating public validation flows and project operations with the Authlix API.
14
+
15
+ **License client** – validates license keys, collects environment metadata and lets end-users update `client_editable` fields.
16
+ **Manager client** – handles authentication plus CRUD operations for projects, metadata schemas and licenses.
17
+ **Environment helpers** – gather HWID, local/public IPs and other contextual information in one call.
18
+
19
+ The SDK targets Python 3.9+ and is published to PyPI as `authlix`.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install authlix
25
+ ```
26
+
27
+
28
+
29
+ ## Quick start
30
+
31
+ ### Validate a license key
32
+
33
+ ```python
34
+ from authlix import LicenseClient
35
+
36
+ client = LicenseClient(
37
+ base_url="https://authlix.io",
38
+ project_id=42,
39
+ )
40
+
41
+ # Automatically collects HWID, LAN/public IP, etc.
42
+ result = client.validate_with_environment("CL-XXXX-XXXX")
43
+ print(result)
44
+ ```
45
+
46
+ To control the payload manually, call `validate_license` and pass `hwid`, `ip`, and `metadata` yourself. The metadata must only contain fields that are marked `client_editable` in your project schema.
47
+
48
+ ### Manage projects and licenses
49
+
50
+ ```python
51
+ from authlix import ManagerClient
52
+
53
+ manager = ManagerClient(base_url="https://authlix.io")
54
+ manager.authenticate("automation_bot", api_key="sk_live_XXXXXXXXXXXXXXXX")
55
+
56
+ # Create project and create license
57
+ project = manager.create_project(
58
+ name="My Awesome App",
59
+ description="Tooling pour la communauté",
60
+ )
61
+ license_data = manager.create_license(
62
+ project_id=project["id"],
63
+ days_valid=30,
64
+ metadata={"plan": "pro"},
65
+ )
66
+
67
+ # Revoke a license
68
+ manager.update_license(
69
+ license_id=license_data["id"],
70
+ is_active=False,
71
+ metadata={"plan": "revoked", "notes": "Chargeback"},
72
+ )
73
+
74
+ # Update a license
75
+ manager.update_license(
76
+ license_id=license_data["id"],
77
+ metadata={"plan": "enterprise", "notes": "Upgraded"},
78
+ )
79
+
80
+ # Delegate day-to-day operations to another teammate
81
+ manager.add_project_manager(project_id=project["id"], username="support_team")
82
+ ```
83
+
84
+ More helpers are exposed for listing projects/licenses, extending expirations, resetting HWIDs, or deleting keys entirely.
85
+
86
+ ### Look up a license by key
87
+
88
+ For efficiency, use `get_license_by_key` when you only need to look up a single license instead of fetching all licenses:
89
+
90
+ ```python
91
+ try:
92
+ license_data = manager.get_license_by_key(
93
+ project_id=42,
94
+ license_key="CL-XXXX-XXXX"
95
+ )
96
+ print(f"License ID: {license_data['id']}, Active: {license_data['is_active']}")
97
+ except NotFoundError:
98
+ print("License key not found in this project")
99
+ except ForbiddenError:
100
+ print("Insufficient permissions for this project")
101
+ ```
102
+
103
+ The key is automatically normalized to uppercase to avoid case mismatches. This method is ideal for dashboards and support tools that need to quickly retrieve license details by key.
104
+
105
+ ### API key management
106
+
107
+ Automations or SDKs can provision scoped API keys and rotate them without touching end-user credentials.
108
+
109
+ ```python
110
+ manager = ManagerClient(base_url="https://authlix.io")
111
+ manager.authenticate("admin", password="password")
112
+
113
+ # Create a key limited to a project (secret is returned once)
114
+ api_key = manager.create_api_key(
115
+ label="CI Runner",
116
+ project_ids=[project["id"]],
117
+ allow_all_projects=False,
118
+ )
119
+
120
+ # Update its scopes or friendly label
121
+ manager.update_api_key(
122
+ api_key_id=api_key["id"],
123
+ project_ids=[project["id"], 7],
124
+ )
125
+
126
+ # Dashboard helper: only show keys relevant to a project
127
+ manager.list_project_api_keys(project_id=project["id"])
128
+
129
+ # Cleanup when the automation is retired
130
+ manager.delete_api_key(api_key_id=api_key["id"])
131
+ ```
132
+
133
+ Use `manager.list_api_keys()` to render an operator-wide view (active + revoked keys).
134
+
135
+ ## Client-editable metadata workflow
136
+
137
+ Certain metadata fields can be safely mutated by the end-user thanks to the `client_editable` flag defined in the project schema.
138
+
139
+ 1. **Manager** defines the schema:
140
+
141
+ ```python
142
+ from authlix import ManagerClient
143
+
144
+ manager = ManagerClient(base_url="https://authlix.io")
145
+ manager.authenticate("admin", "password")
146
+
147
+ manager.update_metadata_schema(
148
+ project_id=42,
149
+ fields=[
150
+ {"name": "notes", "type": "string", "client_editable": True},
151
+ {"name": "plan", "type": "string", "client_editable": False},
152
+ ],
153
+ )
154
+ ```
155
+
156
+ 2. **Client** updates the editable fields:
157
+
158
+ ```python
159
+ from authlix import LicenseClient
160
+
161
+ client = LicenseClient(base_url="https://authlix.io", project_id=42)
162
+
163
+ client.update_client_metadata(
164
+ key="CL-XXXX-XXXX",
165
+ metadata={"notes": "Nouvelle machine"},
166
+ )
167
+ ```
168
+
169
+ `update_client_metadata` validates locally that `project_id` is an `int` and the `metadata` dictionary is non-empty before calling `POST /api/client_metadata`. Server responses bubble up any JSON `msg` errors through `ApiError` for easier debugging (e.g., non editable field, inactive key, etc.).
170
+
171
+ ## Environment helpers
172
+
173
+ ```python
174
+ from authlix import collect_environment_metadata
175
+
176
+ print(collect_environment_metadata())
177
+ ```
178
+
179
+ The helper returns HWID, hostname, LAN IP, optional public IP, and other useful attributes. Use it to enrich `validate_license` calls or to store telemetry on validated machines.
180
+
181
+ ## Error handling
182
+
183
+ The SDK provides specific exception classes for common HTTP errors:
184
+
185
+ - `BadRequestError` – raised for HTTP 400 (Bad Request, e.g., missing or invalid parameters)
186
+ - `ForbiddenError` – raised for HTTP 403 (Forbidden, e.g., insufficient project scope)
187
+ - `NotFoundError` – raised for HTTP 404 (Not Found, e.g., unknown license key)
188
+ - `ApiError(status_code, message, payload)` – generic error for other HTTP ≥ 400 responses
189
+ - `AuthenticationError` – surfaces authentication/authorization issues in manager flows
190
+ - `AuthlixError` – base class for all SDK exceptions for easy broad exception handling
191
+
192
+ All error classes include a `status_code` and `payload` for detailed debugging:
193
+
194
+ ```python
195
+ from authlix import ApiError, BadRequestError, ForbiddenError, NotFoundError
196
+
197
+ try:
198
+ license_data = manager.get_license_by_key(project_id=42, license_key="CL-XXXX")
199
+ except BadRequestError as exc:
200
+ print(f"Invalid request: {exc}")
201
+ except ForbiddenError as exc:
202
+ print(f"Access denied to project: {exc}")
203
+ except NotFoundError as exc:
204
+ print(f"License not found: {exc}")
205
+ except ApiError as exc:
206
+ print(f"API error {exc.status_code}: {exc}")
207
+ print(exc.payload)
208
+ ```
209
+
210
+ ## Local development
211
+
212
+ ```bash
213
+ python -m venv .venv
214
+ .venv\Scripts\activate
215
+ pip install -e .
216
+ python -m build
217
+ ```
218
+
219
+ Additional helpers:
220
+
221
+ - `list_licenses(project_id)` – list all licenses (keys + live metadata and usage counters) for a project (prefer `get_license_by_key` for single lookups)
222
+ - `get_license_by_key(project_id, license_key)` – efficiently retrieve a single license by its key (automatically normalized to uppercase)
223
+ - `create_license(project_id, days_valid=None, metadata=None)` – generate a new license key
224
+ - `update_license(license_id, is_active=None, expires_at=None, reset_hwid=False, metadata=None)` – ban/disable, move expiration, reset HWID, or overwrite metadata
225
+ - `extend_license(project_id, license_id, days)` – convenience method to push expiration forward
226
+ - `delete_license(license_id)` – hard-delete a key
227
+ - `add_project_manager(project_id, username)` – delegate back-office access to teammates
228
+ - `create_api_key(label, project_ids=None, allow_all_projects=False)` – mint scoped API keys from the dashboard
229
+ - `update_api_key(api_key_id, ...)`/`delete_api_key(api_key_id)` – rotate or revoke keys instantly
230
+ - `list_api_keys()`/`list_project_api_keys(project_id)` – audit keys globally or per project
231
+
232
+ ## Environment helpers
233
+
234
+ ```python
235
+ from authlix import collect_environment_metadata
236
+
237
+ print(collect_environment_metadata())
238
+ ```
239
+
240
+ Returns HWID, hostname, LAN IP, and (if available) public IP.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ authlix/__init__.py
4
+ authlix/client.py
5
+ authlix/environment.py
6
+ authlix/exceptions.py
7
+ authlix/manager.py
8
+ authlix.egg-info/PKG-INFO
9
+ authlix.egg-info/SOURCES.txt
10
+ authlix.egg-info/dependency_links.txt
11
+ authlix.egg-info/requires.txt
12
+ authlix.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ requests>=2.31.0
@@ -0,0 +1 @@
1
+ authlix
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "authlix"
7
+ version = "0.3.0"
8
+ description = "Client and manager helpers for Authlix"
9
+ authors = [
10
+ { name = "Authlix", email = "me@showdown.boo" },
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ dependencies = [
15
+ "requests>=2.31.0",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://authlix.io/"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+