feat(license): add Lite SKU; remove user-facing free trial

Two coupled changes:

1. Lite tier
   - New Tier.LITE in src/license/schema.py.
   - FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
     Format Standardizer}. The three universally-useful tools that
     cover the most common bookkeeping / RevOps / Klaviyo prep
     workflows. Other six tools require Core.
   - i18n: license.tier_lite, license.feature_locked_title,
     license.feature_locked_body, license.upgrade_link,
     license.status_locked (en + es).
   - Per-tool feature gate at every GUI tool page
     (require_feature_or_render_upgrade) and every tool CLI
     (guard(feature=...)). A locked tool renders an upgrade
     prompt + Manage-license button (GUI) or exits with code 2
     (CLI).
   - Home grid: tool cards the user's tier doesn't unlock get a
     red 🔒 Locked badge in place of green Ready.

2. Trial removed
   - Activation form's "Start 1-year trial" button removed.
   - license_cli's `trial` subcommand removed.
   - activation.trial_button / activation.trial_help i18n keys
     dropped (pack parity test stays green).
   - Tier.TRIAL stays in the enum (back-compat with any field-
     tested trial licenses); LicenseManager._mint stays internal
     for tests and the seller's key generator.
   - Decision logged in DECISIONS §9b: a 1-year all-features
     trial undercuts paid Lite; paid-only keeps tier economics
     clean.

Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:19:30 +00:00
parent e612c751a8
commit d32b58e61a
33 changed files with 621 additions and 153 deletions

View File

@@ -176,6 +176,8 @@ $49-79/bundle · $149 full suite (when 3+ exist).
| May 1 (v1.6) | Add `src/core/errors.py` structured hierarchy | Uniform helpful messages across CLI + GUI. See TECHNICAL §7. |
| May 13 (v1.6) | Ship in-house JSON i18n + EN/ES packs | Expand addressable market (Spanish-first buyers, LatAm bookkeepers) without a `gettext` build step. JSON packs editable by non-devs; parity test prevents drift. See TECHNICAL §10b. |
| May 13 (v1.6) | Ship licensing: 1-year HMAC-signed blobs, name+email registration, offline verification, tier-scaffolded for future SKUs | Unlock the lifetime-update business model without recurring infra. Honor-system DRM (HMAC + 30-day refund) — sufficient at $49. See §9b below. |
| May 13 (v1.6) | Add Lite SKU (Dedup + Text Cleaner + Format Standardizer) | Lower-priced entry point for buyers who only need the three universal tools. Per-tool feature gating + lock badges on the home grid surface the upgrade path. See §9b. |
| May 13 (v1.6) | Remove user-facing free trial | A 1-year all-features trial undercut the paid Lite SKU. Paid-only keeps tier economics clean. Internal ``_mint`` API still exists for tests and the seller's key generator. See §9b. |
## 9b. Licensing model
@@ -203,6 +205,18 @@ $49-79/bundle · $149 full suite (when 3+ exist).
**Future SKUs**: the ``FEATURES_BY_TIER`` table in ``src/license/features.py`` is the single source of truth for "which tools each tier unlocks". Adding a PRO SKU that excludes the pipeline runner is a 1-line edit there + a 1-line edit at the gate site. No consumer-code churn.
**v1.6 SKU lineup**:
| Tier | Tools unlocked | Notes |
|---|---|---|
| LITE | Deduplicator, Text Cleaner, Format Standardizer | Entry SKU. Three universal tools that handle the most common bookkeeping / RevOps / Klaviyo prep workflows. |
| CORE | All 9 tools | Full v1 suite. |
| PRO | All 9 tools (scaffolded) | Reserved for future per-feature carve-outs (e.g., scheduled pipelines, API access). |
| ENTERPRISE | All 9 tools (scaffolded) | Reserved for future bulk / multi-seat SKUs. |
| TRIAL | Same as LITE | Deprecated — no longer issuable. Mapping kept for any legacy on-disk trial licenses to load without error. |
**Trial removed (v1.6)**: a 1-year free trial that unlocked every tool would undercut the paid Lite SKU (why pay for Lite when trial gives more for longer?). Paid-only keeps the funnel clean. The internal ``LicenseManager._mint`` API still exists for tests and for the seller's ``scripts/generate_license.py`` key generator; there's no user-facing way to self-issue a license.
## 8. Re-lock triggers
These criteria are load-bearing. Triggers for explicit re-evaluation:

View File

@@ -165,8 +165,37 @@ explicitly use ``isolated_license_path`` /
1. Add the enum value to ``Tier``.
2. Add a row to ``FEATURES_BY_TIER`` listing the unlocked flags.
3. Add ``license.tier_<name>`` translation keys to every i18n pack.
4. The activation flow, sidebar status badge, and feature gate all
pick up the new tier automatically.
4. The activation flow, sidebar status badge, feature gate, and home
grid lock badge all pick up the new tier automatically.
**Worked example — the Lite tier**:
```python
# src/license/schema.py
class Tier(str, Enum):
LITE = "lite" # new
CORE = "core"
...
# src/license/features.py
FEATURES_BY_TIER = {
...
Tier.LITE: frozenset({
FeatureFlag.DEDUPLICATOR,
FeatureFlag.TEXT_CLEANER,
FeatureFlag.FORMAT_STANDARDIZER,
}),
Tier.CORE: _all(),
...
}
```
Then in en.json/es.json add ``license.tier_lite``. That's it — the
existing ``require_feature_or_render_upgrade`` (GUI) and
``guard(feature=...)`` (CLI) calls in every tool page/CLI route a
Lite user into the upgrade prompt for any tool the tier doesn't
unlock. The home grid's lock badge fires off the same feature
lookup.
**Minting a license** (creator-only):

View File

@@ -174,13 +174,14 @@ and proceeds.
- **Dev**: pytest, tox.
## 16. Test coverage
- 1,995 tests passing, 0 skipped, 0 xfailed.
- 1,843 core + CLI tests (run with `pytest -m 'not gui'` for a quick loop).
Includes 40 license-layer unit tests + 26 license-CLI tests.
- 152 GUI tests under `tests/gui/` driving Streamlit pages via `AppTest`
- 2,024 tests passing, 0 skipped, 0 xfailed.
- 1,859 core + CLI tests (run with `pytest -m 'not gui'` for a quick loop).
Includes 40 license-layer unit tests, 25 license-CLI tests, and
17 Lite-tier feature-map + guard tests.
- 165 GUI tests under `tests/gui/` driving Streamlit pages via `AppTest`
(smoke + EN/ES localization, chrome, gate, workflows, dedup review,
advanced panels, error paths, findings panel, activation + license
gate). Marked `gui`.
advanced panels, error paths, findings panel, activation +
license gate, Lite-tier per-page lock behaviour). Marked `gui`.
- Includes 15 perf-shape regression tests.
- Fixture corpora: text-cleaner (21), encodings (31), reference UTF-8 (9), format-cleaner (199 buyer cases + 20-row international stress fixture), missing-handler (3 use cases + 16 edge cases), column-mapper (3 use cases + 5 edge cases).
- Run: `python run_tests.py [--tool …] [--fixtures] [--coverage]`.
@@ -199,15 +200,31 @@ and proceeds.
(``DTLIC1:...``) on first launch; app verifies the signature
offline + matches the buyer-entered name/email to the embedded
values.
- **No free trial**: every license requires a paid blob from the
seller. The user-facing trial flow (button + ``license_cli trial``
subcommand) was removed in v1.6 to keep paid-tier economics clean.
- **Lifetime**: every license is 1 year by default. Renewal applies a
fresh blob without losing the embedded buyer identity.
- **Tiers**: ``trial``, ``core`` (the v1 SKU — all 9 tools), ``pro``,
``enterprise``. PRO and ENTERPRISE are scaffolded for future SKUs;
they currently unlock the same feature set as CORE.
fresh blob without losing the embedded buyer identity. Tier may
change during renewal (Lite → Core upgrade path).
- **Tiers**:
- ``lite`` — Deduplicator + Text Cleaner + Format Standardizer.
Buyer pays once, gets the three universally-useful tools.
- ``core`` — every Ready tool (all 9 in v1.6).
- ``pro``, ``enterprise`` — scaffolded for future SKUs; currently
mirror Core. Add per-SKU restrictions by editing
``FEATURES_BY_TIER`` in ``src/license/features.py``.
- ``trial`` — kept in the enum for backwards compat with any
field-tested trial licenses but no longer issuable.
- **Feature flags**: every tool has a stable feature id matching its
``tool_id`` in :mod:`src.gui.tools_registry`. Adding a future per-
tool SKU is a one-line change to ``FEATURES_BY_TIER`` — no consumer
code edits.
- **Per-tool gating**: each tool page (GUI) and tool CLI calls
``require_feature(FeatureFlag.<TOOL>)`` at entry. GUI shows an
upgrade prompt + button to the Activate page; CLI prints a
message naming the locked feature and exits with code 2.
- **Lock badge**: the home grid shows a red 🔒 Locked pill on tool
cards the current tier doesn't unlock.
- **Dev bypass**: ``DATATOOLS_DEV_MODE=1`` skips every check (used by
the test suite and during development).
- **No internet**: signature verification is fully offline. The

View File

@@ -8,16 +8,20 @@
DataTools debe activarse antes de desbloquear cualquier herramienta. En el primer arranque verás la pantalla **Activar**.
| Si tienes… | Haz esto |
Introduce tu nombre completo y correo, pega el código de licencia del correo de compra (empieza con `DTLIC1:`) y pulsa **Activar**. La renovación funciona igual: pega el código de renovación y pulsa **Aplicar renovación**.
**Niveles**:
| Nivel | Herramientas |
|---|---|
| Un código de licencia de pago (del correo de compra) | Introduce tu nombre completo y correo, pega el código completo (empieza con `DTLIC1:`), pulsa **Activar**. |
| Nada todavía, quieres evaluar | Introduce nombre y correo, pulsa **Iniciar prueba de 1 año**. La app emite localmente una licencia de prueba de 1 año — sin pago. |
| **Lite** | Eliminador de duplicados · Limpiador de texto · Estandarizador de formatos |
| **Core** | Las 9 herramientas |
La renovación funciona igual: pega el código de renovación, pulsa **Aplicar renovación**. La fecha de caducidad se reinicia a un año desde la renovación.
Un usuario Lite que abra una herramienta exclusiva de Core verá un mensaje "Actualiza tu licencia". La página de inicio también muestra una marca 🔒 Bloqueado en las tarjetas de las herramientas que tu nivel no incluye. Para actualizar, pega un código Core en la página Activar.
El archivo de licencia vive en `~/.datatools/license.json` (Windows: `C:\Users\<tú>\.datatools\license.json`). La barra lateral muestra tu nivel y los días restantes en todo momento. Aparece un aviso de renovación 30 días antes de la caducidad.
Cada licencia dura 1 año. La barra lateral muestra tu nivel y los días restantes en todo momento; aparece un aviso de renovación 30 días antes de la caducidad. El archivo de licencia vive en `~/.datatools/license.json` (Windows: `C:\Users\<tú>\.datatools\license.json`).
Si quieres usar la misma licencia en otro equipo, desactiva éste (página Activar → **Desactivar este dispositivo**) y vuelve a pegar tu código en el nuevo.
Para usar la misma licencia en otro equipo: desactiva éste (página Activar → **Desactivar este dispositivo**) y vuelve a pegar tu código en el nuevo.
## 1. Instalación

View File

@@ -8,16 +8,20 @@
DataTools must be activated before any tools unlock. On first launch you'll see the **Activate** screen.
| You have… | Do this |
Enter your full name + email, paste the license blob from your purchase email (starts with `DTLIC1:`), and click **Activate**. Renewal works the same way — paste the renewal blob, click **Apply renewal**.
**Tiers**:
| Tier | Tools |
|---|---|
| A paid license blob (from your purchase email) | Enter your full name + email, paste the entire blob (starts with `DTLIC1:`), click **Activate**. |
| Nothing yet, want to evaluate | Enter your name + email, click **Start 1-year trial**. The app self-issues a 1-year trial license — no payment required. |
| **Lite** | Deduplicator · Text Cleaner · Format Standardizer |
| **Core** | All 9 tools |
Renewal works the same way: paste the renewal blob, click **Apply renewal**. The expiry resets to one year from the renewal date.
A Lite user opening a Core-only tool sees an "Upgrade your license" prompt. The home page also shows a 🔒 Locked badge on tool cards your tier doesn't unlock. To upgrade, paste a Core blob on the Activate page.
The license file lives at `~/.datatools/license.json` (Windows: `C:\Users\<you>\.datatools\license.json`). The sidebar shows your tier and days remaining at all times. A renewal warning appears 30 days before expiry.
Every license lasts 1 year. The sidebar shows your tier and days remaining at all times; a renewal warning appears 30 days before expiry. The license file lives at `~/.datatools/license.json` (Windows: `C:\Users\<you>\.datatools\license.json`).
If you ever want to use the same license on a different machine, deactivate this one (Activate page → **Deactivate this device**) and re-paste your blob on the new machine.
To use the same license on a different machine: deactivate this one (Activate page → **Deactivate this device**) and re-paste your blob on the new machine.
## 1. Install

View File

@@ -500,7 +500,8 @@ def _write_match_groups(result, original_df, path: Path) -> None:
def main():
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.DEDUPLICATOR.value)
app()

View File

@@ -349,7 +349,8 @@ def _print_results(result, input_path: Path, options) -> None:
def main():
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.COLUMN_MAPPER.value)
app()

View File

@@ -358,7 +358,8 @@ def standardize(
def main():
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.FORMAT_STANDARDIZER.value)
app()

View File

@@ -30,9 +30,21 @@ def _is_help_invocation() -> bool:
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``."""
def guard(feature: str | None = None) -> None:
"""Block startup if no valid license.
*feature* — when supplied, also requires that the active license's
tier unlocks the named feature flag (e.g.
``"03_format_standardizer"``). A Lite-tier user running
``cli_format`` would pass the global validity check but fail the
feature check; we surface a clear "upgrade your tier" message
rather than letting them hit a runtime error halfway through a
job.
No-op when license is valid (and the feature is unlocked), when
called with ``--help`` / ``-h`` / ``--version``, or under
``DATATOOLS_DEV_MODE=1``.
"""
if _is_help_invocation():
return
# Lazy import so a broken license module doesn't fail ``--help``.
@@ -40,6 +52,7 @@ def guard() -> None:
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
UnsupportedFeatureError,
get_manager,
)
@@ -48,15 +61,33 @@ def guard() -> None:
return
try:
if mgr.is_valid():
return
if not mgr.is_valid():
state = mgr.current_state()
_exit_with_message(state)
if feature is not None:
try:
mgr.require_feature(feature)
except UnsupportedFeatureError as e:
_exit_with_feature_message(feature, str(e))
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)
state = mgr.current_state()
_exit_with_message(state)
def _exit_with_feature_message(feature: str, detail: str) -> NoReturn:
"""Print the upgrade-tier diagnostic and exit. Mirrors the
GUI's ``require_feature_or_render_upgrade`` UX in CLI form."""
msg = (
f"This command requires the {feature!r} feature, which is not "
f"included in your current license tier.\n"
f"Detail: {detail}\n"
"Run ``python -m src.license_cli status`` to see your tier, "
"then activate an upgrade blob with "
"``python -m src.license_cli renew <blob>``."
)
print(f"Error: {msg}", file=sys.stderr)
raise SystemExit(2)
def _exit_with_message(state) -> NoReturn:

View File

@@ -374,7 +374,8 @@ def _print_results(result, input_path: Path, options) -> None:
def main():
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.MISSING_HANDLER.value)
app()

View File

@@ -301,7 +301,8 @@ def run(
def main() -> None:
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.PIPELINE_RUNNER.value)
app()

View File

@@ -371,7 +371,8 @@ def _print_results(result, input_path: Path, options) -> None:
def main():
from src.cli_license_guard import guard
guard()
from src.license import FeatureFlag
guard(feature=FeatureFlag.TEXT_CLEANER.value)
app()

View File

@@ -61,6 +61,16 @@ st.divider()
# ---------------------------------------------------------------------------
from src.gui.tools_registry import TOOLS, tool_description, tool_name
from src.license import get_manager
# Per-tier feature set for badging — Ready tools that the user's
# tier doesn't unlock get a red "🔒 Locked" pill instead of the green
# "Ready" so they see at a glance which tools an upgrade would
# unlock. Dev-mode skips the lock check so the home grid renders the
# full SKU during development.
_license_state = get_manager().current_state()
_unlocked_features = set(_license_state.features) if _license_state.valid else set()
_dev_mode = get_manager().dev_mode
# Render tool cards in a 3-column grid. Cards picked up by the analyzer get a
# coloured "N findings" badge so the user can see at a glance which tools
@@ -73,17 +83,31 @@ for row_start in range(0, len(TOOLS), 3):
break
tool = TOOLS[idx]
with col:
status_key = "status.ready" if tool.status == "Ready" else "status.coming_soon"
status_color = "green" if tool.status == "Ready" else "orange"
tool_locked = (
tool.status == "Ready"
and not _dev_mode
and tool.tool_id not in _unlocked_features
)
if tool_locked:
status_key = "license.status_locked"
status_color = "red"
elif tool.status == "Ready":
status_key = "status.ready"
status_color = "green"
else:
status_key = "status.coming_soon"
status_color = "orange"
badge = ""
n = findings_count_for_tool(tool.tool_id)
if n:
badge_key = "home.findings_badge_one" if n == 1 else "home.findings_badge_other"
badge = f" :red-background[**{t(badge_key, n=n)}**]"
lock_glyph = "🔒 " if tool_locked else ""
st.markdown(
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n"
f"{tool_description(tool.tool_id)}\n\n"
f":{status_color}[**{t(status_key)}**]"
f":{status_color}[**{lock_glyph}{t(status_key)}**]"
)

View File

@@ -41,6 +41,7 @@ from . import _legacy as _legacy # noqa: F401 (keep for direct access)
from .activation import ( # noqa: F401 re-exported
render_activation_form,
render_license_status_sidebar,
require_feature_or_render_upgrade,
require_license_or_render_activation,
)
@@ -53,6 +54,7 @@ __all__ = [
# License gate + activation form
"render_activation_form",
"render_license_status_sidebar",
"require_feature_or_render_upgrade",
"require_license_or_render_activation",
# Dedup widgets
"config_panel",

View File

@@ -125,19 +125,12 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
height=120,
)
col_activate, col_trial = st.columns(2)
with col_activate:
is_renewal = state.activated
label = (
_t("activation.renew_button") if is_renewal
else _t("activation.activate_button")
)
submit = st.form_submit_button(label, type="primary")
with col_trial:
trial = st.form_submit_button(
_t("activation.trial_button"),
help=_t("activation.trial_help"),
)
is_renewal = state.activated
label = (
_t("activation.renew_button") if is_renewal
else _t("activation.activate_button")
)
submit = st.form_submit_button(label, type="primary")
# Process the submission.
if submit:
@@ -159,17 +152,6 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
if trial:
try:
lic = mgr.issue_trial(name=name, email=email)
st.success(_t(
"activation.success",
name=lic.name, expires=lic.expires_at[:10],
))
st.rerun()
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
# Deactivate (only useful when already activated).
if state.activated:
st.divider()
@@ -195,24 +177,61 @@ def require_license_or_render_activation(*, feature: Optional[str] = None) -> No
unlocked), this is a no-op. Otherwise it renders the activation
form in place of the page body and calls ``st.stop()``.
The chrome helper invokes this automatically so individual pages
don't have to call it; the explicit *feature* form is provided
for tools that want to bypass the global gate but enforce a tier
check (future SKU work).
Tool pages that need per-tool gating call
:func:`require_feature_or_render_upgrade` *after* this — that
keeps the global gate (activate / renew flow) separate from the
per-tier feature gate (upgrade flow).
"""
mgr = get_manager()
if mgr.dev_mode:
return
if mgr.is_valid():
# When feature gating gets enabled per-tool, this is the hook.
if feature is not None:
try:
mgr.require_feature(feature)
except LicenseError as e:
st.error(f"⚠️ {e}")
st.stop()
return
# Otherwise: render the activation form inline and stop the page.
render_activation_form(key_prefix="gate")
st.stop()
def require_feature_or_render_upgrade(feature: str) -> None:
"""Per-tool feature gate. Renders an upgrade prompt + stops the
page when the active license's tier doesn't unlock *feature*.
Call from tool pages after ``hide_streamlit_chrome``. The page
body should be wrapped after this call so the upgrade prompt
cleanly replaces the body when the user's tier is too low.
Distinguished from :func:`require_license_or_render_activation`
by what it offers the user: that one drops them into the
activation form (paste a paid blob to activate at all); this one
surfaces an upgrade prompt with a button to the Activate page
(paste a higher-tier blob to upgrade).
"""
from src.license import UnsupportedFeatureError
mgr = get_manager()
if mgr.dev_mode:
return
try:
mgr.require_feature(feature)
return
except UnsupportedFeatureError:
pass # render the upgrade prompt below
except LicenseError as e:
# Not-activated / expired / invalid — fall through to the
# global gate (which would already have run; this is belt
# and braces for callers that bypass it).
st.error(f"⚠️ {e}")
st.stop()
state = mgr.current_state()
tier_label = _t(f"license.tier_{state.tier}") or state.tier.title()
feature_names = ", ".join(
_t(f"tools.{f}.name") if f.startswith("0") and "_" in f else f
for f in state.features
)
st.warning(_t("license.feature_locked_title", tier=tier_label))
st.caption(_t("license.feature_locked_body", features=feature_names))
if st.button(_t("license.upgrade_link"), key="upgrade_to_activate"):
st.switch_page("pages/_Activate.py")
st.stop()

View File

@@ -22,11 +22,14 @@ from src.gui.components import (
hide_streamlit_chrome,
match_group_card,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
results_summary,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.DEDUPLICATOR)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -18,8 +18,10 @@ from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
render_hidden_aware_preview,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
from src.core.text_clean import (
PRESETS,
CleanOptions,
@@ -29,6 +31,7 @@ from src.core.text_clean import (
)
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.TEXT_CLEANER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.format_standardize import (
@@ -25,8 +26,10 @@ from src.core.format_standardize import (
StandardizeOptions,
standardize_dataframe,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.FORMAT_STANDARDIZER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.missing import (
@@ -26,8 +27,10 @@ from src.core.missing import (
handle_missing,
profile_missing,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.MISSING_HANDLER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.column_mapper import (
@@ -27,8 +28,10 @@ from src.core.column_mapper import (
infer_mapping,
map_columns,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.COLUMN_MAPPER)
require_normalization_gate()

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.OUTLIER_DETECTOR)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.MULTI_FILE_MERGER)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.VALIDATOR_REPORTER)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.pipeline import (
@@ -28,8 +29,10 @@ from src.core.pipeline import (
run_pipeline,
validate_pipeline,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.PIPELINE_RUNNER)
require_normalization_gate()

View File

@@ -68,8 +68,6 @@
"blob_help": "Begins with `DTLIC1:` — paste the entire string.",
"activate_button": "Activate",
"renew_button": "Apply renewal",
"trial_button": "Start 1-year trial",
"trial_help": "Skips the paid blob and self-issues a 1-year license tied to your name and email. Useful for evaluating before purchase.",
"or_separator": "— or —",
"success": "Activated! Welcome, {name}. Your license is valid until {expires}.",
"renewed": "License renewed. New expiry: {expires}.",
@@ -86,13 +84,18 @@
"renewal_warning_30": "⚠️ License expires in {days} days. Renew soon to avoid interruption.",
"renewal_warning_expired": "🛑 License expired on {date}. Renew to continue using DataTools.",
"tier_trial": "Trial",
"tier_lite": "Lite",
"tier_core": "Core",
"tier_pro": "Pro",
"tier_enterprise": "Enterprise",
"registered_to": "Registered to {name} · {email}",
"expires_on": "Expires on {date}",
"issued_on": "Issued on {date}",
"view_details": "License details"
"view_details": "License details",
"feature_locked_title": "🔒 This tool isn't on your {tier} license",
"feature_locked_body": "Your current license unlocks: {features}. Upgrade to access this tool.",
"upgrade_link": "Manage license",
"status_locked": "Locked"
},
"tools": {
"01_deduplicator": {

View File

@@ -68,8 +68,6 @@
"blob_help": "Empieza con `DTLIC1:` — pega la cadena completa.",
"activate_button": "Activar",
"renew_button": "Aplicar renovación",
"trial_button": "Iniciar prueba de 1 año",
"trial_help": "Omite el código de pago y emite una licencia local de 1 año vinculada a tu nombre y correo. Útil para evaluar antes de comprar.",
"or_separator": "— o —",
"success": "¡Activado! Bienvenido, {name}. Tu licencia es válida hasta el {expires}.",
"renewed": "Licencia renovada. Nueva fecha de caducidad: {expires}.",
@@ -86,13 +84,18 @@
"renewal_warning_30": "⚠️ La licencia caduca en {days} días. Renueva pronto para evitar interrupciones.",
"renewal_warning_expired": "🛑 La licencia caducó el {date}. Renuévala para seguir usando DataTools.",
"tier_trial": "Prueba",
"tier_lite": "Lite",
"tier_core": "Core",
"tier_pro": "Pro",
"tier_enterprise": "Enterprise",
"registered_to": "Registrado a nombre de {name} · {email}",
"expires_on": "Caduca el {date}",
"issued_on": "Emitida el {date}",
"view_details": "Detalles de la licencia"
"view_details": "Detalles de la licencia",
"feature_locked_title": "🔒 Esta herramienta no está incluida en tu licencia {tier}",
"feature_locked_body": "Tu licencia actual incluye: {features}. Actualiza para acceder a esta herramienta.",
"upgrade_link": "Gestionar licencia",
"status_locked": "Bloqueado"
},
"tools": {
"01_deduplicator": {

View File

@@ -29,7 +29,26 @@ def _all() -> FrozenSet[FeatureFlag]:
FEATURES_BY_TIER: dict[Tier, FrozenSet[FeatureFlag]] = {
Tier.TRIAL: _all(),
# TRIAL is no longer reachable from the activation UI (no free
# trial in v1.6) but we leave the mapping in place so legacy
# trial licenses on disk still load. Effectively LITE-equivalent
# if anyone has one.
Tier.TRIAL: frozenset({
FeatureFlag.DEDUPLICATOR,
FeatureFlag.TEXT_CLEANER,
FeatureFlag.FORMAT_STANDARDIZER,
}),
# LITE — the cheap SKU. Three tools that cover the common
# bookkeeping / RevOps workflow: clean text, standardise formats,
# remove duplicates. Buyers who want missing-handler, column-
# mapper, outlier-detector, validator, or the pipeline runner
# upgrade to CORE.
Tier.LITE: frozenset({
FeatureFlag.DEDUPLICATOR,
FeatureFlag.TEXT_CLEANER,
FeatureFlag.FORMAT_STANDARDIZER,
}),
# CORE — the v1 full SKU. Every Ready tool.
Tier.CORE: _all(),
# Pre-wired for future SKUs. Today they mirror CORE so the gating
# tests exercise the lookup path without making a marketing claim.

View File

@@ -31,12 +31,19 @@ from typing import Any
class Tier(str, Enum):
"""License tier. Drives the feature set the active license unlocks.
Order matters: TRIAL < CORE < PRO < ENTERPRISE. A higher tier
inherits every feature of the lower tiers — see
Order matters for upgrade pricing: LITE < CORE < PRO < ENTERPRISE.
Each higher tier inherits every feature of the lower tiers — see
:data:`.features.FEATURES_BY_TIER`.
``TRIAL`` is retained for backward-compat with the brief trial-
enabled build, but is no longer reachable from any user-facing
entry point (the GUI trial button and the ``license_cli trial``
subcommand were both removed in v1.6). The enum value stays so
a buyer with a trial license on disk doesn't see a load failure.
"""
TRIAL = "trial"
LITE = "lite"
CORE = "core"
PRO = "pro"
ENTERPRISE = "enterprise"

View File

@@ -1,6 +1,6 @@
"""CLI for license management.
Five commands:
Four commands:
- ``activate BLOB --name NAME --email EMAIL``
First-time activation. Verifies the signed blob, ensures the
@@ -9,23 +9,23 @@ Five commands:
- ``renew BLOB``
Apply a renewal blob to the currently-active license. The blob's
embedded name + email must match the active license; tier may
differ (upgrade path).
differ (upgrade path, e.g., Lite → Core).
- ``status [--json]``
Print the current license state. Human-readable by default;
``--json`` emits the same payload as a JSON document for piping
into shell scripts / monitoring.
- ``trial --name NAME --email EMAIL [--years N]``
Self-issue a trial license without a paid blob. Useful for
evaluating the product or for support to repro a buyer's issue
locally without needing a real key.
- ``deactivate``
Remove the local license file.
This CLI is exempt from the guard that protects every tool CLI —
otherwise a user with no license couldn't run ``activate``.
There is **no free-trial subcommand**: every license requires a paid
blob from the seller. The internal ``LicenseManager._mint`` API is
used by tests and by the seller's ``scripts/generate_license.py``;
end users have no way to self-issue a license.
"""
from __future__ import annotations
@@ -134,25 +134,6 @@ def status(
)
@app.command()
def trial(
name: str = typer.Option(..., "--name", "-n"),
email: str = typer.Option(..., "--email", "-e"),
years: int = typer.Option(1, "--years", help="Trial length (default: 1 year)."),
) -> None:
"""Self-issue a trial license without a paid blob."""
mgr = get_manager()
try:
lic = mgr.issue_trial(name=name, email=email, years=years)
except LicenseError as e:
typer.echo(f"Trial issuance failed: {e}", err=True)
raise typer.Exit(code=2)
typer.echo(
f"Trial issued. Key: {lic.license_key} · "
f"Expires: {lic.expires_at[:10]} ({lic.days_remaining()} days)"
)
@app.command()
def deactivate(
confirm: bool = typer.Option(

View File

@@ -152,21 +152,18 @@ class TestActivationFormSubmission:
text = collected_text(home_app)
assert "Data Cleaning Mastery" in text
def test_trial_button_self_issues_license(
self, no_license_env, home_app,
):
def test_trial_button_absent_paid_only(self, no_license_env, home_app):
"""v1.6 dropped the user-facing trial flow — paid licenses only.
Verify the trial button is not on the activation form."""
home_app.run()
home_app.text_input(key="gate_name").set_value("Trial").run()
home_app.text_input(key="gate_email").set_value("trial@example.com").run()
# Click the trial button on the same form.
trial_btn = next(
b for b in home_app.button
if "trial" in b.label.lower() or "prueba" in b.label.lower()
)
trial_btn.click().run()
text = collected_text(home_app)
# Successful activation → home page renders fully.
assert "Data Cleaning Mastery" in text
labels = [b.label for b in home_app.button]
for lbl in labels:
assert "trial" not in lbl.lower(), (
f"trial button leaked into activation form: {lbl!r}"
)
assert "prueba" not in lbl.lower(), (
f"Spanish trial button leaked into form: {lbl!r}"
)
class TestActivationPageDirect:

135
tests/gui/test_lite_tier.py Normal file
View File

@@ -0,0 +1,135 @@
"""GUI tests for the Lite tier.
A Lite license unlocks Deduplicator, Text Cleaner, Format
Standardizer. Opening any other tool page (Missing Values, Column
Mapper, Pipeline Runner, etc.) must render an upgrade prompt and
short-circuit the page body.
The home grid shows a 🔒 Locked badge on the cards for tools the
user's tier doesn't unlock.
"""
from __future__ import annotations
import pytest
from .conftest import collected_text, stash_upload
@pytest.fixture
def lite_license(monkeypatch, tmp_path):
"""Activate a Lite license; return nothing — the env vars route
every page on this test through this license."""
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
monkeypatch.setenv(
"DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"),
)
from src.license.manager import reset_singleton_for_tests
reset_singleton_for_tests()
from src.license import LicenseManager, Tier
LicenseManager()._mint(
name="Lite User", email="lite@example.com", tier=Tier.LITE,
)
yield
reset_singleton_for_tests()
# ---------------------------------------------------------------------------
# Unlocked tools render normally
# ---------------------------------------------------------------------------
class TestLiteUnlockedPages:
@pytest.mark.parametrize("slug,signal", [
("1_Deduplicator", "Deduplicator"),
("2_Text_Cleaner", "Text Cleaner"),
("3_Format_Standardizer", "Format Standardizer"),
])
def test_unlocked_pages_render_body(
self, lite_license, app_factory, slug, signal, small_csv_bytes,
):
app = app_factory(slug)
stash_upload(app, name="messy.csv", data=small_csv_bytes)
app.run()
text = collected_text(app)
assert signal in text
# No upgrade prompt.
assert "isn't on your" not in text and "no está incluida" not in text
# ---------------------------------------------------------------------------
# Locked tools render upgrade prompt
# ---------------------------------------------------------------------------
class TestLiteLockedPages:
@pytest.mark.parametrize("slug", [
"4_Missing_Values",
"5_Column_Mapper",
"9_Pipeline_Runner",
"6_Outlier_Detector",
"7_Multi_File_Merger",
"8_Validator_Reporter",
])
def test_locked_page_shows_upgrade_prompt(
self, lite_license, app_factory, slug, small_csv_bytes,
):
app = app_factory(slug)
stash_upload(app, name="messy.csv", data=small_csv_bytes)
app.run()
text = collected_text(app)
# Upgrade prompt title carries the localized lock + tier label.
assert "isn't on your" in text or "no está incluida" in text, (
f"locked page {slug!r} missing upgrade prompt; got:\n"
f"{text[:500]}"
)
def test_upgrade_button_present(
self, lite_license, app_factory, small_csv_bytes,
):
app = app_factory("4_Missing_Values")
stash_upload(app, name="messy.csv", data=small_csv_bytes)
app.run()
labels = [b.label for b in app.button]
assert any("Manage license" in lbl for lbl in labels), (
f"upgrade prompt missing Manage-license button; got: {labels}"
)
# ---------------------------------------------------------------------------
# Home grid shows lock badges
# ---------------------------------------------------------------------------
class TestLiteHomeGridBadges:
def test_locked_tool_card_shows_lock_badge(
self, lite_license, home_app,
):
home_app.run()
text = collected_text(home_app)
# Missing Value Handler is locked under Lite — its card should
# have a 🔒 Locked badge.
# We assert the lock glyph appears alongside the locked tool's
# display name. Streamlit renders the markdown verbatim so the
# ``🔒 Locked`` text appears in the page markdown stream.
assert "🔒" in text or "Locked" in text, (
"home grid missing lock badge for Lite-locked tool"
)
def test_unlocked_tool_card_no_lock(self, lite_license, home_app):
home_app.run()
# Dedup is unlocked under Lite. Its card markdown should NOT
# contain a lock glyph adjacent to its name. We can't easily
# scope by card without parsing the markdown stream, but we
# can confirm both ``Ready`` (unlocked) and ``Locked``
# (locked) badges coexist on the page.
text = collected_text(home_app)
assert "Ready" in text or "Listo" in text
# ---------------------------------------------------------------------------
# Sidebar status shows Lite
# ---------------------------------------------------------------------------
class TestLiteSidebarStatus:
def test_sidebar_caption_mentions_lite(self, lite_license, home_app):
home_app.run()
captions = " ".join(c.value for c in home_app.sidebar.caption)
assert "Lite" in captions

View File

@@ -59,31 +59,18 @@ class TestLicenseCliStatus:
assert data["days_remaining"] >= 0
class TestLicenseCliTrial:
def test_trial_issues_one_year_license(self, unactivated_license_manager):
runner = CliRunner()
result = runner.invoke(license_app, [
"trial", "--name", "Trial User", "--email", "trial@example.com",
])
assert result.exit_code == 0
assert "Trial issued" in result.stdout
# And the manager now sees it as active.
from src.license import LicenseManager
mgr = LicenseManager()
assert mgr.is_valid()
lic = mgr.load()
assert lic.tier.value == "trial"
class TestNoTrialSubcommand:
"""The ``trial`` subcommand was removed in v1.6 (no free trial —
paid licenses only). Pin its absence so a future re-add has to be
intentional."""
def test_trial_rejects_bad_email(self, unactivated_license_manager):
def test_trial_subcommand_not_registered(self):
runner = CliRunner()
result = runner.invoke(license_app, [
"trial", "--name", "T", "--email", "not-an-email",
])
assert result.exit_code == 2
# ``typer.echo(..., err=True)`` lands in ``result.output`` when
# ``mix_stderr`` is the default True; ``result.stdout`` only has
# the bare stdout.
assert "valid email" in result.output.lower()
result = runner.invoke(license_app, ["trial", "--help"])
assert result.exit_code != 0
# Typer surfaces unknown commands with "No such command" or
# similar — exact wording varies by version, so we just confirm
# it's a usage error, not a successful execution.
class TestLicenseCliActivate:

150
tests/test_lite_tier.py Normal file
View File

@@ -0,0 +1,150 @@
"""Tier-specific tests: Lite tier feature set + gating.
Lite unlocks exactly three tools — Deduplicator, Text Cleaner,
Format Standardizer — and locks the other six. We test:
- The features map for Lite returns the right three flags (and only
those three).
- A Lite-tier license passes ``require_feature`` for any of the three
unlocked flags and raises ``UnsupportedFeatureError`` for any
locked flag.
- The CLI guard exits with code 2 when a Lite user runs a non-Lite
tool CLI.
GUI behaviour is covered in ``tests/gui/test_lite_tier.py`` so the
GUI/non-GUI split stays clean.
"""
from __future__ import annotations
import pytest
from src.license import (
FeatureFlag,
LicenseManager,
Tier,
UnsupportedFeatureError,
)
from src.license.features import FEATURES_BY_TIER, all_features_for_tier
# ---------------------------------------------------------------------------
# Features map
# ---------------------------------------------------------------------------
class TestLiteFeatureMap:
def test_lite_unlocks_exactly_three_tools(self):
flags = FEATURES_BY_TIER[Tier.LITE]
assert flags == frozenset({
FeatureFlag.DEDUPLICATOR,
FeatureFlag.TEXT_CLEANER,
FeatureFlag.FORMAT_STANDARDIZER,
})
def test_lite_locks_other_six_tools(self):
flags = FEATURES_BY_TIER[Tier.LITE]
locked = {
FeatureFlag.MISSING_HANDLER,
FeatureFlag.COLUMN_MAPPER,
FeatureFlag.OUTLIER_DETECTOR,
FeatureFlag.MULTI_FILE_MERGER,
FeatureFlag.VALIDATOR_REPORTER,
FeatureFlag.PIPELINE_RUNNER,
}
for f in locked:
assert f not in flags
def test_all_features_for_tier_lite_returns_sorted_tuple(self):
tup = all_features_for_tier(Tier.LITE)
assert tup == tuple(sorted(tup))
assert len(tup) == 3
def test_core_still_unlocks_every_tool(self):
"""Sanity: adding Lite didn't accidentally narrow Core."""
assert FEATURES_BY_TIER[Tier.CORE] == frozenset(FeatureFlag)
# ---------------------------------------------------------------------------
# Manager gating
# ---------------------------------------------------------------------------
class TestLiteLicenseGating:
@pytest.fixture
def lite_manager(self, isolated_license_path):
"""Pre-activate a Lite license and return the manager."""
mgr = LicenseManager()
mgr._mint(name="Lite User", email="lite@example.com", tier=Tier.LITE)
return mgr
@pytest.mark.parametrize("feature", [
FeatureFlag.DEDUPLICATOR,
FeatureFlag.TEXT_CLEANER,
FeatureFlag.FORMAT_STANDARDIZER,
])
def test_lite_passes_for_included_features(self, lite_manager, feature):
lite_manager.require_feature(feature) # no raise
@pytest.mark.parametrize("feature", [
FeatureFlag.MISSING_HANDLER,
FeatureFlag.COLUMN_MAPPER,
FeatureFlag.OUTLIER_DETECTOR,
FeatureFlag.MULTI_FILE_MERGER,
FeatureFlag.VALIDATOR_REPORTER,
FeatureFlag.PIPELINE_RUNNER,
])
def test_lite_raises_for_locked_features(self, lite_manager, feature):
with pytest.raises(UnsupportedFeatureError):
lite_manager.require_feature(feature)
def test_lite_state_carries_lite_tier(self, lite_manager):
state = lite_manager.current_state()
assert state.tier == "lite"
assert state.valid is True
# ---------------------------------------------------------------------------
# CLI guard
# ---------------------------------------------------------------------------
class TestLiteCliGuard:
"""In-process tests of the guard. Subprocess equivalents would be
slower; the unit-level coverage is sufficient because the guard
is pure Python and the entrypoint plumbing is already tested in
``test_license_cli.py``."""
def _activate_lite(self, monkeypatch, tmp_path):
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
monkeypatch.setenv(
"DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"),
)
from src.license.manager import reset_singleton_for_tests
reset_singleton_for_tests()
from src.license import LicenseManager
LicenseManager()._mint(
name="A", email="a@b.com", tier=Tier.LITE,
)
def test_guard_passes_for_unlocked_feature(self, monkeypatch, tmp_path):
self._activate_lite(monkeypatch, tmp_path)
monkeypatch.setattr("sys.argv", ["prog", "input.csv"])
from src.cli_license_guard import guard
# Lite unlocks text cleaner — guard must not raise.
guard(feature="02_text_cleaner")
def test_guard_rejects_locked_feature(self, monkeypatch, tmp_path, capsys):
self._activate_lite(monkeypatch, tmp_path)
monkeypatch.setattr("sys.argv", ["prog", "input.csv"])
from src.cli_license_guard import guard
with pytest.raises(SystemExit) as ei:
guard(feature="04_missing_handler")
assert ei.value.code == 2
captured = capsys.readouterr()
assert "04_missing_handler" in captured.err
assert "upgrade" in captured.err.lower() or "tier" in captured.err.lower()
def test_guard_help_bypasses_feature_check(self, monkeypatch, tmp_path):
self._activate_lite(monkeypatch, tmp_path)
monkeypatch.setattr("sys.argv", ["prog", "--help"])
from src.cli_license_guard import guard
# --help still bypasses the guard entirely.
guard(feature="04_missing_handler")