scientiveshield-tools 0.1.0__py3-none-any.whl

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,35 @@
1
+ """Official ScientiveShield exporter for Devolens (Cryptolens) licensing data."""
2
+
3
+ from .bundle import SCHEMA_VERSION, validate_bundle_data, validate_import_file
4
+ from .cryptolens import (
5
+ DEFAULT_BASE_URL,
6
+ CryptolensClient,
7
+ create_import_file,
8
+ list_products,
9
+ )
10
+ from .errors import (
11
+ ConfigurationError,
12
+ CryptolensError,
13
+ ScientiveShieldToolsError,
14
+ ValidationError,
15
+ )
16
+ from .models import ExportResult, Product, ValidationResult
17
+
18
+ __all__ = [
19
+ "ConfigurationError",
20
+ "CryptolensClient",
21
+ "CryptolensError",
22
+ "DEFAULT_BASE_URL",
23
+ "ExportResult",
24
+ "Product",
25
+ "SCHEMA_VERSION",
26
+ "ScientiveShieldToolsError",
27
+ "ValidationError",
28
+ "ValidationResult",
29
+ "create_import_file",
30
+ "list_products",
31
+ "validate_bundle_data",
32
+ "validate_import_file",
33
+ ]
34
+
35
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,159 @@
1
+ """Bundle schema helpers and offline validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .errors import ValidationError
11
+ from .models import ValidationResult
12
+
13
+ SCHEMA_VERSION = 2
14
+
15
+
16
+ def normalize_customer_id(value: Any) -> int | None:
17
+ """Cryptolens uses -1 for "no customer"; normalize it to None."""
18
+ if value is None:
19
+ return None
20
+ try:
21
+ number = int(value)
22
+ except (TypeError, ValueError):
23
+ return None
24
+ return number if number > 0 else None
25
+
26
+
27
+ def normalize_machine_limit(value: Any) -> int:
28
+ try:
29
+ number = int(value or 0)
30
+ except (TypeError, ValueError):
31
+ number = 0
32
+ return max(0, number)
33
+
34
+
35
+ def load_bundle(path: str | Path) -> dict[str, Any]:
36
+ try:
37
+ with open(path, encoding="utf-8") as handle:
38
+ data = json.load(handle)
39
+ except (OSError, json.JSONDecodeError) as exc:
40
+ raise ValidationError(f"Could not read bundle: {exc}") from exc
41
+ if not isinstance(data, dict):
42
+ raise ValidationError("The bundle root must be a JSON object.")
43
+ return data
44
+
45
+
46
+ def validate_bundle_data(bundle: dict[str, Any]) -> ValidationResult:
47
+ errors: list[str] = []
48
+ warnings: list[str] = []
49
+
50
+ schema = int(bundle.get("schemaVersion") or 1)
51
+ if schema > SCHEMA_VERSION:
52
+ errors.append(f"Unsupported schema version {schema}; this tool supports up to {SCHEMA_VERSION}.")
53
+
54
+ products = bundle.get("products") or []
55
+ customers = bundle.get("customers") or []
56
+ templates = bundle.get("templates") or []
57
+ keys = bundle.get("keys") or []
58
+ if not isinstance(products, list):
59
+ errors.append("products must be a list.")
60
+ products = []
61
+ if not isinstance(customers, list):
62
+ errors.append("customers must be a list.")
63
+ customers = []
64
+ if not isinstance(templates, list):
65
+ errors.append("templates must be a list.")
66
+ templates = []
67
+ if not isinstance(keys, list):
68
+ errors.append("keys must be a list.")
69
+ keys = []
70
+
71
+ product_ids = {int(p.get("sourceId", 0)) for p in products if isinstance(p, dict)}
72
+ if len(product_ids) != len(products):
73
+ errors.append("Duplicate or missing product source ids.")
74
+ customer_ids = {int(c.get("sourceId", 0)) for c in customers if isinstance(c, dict)}
75
+ if len(customer_ids) != len(customers):
76
+ errors.append("Duplicate or missing customer source ids.")
77
+ template_ids = {int(t.get("sourceId", 0)) for t in templates if isinstance(t, dict)}
78
+ if len(template_ids) != len(templates):
79
+ errors.append("Duplicate or missing template source ids.")
80
+
81
+ for product in products:
82
+ if not isinstance(product, dict) or not str(product.get("name") or "").strip():
83
+ errors.append("A product has no name.")
84
+ break
85
+
86
+ for template in templates:
87
+ if not isinstance(template, dict):
88
+ errors.append("A template is not an object.")
89
+ continue
90
+ source_product = template.get("sourceProductId")
91
+ if source_product not in product_ids:
92
+ errors.append(f"Template {template.get('sourceId')} references unknown product {source_product}.")
93
+ if not str(template.get("name") or "").strip():
94
+ errors.append(f"Template {template.get('sourceId')} has no name.")
95
+
96
+ seen_keys: set[tuple[int, str]] = set()
97
+ activation_count = 0
98
+ data_object_count = 0
99
+ for key in keys:
100
+ if not isinstance(key, dict):
101
+ errors.append("A license is not an object.")
102
+ continue
103
+ source_product = key.get("sourceProductId")
104
+ normalized = str(key.get("key") or "").strip().upper()
105
+ if not normalized:
106
+ errors.append(f"License in product {source_product} has no key string.")
107
+ elif len(normalized) > 32:
108
+ errors.append(f"License {normalized} is longer than 32 characters.")
109
+ elif (source_product, normalized) in seen_keys:
110
+ errors.append(f"Duplicate license {normalized} in product {source_product}.")
111
+ seen_keys.add((source_product, normalized))
112
+ if source_product not in product_ids:
113
+ errors.append(f"License {normalized} references unknown product {source_product}.")
114
+ customer_id = normalize_customer_id(key.get("sourceCustomerId"))
115
+ if customer_id is not None and customer_id not in customer_ids:
116
+ errors.append(f"License {normalized} references unknown customer {customer_id}.")
117
+ if int(key.get("maxNoOfMachines") or 0) < 0:
118
+ warnings.append(f"License {normalized} has a negative machine limit; treating it as unlimited.")
119
+ activations = key.get("activations") or []
120
+ if isinstance(activations, list):
121
+ activation_count += len(activations)
122
+ data_objects = key.get("dataObjects") or []
123
+ if isinstance(data_objects, list):
124
+ data_object_count += len(data_objects)
125
+
126
+ counts = {
127
+ "products": len(products),
128
+ "customers": len(customers),
129
+ "templates": len(templates),
130
+ "keys": len(keys),
131
+ "activations": activation_count,
132
+ "dataObjects": data_object_count,
133
+ }
134
+ return ValidationResult(ok=not errors, errors=errors, warnings=warnings, counts=counts)
135
+
136
+
137
+ def validate_import_file(path: str | Path) -> ValidationResult:
138
+ return validate_bundle_data(load_bundle(path))
139
+
140
+
141
+ def write_bundle(path: str | Path, bundle: dict[str, Any]) -> Path:
142
+ target = Path(path).expanduser()
143
+ target.parent.mkdir(parents=True, exist_ok=True)
144
+ text = json.dumps(bundle, indent=2) + "\n"
145
+ # The bundle contains customer secrets and license keys. Create it owner-only on POSIX.
146
+ if os.name == "posix":
147
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
148
+ try:
149
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
150
+ handle.write(text)
151
+ except Exception:
152
+ try:
153
+ os.close(fd)
154
+ except OSError:
155
+ pass
156
+ raise
157
+ else:
158
+ target.write_text(text, encoding="utf-8")
159
+ return target
@@ -0,0 +1,140 @@
1
+ """Command line entry point for the exporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import os
8
+ import sys
9
+
10
+ from .bundle import validate_import_file
11
+ from .cryptolens import CryptolensClient, list_products
12
+ from .errors import ScientiveShieldToolsError
13
+
14
+
15
+ def _token(value: str | None) -> str:
16
+ token = value or os.environ.get("CRYPTOLENS_TOKEN")
17
+ if token:
18
+ return token.strip()
19
+ if sys.stdin.isatty():
20
+ return getpass.getpass("Cryptolens read-only access token: ").strip()
21
+ raise ScientiveShieldToolsError("Pass --token or set CRYPTOLENS_TOKEN.")
22
+
23
+
24
+ def _select_products(token: str, base_url: str, free_tier: bool, insecure: bool) -> list[int]:
25
+ products = list_products(token, base_url=base_url, free_tier=free_tier, insecure=insecure)
26
+ if not products:
27
+ raise ScientiveShieldToolsError("No products returned. Check the token permissions.")
28
+ print("Products:", file=sys.stderr)
29
+ for index, product in enumerate(products, start=1):
30
+ print(f" {index}. {product.name} (id {product.id})", file=sys.stderr)
31
+ raw = input("Select product numbers (comma-separated): ").strip()
32
+ indexes = {int(part) for part in raw.split(",") if part.strip().isdigit()}
33
+ selected = [product.id for index, product in enumerate(products, start=1) if index in indexes]
34
+ if not selected:
35
+ raise ScientiveShieldToolsError("No products selected.")
36
+ return selected
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = argparse.ArgumentParser(
41
+ prog="scientiveshield-tools",
42
+ description="Export Devolens/Cryptolens licensing data for ScientiveShield.",
43
+ )
44
+ sub = parser.add_subparsers(dest="command", required=True)
45
+
46
+ list_parser = sub.add_parser("list-products", help="List products in the account")
47
+ list_parser.add_argument("--token")
48
+ list_parser.add_argument("--base-url", default=None)
49
+ list_parser.add_argument("--free-tier", action="store_true")
50
+ list_parser.add_argument("--insecure", action="store_true")
51
+ list_parser.add_argument("--json", action="store_true")
52
+
53
+ export_parser = sub.add_parser("export", help="Create a ScientiveShield import bundle")
54
+ export_parser.add_argument("--token")
55
+ export_parser.add_argument("--product", action="append", type=int, default=[])
56
+ export_parser.add_argument("--out", default="bundle.json")
57
+ export_parser.add_argument("--base-url", default=None)
58
+ export_parser.add_argument("--interactive", action="store_true")
59
+ export_parser.add_argument("--free-tier", action="store_true")
60
+ export_parser.add_argument("--skip-data-objects", action="store_true")
61
+ export_parser.add_argument("--insecure", action="store_true")
62
+
63
+ validate_parser = sub.add_parser("validate", help="Validate a bundle offline")
64
+ validate_parser.add_argument("--bundle", required=True)
65
+ validate_parser.add_argument("--json", action="store_true")
66
+
67
+ args = parser.parse_args(argv)
68
+ try:
69
+ if args.command == "list-products":
70
+ token = _token(args.token)
71
+ base_url = args.base_url or "https://api.cryptolens.io/api"
72
+ products = list_products(
73
+ token, base_url=base_url, free_tier=args.free_tier, insecure=args.insecure
74
+ )
75
+ if args.json:
76
+ import json
77
+
78
+ print(json.dumps([
79
+ {"id": p.id, "name": p.name, "keyAlgorithm": p.key_algorithm}
80
+ for p in products
81
+ ], indent=2))
82
+ else:
83
+ for product in products:
84
+ print(f"{product.id}\t{product.name}\t{product.key_algorithm}")
85
+ return 0
86
+
87
+ if args.command == "export":
88
+ token = _token(args.token)
89
+ base_url = args.base_url or "https://api.cryptolens.io/api"
90
+ product_ids = list(args.product)
91
+ if not product_ids and args.interactive:
92
+ product_ids = _select_products(token, base_url, args.free_tier, args.insecure)
93
+ if not product_ids:
94
+ raise ScientiveShieldToolsError("Select products with --product or use --interactive.")
95
+ result = CryptolensClient(
96
+ token,
97
+ base_url=base_url,
98
+ free_tier=args.free_tier,
99
+ insecure=args.insecure,
100
+ ).export(
101
+ product_ids,
102
+ args.out,
103
+ include_data_objects=not args.skip_data_objects,
104
+ )
105
+ print(result.summary)
106
+ for warning in result.warnings:
107
+ print(f"WARNING: {warning}", file=sys.stderr)
108
+ return 0
109
+
110
+ if args.command == "validate":
111
+ result = validate_import_file(args.bundle)
112
+ if args.json:
113
+ import json
114
+
115
+ print(json.dumps({
116
+ "ok": result.ok,
117
+ "errors": result.errors,
118
+ "warnings": result.warnings,
119
+ "counts": result.counts,
120
+ }, indent=2))
121
+ else:
122
+ for error in result.errors:
123
+ print(f"ERROR: {error}", file=sys.stderr)
124
+ for warning in result.warnings:
125
+ print(f"WARNING: {warning}", file=sys.stderr)
126
+ if result.ok:
127
+ print("Bundle OK:", result.counts)
128
+ else:
129
+ return 2
130
+ return 0 if result.ok else 2
131
+ except ScientiveShieldToolsError as exc:
132
+ print(f"ERROR: {exc}", file=sys.stderr)
133
+ return 2
134
+ except KeyboardInterrupt:
135
+ return 130
136
+ return 1
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())
@@ -0,0 +1,436 @@
1
+ """Cryptolens/Devolens export client and bundle builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Iterable
10
+
11
+ import requests
12
+
13
+ from .bundle import (
14
+ SCHEMA_VERSION,
15
+ normalize_customer_id,
16
+ normalize_machine_limit,
17
+ validate_bundle_data,
18
+ write_bundle,
19
+ )
20
+ from .errors import ConfigurationError, CryptolensError, ValidationError
21
+ from .models import ExportResult, Product
22
+
23
+ DEFAULT_BASE_URL = "https://api.cryptolens.io/api"
24
+ USER_AGENT = "scientiveshield-tools/0.1.0"
25
+
26
+
27
+ class CryptolensClient:
28
+ """Small, read-only Cryptolens API client for migration exports."""
29
+
30
+ def __init__(
31
+ self,
32
+ token: str,
33
+ *,
34
+ base_url: str = DEFAULT_BASE_URL,
35
+ timeout: float = 90,
36
+ free_tier: bool = False,
37
+ min_request_interval: float | None = None,
38
+ max_retries: int = 8,
39
+ insecure: bool = False,
40
+ session: requests.Session | None = None,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ clock: Callable[[], float] = time.monotonic,
43
+ ) -> None:
44
+ if not token or not token.strip():
45
+ raise ConfigurationError("A Cryptolens read-only access token is required.")
46
+ self._token = token.strip()
47
+ self.base_url = base_url.rstrip("/")
48
+ self.timeout = timeout
49
+ self.free_tier = free_tier
50
+ self.min_request_interval = max(
51
+ float(min_request_interval or 0),
52
+ 10.0 if free_tier else 0.0,
53
+ )
54
+ self.max_retries = max(1, max_retries)
55
+ self.insecure = insecure
56
+ self._session = session or requests.Session()
57
+ self._session.headers.update({"User-Agent": USER_AGENT})
58
+ self._sleep = sleep
59
+ self._clock = clock
60
+ self._last_request_at: float | None = None
61
+
62
+ def __repr__(self) -> str:
63
+ return f"CryptolensClient(base_url={self.base_url!r}, token=<redacted>)"
64
+
65
+ def _wait_for_turn(self) -> None:
66
+ if self.min_request_interval <= 0 or self._last_request_at is None:
67
+ return
68
+ remaining = self.min_request_interval - (self._clock() - self._last_request_at)
69
+ if remaining > 0:
70
+ self._sleep(remaining)
71
+
72
+ def _retry_delay(self, attempt: int, response: requests.Response | None) -> float:
73
+ if response is not None:
74
+ retry_after = response.headers.get("Retry-After")
75
+ if retry_after:
76
+ try:
77
+ return max(0.0, float(retry_after))
78
+ except ValueError:
79
+ pass
80
+ if response.status_code == 429:
81
+ return 10.0 if self.free_tier else min(2.0**attempt, 20.0)
82
+ return min(2.0**attempt, 20.0)
83
+
84
+ def _post(self, method: str, data: dict[str, Any]) -> dict[str, Any]:
85
+ payload = {"token": self._token, **{k: v for k, v in data.items() if v is not None}}
86
+ url = f"{self.base_url}/{method}"
87
+ delay = 1.0
88
+ for attempt in range(self.max_retries + 1):
89
+ self._wait_for_turn()
90
+ self._last_request_at = self._clock()
91
+ try:
92
+ response = self._session.post(
93
+ url,
94
+ data=payload,
95
+ timeout=self.timeout,
96
+ verify=not self.insecure,
97
+ )
98
+ except requests.RequestException as exc:
99
+ if attempt >= self.max_retries:
100
+ raise CryptolensError(f"{method}: could not reach Cryptolens: {exc}") from exc
101
+ self._sleep(delay)
102
+ delay = min(delay * 2, 20)
103
+ continue
104
+
105
+ if response.status_code == 429 or response.status_code >= 500:
106
+ if attempt >= self.max_retries:
107
+ raise CryptolensError(
108
+ f"{method}: Cryptolens returned HTTP {response.status_code} after retries."
109
+ )
110
+ wait = self._retry_delay(attempt, response)
111
+ if response.status_code == 429:
112
+ # Avoid collecting another 429 on the next call.
113
+ self.min_request_interval = max(self.min_request_interval, 10.0)
114
+ self._sleep(wait)
115
+ delay = max(delay, wait)
116
+ continue
117
+
118
+ if response.status_code >= 400:
119
+ raise CryptolensError(f"{method}: Cryptolens returned HTTP {response.status_code}.")
120
+ try:
121
+ body = response.json()
122
+ except ValueError as exc:
123
+ raise CryptolensError(f"{method}: Cryptolens returned a non-JSON response.") from exc
124
+ if body.get("result") not in (0, None):
125
+ raise CryptolensError(f"{method}: {body.get('message') or 'request failed'}")
126
+ return body
127
+ raise CryptolensError(f"{method}: retries exhausted")
128
+
129
+ def list_products(self) -> list[Product]:
130
+ body = self._post("product/GetProducts", {})
131
+ products = body.get("products") or []
132
+ result: list[Product] = []
133
+ for item in products:
134
+ try:
135
+ product_id = int(item.get("id"))
136
+ except (TypeError, ValueError):
137
+ continue
138
+ result.append(Product(
139
+ id=product_id,
140
+ name=str(item.get("name") or f"Product {product_id}"),
141
+ key_algorithm=key_algorithm(item),
142
+ is_public=bool(item.get("isPublic", False)),
143
+ feature_definitions=tuple(product_features(item)),
144
+ ))
145
+ return result
146
+
147
+ def export(
148
+ self,
149
+ product_ids: Iterable[int],
150
+ output: str | Path = "bundle.json",
151
+ *,
152
+ include_data_objects: bool = True,
153
+ ) -> ExportResult:
154
+ selected_ids = sorted({int(value) for value in product_ids})
155
+ if not selected_ids:
156
+ raise ConfigurationError("Select at least one product id explicitly.")
157
+
158
+ all_products = self.list_products()
159
+ by_id = {product.id: product for product in all_products}
160
+ missing = [value for value in selected_ids if value not in by_id]
161
+ if missing:
162
+ raise ConfigurationError(f"Unknown product ids: {', '.join(map(str, missing))}")
163
+ selected = [by_id[value] for value in selected_ids]
164
+
165
+ products = [
166
+ {
167
+ "sourceId": product.id,
168
+ "name": product.name,
169
+ "keyAlgorithm": product.key_algorithm,
170
+ "isPublic": product.is_public,
171
+ "defaultTrialDays": 15,
172
+ "featureDefinitions": list(product.feature_definitions),
173
+ }
174
+ for product in selected
175
+ ]
176
+
177
+ customers_body = self._post("customer/GetCustomers", {"ModelVersion": 3})
178
+ customers_raw = customers_body.get("customers") or customers_body.get("Customers") or []
179
+ customers = [self._customer(item) for item in customers_raw if isinstance(item, dict)]
180
+
181
+ templates_body = self._post("licensetemplate/GetLicenseTemplates", {})
182
+ templates_raw = templates_body.get("licenseTemplates") or templates_body.get("LicenseTemplates") or []
183
+ templates = [
184
+ parsed for parsed in (parse_template(item) for item in templates_raw if isinstance(item, dict))
185
+ if parsed["sourceProductId"] in selected_ids
186
+ ]
187
+
188
+ warnings: list[str] = []
189
+ keys: list[dict[str, Any]] = []
190
+ for product in selected:
191
+ page = 1
192
+ while True:
193
+ body = self._post(
194
+ "product/GetKeys",
195
+ {"ProductId": product.id, "Page": page, "PageSize": 99},
196
+ )
197
+ page_keys = body.get("licenseKey") or body.get("licenseKeys") or body.get("keys") or []
198
+ for raw_key in page_keys:
199
+ if isinstance(raw_key, dict):
200
+ keys.append(self._key(raw_key, product.id, include_data_objects, warnings))
201
+ returned = int(body.get("returned") or len(page_keys))
202
+ page_count = int(body.get("pageCount") or 0)
203
+ if (page_count and page >= page_count) or returned < 99:
204
+ break
205
+ page += 1
206
+
207
+ bundle = {
208
+ "schemaVersion": SCHEMA_VERSION,
209
+ "source": "cryptolens",
210
+ "exportedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
211
+ "products": products,
212
+ "customers": customers,
213
+ "templates": templates,
214
+ "keys": keys,
215
+ "warnings": warnings,
216
+ }
217
+ bundle["counts"] = {
218
+ "products": len(products),
219
+ "customers": len(customers),
220
+ "templates": len(templates),
221
+ "keys": len(keys),
222
+ "activations": sum(len(k["activations"]) for k in keys),
223
+ "dataObjects": sum(len(k["dataObjects"]) for k in keys),
224
+ }
225
+
226
+ validation = validate_bundle_data(bundle)
227
+ if validation.errors:
228
+ raise ValidationError("; ".join(validation.errors))
229
+ warnings.extend(w for w in validation.warnings if w not in warnings)
230
+ bundle["warnings"] = warnings
231
+
232
+ target = write_bundle(output, bundle)
233
+ return ExportResult(
234
+ path=target,
235
+ products=len(products),
236
+ customers=len(customers),
237
+ templates=len(templates),
238
+ keys=len(keys),
239
+ activations=bundle["counts"]["activations"],
240
+ data_objects=bundle["counts"]["dataObjects"],
241
+ warnings=warnings,
242
+ bytes_written=target.stat().st_size,
243
+ )
244
+
245
+ def _key(
246
+ self,
247
+ raw: dict[str, Any],
248
+ product_id: int,
249
+ include_data_objects: bool,
250
+ warnings: list[str],
251
+ ) -> dict[str, Any]:
252
+ key = str(raw.get("key") or "").strip()
253
+ original_max = raw.get("maxNoOfMachines")
254
+ max_machines = normalize_machine_limit(original_max)
255
+ if original_max is not None and int(original_max or 0) < 0:
256
+ warnings.append(f"License {key} has a negative machine limit; treating it as unlimited.")
257
+
258
+ data_objects: list[dict[str, Any]] = []
259
+ if include_data_objects:
260
+ for obj in raw.get("dataObjects") or []:
261
+ if isinstance(obj, dict):
262
+ _add_data_object(data_objects, obj, "")
263
+ try:
264
+ body = self._post(
265
+ "data/ListDataObjectsToKey",
266
+ {"ProductId": product_id, "Key": key},
267
+ )
268
+ for obj in body.get("dataObjects") or []:
269
+ if isinstance(obj, dict):
270
+ _add_data_object(data_objects, obj, "")
271
+ except CryptolensError as exc:
272
+ warnings.append(f"Data objects for {key}: {exc}")
273
+
274
+ activations = [
275
+ {
276
+ "machineCode": str(item.get("mid") or ""),
277
+ "friendlyName": str(item.get("friendlyName") or ""),
278
+ "ip": str(item.get("ip") or ""),
279
+ "time": item.get("time"),
280
+ "floatingExpires": item.get("floatingExpires"),
281
+ }
282
+ for item in (raw.get("activatedMachines") or [])
283
+ if isinstance(item, dict)
284
+ ]
285
+
286
+ if include_data_objects:
287
+ for activation in activations:
288
+ machine = activation["machineCode"].strip()
289
+ if not machine:
290
+ continue
291
+ try:
292
+ body = self._post(
293
+ "data/ListDataObjectsToMachineCode",
294
+ {"ProductId": product_id, "Key": key, "MachineCode": machine},
295
+ )
296
+ for obj in body.get("dataObjects") or []:
297
+ if isinstance(obj, dict):
298
+ _add_data_object(data_objects, obj, machine)
299
+ except CryptolensError as exc:
300
+ warnings.append(f"Machine data objects for {key} / {machine}: {exc}")
301
+
302
+ return {
303
+ "sourceProductId": product_id,
304
+ "key": key,
305
+ "sourceId": raw.get("id"),
306
+ "sourceGlobalId": raw.get("globalId"),
307
+ "created": raw.get("created"),
308
+ "expires": raw.get("expires"),
309
+ "period": int(raw.get("period") or 0),
310
+ "f1": bool(raw.get("f1", False)),
311
+ "f2": bool(raw.get("f2", False)),
312
+ "f3": bool(raw.get("f3", False)),
313
+ "f4": bool(raw.get("f4", False)),
314
+ "f5": bool(raw.get("f5", False)),
315
+ "f6": bool(raw.get("f6", False)),
316
+ "f7": bool(raw.get("f7", False)),
317
+ "f8": bool(raw.get("f8", False)),
318
+ "notes": str(raw.get("notes") or ""),
319
+ "block": bool(raw.get("block", False)),
320
+ "maxNoOfMachines": max_machines,
321
+ "allowedMachines": str(raw.get("allowedMachines") or ""),
322
+ "trialActivation": bool(raw.get("trialActivation", False)),
323
+ "sourceCustomerId": normalize_customer_id((raw.get("customer") or {}).get("id")),
324
+ "activations": activations,
325
+ "dataObjects": data_objects,
326
+ }
327
+
328
+ @staticmethod
329
+ def _customer(raw: dict[str, Any]) -> dict[str, Any]:
330
+ return {
331
+ "sourceId": raw.get("id"),
332
+ "name": str(raw.get("name") or ""),
333
+ "email": str(raw.get("email") or ""),
334
+ "companyName": str(raw.get("companyName") or ""),
335
+ "secret": str(raw.get("secret") or ""),
336
+ }
337
+
338
+ def _add_data_object(target: list[dict[str, Any]], raw: dict[str, Any], machine: str) -> None:
339
+ name = str(raw.get("name") or "").strip()
340
+ if not name:
341
+ return
342
+ machine_code = machine or str(raw.get("machineCode") or "").strip()
343
+ if any(item["name"] == name and item["machineCode"] == machine_code for item in target):
344
+ return
345
+ target.append({
346
+ "name": name,
347
+ "stringValue": str(raw.get("stringValue") or ""),
348
+ "intValue": int(raw.get("intValue") or 0),
349
+ "machineCode": machine_code,
350
+ })
351
+
352
+
353
+ def parse_template(template: dict[str, Any]) -> dict[str, Any]:
354
+ params = template.get("parameters")
355
+ if isinstance(params, str):
356
+ try:
357
+ params = json.loads(params)
358
+ except json.JSONDecodeError:
359
+ params = {}
360
+ if isinstance(params, list):
361
+ params = next(
362
+ (item.get("Parameters", {}) for item in params if item.get("APIMethodName") == "CreateKey"),
363
+ {},
364
+ )
365
+ if not isinstance(params, dict):
366
+ params = {}
367
+
368
+ def value(name: str, default: Any = None) -> Any:
369
+ return params.get(name, params.get(name[:1].upper() + name[1:], default))
370
+
371
+ return {
372
+ "sourceId": template.get("id"),
373
+ "sourceProductId": template.get("productId") or value("ProductId"),
374
+ "name": template.get("name") or f"Template {template.get('id')}",
375
+ "period": int(value("Period", 30) or 30),
376
+ "f1": bool(value("F1", False)),
377
+ "f2": bool(value("F2", False)),
378
+ "f3": bool(value("F3", False)),
379
+ "f4": bool(value("F4", False)),
380
+ "f5": bool(value("F5", False)),
381
+ "f6": bool(value("F6", False)),
382
+ "f7": bool(value("F7", False)),
383
+ "f8": bool(value("F8", False)),
384
+ "maxNoOfMachines": int(value("MaxNoOfMachines", 0) or 0),
385
+ "trialActivation": bool(value("TrialActivation", False)),
386
+ "block": bool(value("Block", False)),
387
+ "notes": value("Notes") or "",
388
+ }
389
+
390
+
391
+ def key_algorithm(product: dict[str, Any]) -> str:
392
+ return "Skgl" if product.get("keyAlgorithm") == 0 else "Skm15"
393
+
394
+
395
+ def product_features(product: dict[str, Any]) -> list[dict[str, str]]:
396
+ definitions = product.get("featureDefinitions") or {}
397
+ result: list[dict[str, str]] = []
398
+ for index in range(1, 9):
399
+ item = definitions.get(f"f{index}") or {}
400
+ name = item.get("name")
401
+ if name:
402
+ result.append({"key": f"F{index}", "name": str(name)})
403
+ return result
404
+
405
+
406
+ def list_products(
407
+ token: str,
408
+ *,
409
+ base_url: str = DEFAULT_BASE_URL,
410
+ free_tier: bool = False,
411
+ insecure: bool = False,
412
+ ) -> list[Product]:
413
+ return CryptolensClient(
414
+ token,
415
+ base_url=base_url,
416
+ free_tier=free_tier,
417
+ insecure=insecure,
418
+ ).list_products()
419
+
420
+
421
+ def create_import_file(
422
+ token: str,
423
+ product_ids: Iterable[int],
424
+ output: str | Path = "bundle.json",
425
+ *,
426
+ base_url: str = DEFAULT_BASE_URL,
427
+ free_tier: bool = False,
428
+ include_data_objects: bool = True,
429
+ insecure: bool = False,
430
+ ) -> ExportResult:
431
+ return CryptolensClient(
432
+ token,
433
+ base_url=base_url,
434
+ free_tier=free_tier,
435
+ insecure=insecure,
436
+ ).export(product_ids, output, include_data_objects=include_data_objects)
@@ -0,0 +1,17 @@
1
+ """Package exceptions."""
2
+
3
+
4
+ class ScientiveShieldToolsError(Exception):
5
+ """Base exception for the exporter."""
6
+
7
+
8
+ class ConfigurationError(ScientiveShieldToolsError):
9
+ """Invalid local configuration or arguments."""
10
+
11
+
12
+ class CryptolensError(ScientiveShieldToolsError):
13
+ """Cryptolens rejected a request or could not be reached."""
14
+
15
+
16
+ class ValidationError(ScientiveShieldToolsError):
17
+ """An import bundle is not valid."""
@@ -0,0 +1,57 @@
1
+ """Public result and product models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Product:
12
+ id: int
13
+ name: str
14
+ key_algorithm: str = "Skm15"
15
+ is_public: bool = False
16
+ feature_definitions: tuple[dict[str, str], ...] = field(default_factory=tuple)
17
+
18
+
19
+ @dataclass
20
+ class ExportResult:
21
+ path: Path
22
+ products: int = 0
23
+ customers: int = 0
24
+ templates: int = 0
25
+ keys: int = 0
26
+ activations: int = 0
27
+ data_objects: int = 0
28
+ warnings: list[str] = field(default_factory=list)
29
+ bytes_written: int = 0
30
+
31
+ @property
32
+ def summary(self) -> str:
33
+ return (
34
+ f"Wrote {self.path}: {self.products} products, {self.customers} customers, "
35
+ f"{self.templates} templates, {self.keys} keys, {self.activations} activations, "
36
+ f"{self.data_objects} data objects."
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class ValidationResult:
42
+ ok: bool
43
+ errors: list[str] = field(default_factory=list)
44
+ warnings: list[str] = field(default_factory=list)
45
+ counts: dict[str, int] = field(default_factory=dict)
46
+
47
+ def raise_for_errors(self) -> None:
48
+ if self.errors:
49
+ from .errors import ValidationError
50
+
51
+ raise ValidationError("; ".join(self.errors))
52
+
53
+
54
+ @dataclass
55
+ class ExportBundle:
56
+ data: dict[str, Any]
57
+ summary: ExportResult
File without changes
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: scientiveshield-tools
3
+ Version: 0.1.0
4
+ Summary: Export Devolens (Cryptolens) licensing data into a ScientiveShield import bundle.
5
+ Author: ScientiveShield
6
+ License-Expression: BUSL-1.1
7
+ Project-URL: Homepage, https://scientiveshield.pro/migrate/developer/
8
+ Project-URL: Documentation, https://scientiveshield.pro/migrate/developer/
9
+ Project-URL: Repository, https://github.com/franciscomvargas/scientiveshield-tools
10
+ Project-URL: Issues, https://github.com/franciscomvargas/scientiveshield-tools/issues
11
+ Keywords: licensing,cryptolens,devolens,migration,scientiveshield
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: requests>=2.31
25
+ Dynamic: license-file
26
+
27
+ # scientiveshield-tools
28
+
29
+ Export Devolens (formerly Cryptolens) products, customers, templates, licenses, activations and
30
+ data objects into one ScientiveShield import bundle.
31
+
32
+ This package is the official ScientiveShield exporter. It is **not** a Cryptolens SDK.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ python -m pip install --upgrade scientiveshield-tools
38
+ ```
39
+
40
+ Python 3.10+ is required.
41
+
42
+ ## Create an import file
43
+
44
+ Use a read-only Devolens/Cryptolens token with these permissions:
45
+
46
+ - Get Products
47
+ - Get Keys
48
+ - Get Customers
49
+ - License Template
50
+ - List Data Objects
51
+
52
+ ```python
53
+ from scientiveshield_tools import create_import_file
54
+
55
+ result = create_import_file(
56
+ token="read-only-cryptolens-token",
57
+ product_ids=[33453, 33454],
58
+ output="bundle.json",
59
+ )
60
+
61
+ print(result.summary)
62
+ ```
63
+
64
+ The exporter writes schema version 2 and preserves key strings. It includes key-scoped and
65
+ machine-scoped data objects. Product selection is explicit; it never exports every product by
66
+ accident.
67
+
68
+ ### Inspect products
69
+
70
+ ```python
71
+ from scientiveshield_tools import list_products
72
+
73
+ for product in list_products(token="..."):
74
+ print(product.id, product.name)
75
+ ```
76
+
77
+ ### Validate the file
78
+
79
+ ```python
80
+ from scientiveshield_tools import validate_import_file
81
+
82
+ result = validate_import_file("bundle.json")
83
+ print(result.ok, result.errors, result.warnings)
84
+ ```
85
+
86
+ ### Free environment
87
+
88
+ Cryptolens’ limited free environment allows one API request every 10 seconds. Use:
89
+
90
+ ```python
91
+ create_import_file(..., free_tier=True)
92
+ ```
93
+
94
+ The client then spaces requests proactively. On a 429 response it honors `Retry-After`, otherwise
95
+ waits 10 seconds. A free-tier export can take a few minutes; that is expected.
96
+
97
+ ## CLI (optional)
98
+
99
+ ```bash
100
+ python -m scientiveshield_tools list-products --token "$CRYPTOLENS_TOKEN"
101
+ python -m scientiveshield_tools export --interactive --out bundle.json
102
+ python -m scientiveshield_tools validate --bundle bundle.json
103
+ ```
104
+
105
+ ## Security
106
+
107
+ - Use a **read-only** token. Never use the Cryptolens account password.
108
+ - The token is never logged or included in the bundle.
109
+ - The bundle contains customer secrets and license data. Share it only via a secure channel.
110
+ - Revoke the source token after the export.
111
+ - Do not commit `bundle.json` or tokens.
112
+
113
+ ## Import and cutover
114
+
115
+ Upload the bundle in ScientiveShield → **Migration**. The dashboard validates it, shows a dry-run
116
+ preview, imports through a background worker, and provides the app cutover kit.
117
+
118
+ Developer guide: <https://scientiveshield.pro/migrate/developer/>
119
+
120
+ ## License
121
+
122
+ Business Source License 1.1 with an Additional Use Grant. See [LICENSE](LICENSE). Commercial use is
123
+ permitted for migrating your own or your clients’ software into ScientiveShield. The Licensed Work
124
+ may not be offered as a standalone commercial product, managed service, or competing licensing
125
+ platform. The Change Date is 2030-10-21; the Change License is Apache-2.0.
@@ -0,0 +1,14 @@
1
+ scientiveshield_tools/__init__.py,sha256=z2uH2XqkjGscbg9ICwrdwENPoiB_UU8ajA57bNGIhoM,833
2
+ scientiveshield_tools/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ scientiveshield_tools/bundle.py,sha256=7aXVpCfgP2t0DRgquz6iIbbUxYoT1zX0hZTN1hlLtuM,6130
4
+ scientiveshield_tools/cli.py,sha256=BvM5fxIVELmeZPjBq0MZ4W0V_-rSYDplzAivDf6C0rw,5676
5
+ scientiveshield_tools/cryptolens.py,sha256=78_ilakhTI0LnBdSADCLw4OXSvkMO5KBSiuHJZaoPeo,16812
6
+ scientiveshield_tools/errors.py,sha256=OteAFDOLZt4NKB9pJ4hf2aLpH3NmcBl0m0eWf6gLqGs,432
7
+ scientiveshield_tools/models.py,sha256=tk8N7lnyAfUUoR-yjF4gUCs0U1G71ghHJituP8GpXBU,1416
8
+ scientiveshield_tools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ scientiveshield_tools-0.1.0.dist-info/licenses/LICENSE,sha256=7FD0ka9GSHKOmn0GsZsexsbo66K2eNXpNackhuWdtcs,4164
10
+ scientiveshield_tools-0.1.0.dist-info/METADATA,sha256=S9WwWi5ktzVZZuaatbTizk_eN348CkaAACTEe0ABc0E,4054
11
+ scientiveshield_tools-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ scientiveshield_tools-0.1.0.dist-info/entry_points.txt,sha256=Ep0-lYsA6oAEt-zN3dyXlaiiwglZLLAe1og-gCIMaDY,73
13
+ scientiveshield_tools-0.1.0.dist-info/top_level.txt,sha256=GB6YKrYWc-1qmZ-8jCH38VnALvsXywegVbJEoSlFjQg,22
14
+ scientiveshield_tools-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scientiveshield-tools = scientiveshield_tools.cli:main
@@ -0,0 +1,88 @@
1
+ Business Source License 1.1
2
+
3
+ License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.
4
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
5
+
6
+ Terms
7
+
8
+ The Licensor hereby grants you the right to copy, modify, create derivative
9
+ works, redistribute, and make non-production use of the Licensed Work. The
10
+ Licensor may make an Additional Use Grant, above, permitting limited
11
+ production use.
12
+
13
+ Effective on the Change Date, or the fourth anniversary of the first publicly
14
+ available distribution of a specific version of the Licensed Work under this
15
+ License, whichever comes first, the Licensor hereby grants you rights under
16
+ the terms of the Change License, and the rights granted in the paragraph
17
+ above terminate.
18
+
19
+ If your use of the Licensed Work does not comply with the requirements
20
+ currently in effect as described in this License, you must purchase a
21
+ commercial license from the Licensor, its affiliated entities, or authorized
22
+ resellers, or you must refrain from using the Licensed Work.
23
+
24
+ All copies of the original and modified Licensed Work, and derivative works
25
+ of the Licensed Work, are subject to this License. This License applies
26
+ separately for each version of the Licensed Work and the Change Date may vary
27
+ for each version of the Licensed Work released by Licensor.
28
+
29
+ You must conspicuously display this License on each original or modified copy
30
+ of the Licensed Work. If you receive the Licensed Work in original or
31
+ modified form from a third party, the terms and conditions set forth in this
32
+ License apply to your use of that work.
33
+
34
+ Any use of the Licensed Work in violation of this License will automatically
35
+ terminate your rights under this License for the current and all other
36
+ versions of the Licensed Work.
37
+
38
+ This License does not grant you any right in any trademark or logo of
39
+ Licensor or its affiliates (provided that you may use a trademark or logo of
40
+ Licensor as expressly required by this License).
41
+
42
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
43
+ AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
44
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
45
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
46
+ TITLE.
47
+
48
+ MariaDB hereby grants you permission to use this License’s text to license
49
+ your works, and to refer to it using the trademark “Business Source License”,
50
+ as long as you comply with the Covenants of Licensor below.
51
+
52
+ Covenants of Licensor
53
+
54
+ In consideration of the right to use this License’s text and the “Business
55
+ Source License” name and trademark, Licensor covenants to MariaDB, and to all
56
+ other recipients of the licensed work to be provided by Licensor:
57
+
58
+ 1. To specify as the Change License the GPL Version 2.0 or any later version,
59
+ or a license that is compatible with GPL Version 2.0 or a later version,
60
+ where “compatible” means that software provided under the Change License can
61
+ be included in a program with software provided under GPL Version 2.0 or a
62
+ later version. Licensor may specify additional Change Licenses without
63
+ limitation.
64
+
65
+ 2. To either: (a) specify an additional grant of rights to use that does not
66
+ impose any additional restriction on the right granted in this License, as
67
+ the Additional Use Grant; or (b) insert the text “None”.
68
+
69
+ 3. To specify a Change Date.
70
+
71
+ 4. Not to modify this License in any other way.
72
+
73
+ ---
74
+
75
+ License parameters
76
+
77
+ Licensor: ScientiveShield
78
+ Licensed Work: scientiveshield-tools, version 0.1.0 and later versions
79
+ Additional Use Grant: You may use, reproduce, and distribute the Licensed Work,
80
+ including in production, solely to export data from Devolens/Cryptolens for
81
+ import into ScientiveShield. This includes use by the licensed customer's
82
+ employees, contractors, agencies, and other authorized service providers
83
+ acting on the customer's behalf, and internal evaluation, testing, and CI use.
84
+ You may not offer the Licensed Work, or a derivative work, as a standalone
85
+ commercial product, managed service, or as part of a competing software
86
+ licensing platform.
87
+ Change Date: 2030-10-21
88
+ Change License: Apache License, Version 2.0
@@ -0,0 +1 @@
1
+ scientiveshield_tools