QT-PyQt-PySide-Custom-Widgets-Pro 1.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.
Files changed (45) hide show
  1. custom_widgets_pro/__init__.py +73 -0
  2. custom_widgets_pro/_catalog.py +25 -0
  3. custom_widgets_pro/_cli.py +132 -0
  4. custom_widgets_pro/_license.py +419 -0
  5. custom_widgets_pro/_version.py +14 -0
  6. custom_widgets_pro/datatable/__init__.py +17 -0
  7. custom_widgets_pro/datatable/datatable_pro.py +319 -0
  8. custom_widgets_pro/datatable/export.py +143 -0
  9. custom_widgets_pro/datatable/frozen_view.py +112 -0
  10. custom_widgets_pro/datatable/grouping.py +309 -0
  11. custom_widgets_pro/datatable/pivot.py +100 -0
  12. custom_widgets_pro/datatable/provider.py +98 -0
  13. custom_widgets_pro/datatable/virtual_model.py +451 -0
  14. custom_widgets_pro/widgets/__init__.py +7 -0
  15. custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
  16. custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
  17. custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
  18. custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
  19. custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
  20. custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
  21. custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
  22. custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
  23. custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
  24. custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
  25. custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
  26. custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
  27. custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
  28. custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
  29. custom_widgets_pro/widgets/charts/__init__.py +1 -0
  30. custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
  31. custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
  32. custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
  33. custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
  34. custom_widgets_pro/widgets/data/__init__.py +1 -0
  35. custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
  36. custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
  37. custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
  38. custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
  39. custom_widgets_pro/widgets/media/__init__.py +1 -0
  40. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
  41. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
  42. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
  43. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
  44. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
  45. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,73 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # WEBSITE: customwidgets.org
4
+ ########################################################################
5
+ """custom-widgets-pro - commercial Pro widgets for QT-PyQt-PySide-Custom-Widgets.
6
+
7
+ Proprietary. Requires a valid entitlement to develop with (a licence token from
8
+ your Spinn UI account portal, issued for a Stripe subscription or an active
9
+ Patreon membership). Applications you ship run royalty-free. See LICENSE and
10
+ the free-core docs (docs/design/commercial-product.md).
11
+ """
12
+ import os
13
+ import sys
14
+ import warnings
15
+ from importlib import import_module
16
+
17
+ from ._version import __version__
18
+ from ._license import (check_license, require_license, activate,
19
+ LicenseError, LicenseStatus)
20
+ from ._catalog import pro_catalog
21
+
22
+ # The Qt-backed widgets load lazily (PEP 562). Importing any submodule runs
23
+ # this __init__ first, so an eager `.datatable` import here would drag qtpy ->
24
+ # the whole GUI stack into *every* entry point - including the
25
+ # `custom-widgets-pro` licence CLI, which then could not run at all unless the
26
+ # free core and PySide were both installed and healthy. A licence command must
27
+ # work on a broken Qt install; that is often exactly why someone is running it.
28
+ # `_license` and `_catalog` stay eager: both are pure stdlib.
29
+ _LAZY_WIDGETS = (
30
+ "QCustomDataTablePro", "VirtualDataTableModel", "GroupRole",
31
+ "DataProvider", "ListDataProvider", "CallableDataProvider",
32
+ "export_csv", "export_xlsx", "export_table",
33
+ "GroupingEngine", "GroupRow", "aggregate", "AGGREGATORS",
34
+ "pivot_table", "PIVOT_COL_PREFIX", "TOTAL_KEY",
35
+ )
36
+
37
+
38
+ def __getattr__(name):
39
+ """Resolve the Qt-backed names on first use, then cache them in globals()
40
+ so later lookups hit the module dict and never come back here."""
41
+ if name not in _LAZY_WIDGETS:
42
+ raise AttributeError(
43
+ "module %r has no attribute %r" % (__name__, name))
44
+ value = getattr(import_module(".datatable", __name__), name)
45
+ globals()[name] = value
46
+ return value
47
+
48
+
49
+ def __dir__():
50
+ return sorted(__all__)
51
+
52
+ __all__ = [
53
+ "QCustomDataTablePro",
54
+ "VirtualDataTableModel", "GroupRole",
55
+ "DataProvider", "ListDataProvider", "CallableDataProvider",
56
+ "export_csv", "export_xlsx", "export_table",
57
+ "GroupingEngine", "GroupRow", "aggregate", "AGGREGATORS",
58
+ "pivot_table", "PIVOT_COL_PREFIX", "TOTAL_KEY",
59
+ "check_license", "require_license", "activate", "LicenseError", "LicenseStatus",
60
+ "pro_catalog", "__version__",
61
+ ]
62
+
63
+ # Dev-time entitlement check on import: warn once if unlicensed, but do NOT
64
+ # raise - eval/trial and CI of the source tree stay importable. Build/release
65
+ # tooling calls require_license() to hard-gate. The `custom-widgets-pro` CLI
66
+ # reports entitlement itself, so suppress the duplicate import-time warning
67
+ # there (it manages the licence; it should not scold about it).
68
+ _status = check_license()
69
+ if not _status.valid and os.path.basename(sys.argv[0] or "") != "custom-widgets-pro":
70
+ warnings.warn(
71
+ "custom-widgets-pro: %s Running in unlicensed dev/eval mode." % _status.reason,
72
+ stacklevel=2,
73
+ )
@@ -0,0 +1,25 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # WEBSITE: customwidgets.org
4
+ ########################################################################
5
+ """Machine-readable catalog of Pro widgets, aggregated from each widget's
6
+ ``__catalog__`` descriptor. Mirrors the free core's catalog hooks so an agent
7
+ (via MCP) can introspect Pro widgets - their props, variants and tokens - the
8
+ same way it does core widgets.
9
+ """
10
+
11
+ _REGISTRY = []
12
+
13
+
14
+ def register(widget_cls):
15
+ """Register a Pro widget class that exposes ``__catalog__``. Usable as a
16
+ decorator."""
17
+ entry = getattr(widget_cls, "__catalog__", None)
18
+ if entry is not None and entry not in _REGISTRY:
19
+ _REGISTRY.append(entry)
20
+ return widget_cls
21
+
22
+
23
+ def pro_catalog():
24
+ """Return the list of registered Pro widget catalog descriptors."""
25
+ return list(_REGISTRY)
@@ -0,0 +1,132 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # WEBSITE: customwidgets.org
4
+ ########################################################################
5
+ """Command-line entry point for custom-widgets-pro entitlement management.
6
+
7
+ Installed as the ``custom-widgets-pro`` console script (see [project.scripts]
8
+ in pyproject.toml). Thin wrapper over ``_license`` - it validates the licence
9
+ token from your account portal against the billing API, caches the signed
10
+ result, and reports status. Shipped apps stay royalty-free and never call this.
11
+
12
+ custom-widgets-pro status
13
+ custom-widgets-pro activate <licence-token>
14
+ custom-widgets-pro deactivate
15
+ """
16
+ import argparse
17
+ import os
18
+ import sys
19
+
20
+ from ._version import __version__
21
+ from . import _license
22
+ from ._license import activate, check_license, ENV_API, ENV_KEY
23
+
24
+ PROG = "custom-widgets-pro"
25
+
26
+
27
+ def _print_status(status):
28
+ """Human-readable one-block summary of a LicenseStatus."""
29
+ print("%s entitlement: %s" % (PROG, "valid" if status.valid else "not valid"))
30
+ print(" source : %s" % status.source)
31
+ if status.plan_name or status.tier:
32
+ print(" plan : %s" % (status.plan_name or status.tier))
33
+ if status.expires:
34
+ print(" expires : %s" % status.expires)
35
+ if status.max_devices:
36
+ print(" devices : up to %s" % status.max_devices)
37
+ print(" detail : %s" % status.reason)
38
+ print(" server : %s" % _license._api_base())
39
+ print(" cache : %s" % _license._config_path())
40
+
41
+
42
+ def _cmd_status(args):
43
+ status = check_license(refresh=args.refresh)
44
+ _print_status(status)
45
+ return 0 if status.valid else 1
46
+
47
+
48
+ def _cmd_activate(args):
49
+ if args.api_url:
50
+ os.environ[ENV_API] = args.api_url
51
+ token = _license._resolve_token(args.token)
52
+ if not token:
53
+ print("error: provide the licence token from your account portal "
54
+ "(or set %s)" % ENV_KEY, file=sys.stderr)
55
+ return 2
56
+
57
+ print("Validating licence against %s ..." % _license._api_base())
58
+ status = activate(token=token)
59
+ _print_status(status)
60
+ if status.valid:
61
+ print("\nActivated. This machine is now entitled to develop with Pro.")
62
+ return 0
63
+ print("\nActivation failed. Nothing was cached.", file=sys.stderr)
64
+ return 1
65
+
66
+
67
+ def _cmd_deactivate(args):
68
+ """Drop the local cache and stored token. This does not free the device
69
+ seat on the server - that is done from the account portal, which is the
70
+ only place holding the user credentials needed to release it. The device
71
+ id is deliberately kept, so re-activating reuses the same seat."""
72
+ path = _license._config_path()
73
+ _license._cached_status = None
74
+ removed = False
75
+ for target in (path, _license._token_path()):
76
+ try:
77
+ os.remove(target)
78
+ removed = True
79
+ except FileNotFoundError:
80
+ pass
81
+ except OSError as exc:
82
+ print("error: could not remove %s: %s" % (target, exc),
83
+ file=sys.stderr)
84
+ return 1
85
+ if removed:
86
+ print("Removed local entitlement cache: %s" % path)
87
+ else:
88
+ print("No local entitlement cache to remove (%s)" % path)
89
+ print("Note: to free this device's seat, deactivate it in your account "
90
+ "portal.")
91
+ return 0
92
+
93
+
94
+ def build_parser():
95
+ p = argparse.ArgumentParser(
96
+ prog=PROG,
97
+ description="Manage your custom-widgets-pro developer entitlement.")
98
+ p.add_argument("--version", action="version",
99
+ version="%s %s" % (PROG, __version__))
100
+ sub = p.add_subparsers(dest="command")
101
+
102
+ s = sub.add_parser("status", help="show the current entitlement status")
103
+ s.add_argument("--refresh", action="store_true",
104
+ help="force online re-validation (skip the cache)")
105
+ s.set_defaults(func=_cmd_status)
106
+
107
+ a = sub.add_parser("activate",
108
+ help="validate your licence token online and cache it")
109
+ a.add_argument("token", nargs="?",
110
+ help="licence token from your account portal")
111
+ a.add_argument("--api-url", metavar="URL",
112
+ help="override the billing API base URL (staging / self-host)")
113
+ a.set_defaults(func=_cmd_activate)
114
+
115
+ d = sub.add_parser("deactivate",
116
+ help="remove the local entitlement cache (log out)")
117
+ d.set_defaults(func=_cmd_deactivate)
118
+
119
+ return p
120
+
121
+
122
+ def main(argv=None):
123
+ parser = build_parser()
124
+ args = parser.parse_args(argv)
125
+ if not getattr(args, "func", None):
126
+ parser.print_help()
127
+ return 0
128
+ return args.func(args)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())
@@ -0,0 +1,419 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # WEBSITE: customwidgets.org
4
+ ########################################################################
5
+ """Dev-time entitlement check for custom-widgets-pro.
6
+
7
+ Enforcement is **development-time only**. A valid entitlement is required to
8
+ *develop and build* with the Pro widgets; applications you ship run
9
+ **royalty-free** and must never call back here.
10
+
11
+ Entitlement is a single **licence token** issued by the Spinn UI account portal
12
+ and validated against our own billing API. The server is the only authority on
13
+ who is entitled: it already resolves Stripe subscriptions *and* Patreon
14
+ memberships into the same ``license_tokens`` table, so the client never talks to
15
+ a payment provider. There is deliberately no Gumroad / LemonSqueezy /
16
+ Patreon-direct path here - selling through a third-party storefront was
17
+ considered and rejected in favour of owning billing.
18
+
19
+ Verification uses only the Python standard library (``urllib``) so the Pro
20
+ package pulls in no extra runtime dependency. A validated entitlement is cached
21
+ to a signed local file with an **offline grace** period and **perpetual
22
+ fallback** (the covered version keeps working after a subscription lapses).
23
+ Enforcement is deliberately soft at runtime - the real teeth are native
24
+ compilation of the shipped wheels plus the licence agreement.
25
+
26
+ Configuration:
27
+ CUSTOM_WIDGETS_PRO_LICENSE licence token from the account portal
28
+ CUSTOM_WIDGETS_PRO_API_URL billing API base URL (staging / self-host)
29
+ CUSTOM_WIDGETS_PRO_NO_CHECK opt-out (CI of licensed users)
30
+ """
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+ import os
35
+ import platform
36
+ import sys
37
+ import time
38
+ import urllib.error
39
+ import urllib.request
40
+ import uuid
41
+
42
+ ENV_KEY = "CUSTOM_WIDGETS_PRO_LICENSE" # licence token
43
+ ENV_API = "CUSTOM_WIDGETS_PRO_API_URL" # billing API base URL
44
+ ENV_DISABLE = "CUSTOM_WIDGETS_PRO_NO_CHECK" # opt-out for CI of licensed users
45
+
46
+ # The billing API that issues and validates tokens. Env-overridable so a build
47
+ # can be pointed at a staging or self-hosted server.
48
+ _DEFAULT_API_URL = "https://billing.customwidgets.org"
49
+ _VALIDATE_PATH = "/api/licenses/validate"
50
+
51
+ _GRACE_DAYS = 14 # offline grace once validated
52
+ _HTTP_TIMEOUT = 8 # seconds per API call
53
+ _CACHE_VERSION = 2 # v1 cached store/Patreon grants
54
+ # Obfuscation-grade only: the cache signature deters casual hand-editing, not a
55
+ # determined attacker (a client-side key can't be secret). Enforcement is soft
56
+ # by design; the real protection is native compilation + the licence agreement.
57
+ _CACHE_HMAC_KEY = b"custom-widgets-pro/entitlement-cache/v1"
58
+
59
+ _cached_status = None # process-level memo
60
+
61
+
62
+ class LicenseError(RuntimeError):
63
+ """Raised by require_license() when no valid entitlement is present."""
64
+
65
+
66
+ class _NetworkError(Exception):
67
+ """Internal: the API could not be reached (offline / timeout)."""
68
+
69
+
70
+ class LicenseStatus(object):
71
+ def __init__(self, valid, source="none", tier=None, reason="",
72
+ commercial=None, expires=None, plan_name=None,
73
+ max_devices=None):
74
+ self.valid = valid # bool
75
+ self.source = source # "runtime" | "server" | "cache" | "none"
76
+ self.tier = tier # plan slug, e.g. "pro" / "studio" / "lifetime"
77
+ self.reason = reason # human-readable explanation
78
+ self.commercial = commercial # reserved; the server owns entitlement now
79
+ self.expires = expires # ISO date string, or None for perpetual
80
+ self.plan_name = plan_name # display name, e.g. "Pro Yearly"
81
+ self.max_devices = max_devices # seat count; 0 == unlimited
82
+
83
+ def __bool__(self):
84
+ return self.valid
85
+
86
+ def __repr__(self):
87
+ return ("LicenseStatus(valid=%r, source=%r, tier=%r, expires=%r)"
88
+ % (self.valid, self.source, self.tier, self.expires))
89
+
90
+
91
+ def is_frozen():
92
+ """True when running inside a packaged/shipped app (PyInstaller, Nuitka,
93
+ cx_Freeze). Shipped apps are royalty-free, so the check is skipped."""
94
+ return bool(getattr(sys, "frozen", False) or
95
+ globals().get("__compiled__") or
96
+ getattr(sys, "_MEIPASS", None))
97
+
98
+
99
+ def _config_dir():
100
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
101
+ return os.path.join(base, "custom-widgets-pro")
102
+
103
+
104
+ def _config_path():
105
+ return os.path.join(_config_dir(), "license.json")
106
+
107
+
108
+ def _token_path():
109
+ return os.path.join(_config_dir(), "token")
110
+
111
+
112
+ def _store_token(token):
113
+ """Persist the validated token so re-validation (after the offline grace
114
+ lapses, or on ``status --refresh``) doesn't ask the user for it again.
115
+ Written 0600 - it is a bearer credential."""
116
+ path = _token_path()
117
+ try:
118
+ os.makedirs(_config_dir(), exist_ok=True)
119
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
120
+ with os.fdopen(fd, "w") as fh:
121
+ fh.write(token)
122
+ except OSError:
123
+ pass
124
+
125
+
126
+ def _stored_token():
127
+ try:
128
+ with open(_token_path()) as fh:
129
+ return fh.read().strip()
130
+ except OSError:
131
+ return ""
132
+
133
+
134
+ def _resolve_token(explicit=None):
135
+ """Token resolution order: explicit argument, environment, stored file."""
136
+ return (explicit or os.environ.get(ENV_KEY) or _stored_token() or "").strip()
137
+
138
+
139
+ def _api_base():
140
+ return (os.environ.get(ENV_API) or _DEFAULT_API_URL).rstrip("/")
141
+
142
+
143
+ def _installed_version():
144
+ try:
145
+ # Straight from `_version`, never `from custom_widgets_pro import ...`:
146
+ # the latter re-enters the package __init__ and used to report a
147
+ # hand-maintained string that had drifted from the built version.
148
+ from ._version import __version__
149
+ return __version__
150
+ except Exception:
151
+ return "0.0.0"
152
+
153
+
154
+ def _version_tuple(v):
155
+ out = []
156
+ for part in str(v).split("."):
157
+ digits = "".join(ch for ch in part if ch.isdigit())
158
+ out.append(int(digits) if digits else 0)
159
+ return tuple(out) or (0,)
160
+
161
+
162
+ # ---------------------------------------------------------------------- #
163
+ ## Device identity (the server enforces per-plan seat counts)
164
+ # ---------------------------------------------------------------------- #
165
+ def _device_identity():
166
+ """A stable (id, name) pair for this machine.
167
+
168
+ The id is persisted rather than derived from hardware: hostnames and MAC
169
+ addresses change (docks, VPNs, renames), and a shifting id would silently
170
+ burn a seat on every change. Generated once, kept beside the cache."""
171
+ id_file = os.path.join(_config_dir(), "device_id")
172
+ try:
173
+ with open(id_file) as fh:
174
+ device_id = fh.read().strip()
175
+ except OSError:
176
+ device_id = ""
177
+
178
+ if not device_id:
179
+ device_id = uuid.uuid4().hex
180
+ try:
181
+ os.makedirs(_config_dir(), exist_ok=True)
182
+ with open(id_file, "w") as fh:
183
+ fh.write(device_id)
184
+ except OSError:
185
+ # Read-only home: carry on with an ephemeral id. The server treats
186
+ # it as a new device, which is the safe direction.
187
+ pass
188
+
189
+ name = "%s (%s)" % (platform.node() or "unknown", platform.system())
190
+ return device_id, name
191
+
192
+
193
+ # ---------------------------------------------------------------------- #
194
+ ## HTTP (stdlib only; monkeypatched in tests)
195
+ # ---------------------------------------------------------------------- #
196
+ def _http_json(method, url, data=None, headers=None, timeout=_HTTP_TIMEOUT):
197
+ """Make a JSON HTTP request. Returns (status_code, parsed_body). Raises
198
+ _NetworkError when the endpoint can't be reached. HTTP 4xx/5xx with a JSON
199
+ body are returned (not raised) so callers can read error details.
200
+
201
+ ``data`` is serialised as a JSON body - the API parses ``php://input`` as
202
+ JSON and ignores form-encoded input."""
203
+ hdrs = {"Accept": "application/json", "User-Agent": "custom-widgets-pro"}
204
+ if headers:
205
+ hdrs.update(headers)
206
+ body = None
207
+ if data is not None:
208
+ body = json.dumps(data).encode("utf-8")
209
+ hdrs["Content-Type"] = "application/json"
210
+ req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
211
+ try:
212
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
213
+ raw = resp.read().decode("utf-8", "replace")
214
+ code = resp.getcode()
215
+ except urllib.error.HTTPError as exc:
216
+ raw = exc.read().decode("utf-8", "replace")
217
+ code = exc.code
218
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
219
+ raise _NetworkError(str(exc))
220
+ try:
221
+ return code, (json.loads(raw) if raw else {})
222
+ except ValueError:
223
+ return code, {}
224
+
225
+
226
+ # ---------------------------------------------------------------------- #
227
+ ## Token verification against our own billing API
228
+ # ---------------------------------------------------------------------- #
229
+ def _verify_token(token):
230
+ """Validate a licence token against the billing API, claiming a device seat.
231
+ Returns a LicenseStatus; raises _NetworkError when offline."""
232
+ if not token or len(token.strip()) < 8:
233
+ return LicenseStatus(False, "server", reason="malformed licence token")
234
+ token = token.strip()
235
+ device_id, device_name = _device_identity()
236
+
237
+ code, body = _http_json(
238
+ "POST", _api_base() + _VALIDATE_PATH,
239
+ data={"token": token, "device_id": device_id,
240
+ "device_name": device_name})
241
+
242
+ # The API envelope is {status, message, data}; errors carry only a message.
243
+ message = body.get("message") or ""
244
+ data = body.get("data") or {}
245
+
246
+ if code == 409:
247
+ # Seat limit. Distinct from "invalid" - the token is good, the machine
248
+ # just can't have a seat until one is freed from the portal.
249
+ return LicenseStatus(False, "server",
250
+ reason=message or "device seat limit reached")
251
+ if code == 403:
252
+ return LicenseStatus(False, "server",
253
+ reason=message or "licence token invalid or expired")
254
+ if code >= 400 or not data.get("valid"):
255
+ return LicenseStatus(False, "server",
256
+ reason=message or "licence token rejected")
257
+
258
+ status = LicenseStatus(
259
+ True, "server",
260
+ tier=data.get("plan") or "pro",
261
+ plan_name=data.get("plan_name"),
262
+ expires=data.get("expires"),
263
+ max_devices=data.get("max_devices"),
264
+ reason="licence validated (%s)" % (data.get("plan_name")
265
+ or data.get("plan") or "pro"))
266
+ _write_cache(status)
267
+ _store_token(token)
268
+ return status
269
+
270
+
271
+ # ---------------------------------------------------------------------- #
272
+ ## Local cache (offline grace + perpetual fallback), tamper-evident
273
+ # ---------------------------------------------------------------------- #
274
+ def _canonical(payload):
275
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
276
+
277
+
278
+ def _sign(payload):
279
+ return hmac.new(_CACHE_HMAC_KEY, _canonical(payload).encode("utf-8"),
280
+ hashlib.sha256).hexdigest()
281
+
282
+
283
+ def _write_cache(status):
284
+ try:
285
+ payload = {
286
+ "v": _CACHE_VERSION,
287
+ "source": status.source,
288
+ "tier": status.tier,
289
+ "plan_name": status.plan_name,
290
+ "expires": status.expires,
291
+ "max_devices": status.max_devices,
292
+ "validated_at": int(time.time()),
293
+ "covered_version": _installed_version(),
294
+ }
295
+ path = _config_path()
296
+ os.makedirs(os.path.dirname(path), exist_ok=True)
297
+ with open(path, "w") as fh:
298
+ json.dump({"payload": payload, "sig": _sign(payload)}, fh)
299
+ except OSError:
300
+ pass
301
+
302
+
303
+ def _read_cache():
304
+ try:
305
+ with open(_config_path()) as fh:
306
+ doc = json.load(fh)
307
+ except (OSError, ValueError):
308
+ return LicenseStatus(False, "cache", reason="no cached entitlement")
309
+ payload = doc.get("payload") or {}
310
+ sig = doc.get("sig") or ""
311
+ if not hmac.compare_digest(sig, _sign(payload)):
312
+ return LicenseStatus(False, "cache",
313
+ reason="cached entitlement tampered / invalid")
314
+ if payload.get("v") != _CACHE_VERSION:
315
+ # A v1 cache recorded a Gumroad/LemonSqueezy/Patreon grant that this
316
+ # build can no longer refresh. Force one online validation.
317
+ return LicenseStatus(False, "cache",
318
+ reason="cached entitlement predates the current "
319
+ "licensing scheme; re-activate once online")
320
+ validated_at = payload.get("validated_at", 0)
321
+ covered = payload.get("covered_version", "0")
322
+ common = dict(tier=payload.get("tier"), plan_name=payload.get("plan_name"),
323
+ expires=payload.get("expires"),
324
+ max_devices=payload.get("max_devices"))
325
+ fresh = (time.time() - validated_at) <= _GRACE_DAYS * 86400
326
+ perpetual = _version_tuple(_installed_version()) <= _version_tuple(covered)
327
+ if fresh:
328
+ return LicenseStatus(True, "cache",
329
+ reason="cached entitlement (offline grace)", **common)
330
+ if perpetual:
331
+ return LicenseStatus(True, "cache",
332
+ reason="perpetual fallback (covers installed version)",
333
+ **common)
334
+ return LicenseStatus(False, "cache",
335
+ reason="cached entitlement expired; re-validate online")
336
+
337
+
338
+ # ---------------------------------------------------------------------- #
339
+ ## Public API
340
+ # ---------------------------------------------------------------------- #
341
+ def check_license(refresh=False):
342
+ """Resolve the current entitlement without raising. Memoised per process.
343
+
344
+ Order: royalty-free runtime / opt-out -> a valid signed cache (so normal dev
345
+ doesn't hit the network) -> online validation of the token in the
346
+ environment (which then caches). ``refresh=True`` skips the memo and the
347
+ cache shortcut and forces online re-validation."""
348
+ global _cached_status
349
+ if _cached_status is not None and not refresh:
350
+ return _cached_status
351
+
352
+ if is_frozen() or os.environ.get(ENV_DISABLE):
353
+ _cached_status = LicenseStatus(True, "runtime",
354
+ reason="royalty-free runtime / check disabled")
355
+ return _cached_status
356
+
357
+ if not refresh:
358
+ cached = _read_cache()
359
+ if cached.valid:
360
+ _cached_status = cached
361
+ return _cached_status
362
+
363
+ token = _resolve_token()
364
+ if token:
365
+ try:
366
+ status = _verify_token(token)
367
+ if status.valid:
368
+ _cached_status = status
369
+ return _cached_status
370
+ _cached_status = status # e.g. expired / revoked / seat limit
371
+ return _cached_status
372
+ except _NetworkError:
373
+ cached = _read_cache() # offline: lean on the cache
374
+ if cached.valid:
375
+ _cached_status = cached
376
+ return _cached_status
377
+ _cached_status = LicenseStatus(
378
+ False, "none",
379
+ reason="offline and no valid cached entitlement; "
380
+ "connect once to activate")
381
+ return _cached_status
382
+
383
+ _cached_status = LicenseStatus(
384
+ False, "none",
385
+ reason=("no entitlement - run 'custom-widgets-pro activate <token>' with "
386
+ "the licence token from your account portal, or set %s. "
387
+ "Unlicensed dev/eval mode." % ENV_KEY))
388
+ return _cached_status
389
+
390
+
391
+ def activate(token=None):
392
+ """Validate a licence token **online now** and cache the result. Backs the
393
+ ``custom-widgets-pro activate <token>`` flow. Returns a LicenseStatus
394
+ (raises nothing; a network failure yields an invalid status with a reason)."""
395
+ global _cached_status
396
+ token = _resolve_token(token)
397
+ if not token:
398
+ return LicenseStatus(
399
+ False, "none",
400
+ reason="provide the licence token from your account portal")
401
+ try:
402
+ status = _verify_token(token)
403
+ except _NetworkError as exc:
404
+ return LicenseStatus(False, "none",
405
+ reason="could not reach the licence server: %s" % exc)
406
+ if status.valid:
407
+ _cached_status = status
408
+ return status
409
+
410
+
411
+ def require_license(feature=None):
412
+ """Raise LicenseError if there is no valid entitlement. Use to gate a Pro
413
+ feature/build step. Not called at shipped-app runtime."""
414
+ status = check_license()
415
+ if not status.valid:
416
+ what = (" for %r" % feature) if feature else ""
417
+ raise LicenseError("custom-widgets-pro entitlement required%s: %s"
418
+ % (what, status.reason))
419
+ return status
@@ -0,0 +1,14 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # WEBSITE: customwidgets.org
4
+ ########################################################################
5
+ """Single source of truth for the package version.
6
+
7
+ Deliberately a standalone module with **no imports**: the licence CLI and
8
+ ``_license`` both need the version string without dragging in Qt, and
9
+ ``importlib.metadata`` is unavailable when running against the source tree.
10
+
11
+ Keep in step with ``version`` in pyproject.toml - ``tests/test_version.py``
12
+ fails the build if the two ever drift.
13
+ """
14
+ __version__ = "1.1.0"
@@ -0,0 +1,17 @@
1
+ from .datatable_pro import QCustomDataTablePro
2
+ from .virtual_model import VirtualDataTableModel, GroupRole
3
+ from .provider import DataProvider, ListDataProvider, CallableDataProvider
4
+ from .export import export_csv, export_xlsx, export_table
5
+ from .frozen_view import FrozenHostView
6
+ from .grouping import GroupingEngine, GroupRow, aggregate, AGGREGATORS
7
+ from .pivot import pivot_table, PIVOT_COL_PREFIX, TOTAL_KEY
8
+
9
+ __all__ = [
10
+ "QCustomDataTablePro",
11
+ "VirtualDataTableModel", "GroupRole",
12
+ "DataProvider", "ListDataProvider", "CallableDataProvider",
13
+ "export_csv", "export_xlsx", "export_table",
14
+ "FrozenHostView",
15
+ "GroupingEngine", "GroupRow", "aggregate", "AGGREGATORS",
16
+ "pivot_table", "PIVOT_COL_PREFIX", "TOTAL_KEY",
17
+ ]