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.
- custom_widgets_pro/__init__.py +73 -0
- custom_widgets_pro/_catalog.py +25 -0
- custom_widgets_pro/_cli.py +132 -0
- custom_widgets_pro/_license.py +419 -0
- custom_widgets_pro/_version.py +14 -0
- custom_widgets_pro/datatable/__init__.py +17 -0
- custom_widgets_pro/datatable/datatable_pro.py +319 -0
- custom_widgets_pro/datatable/export.py +143 -0
- custom_widgets_pro/datatable/frozen_view.py +112 -0
- custom_widgets_pro/datatable/grouping.py +309 -0
- custom_widgets_pro/datatable/pivot.py +100 -0
- custom_widgets_pro/datatable/provider.py +98 -0
- custom_widgets_pro/datatable/virtual_model.py +451 -0
- custom_widgets_pro/widgets/__init__.py +7 -0
- custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
- custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
- custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
- custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
- custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
- custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
- custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
- custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
- custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
- custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
- custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
- custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
- custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
- custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
- custom_widgets_pro/widgets/charts/__init__.py +1 -0
- custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
- custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
- custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
- custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
- custom_widgets_pro/widgets/data/__init__.py +1 -0
- custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
- custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
- custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
- custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
- custom_widgets_pro/widgets/media/__init__.py +1 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
- 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
|
+
]
|