pylicensify 1.0.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.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylicensify
3
+ Version: 1.0.0
4
+ Summary: Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses.
5
+ Author: PyLicense
6
+ License: MIT
7
+ Project-URL: Homepage, https://license.pyobfuscate.com
8
+ Keywords: license,licensing,activation,drm,software-licensing
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.25
15
+ Requires-Dist: cryptography>=41.0
16
+
17
+ # pylicensify
18
+
19
+ Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
20
+ license keys with **tamper-proof, signed responses**. A cracked "fake server"
21
+ can't grant access, because every response is verified against your public key.
22
+
23
+ ```bash
24
+ pip install pylicensify
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ Get your **public key** from the dashboard → *Integration* page (safe to embed
30
+ in your distributed app; the private key never leaves the server).
31
+
32
+ ```python
33
+ from pylicensify import LicenseClient
34
+
35
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
36
+
37
+ result = client.validate("PY-XXXXXXXXXXXXXXXX")
38
+ if result:
39
+ print("Licensed. Entitlements:", result.features)
40
+ if result.feature("pro"):
41
+ enable_pro_features()
42
+ else:
43
+ print("Not licensed:", result.error)
44
+ raise SystemExit(1)
45
+ ```
46
+
47
+ `validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
48
+ replayed responses automatically. It raises `SignatureError` on tampering and
49
+ `NetworkError` if the server is unreachable.
50
+
51
+ ## Feature gating
52
+
53
+ ```python
54
+ result = client.validate(key)
55
+ seats = result.feature("seats", 1)
56
+ if result.feature("pro"):
57
+ ...
58
+ ```
59
+
60
+ ## Offline license files (air-gapped)
61
+
62
+ Issue a signed `.lic` file from a license row in the dashboard, ship it with the
63
+ app, and verify it locally with **zero network calls**:
64
+
65
+ ```python
66
+ from pylicensify import verify_offline_license
67
+
68
+ data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
69
+ print("Valid for:", data.get("customer"), data.get("features"))
70
+ ```
71
+
72
+ Checks signature, expiry, device binding, and OS lock.
73
+
74
+ ## Floating / concurrent licenses
75
+
76
+ For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
77
+ in the background, and releases on exit:
78
+
79
+ ```python
80
+ from pylicensify import LicenseClient, SeatUnavailable
81
+
82
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
83
+ try:
84
+ with client.session("PY-XXXX...") as sess:
85
+ print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
86
+ run_app() # released automatically on exit
87
+ except SeatUnavailable:
88
+ print("All seats are in use — try again later.")
89
+ ```
90
+
91
+ ## Update checks
92
+
93
+ ```python
94
+ info = client.check_update("PY-XXXX...", current_version="1.0.0")
95
+ if info["update_available"]:
96
+ print("New version:", info["version"], info["notes"])
97
+ # info["download_url"] is a license-gated download link
98
+ ```
99
+
100
+ ## Self-hosted / custom domain
101
+
102
+ ```python
103
+ client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
104
+ ```
105
+
106
+ ## Hardware ID
107
+
108
+ By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
109
+ a stronger fingerprint:
110
+
111
+ ```python
112
+ client.validate(key, hwid=my_custom_fingerprint())
113
+ ```
114
+
115
+ ## Errors
116
+
117
+ | Exception | Meaning |
118
+ |---|---|
119
+ | `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
120
+ | `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
121
+ | `NetworkError` | Couldn't reach the license server |
122
+ | `SeatUnavailable` | Floating license has no free seats |
123
+
124
+ All inherit from `LicenseError`.
125
+
126
+ ## Hardening
127
+
128
+ Obfuscate your client so the check itself can't be trivially patched out —
129
+ e.g. with [PyObfuscate](https://pyobfuscate.com).
@@ -0,0 +1,113 @@
1
+ # pylicensify
2
+
3
+ Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
4
+ license keys with **tamper-proof, signed responses**. A cracked "fake server"
5
+ can't grant access, because every response is verified against your public key.
6
+
7
+ ```bash
8
+ pip install pylicensify
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ Get your **public key** from the dashboard → *Integration* page (safe to embed
14
+ in your distributed app; the private key never leaves the server).
15
+
16
+ ```python
17
+ from pylicensify import LicenseClient
18
+
19
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
20
+
21
+ result = client.validate("PY-XXXXXXXXXXXXXXXX")
22
+ if result:
23
+ print("Licensed. Entitlements:", result.features)
24
+ if result.feature("pro"):
25
+ enable_pro_features()
26
+ else:
27
+ print("Not licensed:", result.error)
28
+ raise SystemExit(1)
29
+ ```
30
+
31
+ `validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
32
+ replayed responses automatically. It raises `SignatureError` on tampering and
33
+ `NetworkError` if the server is unreachable.
34
+
35
+ ## Feature gating
36
+
37
+ ```python
38
+ result = client.validate(key)
39
+ seats = result.feature("seats", 1)
40
+ if result.feature("pro"):
41
+ ...
42
+ ```
43
+
44
+ ## Offline license files (air-gapped)
45
+
46
+ Issue a signed `.lic` file from a license row in the dashboard, ship it with the
47
+ app, and verify it locally with **zero network calls**:
48
+
49
+ ```python
50
+ from pylicensify import verify_offline_license
51
+
52
+ data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
53
+ print("Valid for:", data.get("customer"), data.get("features"))
54
+ ```
55
+
56
+ Checks signature, expiry, device binding, and OS lock.
57
+
58
+ ## Floating / concurrent licenses
59
+
60
+ For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
61
+ in the background, and releases on exit:
62
+
63
+ ```python
64
+ from pylicensify import LicenseClient, SeatUnavailable
65
+
66
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
67
+ try:
68
+ with client.session("PY-XXXX...") as sess:
69
+ print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
70
+ run_app() # released automatically on exit
71
+ except SeatUnavailable:
72
+ print("All seats are in use — try again later.")
73
+ ```
74
+
75
+ ## Update checks
76
+
77
+ ```python
78
+ info = client.check_update("PY-XXXX...", current_version="1.0.0")
79
+ if info["update_available"]:
80
+ print("New version:", info["version"], info["notes"])
81
+ # info["download_url"] is a license-gated download link
82
+ ```
83
+
84
+ ## Self-hosted / custom domain
85
+
86
+ ```python
87
+ client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
88
+ ```
89
+
90
+ ## Hardware ID
91
+
92
+ By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
93
+ a stronger fingerprint:
94
+
95
+ ```python
96
+ client.validate(key, hwid=my_custom_fingerprint())
97
+ ```
98
+
99
+ ## Errors
100
+
101
+ | Exception | Meaning |
102
+ |---|---|
103
+ | `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
104
+ | `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
105
+ | `NetworkError` | Couldn't reach the license server |
106
+ | `SeatUnavailable` | Floating license has no free seats |
107
+
108
+ All inherit from `LicenseError`.
109
+
110
+ ## Hardening
111
+
112
+ Obfuscate your client so the check itself can't be trivially patched out —
113
+ e.g. with [PyObfuscate](https://pyobfuscate.com).
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pylicensify"
7
+ version = "1.0.0"
8
+ description = "Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "PyLicense" }]
13
+ keywords = ["license", "licensing", "activation", "drm", "software-licensing"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "requests>=2.25",
21
+ "cryptography>=41.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://license.pyobfuscate.com"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ """PyLicense client SDK — verify software license keys with tamper-proof,
2
+ signed responses.
3
+
4
+ from pylicensify import LicenseClient
5
+
6
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
7
+ result = client.validate("PY-XXXX...")
8
+ if result:
9
+ print("Licensed!", result.features)
10
+ """
11
+ from .client import LicenseClient, ValidationResult, FloatingSession
12
+ from .offline import verify_offline_license
13
+ from ._hwid import get_hwid
14
+ from .errors import (
15
+ LicenseError,
16
+ NetworkError,
17
+ InvalidLicense,
18
+ SignatureError,
19
+ SeatUnavailable,
20
+ )
21
+
22
+ __version__ = "1.0.0"
23
+ __all__ = [
24
+ "LicenseClient",
25
+ "ValidationResult",
26
+ "FloatingSession",
27
+ "verify_offline_license",
28
+ "get_hwid",
29
+ "LicenseError",
30
+ "NetworkError",
31
+ "InvalidLicense",
32
+ "SignatureError",
33
+ "SeatUnavailable",
34
+ ]
@@ -0,0 +1,8 @@
1
+ """Hardware ID helper. Override by passing your own hwid if you need a
2
+ stronger/OS-specific fingerprint."""
3
+ import uuid
4
+
5
+
6
+ def get_hwid() -> str:
7
+ """A stable per-machine identifier (MAC-derived). Good enough for most apps."""
8
+ return str(uuid.getnode())
@@ -0,0 +1,222 @@
1
+ """Online license validation, floating sessions, and update checks."""
2
+ import base64
3
+ import platform as _plat
4
+ import secrets
5
+ import socket
6
+ import threading
7
+ import time
8
+
9
+ import requests
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
11
+ from cryptography.exceptions import InvalidSignature
12
+
13
+ from ._hwid import get_hwid
14
+ from .errors import NetworkError, InvalidLicense, SignatureError, SeatUnavailable
15
+
16
+ DEFAULT_API = "https://license.pyobfuscate.com"
17
+
18
+
19
+ class ValidationResult:
20
+ """Outcome of :meth:`LicenseClient.validate`."""
21
+
22
+ def __init__(self, valid, product_id=None, expires_at=None, features=None, raw=None, error=None):
23
+ self.valid = valid
24
+ self.product_id = product_id
25
+ self.expires_at = expires_at # ISO string or None (lifetime)
26
+ self.features = features or {} # signed entitlements, e.g. {"pro": True}
27
+ self.error = error # server message when not valid
28
+ self.raw = raw or {}
29
+
30
+ def feature(self, name, default=None):
31
+ return self.features.get(name, default)
32
+
33
+ def __bool__(self):
34
+ return self.valid
35
+
36
+ def __repr__(self):
37
+ return f"<ValidationResult valid={self.valid} product={self.product_id} features={self.features}>"
38
+
39
+
40
+ class LicenseClient:
41
+ """Verifies license keys against a PyLicense server.
42
+
43
+ Args:
44
+ public_key: your account's Ed25519 public key (from the dashboard's
45
+ Integration page). Safe to embed in your distributed app.
46
+ api_url: license server base URL (defaults to the hosted platform).
47
+ timeout: per-request timeout in seconds.
48
+ max_skew: reject signed responses older than this many seconds (replay guard).
49
+ """
50
+
51
+ def __init__(self, public_key, api_url=DEFAULT_API, timeout=5, max_skew=300):
52
+ if not public_key:
53
+ raise ValueError("public_key is required (get it from the dashboard)")
54
+ self._pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key))
55
+ self.api_url = api_url.rstrip("/")
56
+ self.timeout = timeout
57
+ self.max_skew = max_skew
58
+
59
+ # ---- Online validation -------------------------------------------------
60
+ def validate(self, key, hwid=None, device_name=None, raise_on_invalid=False):
61
+ """Validate a key online and verify the signed response.
62
+
63
+ Returns a :class:`ValidationResult`. Set ``raise_on_invalid=True`` to
64
+ raise :class:`InvalidLicense` instead of returning ``valid=False``.
65
+ Always raises :class:`SignatureError` on tamper/replay and
66
+ :class:`NetworkError` if the server is unreachable.
67
+ """
68
+ hwid = hwid or get_hwid()
69
+ nonce = secrets.token_hex(8)
70
+ try:
71
+ r = requests.post(
72
+ f"{self.api_url}/api/v1/validate",
73
+ json={
74
+ "key": key,
75
+ "hwid": hwid,
76
+ "nonce": nonce,
77
+ "device_name": device_name or socket.gethostname(),
78
+ "platform": f"{_plat.system()} {_plat.release()}",
79
+ },
80
+ timeout=self.timeout,
81
+ )
82
+ except requests.RequestException as e:
83
+ raise NetworkError(str(e))
84
+
85
+ data = r.json() if r.content else {}
86
+ if r.status_code != 200 or not data.get("valid"):
87
+ msg = data.get("error", "License is not valid")
88
+ if raise_on_invalid:
89
+ raise InvalidLicense(msg, status=r.status_code)
90
+ return ValidationResult(False, raw=data, error=msg)
91
+
92
+ self._verify_signature(data, hwid, nonce)
93
+ return ValidationResult(
94
+ True,
95
+ product_id=data.get("product_id"),
96
+ expires_at=data.get("expires_at"),
97
+ features=data.get("features"),
98
+ raw=data,
99
+ )
100
+
101
+ def _verify_signature(self, data, hwid, nonce):
102
+ sig = data.get("signature")
103
+ if not sig:
104
+ raise SignatureError("Response was not signed — refusing to trust it")
105
+ message = (
106
+ f"pylicense.v2|{data['product_id']}|{hwid}|"
107
+ f"{data.get('expires_at') or ''}|{nonce}|{data['issued_at']}|"
108
+ f"{data.get('features_b64', '')}"
109
+ )
110
+ try:
111
+ self._pub.verify(base64.b64decode(sig), message.encode())
112
+ except (InvalidSignature, Exception):
113
+ raise SignatureError("Signature verification failed — response may be forged")
114
+ if data.get("nonce") != nonce:
115
+ raise SignatureError("Nonce mismatch — possible replay")
116
+ if abs(time.time() - data["issued_at"]) > self.max_skew:
117
+ raise SignatureError("Stale response — possible replay")
118
+
119
+ # ---- Floating / concurrent sessions ------------------------------------
120
+ def lease(self, key, hwid=None):
121
+ """Reserve a concurrent seat. Returns the lease dict (lease_id, interval, seats)."""
122
+ hwid = hwid or get_hwid()
123
+ try:
124
+ r = requests.post(f"{self.api_url}/api/v1/lease", json={"key": key, "hwid": hwid}, timeout=self.timeout)
125
+ except requests.RequestException as e:
126
+ raise NetworkError(str(e))
127
+ data = r.json() if r.content else {}
128
+ if r.status_code == 409:
129
+ raise SeatUnavailable(data.get("error", "All concurrent sessions are in use"))
130
+ if r.status_code != 200:
131
+ raise InvalidLicense(data.get("error", "Could not acquire a seat"), status=r.status_code)
132
+ return data
133
+
134
+ def heartbeat(self, lease_id):
135
+ """Keep a seat alive. Returns True if still held, False if it was released/expired."""
136
+ try:
137
+ r = requests.post(f"{self.api_url}/api/v1/heartbeat", json={"lease_id": lease_id}, timeout=self.timeout)
138
+ except requests.RequestException:
139
+ return True # transient network blip — assume still held, retry next tick
140
+ return r.status_code == 200
141
+
142
+ def release(self, lease_id):
143
+ """Free a seat (idempotent)."""
144
+ try:
145
+ requests.post(f"{self.api_url}/api/v1/release", json={"lease_id": lease_id}, timeout=self.timeout)
146
+ except requests.RequestException:
147
+ pass
148
+
149
+ def session(self, key, hwid=None):
150
+ """Context manager that checks out a seat, heartbeats in the background,
151
+ and releases on exit::
152
+
153
+ with client.session(key):
154
+ run_app()
155
+ """
156
+ return FloatingSession(self, key, hwid)
157
+
158
+ # ---- Updates -----------------------------------------------------------
159
+ def check_update(self, key, current_version, channel="stable"):
160
+ """Ask whether a newer build exists. Returns the update dict
161
+ (update_available, version, notes, download_url, size)."""
162
+ try:
163
+ r = requests.post(
164
+ f"{self.api_url}/api/v1/update",
165
+ json={"key": key, "current_version": current_version, "channel": channel},
166
+ timeout=self.timeout,
167
+ )
168
+ except requests.RequestException as e:
169
+ raise NetworkError(str(e))
170
+ data = r.json() if r.content else {}
171
+ if r.status_code != 200:
172
+ raise InvalidLicense(data.get("error", "Update check failed"), status=r.status_code)
173
+ return data
174
+
175
+
176
+ class FloatingSession:
177
+ """Holds a concurrent seat and heartbeats until released. Use via
178
+ :meth:`LicenseClient.session`."""
179
+
180
+ def __init__(self, client, key, hwid=None):
181
+ self._client = client
182
+ self._key = key
183
+ self._hwid = hwid
184
+ self.lease_id = None
185
+ self.seats = None
186
+ self._interval = 60
187
+ self._stop = threading.Event()
188
+ self._thread = None
189
+
190
+ def start(self):
191
+ data = self._client.lease(self._key, self._hwid) # raises SeatUnavailable if full
192
+ self.lease_id = data["lease_id"]
193
+ self.seats = data.get("seats")
194
+ self._interval = max(15, int(data.get("interval", 60)))
195
+ self._thread = threading.Thread(target=self._loop, daemon=True)
196
+ self._thread.start()
197
+ return self
198
+
199
+ def _loop(self):
200
+ while not self._stop.wait(self._interval):
201
+ if not self.lease_id:
202
+ return
203
+ if not self._client.heartbeat(self.lease_id):
204
+ self.lease_id = None # server released us; app can react via .active
205
+ return
206
+
207
+ @property
208
+ def active(self):
209
+ return self.lease_id is not None
210
+
211
+ def stop(self):
212
+ self._stop.set()
213
+ if self.lease_id:
214
+ self._client.release(self.lease_id)
215
+ self.lease_id = None
216
+
217
+ def __enter__(self):
218
+ return self.start()
219
+
220
+ def __exit__(self, *exc):
221
+ self.stop()
222
+ return False
@@ -0,0 +1,25 @@
1
+ """Exceptions raised by the PyLicense client."""
2
+
3
+
4
+ class LicenseError(Exception):
5
+ """Base class for all PyLicense errors."""
6
+
7
+
8
+ class NetworkError(LicenseError):
9
+ """The license server could not be reached."""
10
+
11
+
12
+ class InvalidLicense(LicenseError):
13
+ """The server rejected the license (invalid, expired, revoked, blocked...)."""
14
+
15
+ def __init__(self, message, status=None):
16
+ super().__init__(message)
17
+ self.status = status
18
+
19
+
20
+ class SignatureError(LicenseError):
21
+ """Response signature/nonce/timestamp check failed — possibly forged or replayed."""
22
+
23
+
24
+ class SeatUnavailable(LicenseError):
25
+ """A floating license has no free concurrent seats right now."""
@@ -0,0 +1,62 @@
1
+ """Verify a signed offline ``.lic`` file with zero network calls."""
2
+ import base64
3
+ import json
4
+ import time
5
+
6
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
7
+ from cryptography.exceptions import InvalidSignature
8
+
9
+ from ._hwid import get_hwid
10
+ from .errors import SignatureError, InvalidLicense
11
+
12
+
13
+ def _detect_os() -> str:
14
+ import platform as _plat
15
+ s = _plat.system().lower()
16
+ if "win" in s:
17
+ return "windows"
18
+ if "darwin" in s or "mac" in s:
19
+ return "macos"
20
+ if "linux" in s:
21
+ return "linux"
22
+ return ""
23
+
24
+
25
+ def verify_offline_license(public_key: str, token_or_path: str, hwid: str = None) -> dict:
26
+ """Verify an offline license file (or its raw token string).
27
+
28
+ Checks the Ed25519 signature, expiry, device binding, and OS lock — all
29
+ locally. Returns the license payload dict on success.
30
+
31
+ Raises :class:`SignatureError` if tampered, :class:`InvalidLicense` if
32
+ expired / wrong device / wrong OS.
33
+ """
34
+ token = token_or_path
35
+ # If it looks like a path, read the file; otherwise treat it as the token.
36
+ if "." in token_or_path and "\n" not in token_or_path and len(token_or_path) < 4096:
37
+ try:
38
+ with open(token_or_path, "r", encoding="utf-8") as f:
39
+ token = f.read().strip()
40
+ except (OSError, ValueError):
41
+ token = token_or_path
42
+
43
+ try:
44
+ payload_b64, sig_b64 = token.strip().split(".")
45
+ pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key))
46
+ pub.verify(base64.b64decode(sig_b64), payload_b64.encode())
47
+ except (InvalidSignature, ValueError, Exception):
48
+ raise SignatureError("Invalid or tampered license file")
49
+
50
+ data = json.loads(base64.b64decode(payload_b64))
51
+
52
+ if data.get("expires_at") and time.time() > data["expires_at"]:
53
+ raise InvalidLicense("License file has expired")
54
+
55
+ hwid = hwid or get_hwid()
56
+ if data.get("hwid") and data["hwid"] != hwid:
57
+ raise InvalidLicense("License file is not valid for this machine")
58
+
59
+ if data.get("os_lock") and data["os_lock"] != _detect_os():
60
+ raise InvalidLicense("License file is not valid on this operating system")
61
+
62
+ return data
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylicensify
3
+ Version: 1.0.0
4
+ Summary: Client SDK for PyLicense — verify software license keys with tamper-proof, signed responses.
5
+ Author: PyLicense
6
+ License: MIT
7
+ Project-URL: Homepage, https://license.pyobfuscate.com
8
+ Keywords: license,licensing,activation,drm,software-licensing
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.25
15
+ Requires-Dist: cryptography>=41.0
16
+
17
+ # pylicensify
18
+
19
+ Client SDK for [PyLicense](https://license.pyobfuscate.com) — verify software
20
+ license keys with **tamper-proof, signed responses**. A cracked "fake server"
21
+ can't grant access, because every response is verified against your public key.
22
+
23
+ ```bash
24
+ pip install pylicensify
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ Get your **public key** from the dashboard → *Integration* page (safe to embed
30
+ in your distributed app; the private key never leaves the server).
31
+
32
+ ```python
33
+ from pylicensify import LicenseClient
34
+
35
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
36
+
37
+ result = client.validate("PY-XXXXXXXXXXXXXXXX")
38
+ if result:
39
+ print("Licensed. Entitlements:", result.features)
40
+ if result.feature("pro"):
41
+ enable_pro_features()
42
+ else:
43
+ print("Not licensed:", result.error)
44
+ raise SystemExit(1)
45
+ ```
46
+
47
+ `validate()` sends a nonce, verifies the Ed25519 signature, and rejects stale /
48
+ replayed responses automatically. It raises `SignatureError` on tampering and
49
+ `NetworkError` if the server is unreachable.
50
+
51
+ ## Feature gating
52
+
53
+ ```python
54
+ result = client.validate(key)
55
+ seats = result.feature("seats", 1)
56
+ if result.feature("pro"):
57
+ ...
58
+ ```
59
+
60
+ ## Offline license files (air-gapped)
61
+
62
+ Issue a signed `.lic` file from a license row in the dashboard, ship it with the
63
+ app, and verify it locally with **zero network calls**:
64
+
65
+ ```python
66
+ from pylicensify import verify_offline_license
67
+
68
+ data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
69
+ print("Valid for:", data.get("customer"), data.get("features"))
70
+ ```
71
+
72
+ Checks signature, expiry, device binding, and OS lock.
73
+
74
+ ## Floating / concurrent licenses
75
+
76
+ For keys with a concurrent-session limit — the SDK checks out a seat, heartbeats
77
+ in the background, and releases on exit:
78
+
79
+ ```python
80
+ from pylicensify import LicenseClient, SeatUnavailable
81
+
82
+ client = LicenseClient(public_key="YOUR_PUBLIC_KEY")
83
+ try:
84
+ with client.session("PY-XXXX...") as sess:
85
+ print("Seat acquired:", sess.seats) # {'used': 1, 'max': 5}
86
+ run_app() # released automatically on exit
87
+ except SeatUnavailable:
88
+ print("All seats are in use — try again later.")
89
+ ```
90
+
91
+ ## Update checks
92
+
93
+ ```python
94
+ info = client.check_update("PY-XXXX...", current_version="1.0.0")
95
+ if info["update_available"]:
96
+ print("New version:", info["version"], info["notes"])
97
+ # info["download_url"] is a license-gated download link
98
+ ```
99
+
100
+ ## Self-hosted / custom domain
101
+
102
+ ```python
103
+ client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
104
+ ```
105
+
106
+ ## Hardware ID
107
+
108
+ By default the SDK derives a machine id from `uuid.getnode()`. Pass your own for
109
+ a stronger fingerprint:
110
+
111
+ ```python
112
+ client.validate(key, hwid=my_custom_fingerprint())
113
+ ```
114
+
115
+ ## Errors
116
+
117
+ | Exception | Meaning |
118
+ |---|---|
119
+ | `InvalidLicense` | Server rejected the key (invalid/expired/revoked/blocked) |
120
+ | `SignatureError` | Signature/nonce/timestamp failed — forged or replayed |
121
+ | `NetworkError` | Couldn't reach the license server |
122
+ | `SeatUnavailable` | Floating license has no free seats |
123
+
124
+ All inherit from `LicenseError`.
125
+
126
+ ## Hardening
127
+
128
+ Obfuscate your client so the check itself can't be trivially patched out —
129
+ e.g. with [PyObfuscate](https://pyobfuscate.com).
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pylicensify/__init__.py
4
+ src/pylicensify/_hwid.py
5
+ src/pylicensify/client.py
6
+ src/pylicensify/errors.py
7
+ src/pylicensify/offline.py
8
+ src/pylicensify.egg-info/PKG-INFO
9
+ src/pylicensify.egg-info/SOURCES.txt
10
+ src/pylicensify.egg-info/dependency_links.txt
11
+ src/pylicensify.egg-info/requires.txt
12
+ src/pylicensify.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ requests>=2.25
2
+ cryptography>=41.0
@@ -0,0 +1 @@
1
+ pylicensify