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 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 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) | 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
|
## 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.
|
**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
|
## 8. Re-lock triggers
|
||||||
|
|
||||||
These criteria are load-bearing. Triggers for explicit re-evaluation:
|
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``.
|
1. Add the enum value to ``Tier``.
|
||||||
2. Add a row to ``FEATURES_BY_TIER`` listing the unlocked flags.
|
2. Add a row to ``FEATURES_BY_TIER`` listing the unlocked flags.
|
||||||
3. Add ``license.tier_<name>`` translation keys to every i18n pack.
|
3. Add ``license.tier_<name>`` translation keys to every i18n pack.
|
||||||
4. The activation flow, sidebar status badge, and feature gate all
|
4. The activation flow, sidebar status badge, feature gate, and home
|
||||||
pick up the new tier automatically.
|
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):
|
**Minting a license** (creator-only):
|
||||||
|
|
||||||
|
|||||||
@@ -174,13 +174,14 @@ and proceeds.
|
|||||||
- **Dev**: pytest, tox.
|
- **Dev**: pytest, tox.
|
||||||
|
|
||||||
## 16. Test coverage
|
## 16. Test coverage
|
||||||
- 1,995 tests passing, 0 skipped, 0 xfailed.
|
- 2,024 tests passing, 0 skipped, 0 xfailed.
|
||||||
- 1,843 core + CLI tests (run with `pytest -m 'not gui'` for a quick loop).
|
- 1,859 core + CLI tests (run with `pytest -m 'not gui'` for a quick loop).
|
||||||
Includes 40 license-layer unit tests + 26 license-CLI tests.
|
Includes 40 license-layer unit tests, 25 license-CLI tests, and
|
||||||
- 152 GUI tests under `tests/gui/` driving Streamlit pages via `AppTest`
|
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,
|
(smoke + EN/ES localization, chrome, gate, workflows, dedup review,
|
||||||
advanced panels, error paths, findings panel, activation + license
|
advanced panels, error paths, findings panel, activation +
|
||||||
gate). Marked `gui`.
|
license gate, Lite-tier per-page lock behaviour). Marked `gui`.
|
||||||
- Includes 15 perf-shape regression tests.
|
- 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).
|
- 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]`.
|
- Run: `python run_tests.py [--tool …] [--fixtures] [--coverage]`.
|
||||||
@@ -199,15 +200,31 @@ and proceeds.
|
|||||||
(``DTLIC1:...``) on first launch; app verifies the signature
|
(``DTLIC1:...``) on first launch; app verifies the signature
|
||||||
offline + matches the buyer-entered name/email to the embedded
|
offline + matches the buyer-entered name/email to the embedded
|
||||||
values.
|
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
|
- **Lifetime**: every license is 1 year by default. Renewal applies a
|
||||||
fresh blob without losing the embedded buyer identity.
|
fresh blob without losing the embedded buyer identity. Tier may
|
||||||
- **Tiers**: ``trial``, ``core`` (the v1 SKU — all 9 tools), ``pro``,
|
change during renewal (Lite → Core upgrade path).
|
||||||
``enterprise``. PRO and ENTERPRISE are scaffolded for future SKUs;
|
- **Tiers**:
|
||||||
they currently unlock the same feature set as CORE.
|
- ``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
|
- **Feature flags**: every tool has a stable feature id matching its
|
||||||
``tool_id`` in :mod:`src.gui.tools_registry`. Adding a future per-
|
``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
|
tool SKU is a one-line change to ``FEATURES_BY_TIER`` — no consumer
|
||||||
code edits.
|
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
|
- **Dev bypass**: ``DATATOOLS_DEV_MODE=1`` skips every check (used by
|
||||||
the test suite and during development).
|
the test suite and during development).
|
||||||
- **No internet**: signature verification is fully offline. The
|
- **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**.
|
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**. |
|
| **Lite** | Eliminador de duplicados · Limpiador de texto · Estandarizador de formatos |
|
||||||
| 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. |
|
| **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
|
## 1. Instalación
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,20 @@
|
|||||||
|
|
||||||
DataTools must be activated before any tools unlock. On first launch you'll see the **Activate** screen.
|
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**. |
|
| **Lite** | Deduplicator · Text Cleaner · Format Standardizer |
|
||||||
| 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. |
|
| **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
|
## 1. Install
|
||||||
|
|
||||||
|
|||||||
@@ -500,7 +500,8 @@ def _write_match_groups(result, original_df, path: Path) -> None:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.DEDUPLICATOR.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -349,7 +349,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.COLUMN_MAPPER.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,8 @@ def standardize(
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.FORMAT_STANDARDIZER.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,21 @@ def _is_help_invocation() -> bool:
|
|||||||
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
|
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
def guard() -> None:
|
def guard(feature: str | None = None) -> None:
|
||||||
"""Block startup if no valid license. No-op when license is valid,
|
"""Block startup if no valid license.
|
||||||
when called with ``--help``, or under ``DATATOOLS_DEV_MODE``."""
|
|
||||||
|
*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():
|
if _is_help_invocation():
|
||||||
return
|
return
|
||||||
# Lazy import so a broken license module doesn't fail ``--help``.
|
# Lazy import so a broken license module doesn't fail ``--help``.
|
||||||
@@ -40,6 +52,7 @@ def guard() -> None:
|
|||||||
ExpiredLicenseError,
|
ExpiredLicenseError,
|
||||||
InvalidLicenseError,
|
InvalidLicenseError,
|
||||||
LicenseError,
|
LicenseError,
|
||||||
|
UnsupportedFeatureError,
|
||||||
get_manager,
|
get_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,15 +61,33 @@ def guard() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if mgr.is_valid():
|
if not mgr.is_valid():
|
||||||
return
|
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:
|
except LicenseError:
|
||||||
# ``is_valid()`` swallows errors and returns False, but be
|
state = mgr.current_state()
|
||||||
# paranoid: fall through to the state-based diagnostic.
|
_exit_with_message(state)
|
||||||
pass
|
|
||||||
|
|
||||||
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:
|
def _exit_with_message(state) -> NoReturn:
|
||||||
|
|||||||
@@ -374,7 +374,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.MISSING_HANDLER.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,8 @@ def run(
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.PIPELINE_RUNNER.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -371,7 +371,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
from src.cli_license_guard import guard
|
from src.cli_license_guard import guard
|
||||||
guard()
|
from src.license import FeatureFlag
|
||||||
|
guard(feature=FeatureFlag.TEXT_CLEANER.value)
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ st.divider()
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from src.gui.tools_registry import TOOLS, tool_description, tool_name
|
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
|
# 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
|
# 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
|
break
|
||||||
tool = TOOLS[idx]
|
tool = TOOLS[idx]
|
||||||
with col:
|
with col:
|
||||||
status_key = "status.ready" if tool.status == "Ready" else "status.coming_soon"
|
tool_locked = (
|
||||||
status_color = "green" if tool.status == "Ready" else "orange"
|
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 = ""
|
badge = ""
|
||||||
n = findings_count_for_tool(tool.tool_id)
|
n = findings_count_for_tool(tool.tool_id)
|
||||||
if n:
|
if n:
|
||||||
badge_key = "home.findings_badge_one" if n == 1 else "home.findings_badge_other"
|
badge_key = "home.findings_badge_one" if n == 1 else "home.findings_badge_other"
|
||||||
badge = f" :red-background[**{t(badge_key, n=n)}**]"
|
badge = f" :red-background[**{t(badge_key, n=n)}**]"
|
||||||
|
lock_glyph = "🔒 " if tool_locked else ""
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n"
|
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n"
|
||||||
f"{tool_description(tool.tool_id)}\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
|
from .activation import ( # noqa: F401 re-exported
|
||||||
render_activation_form,
|
render_activation_form,
|
||||||
render_license_status_sidebar,
|
render_license_status_sidebar,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_license_or_render_activation,
|
require_license_or_render_activation,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
# License gate + activation form
|
# License gate + activation form
|
||||||
"render_activation_form",
|
"render_activation_form",
|
||||||
"render_license_status_sidebar",
|
"render_license_status_sidebar",
|
||||||
|
"require_feature_or_render_upgrade",
|
||||||
"require_license_or_render_activation",
|
"require_license_or_render_activation",
|
||||||
# Dedup widgets
|
# Dedup widgets
|
||||||
"config_panel",
|
"config_panel",
|
||||||
|
|||||||
@@ -125,19 +125,12 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
|
|||||||
height=120,
|
height=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
col_activate, col_trial = st.columns(2)
|
is_renewal = state.activated
|
||||||
with col_activate:
|
label = (
|
||||||
is_renewal = state.activated
|
_t("activation.renew_button") if is_renewal
|
||||||
label = (
|
else _t("activation.activate_button")
|
||||||
_t("activation.renew_button") if is_renewal
|
)
|
||||||
else _t("activation.activate_button")
|
submit = st.form_submit_button(label, type="primary")
|
||||||
)
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process the submission.
|
# Process the submission.
|
||||||
if submit:
|
if submit:
|
||||||
@@ -159,17 +152,6 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
|
|||||||
except LicenseError as e:
|
except LicenseError as e:
|
||||||
st.error(f"{_t('activation.errors_heading')}: {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).
|
# Deactivate (only useful when already activated).
|
||||||
if state.activated:
|
if state.activated:
|
||||||
st.divider()
|
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
|
unlocked), this is a no-op. Otherwise it renders the activation
|
||||||
form in place of the page body and calls ``st.stop()``.
|
form in place of the page body and calls ``st.stop()``.
|
||||||
|
|
||||||
The chrome helper invokes this automatically so individual pages
|
Tool pages that need per-tool gating call
|
||||||
don't have to call it; the explicit *feature* form is provided
|
:func:`require_feature_or_render_upgrade` *after* this — that
|
||||||
for tools that want to bypass the global gate but enforce a tier
|
keeps the global gate (activate / renew flow) separate from the
|
||||||
check (future SKU work).
|
per-tier feature gate (upgrade flow).
|
||||||
"""
|
"""
|
||||||
mgr = get_manager()
|
mgr = get_manager()
|
||||||
if mgr.dev_mode:
|
if mgr.dev_mode:
|
||||||
return
|
return
|
||||||
if mgr.is_valid():
|
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
|
return
|
||||||
|
|
||||||
# Otherwise: render the activation form inline and stop the page.
|
# Otherwise: render the activation form inline and stop the page.
|
||||||
render_activation_form(key_prefix="gate")
|
render_activation_form(key_prefix="gate")
|
||||||
st.stop()
|
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,
|
hide_streamlit_chrome,
|
||||||
match_group_card,
|
match_group_card,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
results_summary,
|
results_summary,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.DEDUPLICATOR)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ from src.gui.components import (
|
|||||||
hide_streamlit_chrome,
|
hide_streamlit_chrome,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
render_hidden_aware_preview,
|
render_hidden_aware_preview,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
from src.core.text_clean import (
|
from src.core.text_clean import (
|
||||||
PRESETS,
|
PRESETS,
|
||||||
CleanOptions,
|
CleanOptions,
|
||||||
@@ -29,6 +31,7 @@ from src.core.text_clean import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.TEXT_CLEANER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
|
|||||||
from src.gui.components import (
|
from src.gui.components import (
|
||||||
hide_streamlit_chrome,
|
hide_streamlit_chrome,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
)
|
)
|
||||||
from src.core.format_standardize import (
|
from src.core.format_standardize import (
|
||||||
@@ -25,8 +26,10 @@ from src.core.format_standardize import (
|
|||||||
StandardizeOptions,
|
StandardizeOptions,
|
||||||
standardize_dataframe,
|
standardize_dataframe,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.FORMAT_STANDARDIZER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
|
|||||||
from src.gui.components import (
|
from src.gui.components import (
|
||||||
hide_streamlit_chrome,
|
hide_streamlit_chrome,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
)
|
)
|
||||||
from src.core.missing import (
|
from src.core.missing import (
|
||||||
@@ -26,8 +27,10 @@ from src.core.missing import (
|
|||||||
handle_missing,
|
handle_missing,
|
||||||
profile_missing,
|
profile_missing,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.MISSING_HANDLER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
|
|||||||
from src.gui.components import (
|
from src.gui.components import (
|
||||||
hide_streamlit_chrome,
|
hide_streamlit_chrome,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
)
|
)
|
||||||
from src.core.column_mapper import (
|
from src.core.column_mapper import (
|
||||||
@@ -27,8 +28,10 @@ from src.core.column_mapper import (
|
|||||||
infer_mapping,
|
infer_mapping,
|
||||||
map_columns,
|
map_columns,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.COLUMN_MAPPER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|||||||
if str(_project_root) not in sys.path:
|
if str(_project_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_project_root))
|
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()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.OUTLIER_DETECTOR)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|||||||
if str(_project_root) not in sys.path:
|
if str(_project_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_project_root))
|
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()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.MULTI_FILE_MERGER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|||||||
if str(_project_root) not in sys.path:
|
if str(_project_root) not in sys.path:
|
||||||
sys.path.insert(0, str(_project_root))
|
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()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.VALIDATOR_REPORTER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
|
|||||||
from src.gui.components import (
|
from src.gui.components import (
|
||||||
hide_streamlit_chrome,
|
hide_streamlit_chrome,
|
||||||
pickup_or_upload,
|
pickup_or_upload,
|
||||||
|
require_feature_or_render_upgrade,
|
||||||
require_normalization_gate,
|
require_normalization_gate,
|
||||||
)
|
)
|
||||||
from src.core.pipeline import (
|
from src.core.pipeline import (
|
||||||
@@ -28,8 +29,10 @@ from src.core.pipeline import (
|
|||||||
run_pipeline,
|
run_pipeline,
|
||||||
validate_pipeline,
|
validate_pipeline,
|
||||||
)
|
)
|
||||||
|
from src.license import FeatureFlag
|
||||||
|
|
||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
|
require_feature_or_render_upgrade(FeatureFlag.PIPELINE_RUNNER)
|
||||||
require_normalization_gate()
|
require_normalization_gate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,6 @@
|
|||||||
"blob_help": "Begins with `DTLIC1:` — paste the entire string.",
|
"blob_help": "Begins with `DTLIC1:` — paste the entire string.",
|
||||||
"activate_button": "Activate",
|
"activate_button": "Activate",
|
||||||
"renew_button": "Apply renewal",
|
"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 —",
|
"or_separator": "— or —",
|
||||||
"success": "Activated! Welcome, {name}. Your license is valid until {expires}.",
|
"success": "Activated! Welcome, {name}. Your license is valid until {expires}.",
|
||||||
"renewed": "License renewed. New expiry: {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_30": "⚠️ License expires in {days} days. Renew soon to avoid interruption.",
|
||||||
"renewal_warning_expired": "🛑 License expired on {date}. Renew to continue using DataTools.",
|
"renewal_warning_expired": "🛑 License expired on {date}. Renew to continue using DataTools.",
|
||||||
"tier_trial": "Trial",
|
"tier_trial": "Trial",
|
||||||
|
"tier_lite": "Lite",
|
||||||
"tier_core": "Core",
|
"tier_core": "Core",
|
||||||
"tier_pro": "Pro",
|
"tier_pro": "Pro",
|
||||||
"tier_enterprise": "Enterprise",
|
"tier_enterprise": "Enterprise",
|
||||||
"registered_to": "Registered to {name} · {email}",
|
"registered_to": "Registered to {name} · {email}",
|
||||||
"expires_on": "Expires on {date}",
|
"expires_on": "Expires on {date}",
|
||||||
"issued_on": "Issued 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": {
|
"tools": {
|
||||||
"01_deduplicator": {
|
"01_deduplicator": {
|
||||||
|
|||||||
@@ -68,8 +68,6 @@
|
|||||||
"blob_help": "Empieza con `DTLIC1:` — pega la cadena completa.",
|
"blob_help": "Empieza con `DTLIC1:` — pega la cadena completa.",
|
||||||
"activate_button": "Activar",
|
"activate_button": "Activar",
|
||||||
"renew_button": "Aplicar renovación",
|
"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 —",
|
"or_separator": "— o —",
|
||||||
"success": "¡Activado! Bienvenido, {name}. Tu licencia es válida hasta el {expires}.",
|
"success": "¡Activado! Bienvenido, {name}. Tu licencia es válida hasta el {expires}.",
|
||||||
"renewed": "Licencia renovada. Nueva fecha de caducidad: {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_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.",
|
"renewal_warning_expired": "🛑 La licencia caducó el {date}. Renuévala para seguir usando DataTools.",
|
||||||
"tier_trial": "Prueba",
|
"tier_trial": "Prueba",
|
||||||
|
"tier_lite": "Lite",
|
||||||
"tier_core": "Core",
|
"tier_core": "Core",
|
||||||
"tier_pro": "Pro",
|
"tier_pro": "Pro",
|
||||||
"tier_enterprise": "Enterprise",
|
"tier_enterprise": "Enterprise",
|
||||||
"registered_to": "Registrado a nombre de {name} · {email}",
|
"registered_to": "Registrado a nombre de {name} · {email}",
|
||||||
"expires_on": "Caduca el {date}",
|
"expires_on": "Caduca el {date}",
|
||||||
"issued_on": "Emitida 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": {
|
"tools": {
|
||||||
"01_deduplicator": {
|
"01_deduplicator": {
|
||||||
|
|||||||
@@ -29,7 +29,26 @@ def _all() -> FrozenSet[FeatureFlag]:
|
|||||||
|
|
||||||
|
|
||||||
FEATURES_BY_TIER: dict[Tier, 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(),
|
Tier.CORE: _all(),
|
||||||
# Pre-wired for future SKUs. Today they mirror CORE so the gating
|
# Pre-wired for future SKUs. Today they mirror CORE so the gating
|
||||||
# tests exercise the lookup path without making a marketing claim.
|
# tests exercise the lookup path without making a marketing claim.
|
||||||
|
|||||||
@@ -31,12 +31,19 @@ from typing import Any
|
|||||||
class Tier(str, Enum):
|
class Tier(str, Enum):
|
||||||
"""License tier. Drives the feature set the active license unlocks.
|
"""License tier. Drives the feature set the active license unlocks.
|
||||||
|
|
||||||
Order matters: TRIAL < CORE < PRO < ENTERPRISE. A higher tier
|
Order matters for upgrade pricing: LITE < CORE < PRO < ENTERPRISE.
|
||||||
inherits every feature of the lower tiers — see
|
Each higher tier inherits every feature of the lower tiers — see
|
||||||
:data:`.features.FEATURES_BY_TIER`.
|
: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"
|
TRIAL = "trial"
|
||||||
|
LITE = "lite"
|
||||||
CORE = "core"
|
CORE = "core"
|
||||||
PRO = "pro"
|
PRO = "pro"
|
||||||
ENTERPRISE = "enterprise"
|
ENTERPRISE = "enterprise"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""CLI for license management.
|
"""CLI for license management.
|
||||||
|
|
||||||
Five commands:
|
Four commands:
|
||||||
|
|
||||||
- ``activate BLOB --name NAME --email EMAIL``
|
- ``activate BLOB --name NAME --email EMAIL``
|
||||||
First-time activation. Verifies the signed blob, ensures the
|
First-time activation. Verifies the signed blob, ensures the
|
||||||
@@ -9,23 +9,23 @@ Five commands:
|
|||||||
- ``renew BLOB``
|
- ``renew BLOB``
|
||||||
Apply a renewal blob to the currently-active license. The blob's
|
Apply a renewal blob to the currently-active license. The blob's
|
||||||
embedded name + email must match the active license; tier may
|
embedded name + email must match the active license; tier may
|
||||||
differ (upgrade path).
|
differ (upgrade path, e.g., Lite → Core).
|
||||||
|
|
||||||
- ``status [--json]``
|
- ``status [--json]``
|
||||||
Print the current license state. Human-readable by default;
|
Print the current license state. Human-readable by default;
|
||||||
``--json`` emits the same payload as a JSON document for piping
|
``--json`` emits the same payload as a JSON document for piping
|
||||||
into shell scripts / monitoring.
|
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``
|
- ``deactivate``
|
||||||
Remove the local license file.
|
Remove the local license file.
|
||||||
|
|
||||||
This CLI is exempt from the guard that protects every tool CLI —
|
This CLI is exempt from the guard that protects every tool CLI —
|
||||||
otherwise a user with no license couldn't run ``activate``.
|
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
|
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()
|
@app.command()
|
||||||
def deactivate(
|
def deactivate(
|
||||||
confirm: bool = typer.Option(
|
confirm: bool = typer.Option(
|
||||||
|
|||||||
@@ -152,21 +152,18 @@ class TestActivationFormSubmission:
|
|||||||
text = collected_text(home_app)
|
text = collected_text(home_app)
|
||||||
assert "Data Cleaning Mastery" in text
|
assert "Data Cleaning Mastery" in text
|
||||||
|
|
||||||
def test_trial_button_self_issues_license(
|
def test_trial_button_absent_paid_only(self, no_license_env, home_app):
|
||||||
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.run()
|
||||||
home_app.text_input(key="gate_name").set_value("Trial").run()
|
labels = [b.label for b in home_app.button]
|
||||||
home_app.text_input(key="gate_email").set_value("trial@example.com").run()
|
for lbl in labels:
|
||||||
# Click the trial button on the same form.
|
assert "trial" not in lbl.lower(), (
|
||||||
trial_btn = next(
|
f"trial button leaked into activation form: {lbl!r}"
|
||||||
b for b in home_app.button
|
)
|
||||||
if "trial" in b.label.lower() or "prueba" in b.label.lower()
|
assert "prueba" not in lbl.lower(), (
|
||||||
)
|
f"Spanish trial button leaked into form: {lbl!r}"
|
||||||
trial_btn.click().run()
|
)
|
||||||
text = collected_text(home_app)
|
|
||||||
# Successful activation → home page renders fully.
|
|
||||||
assert "Data Cleaning Mastery" in text
|
|
||||||
|
|
||||||
|
|
||||||
class TestActivationPageDirect:
|
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
|
assert data["days_remaining"] >= 0
|
||||||
|
|
||||||
|
|
||||||
class TestLicenseCliTrial:
|
class TestNoTrialSubcommand:
|
||||||
def test_trial_issues_one_year_license(self, unactivated_license_manager):
|
"""The ``trial`` subcommand was removed in v1.6 (no free trial —
|
||||||
runner = CliRunner()
|
paid licenses only). Pin its absence so a future re-add has to be
|
||||||
result = runner.invoke(license_app, [
|
intentional."""
|
||||||
"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"
|
|
||||||
|
|
||||||
def test_trial_rejects_bad_email(self, unactivated_license_manager):
|
def test_trial_subcommand_not_registered(self):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(license_app, [
|
result = runner.invoke(license_app, ["trial", "--help"])
|
||||||
"trial", "--name", "T", "--email", "not-an-email",
|
assert result.exit_code != 0
|
||||||
])
|
# Typer surfaces unknown commands with "No such command" or
|
||||||
assert result.exit_code == 2
|
# similar — exact wording varies by version, so we just confirm
|
||||||
# ``typer.echo(..., err=True)`` lands in ``result.output`` when
|
# it's a usage error, not a successful execution.
|
||||||
# ``mix_stderr`` is the default True; ``result.stdout`` only has
|
|
||||||
# the bare stdout.
|
|
||||||
assert "valid email" in result.output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestLicenseCliActivate:
|
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