diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1f97b33..94fb5dc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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: diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 71b5d41..6a5e448 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -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_`` 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): diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index bdeb562..1d8e42f 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -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.)`` 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 diff --git a/docs/USER-GUIDE.es.md b/docs/USER-GUIDE.es.md index c7ee1fe..963019e 100644 --- a/docs/USER-GUIDE.es.md +++ b/docs/USER-GUIDE.es.md @@ -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\\.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\\.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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index ad9e886..d82954a 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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\\.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\\.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 diff --git a/src/cli.py b/src/cli.py index f4002e9..7428cac 100644 --- a/src/cli.py +++ b/src/cli.py @@ -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() diff --git a/src/cli_column_map.py b/src/cli_column_map.py index 01dba55..05cb6a5 100644 --- a/src/cli_column_map.py +++ b/src/cli_column_map.py @@ -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() diff --git a/src/cli_format.py b/src/cli_format.py index 6bbb4c3..766be06 100644 --- a/src/cli_format.py +++ b/src/cli_format.py @@ -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() diff --git a/src/cli_license_guard.py b/src/cli_license_guard.py index 9f49a41..1ec6305 100644 --- a/src/cli_license_guard.py +++ b/src/cli_license_guard.py @@ -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 ``." + ) + print(f"Error: {msg}", file=sys.stderr) + raise SystemExit(2) def _exit_with_message(state) -> NoReturn: diff --git a/src/cli_missing.py b/src/cli_missing.py index 02ac6d7..e33a315 100644 --- a/src/cli_missing.py +++ b/src/cli_missing.py @@ -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() diff --git a/src/cli_pipeline.py b/src/cli_pipeline.py index 2769d05..e1b40a7 100644 --- a/src/cli_pipeline.py +++ b/src/cli_pipeline.py @@ -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() diff --git a/src/cli_text_clean.py b/src/cli_text_clean.py index f8ccb47..b5f556b 100644 --- a/src/cli_text_clean.py +++ b/src/cli_text_clean.py @@ -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() diff --git a/src/gui/app.py b/src/gui/app.py index 4378411..a7014b7 100644 --- a/src/gui/app.py +++ b/src/gui/app.py @@ -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)}**]" ) diff --git a/src/gui/components/__init__.py b/src/gui/components/__init__.py index b0670de..4b590b8 100644 --- a/src/gui/components/__init__.py +++ b/src/gui/components/__init__.py @@ -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", diff --git a/src/gui/components/activation.py b/src/gui/components/activation.py index 1b590c4..93d812d 100644 --- a/src/gui/components/activation.py +++ b/src/gui/components/activation.py @@ -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() diff --git a/src/gui/pages/1_Deduplicator.py b/src/gui/pages/1_Deduplicator.py index 4cf5b0a..c4a2499 100644 --- a/src/gui/pages/1_Deduplicator.py +++ b/src/gui/pages/1_Deduplicator.py @@ -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() # --------------------------------------------------------------------------- diff --git a/src/gui/pages/2_Text_Cleaner.py b/src/gui/pages/2_Text_Cleaner.py index f7e74cc..f2c016e 100644 --- a/src/gui/pages/2_Text_Cleaner.py +++ b/src/gui/pages/2_Text_Cleaner.py @@ -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() diff --git a/src/gui/pages/3_Format_Standardizer.py b/src/gui/pages/3_Format_Standardizer.py index 4c605e7..ed2a519 100644 --- a/src/gui/pages/3_Format_Standardizer.py +++ b/src/gui/pages/3_Format_Standardizer.py @@ -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() diff --git a/src/gui/pages/4_Missing_Values.py b/src/gui/pages/4_Missing_Values.py index fa16011..c53ae0f 100644 --- a/src/gui/pages/4_Missing_Values.py +++ b/src/gui/pages/4_Missing_Values.py @@ -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() diff --git a/src/gui/pages/5_Column_Mapper.py b/src/gui/pages/5_Column_Mapper.py index 0797cbe..2c83e80 100644 --- a/src/gui/pages/5_Column_Mapper.py +++ b/src/gui/pages/5_Column_Mapper.py @@ -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() diff --git a/src/gui/pages/6_Outlier_Detector.py b/src/gui/pages/6_Outlier_Detector.py index 169c816..e3e5cee 100644 --- a/src/gui/pages/6_Outlier_Detector.py +++ b/src/gui/pages/6_Outlier_Detector.py @@ -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() # --------------------------------------------------------------------------- diff --git a/src/gui/pages/7_Multi_File_Merger.py b/src/gui/pages/7_Multi_File_Merger.py index 4c41c79..3151e80 100644 --- a/src/gui/pages/7_Multi_File_Merger.py +++ b/src/gui/pages/7_Multi_File_Merger.py @@ -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() # --------------------------------------------------------------------------- diff --git a/src/gui/pages/8_Validator_Reporter.py b/src/gui/pages/8_Validator_Reporter.py index dac5819..576a948 100644 --- a/src/gui/pages/8_Validator_Reporter.py +++ b/src/gui/pages/8_Validator_Reporter.py @@ -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() # --------------------------------------------------------------------------- diff --git a/src/gui/pages/9_Pipeline_Runner.py b/src/gui/pages/9_Pipeline_Runner.py index 34819ac..a9f2e73 100644 --- a/src/gui/pages/9_Pipeline_Runner.py +++ b/src/gui/pages/9_Pipeline_Runner.py @@ -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() diff --git a/src/i18n/packs/en.json b/src/i18n/packs/en.json index ece5aa7..73e22a5 100644 --- a/src/i18n/packs/en.json +++ b/src/i18n/packs/en.json @@ -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": { diff --git a/src/i18n/packs/es.json b/src/i18n/packs/es.json index 33ec26a..e028d6c 100644 --- a/src/i18n/packs/es.json +++ b/src/i18n/packs/es.json @@ -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": { diff --git a/src/license/features.py b/src/license/features.py index cf6a71d..169a8b3 100644 --- a/src/license/features.py +++ b/src/license/features.py @@ -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. diff --git a/src/license/schema.py b/src/license/schema.py index 297fe07..44889e8 100644 --- a/src/license/schema.py +++ b/src/license/schema.py @@ -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" diff --git a/src/license_cli.py b/src/license_cli.py index 049a75c..49035e9 100644 --- a/src/license_cli.py +++ b/src/license_cli.py @@ -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( diff --git a/tests/gui/test_activation.py b/tests/gui/test_activation.py index d9ea370..f3a484d 100644 --- a/tests/gui/test_activation.py +++ b/tests/gui/test_activation.py @@ -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: diff --git a/tests/gui/test_lite_tier.py b/tests/gui/test_lite_tier.py new file mode 100644 index 0000000..5fb62d0 --- /dev/null +++ b/tests/gui/test_lite_tier.py @@ -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 diff --git a/tests/test_license_cli.py b/tests/test_license_cli.py index d21e517..2436a08 100644 --- a/tests/test_license_cli.py +++ b/tests/test_license_cli.py @@ -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: diff --git a/tests/test_lite_tier.py b/tests/test_lite_tier.py new file mode 100644 index 0000000..979f905 --- /dev/null +++ b/tests/test_lite_tier.py @@ -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")