licentivo-sdk 0.10.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spf86 and Licentivo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ Licentivo client SDKs
2
+ Copyright (c) 2026 spf86 and Licentivo contributors
3
+ Project: https://github.com/spf86/licentivo-sdk
4
+
5
+ The MIT license applies to the Licentivo client SDK code, examples and
6
+ installation tools. The licensing server and administration application are
7
+ separate software and are not included in this SDK distribution.
8
+
9
+ Bundled third-party source retains its original license:
10
+ - cJSON: see sdk/c/vendor/cJSON-LICENSE (or c/vendor/cJSON-LICENSE).
11
+ - Monocypher and its Ed25519 extension: see
12
+ sdk/c/vendor/Monocypher-LICENCE.md (or c/vendor/Monocypher-LICENCE.md).
13
+ Native packages include these files in the licenses directory.
14
+
15
+ External dependencies such as libcurl, OpenSSL, cryptography, undici and
16
+ BouncyCastle retain their own licenses. Their installation is handled by
17
+ the corresponding package manager or the target system.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: licentivo-sdk
3
+ Version: 0.10.0
4
+ Summary: Licentivo software licensing client
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 spf86 and Licentivo contributors
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/spf86/licentivo-sdk
28
+ Project-URL: Documentation, https://www.licentivo.com/en/docs.html
29
+ Project-URL: Issues, https://github.com/spf86/licentivo-sdk/issues
30
+ Requires-Python: >=3.10
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ License-File: NOTICE
34
+ Requires-Dist: cryptography<49,>=44.0.1
35
+ Dynamic: license-file
36
+
37
+ # Licentivo Python SDK
38
+
39
+ Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
40
+
41
+ ## Features
42
+
43
+ - Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
44
+ - Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
45
+ - Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
46
+ - Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
47
+ - Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
48
+
49
+ Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
50
+
51
+ ## Installation
52
+
53
+ Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
54
+
55
+ ```bash
56
+ python -m pip install "licentivo-sdk==0.10.0"
57
+ ```
58
+
59
+ For an offline release, install local sources with `python -m pip install ./sdk/python`,
60
+ or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
61
+ The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
62
+ For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
63
+ Keep `product.json` in application resources. Read the customer's key from your
64
+ activation UI; after activation pass an empty string to restore the credential.
65
+
66
+ ```python
67
+ from licentivo import Client
68
+ from pathlib import Path
69
+
70
+ with Client.open_with_license('product.json', customer_key, on_error=print) as client:
71
+ client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
72
+ # In your app, keep this context open until the application exits.
73
+ ```
74
+
75
+ For a configured metered feature, pass your business callback to the SDK:
76
+
77
+ ```python
78
+ client.run_metered_feature('export', 1, persistent_job_id, export_report)
79
+ ```
80
+
81
+ The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
82
+ job ID and durable business result. Repeating this call after a pending commit,
83
+ including after restart, retries only confirmation and never repeats business.
84
+ If a process crashes during the callback, inspect its result and call
85
+ `resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
86
+ One workflow runs per client; different processes need different job IDs.
87
+ The lower-level reserve/commit/cancel methods remain available for custom flows.
88
+ The counter requires an online connection. `consume()` immediately charges
89
+ usage and is for operations that cannot subsequently fail.
90
+
91
+ `offline_request()` returns a dictionary to save as JSON;
92
+ `import_offline(request_dict, response_dict)` verifies an offline activation.
93
+ Closing a floating client releases its seat; device bindings are kept.
94
+
95
+ Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
96
+ code through your activation UI. After activation, the SDK saves a machine-scoped
97
+ credential beside `cache_path`. Later starts can use an empty `license_key`
98
+ with the same product, trusted keys and cache path. Keep the credential in the
99
+ user's application data directory; never ship it with the software.
100
+
101
+ With a valid device-bound offline cache, startup restores access immediately
102
+ and refreshes online in the background. Online-only and floating licenses wait
103
+ for the server. `feature_status('export')` explains a denial without a network
104
+ request. Call `close()` on exit. Python's synchronous HTTP transport may finish
105
+ an in-flight request after close; its timeout bounds that work.
106
+
107
+ Run `python desktop.py product.json` for an activation and report-export window.
108
+ It retains an export journal for commit recovery. An interrupted output still
109
+ requires your application to check whether the business operation completed.
@@ -0,0 +1,73 @@
1
+ # Licentivo Python SDK
2
+
3
+ Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
4
+
5
+ ## Features
6
+
7
+ - Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
8
+ - Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
9
+ - Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
10
+ - Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
11
+ - Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
12
+
13
+ Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
14
+
15
+ ## Installation
16
+
17
+ Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
18
+
19
+ ```bash
20
+ python -m pip install "licentivo-sdk==0.10.0"
21
+ ```
22
+
23
+ For an offline release, install local sources with `python -m pip install ./sdk/python`,
24
+ or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
25
+ The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
26
+ For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
27
+ Keep `product.json` in application resources. Read the customer's key from your
28
+ activation UI; after activation pass an empty string to restore the credential.
29
+
30
+ ```python
31
+ from licentivo import Client
32
+ from pathlib import Path
33
+
34
+ with Client.open_with_license('product.json', customer_key, on_error=print) as client:
35
+ client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
36
+ # In your app, keep this context open until the application exits.
37
+ ```
38
+
39
+ For a configured metered feature, pass your business callback to the SDK:
40
+
41
+ ```python
42
+ client.run_metered_feature('export', 1, persistent_job_id, export_report)
43
+ ```
44
+
45
+ The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
46
+ job ID and durable business result. Repeating this call after a pending commit,
47
+ including after restart, retries only confirmation and never repeats business.
48
+ If a process crashes during the callback, inspect its result and call
49
+ `resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
50
+ One workflow runs per client; different processes need different job IDs.
51
+ The lower-level reserve/commit/cancel methods remain available for custom flows.
52
+ The counter requires an online connection. `consume()` immediately charges
53
+ usage and is for operations that cannot subsequently fail.
54
+
55
+ `offline_request()` returns a dictionary to save as JSON;
56
+ `import_offline(request_dict, response_dict)` verifies an offline activation.
57
+ Closing a floating client releases its seat; device bindings are kept.
58
+
59
+ Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
60
+ code through your activation UI. After activation, the SDK saves a machine-scoped
61
+ credential beside `cache_path`. Later starts can use an empty `license_key`
62
+ with the same product, trusted keys and cache path. Keep the credential in the
63
+ user's application data directory; never ship it with the software.
64
+
65
+ With a valid device-bound offline cache, startup restores access immediately
66
+ and refreshes online in the background. Online-only and floating licenses wait
67
+ for the server. `feature_status('export')` explains a denial without a network
68
+ request. Call `close()` on exit. Python's synchronous HTTP transport may finish
69
+ an in-flight request after close; its timeout bounds that work.
70
+
71
+ Run `python desktop.py product.json` for an activation and report-export window.
72
+ It retains an export journal for commit recovery. An interrupted output still
73
+ requires your application to check whether the business operation completed.
@@ -0,0 +1,430 @@
1
+ """Licentivo runtime SDK, Python 3.10+. Pin public keys; never put a vendor API key here."""
2
+ import base64
3
+ import hashlib
4
+ import json
5
+ import licentivo_tasks
6
+ import os
7
+ import platform
8
+ import subprocess
9
+ import random
10
+ import re
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from pathlib import Path
19
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
20
+
21
+ LIMIT = 16384
22
+ FIELDS = {'version','key_id','tenant_id','product_id','license_id','device_id','activation_id','mode','features','max_devices','issued_at','expires_at'}
23
+ UUID = re.compile(r'^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
24
+
25
+ def _pairs(pairs):
26
+ result = {}
27
+ for key,value in pairs:
28
+ if key in result: raise ValueError('duplicate JSON field')
29
+ result[key] = value
30
+ return result
31
+
32
+ def loads(data):
33
+ if len(data) > LIMIT: raise ValueError('response size limit')
34
+ return json.loads(data, object_pairs_hook=_pairs, parse_constant=lambda x: (_ for _ in ()).throw(ValueError('invalid number')))
35
+
36
+ def decode(s):
37
+ if not isinstance(s,str) or not re.fullmatch(r'[A-Za-z0-9_-]+',s): raise ValueError('invalid base64url')
38
+ b = base64.urlsafe_b64decode(s + '='*((-len(s))%4))
39
+ if base64.urlsafe_b64encode(b).decode().rstrip('=') != s: raise ValueError('noncanonical base64url')
40
+ return b
41
+
42
+ def system_device_id(product):
43
+ system=platform.system(); identity=''
44
+ if system=='Windows':
45
+ import winreg
46
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,r'SOFTWARE\Microsoft\Cryptography',0,winreg.KEY_READ|winreg.KEY_WOW64_64KEY) as key:
47
+ identity=winreg.QueryValueEx(key,'MachineGuid')[0]
48
+ elif system=='Linux':
49
+ for p in ['/etc/machine-id','/var/lib/dbus/machine-id']:
50
+ try: identity=Path(p).read_text().strip()
51
+ except OSError: continue
52
+ if identity: break
53
+ elif system=='Darwin':
54
+ result=subprocess.check_output(['/usr/sbin/ioreg','-rd1','-c','IOPlatformExpertDevice'],timeout=3,text=True)
55
+ match=re.search(r'"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"',result)
56
+ if match: identity=match[1]
57
+ identity=identity.strip().lower()
58
+ if not re.fullmatch(r'(?:[0-9a-f]{32}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})',identity) or not identity.replace('-','').strip('0'):
59
+ raise ValueError('OS machine identity unavailable; cannot bind license')
60
+ return hashlib.sha256(('LicenovaDevice/v2\n'+product.lower()+'\n'+identity).encode()).hexdigest()
61
+
62
+ def machine_profile(product):
63
+ system=system_device_id(product); hardware='';description='';os_name=platform.system()
64
+ try:
65
+ if os_name=='Windows':
66
+ result=subprocess.check_output(['powershell.exe','-NoProfile','-NonInteractive','-Command','(Get-CimInstance Win32_ComputerSystemProduct).UUID; $v=Get-CimInstance Win32_ComputerSystem; $v.Manufacturer; $v.Model'],timeout=5,text=True,creationflags=subprocess.CREATE_NO_WINDOW)
67
+ hardware=result.strip().splitlines()[0];description=result
68
+ elif os_name=='Linux':
69
+ try: hardware=Path('/sys/class/dmi/id/product_uuid').read_text()
70
+ except OSError: pass
71
+ for path in ['/sys/class/dmi/id/sys_vendor','/sys/class/dmi/id/product_name']:
72
+ try: description+=' '+Path(path).read_text()
73
+ except OSError: pass
74
+ if Path('/.dockerenv').exists(): description+=' virtual machine'
75
+ elif os_name=='Darwin':
76
+ result=subprocess.check_output(['/usr/sbin/ioreg','-rd1','-c','IOPlatformExpertDevice'],timeout=5,text=True)
77
+ hardware=re.search(r'"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"',result)[1];description=result
78
+ except (OSError,subprocess.SubprocessError,IndexError,TypeError): pass
79
+ hardware=hardware.strip().lower();hw=''
80
+ if UUID.fullmatch(hardware) and hardware.replace('-','').strip('0f'):
81
+ hw=hashlib.sha256(('LicenovaHardware/v1\n'+product.lower()+'\n'+hardware).encode()).hexdigest()
82
+ profile={'version':3,'system_id':system,'hardware_id':hw,'virtual_machine':bool(re.search(r'vmware|virtualbox|virtual machine|kvm|qemu|xen|parallels',description,re.I)),'platform':{'Windows':'windows','Linux':'linux','Darwin':'darwin'}[os_name]}
83
+ return profile,hashlib.sha256(('LicenovaDevice/v3\n'+product.lower()+'\n'+system+'\n'+hw).encode()).hexdigest()
84
+ def machine_device_id(product): return machine_profile(product)[1]
85
+
86
+ def extended_claims(c):
87
+ if 'offline_request_id' in c and (not isinstance(c['offline_request_id'],str) or not UUID.fullmatch(c['offline_request_id'])): raise ValueError('invalid offline request ID')
88
+ if 'capabilities' not in c: return
89
+ x=c['capabilities'];allowed={'license_model','session_id','session_expires_at','app_version','version_released_at','maintenance_until'}
90
+ if not isinstance(x,dict) or set(x)-allowed or x.get('license_model') not in ['node_locked','floating']: raise ValueError('invalid license capabilities')
91
+ for field in ['session_expires_at','version_released_at','maintenance_until']:
92
+ if field in x and (type(x[field]) is not int or not 0<=x[field]<=253402300799): raise ValueError('invalid entitlement date')
93
+ if 'app_version' in x and (not isinstance(x['app_version'],str) or not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})',x['app_version'])): raise ValueError('invalid application version')
94
+ if x['license_model']=='floating' and (not isinstance(x.get('session_id'),str) or not UUID.fullmatch(x['session_id']) or x.get('session_expires_at',0)<=c['issued_at'] or c['expires_at']>x['session_expires_at'] or c.get('runtime_policy',{}).get('heartbeat_seconds',0)==0 or policy_mode(c)=='permanent'): raise ValueError('invalid floating session')
95
+ if x.get('version_released_at',0)>c['issued_at'] or x.get('maintenance_until',0)>0 and (not x.get('app_version') or x.get('version_released_at',0)==0 or x['version_released_at']>x['maintenance_until']): raise ValueError('invalid maintenance entitlement')
96
+
97
+ def policy_mode(c): return c.get('runtime_policy',{}).get('offline_mode','limited')
98
+ def max_span(c):
99
+ if 'runtime_policy' not in c: return 86400
100
+ p=c['runtime_policy']
101
+ if not isinstance(p,dict) or set(p)!={'offline_mode','heartbeat_seconds','offline_seconds'} or any(type(p[f]) is not int for f in ['heartbeat_seconds','offline_seconds']): raise ValueError('invalid runtime policy')
102
+ mode,h,o=p['offline_mode'],p['heartbeat_seconds'],p['offline_seconds']
103
+ if h!=0 and not 10<=h<=86400: raise ValueError('invalid heartbeat interval')
104
+ if mode=='permanent' and o==0: return 253402300799-c['issued_at']
105
+ if mode=='none' and o==0: return 10 if h==0 else h+(h+9)//10+30
106
+ if mode=='limited' and 1<=o<=31536000: return o
107
+ raise ValueError('invalid runtime policy')
108
+
109
+ def verify(envelope, keys, product, device, now=None):
110
+ now = int(time.time()) if now is None else now
111
+ if set(envelope) != {'version','key_id','payload','signature'} or type(envelope['version']) is not int or envelope['version'] != 1: raise ValueError('invalid envelope')
112
+ raw = decode(keys[envelope['key_id']]); payload = decode(envelope['payload']); signature = decode(envelope['signature'])
113
+ if len(raw)!=32 or len(signature)!=64 or len(payload)>LIMIT: raise ValueError('invalid key/signature length')
114
+ Ed25519PublicKey.from_public_bytes(raw).verify(signature,b'LicenovaLease/v1\n'+payload)
115
+ c = loads(payload)
116
+ if set(c) != FIELDS | (set(c)&{'runtime_policy','capabilities','offline_request_id'}): raise ValueError('invalid claims fields')
117
+ if type(c['version']) is not int or c['version']!=1 or c['key_id']!=envelope['key_id'] or c['product_id']!=product or c['device_id']!=device: raise ValueError('lease context mismatch')
118
+ if any(not isinstance(c[f],str) or not UUID.fullmatch(c[f]) for f in ['tenant_id','product_id','license_id','activation_id']): raise ValueError('invalid identifiers')
119
+ if not re.fullmatch(r'[0-9a-f]{64}',device) or c['mode'] not in ['perpetual','timed','trial']: raise ValueError('invalid mode/device')
120
+ if any(type(c[f]) is not int for f in ['max_devices','issued_at','expires_at']): raise ValueError('invalid integer claim')
121
+ if not 1<=c['max_devices']<=10000 or c['issued_at']<=0 or not 0<c['expires_at']-c['issued_at']<=max_span(c) or c['expires_at']>253402300799 or c['issued_at']>now+300 or now>=c['expires_at']: raise ValueError('expired lease/clock')
122
+ fs=c['features']
123
+ if not isinstance(fs,list) or len(fs)>32 or any(not isinstance(f,str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}',f) for f in fs) or sorted(set(fs))!=fs: raise ValueError('invalid features')
124
+ extended_claims(c)
125
+ return c
126
+
127
+ def device_id(product, installation_id):
128
+ return hashlib.sha256(('licentivo-device-v1\n'+product+'\n'+installation_id.strip().lower()).encode()).hexdigest()
129
+
130
+ class APIError(Exception):
131
+ def __init__(self, status, code, request_id='', retry_after_seconds=0):
132
+ self.status=status; self.code=code; self.request_id=request_id; self.retryable=status==429 or status>=500; self.retry_after_seconds=retry_after_seconds; super().__init__(f'Licentivo {code} ({status})')
133
+
134
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
135
+ def redirect_request(self,*args,**kwargs): return None
136
+
137
+ class Client:
138
+ def __init__(self, config):
139
+ self.feature_task_lock = threading.Lock()
140
+ self.config=dict(config); self.config.setdefault('license_key',''); self.config['base_url']=self.config['base_url'].rstrip('/');
141
+ if not self.config.get('cache_path'): self.config['cache_path']=str(Path(os.environ.get('LOCALAPPDATA') or os.environ.get('XDG_CACHE_HOME') or str(Path.home()/'.cache'))/'Licentivo'/hashlib.sha256((self.config['base_url']+'\n'+self.config['product_id']).encode()).hexdigest()/'lease.json')
142
+ self.profile,self.config['device_id']=machine_profile(config['product_id']); self.session_id=str(uuid.uuid4()); config=self.config; u=urllib.parse.urlsplit(config['base_url'])
143
+ if u.username or not u.hostname or u.query or u.fragment or u.path not in ['','/']: raise ValueError('base_url must be an origin')
144
+ if u.scheme!='https' and not (config.get('allow_http') is True and u.scheme=='http' and u.hostname in ['localhost','127.0.0.1','::1']): raise ValueError('HTTPS required')
145
+ if not re.fullmatch(r'[0-9a-f]{64}',config['device_id']) or config['license_key'] and not re.fullmatch(r'lv_(lic|tmp)_[A-Za-z0-9_-]{43}',config['license_key']): raise ValueError('invalid device/license')
146
+ if not config['trusted_keys'] or any(len(decode(k))!=32 for k in config['trusted_keys'].values()): raise ValueError('pin a public key')
147
+ self.lock=threading.RLock(); self.state=None; self.interval=3600; self.wall=0; self.tick=time.monotonic(); self.stop=threading.Event(); self.thread=None; self.refresh_thread=None; self.closed=False
148
+ self.timeout=config.get('timeout_seconds',10)
149
+ if not isinstance(self.timeout,(int,float)) or not 1<=self.timeout<=120: raise ValueError('timeout_seconds must be 1–120')
150
+ handlers=[_NoRedirect()]
151
+ if config.get('proxy_url'): handlers.append(urllib.request.ProxyHandler({'http':config['proxy_url'],'https':config['proxy_url']}))
152
+ self.opener=urllib.request.build_opener(*handlers);self.on_status_changed=None;self.last_error=None;self.online=False;self.retry_at=0;self.snapshot=(None,0,self.tick)
153
+ self.auth=None;self.auth_dirty=False;self._load_credential()
154
+ if not config['license_key'] and not self.auth: raise ValueError('enter a license key or temporary activation code')
155
+ try:
156
+ p=Path(config.get('cache_path','')); s=loads(p.read_bytes()) if p.is_file() and p.stat().st_size<=LIMIT else None
157
+ if s and s['version']==1 and s['license_hash']==self._hash() and time.time()+300>=s['observed_at']:
158
+ c=verify(s['lease'],config['trusted_keys'],config['product_id'],config['device_id'])
159
+ if policy_mode(c)!='none' and c.get('capabilities',{}).get('license_model')!='floating' and c.get('capabilities',{}).get('app_version','')==config.get('app_version',''):
160
+ self.state=s;self.wall=s['observed_at'];self.interval=c.get('runtime_policy',{}).get('heartbeat_seconds',3600)
161
+ except Exception: pass # A corrupt/expired cache never grants access.
162
+ self._publish(False)
163
+
164
+ def _hash(self): return self.auth['source_hash'] if not self.config['license_key'] and self.auth else hashlib.sha256(self.config['license_key'].encode()).hexdigest()
165
+ def _temporary(self): return bool(self.config['license_key']) or self.auth is not None
166
+ def _runtime_key(self): return self.auth.get('device_key') or self.config['license_key'] if self.auth else self.config['license_key']
167
+ def _load_credential(self):
168
+ if not self.config.get('cache_path'): return
169
+ try:
170
+ path=Path(self.config['cache_path']+'.credential.json')
171
+ if path.stat().st_size>LIMIT: return
172
+ s=loads(path.read_bytes())
173
+ if s.get('version')!=1 or s.get('origin')!=self.config['base_url'] or not re.fullmatch(r'[0-9a-f]{64}',s.get('source_hash','')) or self.config['license_key'] and s.get('source_hash')!=self._hash() or any(s.get(k)!=self.config[k] for k in ['product_id','device_id']) or not UUID.fullmatch(s.get('request_id','')) or not UUID.fullmatch(s.get('session_id','')) or s.get('device_key') and not re.fullmatch(r'lv_dev_[A-Za-z0-9_-]{43}',s['device_key']): return
174
+ if not self.config['license_key'] and not s.get('device_key'): return
175
+ self.auth=s
176
+ if not s.get('device_key'): self.session_id=s['session_id']
177
+ except (OSError,ValueError,KeyError,TypeError): pass
178
+ def _prepare_credential(self):
179
+ if not self._temporary(): return
180
+ if not self.config.get('cache_path'): raise ValueError('cache_path required to retain a transferred license')
181
+ if not self.auth:
182
+ self.auth={'version':1,'origin':self.config['base_url'],'source_hash':self._hash(),'product_id':self.config['product_id'],'device_id':self.config['device_id'],'device_key':'','request_id':str(uuid.uuid4()),'session_id':self.session_id}
183
+ self.auth_dirty=True
184
+ if self.auth_dirty: self._save_credential()
185
+ def _save_credential(self):
186
+ p=Path(self.config['cache_path']+'.credential.json');p.parent.mkdir(parents=True,exist_ok=True)
187
+ fd,tmp=tempfile.mkstemp(prefix='.credential-',dir=p.parent)
188
+ try:
189
+ os.chmod(tmp,0o600)
190
+ with os.fdopen(fd,'w',encoding='utf-8') as f: json.dump(self.auth,f,separators=(',',':'))
191
+ os.replace(tmp,p);self.auth_dirty=False
192
+ finally:
193
+ if os.path.exists(tmp): os.unlink(tmp)
194
+ def _accept_credential(self,result):
195
+ if not self._temporary() or self.auth and self.auth.get('device_key'): return
196
+ if not re.fullmatch(r'lv_dev_[A-Za-z0-9_-]{43}',result.get('device_key','')):
197
+ if not self.config['license_key'].startswith('lv_tmp_'): return
198
+ raise ValueError('server omitted device credential')
199
+ self._prepare_credential();self.auth['device_key']=result['device_key'];self.auth_dirty=True;self._save_credential()
200
+ def _check(self):
201
+ if self.wall and time.time() < self.wall+(time.monotonic()-self.tick)-300: raise ValueError('clock rollback; verify online')
202
+ if not self.state: raise ValueError('no valid lease')
203
+ c=verify(self.state['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(c);return c
204
+ def _invalidate(self):
205
+ self.state=None
206
+ self._publish(False)
207
+ path=self.config.get('cache_path')
208
+ if path:
209
+ try: Path(path).unlink(missing_ok=True)
210
+ except OSError: pass
211
+ def _save(self):
212
+ path=self.config.get('cache_path')
213
+ if not path: return
214
+ p=Path(path);p.parent.mkdir(parents=True,exist_ok=True)
215
+ fd,tmp=tempfile.mkstemp(prefix='.lease-',dir=p.parent)
216
+ try:
217
+ os.chmod(tmp,0o600)
218
+ with os.fdopen(fd,'w',encoding='utf-8') as f: json.dump(self.state,f,separators=(',',':'))
219
+ os.replace(tmp,p)
220
+ finally:
221
+ if os.path.exists(tmp): os.unlink(tmp)
222
+ def _request(self,operation,activation,operation_id=None,extra=None):
223
+ self._prepare_credential()
224
+ cfg=self.config; body=json.dumps(self._body(activation) | (extra or {})).encode()
225
+ idem=operation_id or str(uuid.uuid4());last=None
226
+ if operation=='activate' and self._temporary() and not self.auth.get('device_key') and not self.state: idem=self.auth['request_id']
227
+ for attempt in range(2):
228
+ if attempt: time.sleep(.2)
229
+ req=urllib.request.Request(cfg['base_url'].rstrip('/')+'/api/v1/'+operation,body,{'Content-Type':'application/json','Idempotency-Key':idem},method='POST')
230
+ try:
231
+ try: res=self.opener.open(req,timeout=self.timeout)
232
+ except urllib.error.HTTPError as e: res=e
233
+ with res:
234
+ status=res.code; request_headers=res.headers; b=res.read(LIMIT+1)
235
+ if len(b)>LIMIT: raise ValueError('oversized response')
236
+ try: env=loads(b)
237
+ except ValueError:
238
+ if status!=429 and status<500: raise
239
+ retry=request_headers.get('Retry-After','0')
240
+ error=APIError(status,'UNEXPECTED_RESPONSE',request_headers.get('X-Request-ID',''),min(86400,int(retry)) if retry.isdigit() else 0)
241
+ if status>=500: last=error;continue
242
+ raise error
243
+ if status!=200 or env.get('error'):
244
+ retry=int(request_headers.get('Retry-After','0')) if request_headers.get('Retry-After','0').isdigit() else 0
245
+ error=APIError(status,env.get('error',{}).get('code','UNEXPECTED_RESPONSE'),env.get('request_id',''),min(86400,retry))
246
+ if status>=500: last=error;continue
247
+ raise error
248
+ return env['data']
249
+ except (urllib.error.URLError,TimeoutError,OSError) as e: last=e
250
+ raise last
251
+ def _call(self,operation,force_online=False):
252
+ with self.lock:
253
+ try:
254
+ cached=self._check()
255
+ if not force_online and operation=='activate' and policy_mode(cached)=='permanent' and cached['runtime_policy']['heartbeat_seconds']==0: return cached
256
+ except Exception: pass
257
+ activation=''
258
+ if operation!='activate':
259
+ if not self.state: raise ValueError('activate first')
260
+ activation=loads(decode(self.state['lease']['payload']))['activation_id']
261
+ try: result=self._request(operation,activation)
262
+ except Exception as e:
263
+ transient=isinstance(e,(urllib.error.URLError,TimeoutError,OSError)) or isinstance(e,APIError) and e.retryable
264
+ if transient:
265
+ try:
266
+ c=self._check()
267
+ if not force_online and policy_mode(c)!='none': self._publish(False,e);return c
268
+ if policy_mode(c)=='none': self._invalidate()
269
+ except Exception: pass
270
+ self._publish(False,e);raise e
271
+ self._invalidate();self._publish(False,e);raise
272
+ try:
273
+ c=verify(result['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id'])
274
+ self._context(c)
275
+ if c['activation_id']!=result['activation_id']: raise ValueError('activation mismatch')
276
+ except Exception: self._invalidate();raise
277
+ self._accept_credential(result)
278
+ self.wall=time.time();self.tick=time.monotonic();self.state={'version':1,'license_hash':self._hash(),'lease':result['lease'],'observed_at':int(self.wall)}
279
+ self.interval=c.get('runtime_policy',{}).get('heartbeat_seconds',max(10,min(86400,result.get('heartbeat_seconds',3600))));self._save();self._publish(True);return c
280
+ def activate(self): return self._call('activate')
281
+ def validate(self): return self._call('validate')
282
+ def heartbeat(self): return self._call('heartbeat')
283
+ def refresh_online(self): return self._call('activate',True)
284
+ def check_feature(self,name):
285
+ return self.feature_status(name)['allowed']
286
+ def deactivate(self):
287
+ with self.lock:
288
+ claims=loads(decode(self.state['lease']['payload'])) if self.state else {}
289
+ activation=claims.get('activation_id','');floating=claims.get('capabilities',{}).get('license_model')=='floating'
290
+ self._invalidate()
291
+ if activation: self._request('deactivate',activation)
292
+ if not floating: Path(self.config['cache_path']+'.credential.json').unlink(missing_ok=True);self.auth=None
293
+ self._publish(False,APIError(0,'DEVICE_RELEASED'))
294
+ def start_heartbeat(self,on_error=None):
295
+ try:
296
+ if self.interval==0: return
297
+ except Exception: pass
298
+ if self.thread and self.thread.is_alive(): return
299
+ self.stop.clear()
300
+ def run():
301
+ while self.interval and not self.stop.wait(max(max(600,self.interval)*random.uniform(1,1.1),self.retry_at-time.time())):
302
+ try: self.heartbeat()
303
+ except Exception as e:
304
+ if on_error: on_error(e)
305
+ self.thread=threading.Thread(target=run,daemon=True);self.thread.start()
306
+ def close(self):
307
+ if self.closed: return
308
+ self.closed=True
309
+ self.stop.set()
310
+ for worker in [self.thread,self.refresh_thread]:
311
+ if worker and worker is not threading.current_thread(): worker.join(timeout=5)
312
+ self.thread=None
313
+ self.refresh_thread=None
314
+ try:
315
+ if self._check().get('capabilities',{}).get('license_model')=='floating': self.deactivate()
316
+ except Exception: pass
317
+ def _context(self,c):
318
+ x=c.get('capabilities')
319
+ if x and (x.get('app_version','')!=self.config.get('app_version','') or x['license_model']=='floating' and x.get('session_id')!=self.session_id): raise ValueError('lease belongs to another version/session')
320
+ def _body(self,activation=''):
321
+ return {k:self.config.get(k,'') for k in ['product_id','device_id','device_name','app_version']} | {'license_key':self._runtime_key(),'activation_id':activation,'session_id':self.session_id,'device_profile':self.profile}
322
+ def start(self,on_error=None):
323
+ if self.closed: raise RuntimeError('client is closed')
324
+ try:
325
+ result=self._check()
326
+ if policy_mode(result)=='none' or result.get('capabilities',{}).get('license_model')=='floating': raise ValueError('online startup required')
327
+ except Exception:
328
+ result=self.activate();self.start_heartbeat(on_error);return result
329
+ self.start_heartbeat(on_error)
330
+ def refresh():
331
+ if self.stop.is_set(): return
332
+ try: self.refresh_online()
333
+ except Exception as e:
334
+ if on_error and not self.stop.is_set(): on_error(e)
335
+ if not self.refresh_thread or not self.refresh_thread.is_alive():
336
+ self.refresh_thread=threading.Thread(target=refresh,daemon=True);self.refresh_thread.start()
337
+ return result
338
+ @classmethod
339
+ def open(cls,path,on_error=None):
340
+ client=cls(loads(Path(path).read_bytes()))
341
+ try: client.start(on_error);return client
342
+ except Exception: client.close();raise
343
+ @classmethod
344
+ def open_with_license(cls,path,license_key,on_error=None):
345
+ """Load shared product settings and use the customer's license key."""
346
+ config=loads(Path(path).read_bytes())
347
+ config['license_key']=license_key.strip()
348
+ client=cls(config)
349
+ try: client.start(on_error);return client
350
+ except Exception: client.close();raise
351
+ def consume(self,feature,quantity,operation_id):
352
+ if not operation_id: raise ValueError('retain an operation_id for retries')
353
+ with self.lock:
354
+ activation=self._check()['activation_id']
355
+ try: return self._request('consume',activation,operation_id,{'feature':feature,'quantity':quantity})['consumption']
356
+ except APIError as e:
357
+ if e.code in ['LICENSE_REVOKED','LICENSE_SUSPENDED','LICENSE_EXPIRED','DEVICE_RELEASED','DEVICE_CREDENTIAL_REVOKED']: self._invalidate()
358
+ self._publish(False,e)
359
+ raise
360
+ def offline_request(self,operation='activate'):
361
+ with self.lock:
362
+ self._prepare_credential()
363
+ if operation not in ['activate','deactivate']: raise ValueError('invalid offline operation')
364
+ activation=''
365
+ if operation=='deactivate': activation=self._check()['activation_id'];self._invalidate()
366
+ return self._body(activation) | {'version':1,'request_id':str(uuid.uuid4()),'created_at':int(time.time()),'operation':operation}
367
+ def import_offline(self,request,response):
368
+ with self.lock:
369
+ if response.get('version')!=1 or request.get('operation')!='activate' or response.get('operation')!='activate' or response.get('request_id')!=request.get('request_id') or any(request.get(k)!=self._body().get(k) for k in ['product_id','device_id','license_key']): raise ValueError('offline context mismatch')
370
+ result=response['result'];c=verify(result['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(c)
371
+ if policy_mode(c)=='none' or c.get('offline_request_id')!=request['request_id'] or c['activation_id']!=result['activation_id']: raise ValueError('offline signature context mismatch')
372
+ self._accept_credential(result)
373
+ self.wall=time.time();self.tick=time.monotonic();self.state={'version':1,'license_hash':self._hash(),'lease':result['lease'],'observed_at':int(self.wall)};self.interval=c['runtime_policy']['heartbeat_seconds'];self._save();self._publish(False);return c
374
+ def _publish(self,online,error=None):
375
+ before=self.status();self.snapshot=(self.state,self.wall,self.tick);self.online=online;self.last_error=error
376
+ if isinstance(error,APIError) and error.retry_after_seconds: self.retry_at=time.time()+error.retry_after_seconds
377
+ after=self.status()
378
+ if before!=after and self.on_status_changed:
379
+ callback=self.on_status_changed
380
+ threading.Thread(target=lambda:callback(after),daemon=True).start()
381
+ def status(self):
382
+ state,wall,tick=self.snapshot
383
+ result={'state':'verification_required','allowed':False,'code':'','request_id':'','retryable':False,'lease_valid_until':0}
384
+ if self.last_error:
385
+ result.update(code=getattr(self.last_error,'code','NETWORK_UNAVAILABLE'),request_id=getattr(self.last_error,'request_id',''),retryable=getattr(self.last_error,'retryable',True))
386
+ try:
387
+ if wall and time.time()<wall+time.monotonic()-tick-300: raise ValueError('clock rollback')
388
+ claims=verify(state['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id']);self._context(claims)
389
+ result.update(state='active' if self.online else 'offline_valid',allowed=True,lease_valid_until=claims['expires_at'])
390
+ except Exception: pass
391
+ result['state']={'LICENSE_EXPIRED':'expired','LICENSE_REVOKED':'revoked','LICENSE_SUSPENDED':'revoked','DEVICE_RELEASED':'released','DEVICE_CREDENTIAL_REVOKED':'released','FEATURE_QUOTA_EXCEEDED':'quota_exhausted'}.get(result['code'],result['state'])
392
+ return result
393
+ def run_feature(self, name, action):
394
+ """Run a non-metered business action only when the signed grant allows it."""
395
+ if not callable(action): raise TypeError('business action is required')
396
+ status = self.feature_status(name)
397
+ if not status['allowed']:
398
+ raise RuntimeError(f"feature denied: {status['code']} ({status['state']}); request_id={status['request_id']}")
399
+ return action()
400
+
401
+ def run_metered_feature(self, name, quantity, operation_id, action):
402
+ return licentivo_tasks.run(self, name, quantity, operation_id, action)
403
+
404
+ def resolve_metered_feature(self, operation_id, succeeded):
405
+ return licentivo_tasks.resolve(self, operation_id, succeeded)
406
+
407
+ def feature_status(self,name):
408
+ result=self.status()
409
+ if result['allowed']:
410
+ try:
411
+ claims=verify(self.snapshot[0]['lease'],self.config['trusted_keys'],self.config['product_id'],self.config['device_id'])
412
+ if name not in claims['features']: result.update(allowed=False,code='FEATURE_NOT_ENTITLED')
413
+ except Exception: result.update(allowed=False,state='verification_required')
414
+ return result
415
+ def _reservation(self,operation,operation_id,extra):
416
+ if not operation_id: raise ValueError('retain operation_id for retries')
417
+ with self.lock:
418
+ activation=self._check()['activation_id']
419
+ try:
420
+ result=self._request('consume/'+operation,activation,operation_id,extra)['reservation'];self._publish(True);return result
421
+ except Exception as e:
422
+ if getattr(e,'code','') in ['LICENSE_REVOKED','LICENSE_SUSPENDED','LICENSE_EXPIRED','DEVICE_RELEASED','DEVICE_CREDENTIAL_REVOKED']: self._invalidate()
423
+ self._publish(False,e);raise
424
+ def reserve(self,feature,quantity,operation_id): return self._reservation('reserve',operation_id,{'feature':feature,'quantity':quantity})
425
+ def commit(self,reservation_id,operation_id): return self._reservation('commit',operation_id,{'reservation_id':reservation_id})
426
+ def cancel(self,reservation_id,operation_id): return self._reservation('cancel',operation_id,{'reservation_id':reservation_id})
427
+ def stop_client(self):
428
+ self.close()
429
+ def __enter__(self): return self
430
+ def __exit__(self,*args): self.close()
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: licentivo-sdk
3
+ Version: 0.10.0
4
+ Summary: Licentivo software licensing client
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 spf86 and Licentivo contributors
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/spf86/licentivo-sdk
28
+ Project-URL: Documentation, https://www.licentivo.com/en/docs.html
29
+ Project-URL: Issues, https://github.com/spf86/licentivo-sdk/issues
30
+ Requires-Python: >=3.10
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ License-File: NOTICE
34
+ Requires-Dist: cryptography<49,>=44.0.1
35
+ Dynamic: license-file
36
+
37
+ # Licentivo Python SDK
38
+
39
+ Add software license activation and protected features to a Python application without implementing the licensing HTTP protocol yourself.
40
+
41
+ ## Features
42
+
43
+ - Activates a customer license, derives device identity, stores a private device credential and restores it after restart.
44
+ - Verifies signed grants with pinned public keys and checks license/feature status before running your business callback.
45
+ - Schedules policy heartbeats, supports permitted offline grants and returns floating seats on close.
46
+ - Handles metered-feature reservation, commit/cancel and recovery of pending confirmations using a stable job ID.
47
+ - Provides explicit refresh/deactivation, offline activation file exchange and structured error details.
48
+
49
+ Your application supplies the activation interface, business function and durable business results. The SDK handles the authorization workflow; management API keys stay on your server.
50
+
51
+ ## Installation
52
+
53
+ Use Python 3.10+ and a virtual environment. Install the pinned release from PyPI:
54
+
55
+ ```bash
56
+ python -m pip install "licentivo-sdk==0.10.0"
57
+ ```
58
+
59
+ For an offline release, install local sources with `python -m pip install ./sdk/python`,
60
+ or a downloaded wheel with `python -m pip install ./licentivo_sdk-0.10.0-py3-none-any.whl`.
61
+ The [website installation guide](https://www.licentivo.com/en/docs.html#sdk) shows current release availability and the common installer.
62
+ For the full starter, see QUICKSTART.md at the configured ZIP root and run `start.ps1` / `start.sh`.
63
+ Keep `product.json` in application resources. Read the customer's key from your
64
+ activation UI; after activation pass an empty string to restore the credential.
65
+
66
+ ```python
67
+ from licentivo import Client
68
+ from pathlib import Path
69
+
70
+ with Client.open_with_license('product.json', customer_key, on_error=print) as client:
71
+ client.run_feature('export', lambda: Path('report.txt').write_text('Report\n'))
72
+ # In your app, keep this context open until the application exits.
73
+ ```
74
+
75
+ For a configured metered feature, pass your business callback to the SDK:
76
+
77
+ ```python
78
+ client.run_metered_feature('export', 1, persistent_job_id, export_report)
79
+ ```
80
+
81
+ The SDK reserves, runs, confirms/cancels and stores a private journal. Retain your
82
+ job ID and durable business result. Repeating this call after a pending commit,
83
+ including after restart, retries only confirmation and never repeats business.
84
+ If a process crashes during the callback, inspect its result and call
85
+ `resolve_metered_feature(job_id, succeeded)`; the SDK does not guess or rerun it.
86
+ One workflow runs per client; different processes need different job IDs.
87
+ The lower-level reserve/commit/cancel methods remain available for custom flows.
88
+ The counter requires an online connection. `consume()` immediately charges
89
+ usage and is for operations that cannot subsequently fail.
90
+
91
+ `offline_request()` returns a dictionary to save as JSON;
92
+ `import_offline(request_dict, response_dict)` verifies an offline activation.
93
+ Closing a floating client releases its seat; device bindings are kept.
94
+
95
+ Accept an ordinary `lv_lic_...` key or a replacement computer's `lv_tmp_...`
96
+ code through your activation UI. After activation, the SDK saves a machine-scoped
97
+ credential beside `cache_path`. Later starts can use an empty `license_key`
98
+ with the same product, trusted keys and cache path. Keep the credential in the
99
+ user's application data directory; never ship it with the software.
100
+
101
+ With a valid device-bound offline cache, startup restores access immediately
102
+ and refreshes online in the background. Online-only and floating licenses wait
103
+ for the server. `feature_status('export')` explains a denial without a network
104
+ request. Call `close()` on exit. Python's synchronous HTTP transport may finish
105
+ an in-flight request after close; its timeout bounds that work.
106
+
107
+ Run `python desktop.py product.json` for an activation and report-export window.
108
+ It retains an export journal for commit recovery. An interrupted output still
109
+ requires your application to check whether the business operation completed.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ NOTICE
3
+ README.md
4
+ licentivo.py
5
+ licentivo_tasks.py
6
+ pyproject.toml
7
+ licentivo_sdk.egg-info/PKG-INFO
8
+ licentivo_sdk.egg-info/SOURCES.txt
9
+ licentivo_sdk.egg-info/dependency_links.txt
10
+ licentivo_sdk.egg-info/requires.txt
11
+ licentivo_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ cryptography<49,>=44.0.1
@@ -0,0 +1,2 @@
1
+ licentivo
2
+ licentivo_tasks
@@ -0,0 +1,110 @@
1
+ """Private task journal. It contains IDs/outcome only, never customer business data."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def _path(client, operation_id):
10
+ if not isinstance(operation_id, str) or not 1 <= len(operation_id) <= 80 or any(ord(char) < 33 or ord(char) > 126 for char in operation_id):
11
+ raise ValueError('a stable operation_id of 1-80 non-whitespace ASCII characters is required')
12
+ digest = hashlib.sha256((client._hash() + '\n' + operation_id).encode()).hexdigest()
13
+ return Path(client.config['cache_path'] + '.task-' + digest + '.json')
14
+
15
+
16
+ def _read(path, operation_id):
17
+ if not path.exists():
18
+ return dict(version=1, operation_id=operation_id, reservation_id='', state='')
19
+ raw = path.read_bytes()
20
+ task = json.loads(raw) if len(raw) <= 4096 else None
21
+ if not isinstance(task, dict) or task.get('version') != 1 or task.get('operation_id') != operation_id or not isinstance(task.get('feature'), str) or not task['feature'] or not isinstance(task.get('quantity'), int) or task['quantity'] <= 0 or not isinstance(task.get('state'), str) or not isinstance(task.get('reservation_id'), str) or not task['reservation_id']:
22
+ raise ValueError('invalid feature task journal; do not repeat business')
23
+ return task
24
+
25
+
26
+ def _save(path, task):
27
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
28
+ fd, temporary = tempfile.mkstemp(prefix='.feature-task-', dir=path.parent)
29
+ try:
30
+ with os.fdopen(fd, 'w', encoding='utf-8') as stream:
31
+ json.dump(task, stream)
32
+ stream.flush()
33
+ os.fsync(stream.fileno())
34
+ os.replace(temporary, path)
35
+ finally:
36
+ Path(temporary).unlink(missing_ok=True)
37
+
38
+
39
+ def _finish(client, path, task, succeeded):
40
+ try:
41
+ result = (client.commit if succeeded else client.cancel)(task['reservation_id'], task['operation_id'])
42
+ except Exception as error:
43
+ raise RuntimeError(f"confirmation pending for operation {task['operation_id']}; do not repeat business") from error
44
+ expected = 'committed' if succeeded else 'canceled'
45
+ if result['status'] != expected:
46
+ raise RuntimeError('reservation closed without the requested outcome; do not repeat business')
47
+ task['state'] = expected
48
+ _save(path, task)
49
+
50
+
51
+ def run(client, feature, quantity, operation_id, action):
52
+ if not callable(action) or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:
53
+ raise ValueError('business action and positive integer quantity are required')
54
+ if not client.feature_task_lock.acquire(blocking=False):
55
+ raise RuntimeError('another feature task is running')
56
+ try:
57
+ path = _path(client, operation_id)
58
+ task = _read(path, operation_id)
59
+ state = task['state']
60
+ if state and (task['feature'] != feature or task['quantity'] != quantity):
61
+ raise ValueError('operation ID was already used for another feature or quantity')
62
+ task.update(feature=feature, quantity=quantity)
63
+ if state == 'committed': return
64
+ if state == 'commit_pending': return _finish(client, path, task, True)
65
+ if state == 'cancel_pending': _finish(client, path, task, False); state = 'canceled'
66
+ if state == 'canceled': raise RuntimeError('operation canceled; use a new job ID for a new action')
67
+ if state == 'business_running': raise RuntimeError('business outcome unknown; verify its result and call resolve_metered_feature')
68
+ if state: raise ValueError('invalid feature task state')
69
+ client.run_feature(feature, lambda: None)
70
+ hold = client.reserve(feature, quantity, operation_id)
71
+ if hold['status'] == 'committed':
72
+ task.update(reservation_id=hold['reservation_id'], state='committed')
73
+ _save(path, task)
74
+ return
75
+ if hold['status'] != 'pending': raise RuntimeError('reservation is closed; business was not run')
76
+ task.update(reservation_id=hold['reservation_id'], state='business_running')
77
+ try: _save(path, task)
78
+ except Exception:
79
+ try: client.cancel(hold['reservation_id'], operation_id)
80
+ except Exception: pass
81
+ raise
82
+ try: action()
83
+ except Exception:
84
+ task['state'] = 'cancel_pending'
85
+ try: _save(path, task); _finish(client, path, task, False)
86
+ except Exception: pass # The original business error remains authoritative.
87
+ raise
88
+ task['state'] = 'commit_pending'
89
+ try: _save(path, task)
90
+ except Exception as error:
91
+ raise RuntimeError(f'business completed; journal update failed; do not repeat operation {operation_id}') from error
92
+ _finish(client, path, task, True)
93
+ finally:
94
+ client.feature_task_lock.release()
95
+
96
+
97
+ def resolve(client, operation_id, succeeded):
98
+ if not client.feature_task_lock.acquire(blocking=False): raise RuntimeError('another feature task is running')
99
+ try:
100
+ path = _path(client, operation_id)
101
+ task = _read(path, operation_id)
102
+ state = task['state']
103
+ if state == ('committed' if succeeded else 'canceled'): return
104
+ if not isinstance(succeeded, bool) or not task['reservation_id'] or state not in ('business_running', 'commit_pending', 'cancel_pending') or state == 'commit_pending' and not succeeded or state == 'cancel_pending' and succeeded:
105
+ raise RuntimeError('task cannot be resolved with that outcome')
106
+ task['state'] = 'commit_pending' if succeeded else 'cancel_pending'
107
+ _save(path, task)
108
+ _finish(client, path, task, succeeded)
109
+ finally:
110
+ client.feature_task_lock.release()
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "licentivo-sdk"
7
+ version = "0.10.0"
8
+ description = "Licentivo software licensing client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ dependencies = ["cryptography>=44.0.1,<49"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/spf86/licentivo-sdk"
16
+ Documentation = "https://www.licentivo.com/en/docs.html"
17
+ Issues = "https://github.com/spf86/licentivo-sdk/issues"
18
+
19
+ [tool.setuptools]
20
+ py-modules = ["licentivo", "licentivo_tasks"]
21
+ license-files = ["LICENSE", "NOTICE"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+