"""License guard for the tool CLIs. Every tool CLI (``cli.py``, ``cli_text_clean.py``, ``cli_format.py``, ``cli_missing.py``, ``cli_column_map.py``, ``cli_pipeline.py``, ``cli_analyze.py``) calls :func:`guard` from its ``main()`` before delegating to Typer. The guard: 1. Lets ``--help`` / ``-h`` through unconditionally so users can always see what a command does. 2. Lets the ``DATATOOLS_DEV_MODE=1`` env var bypass the check. 3. Otherwise, verifies a valid license is on disk. If not, prints a one-line user-facing message naming the exact ``datatools-license`` subcommand to run, then exits with status 2. The exit code (2) is the same Typer uses for argument errors — fits into ``run_tests.py`` / CI pipelines without special casing. """ from __future__ import annotations import sys from typing import NoReturn _HELP_FLAGS = {"--help", "-h", "--version"} def _is_help_invocation() -> bool: """True when the user is asking for help, not running work.""" return any(arg in _HELP_FLAGS for arg in sys.argv[1:]) def guard(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``. from src.license import ( ExpiredLicenseError, InvalidLicenseError, LicenseError, UnsupportedFeatureError, assert_production_safe, get_manager, ) # Refuse to run a misconfigured shipped build. No-op in # development / pytest runs. assert_production_safe() mgr = get_manager() if mgr.dev_mode: return try: 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: state = mgr.current_state() _exit_with_message(state) def _exit_with_feature_message(feature: str, detail: str) -> NoReturn: """Print the upgrade-tier diagnostic and exit. Mirrors the GUI's ``require_feature_or_render_upgrade`` UX in CLI form.""" msg = ( f"This command requires the {feature!r} feature, which is not " f"included in your current license tier.\n" f"Detail: {detail}\n" "Run ``python -m src.license_cli status`` to see your tier, " "then activate an upgrade blob with " "``python -m src.license_cli renew ``." ) print(f"Error: {msg}", file=sys.stderr) raise SystemExit(2) def _exit_with_message(state) -> NoReturn: """Print the right one-liner for the current state and exit.""" if state.error_kind == "not_activated": msg = ( "DataTools is not activated.\n" "Run: python -m src.license_cli activate \n" "Or start a 1-year trial: " "python -m src.license_cli trial --name 'Your Name' --email you@example.com" ) elif state.error_kind == "expired": msg = ( f"License expired on {state.expires_at[:10]}.\n" "Renew with: python -m src.license_cli renew " ) elif state.error_kind == "invalid": msg = ( "License file is present but invalid.\n" f"Detail: {state.error_message}\n" "Re-paste the blob from your delivery email: " "python -m src.license_cli activate " ) else: msg = "License is not valid. Run: python -m src.license_cli status" print(f"Error: {msg}", file=sys.stderr) raise SystemExit(2)