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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)}**]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
135
tests/gui/test_lite_tier.py
Normal 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
|
||||
@@ -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
150
tests/test_lite_tier.py
Normal 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")
|
||||
Reference in New Issue
Block a user