feat(license): registration + 1-year licenses + tier scaffolding
A complete offline licensing layer (no internet at any step): Core - src/license/ — schema (License, Tier, FeatureFlag), HMAC crypto, JSON storage, LicenseManager singleton with activate/renew/ deactivate/issue_trial. Tier-scaffolded so future SKUs can carve per-tool feature sets without consumer-code edits. - scripts/generate_license.py — creator-only key generator. Mints a DTLIC1: blob the buyer pastes into the activation page. GUI - New activation form component (src/gui/components/activation.py). - hide_streamlit_chrome() now inline-renders the activation form when no valid license is present (every page short-circuits to the form until activated). - Sidebar shows tier + days remaining; renewal warning under 30 days. - New pages/_Activate.py for revisiting the form after activation. CLI - src/license_cli.py — activate / renew / status / trial / deactivate commands. Exempt from the guard. - src/cli_license_guard.py — drop-in guard call added to every tool CLI's main(). Lets --help through; respects DATATOOLS_DEV_MODE. i18n - New activation.* and license.* keys in en.json + es.json (page title, form labels, status badges, renewal warnings, error messages). Pack parity test stays green. Test infrastructure - tests/conftest.py autouse fixture sets DATATOOLS_DEV_MODE=1 so the existing 1916 tests continue to pass. - isolated_license_path / activated_license_manager / unactivated_license_manager fixtures for tests that want to drive the real check. Tests (+79) - tests/test_license.py (40): schema, crypto roundtrip, blob encode/decode, tier→feature mapping, activation flow, name/email mismatch rejection, tamper detection, expiration, renewal, dev-mode bypass. - tests/test_license_cli.py (26): every license_cli command + subprocess tests confirming every tool CLI refuses to run without a license, --help always works, DEV_MODE bypasses. - tests/gui/test_activation.py (13): gate blocks without license, passes with trial, activation form submission unlocks the gate, sidebar status, renewal warning, i18n. Total: 1916 → 1995 tests. All pass under the strict warning filter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
src/cli_license_guard.py
Normal file
86
src/cli_license_guard.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""License guard for the tool CLIs.
|
||||
|
||||
Every tool CLI (``cli.py``, ``cli_text_clean.py``, ``cli_format.py``,
|
||||
``cli_missing.py``, ``cli_column_map.py``, ``cli_pipeline.py``,
|
||||
``cli_analyze.py``) calls :func:`guard` from its ``main()`` before
|
||||
delegating to Typer. The guard:
|
||||
|
||||
1. Lets ``--help`` / ``-h`` through unconditionally so users can
|
||||
always see what a command does.
|
||||
2. Lets the ``DATATOOLS_DEV_MODE=1`` env var bypass the check.
|
||||
3. Otherwise, verifies a valid license is on disk. If not, prints a
|
||||
one-line user-facing message naming the exact ``datatools-license``
|
||||
subcommand to run, then exits with status 2.
|
||||
|
||||
The exit code (2) is the same Typer uses for argument errors — fits
|
||||
into ``run_tests.py`` / CI pipelines without special casing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
_HELP_FLAGS = {"--help", "-h", "--version"}
|
||||
|
||||
|
||||
def _is_help_invocation() -> bool:
|
||||
"""True when the user is asking for help, not running work."""
|
||||
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
|
||||
|
||||
|
||||
def guard() -> None:
|
||||
"""Block startup if no valid license. No-op when license is valid,
|
||||
when called with ``--help``, or under ``DATATOOLS_DEV_MODE``."""
|
||||
if _is_help_invocation():
|
||||
return
|
||||
# Lazy import so a broken license module doesn't fail ``--help``.
|
||||
from src.license import (
|
||||
ExpiredLicenseError,
|
||||
InvalidLicenseError,
|
||||
LicenseError,
|
||||
get_manager,
|
||||
)
|
||||
|
||||
mgr = get_manager()
|
||||
if mgr.dev_mode:
|
||||
return
|
||||
|
||||
try:
|
||||
if mgr.is_valid():
|
||||
return
|
||||
except LicenseError:
|
||||
# ``is_valid()`` swallows errors and returns False, but be
|
||||
# paranoid: fall through to the state-based diagnostic.
|
||||
pass
|
||||
|
||||
state = mgr.current_state()
|
||||
_exit_with_message(state)
|
||||
|
||||
|
||||
def _exit_with_message(state) -> NoReturn:
|
||||
"""Print the right one-liner for the current state and exit."""
|
||||
if state.error_kind == "not_activated":
|
||||
msg = (
|
||||
"DataTools is not activated.\n"
|
||||
"Run: python -m src.license_cli activate <license-blob>\n"
|
||||
"Or start a 1-year trial: "
|
||||
"python -m src.license_cli trial --name 'Your Name' --email you@example.com"
|
||||
)
|
||||
elif state.error_kind == "expired":
|
||||
msg = (
|
||||
f"License expired on {state.expires_at[:10]}.\n"
|
||||
"Renew with: python -m src.license_cli renew <license-blob>"
|
||||
)
|
||||
elif state.error_kind == "invalid":
|
||||
msg = (
|
||||
"License file is present but invalid.\n"
|
||||
f"Detail: {state.error_message}\n"
|
||||
"Re-paste the blob from your delivery email: "
|
||||
"python -m src.license_cli activate <license-blob>"
|
||||
)
|
||||
else:
|
||||
msg = "License is not valid. Run: python -m src.license_cli status"
|
||||
print(f"Error: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
Reference in New Issue
Block a user