Source code for pywasp._cfg

import functools
import hashlib
import warnings
import json
from logging import getLogger
import os
import random
import re
import time
import uuid
from pathlib import Path
from typing import Annotated, Union, Literal
import pandas as pd

from pydantic import (
    BaseModel,
    Field,
    PrivateAttr,
    ValidationError,
    field_serializer,
    field_validator,
    Discriminator,
    ConfigDict,
)
from pydantic_settings import (
    BaseSettings,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
    TomlConfigSettingsSource,
)

try:  # pydantic-settings >= 2.4 exports it at the top level
    from pydantic_settings import SettingsError
except ImportError:  # older versions keep it in .sources
    from pydantic_settings.sources import SettingsError

import requests
from platformdirs import PlatformDirs
import toml

from ._errors import (
    PywaspError,
    LicenseError,
    LicenseConfigError,
    LicenseServerUnreachableError,
    PointsLimitError,
)
from ._warnings import _find_user_stacklevel

logger = getLogger(__name__)

# Constants
APP_NAME = "pywasp"
APP_AUTHOR = "DTU Wind Energy"
DEFAULT_PORT = "34523"
DEFAULT_DTU_HOST = "130.226.48.178"
ACCOUNT = "0244d026-758b-442b-9668-811ce125a7e3"
PRODUCT_ID = "f0c12bfb-c1f5-4fc2-bfb1-bd20fbdae727"

MAX_PTS = {
    "b32d02b9-3d7e-43a4-80a3-4b8aa13083c6": 25_000,  # Tier 1
    "87fcb9e4-09df-4673-bb32-e2748c49a183": 125_000,  # Tier 2
    "819d348f-b49e-4ddd-aeac-c1074de9c971": 625_000,  # Tier 3
}

# Must use roaming=True to be consistent with rusts app_dirs2 package.
app_dir = PlatformDirs(appname=APP_NAME, appauthor=APP_AUTHOR, roaming=True)

_CONFIG_FILE = app_dir.user_config_path / "pywasp_config.toml"
_POINTS_LIMIT_CACHE_FILE = app_dir.user_cache_path / "points_limit_cache.json"
_POINTS_LIMIT_CACHE_TTL = 24 * 60 * 60  # seconds


class DownloadCfg(BaseModel):
    download_prompt: bool = True
    download_global_nc_files: bool = True


class _UnknownPolicyError(PywaspError):
    """The license server returned a policy id with no known point limit.

    Internal: callers treat it like an unreachable server (warn once and
    skip the points check for the session) so that a valid license on a
    tier newer than this pywasp version keeps working.
    """


def _retry_after_detail(response) -> str:
    """Human-readable wait hint from a rate-limited response.

    Keygen includes a ``Retry-After`` header (seconds) on HTTP 429
    responses; fall back to generic advice when it is absent or unparseable.
    """
    try:
        seconds = int(response.headers.get("Retry-After"))
        return f"retry after {seconds} seconds"
    except (AttributeError, TypeError, ValueError):
        return "try again later"


def _check_server_status(test_url):
    """Check whether a license server is reachable.

    This is a pure reachability probe against an unauthenticated endpoint;
    it says nothing about credentials. A 429 counts as reachable: it proves
    a working, rate-limiting license server. Only 2xx responses (and 429)
    count as "ok" — anything else, including a redirect (requests are made
    with ``allow_redirects=False``, so a 3xx usually means a captive portal
    or a wrong host), means the configured address does not behave like a
    license server.

    Parameters
    ----------
    test_url : str
        The URL to test connectivity against.

    Returns
    -------
    tuple of (str, str)
        Status ("ok" or "unreachable") and a human-readable detail message.

    Raises
    ------
    LicenseError
        If the server certificate cannot be verified (code 123).
    PywaspError
        On other SSL problems.
    """
    logger.debug(f"Attempting to connect to: {test_url}")
    try:
        response = requests.get(test_url, timeout=10, allow_redirects=False)
    except requests.exceptions.SSLError as e:
        if "SSL: CERTIFICATE_VERIFY_FAILED" in str(e):
            raise LicenseError(123)
        raise PywaspError(
            f"Problem with ssl verification, try adding a root_ca_filepath to your Licensing configuration: {e}"
        )
    except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
        return (
            "unreachable",
            f"Failed to connect to license server at {test_url}. "
            "You may be offline, or the host/port in your configuration may "
            f"be incorrect (`pywasp configure` updates them). Error: {e}",
        )
    except requests.exceptions.RequestException as e:
        return (
            "unreachable",
            f"An error occurred while checking license server at {test_url}. Error: {e}",
        )

    if response.status_code == 429:
        return (
            "ok",
            f"The license server at {test_url} is reachable but rate limiting "
            f"requests (HTTP 429); {_retry_after_detail(response)}.",
        )
    if 200 <= response.status_code < 300:
        return (
            "ok",
            f"Successfully connected to {test_url} (Status: {response.status_code})",
        )
    return (
        "unreachable",
        f"The server at {test_url} returned an unexpected response "
        f"(HTTP {response.status_code}); it does not appear to be a "
        "working license server.",
    )


def _points_cache_key(license_id: str) -> str:
    """Cache key for a license id: hashed so the id (a secret) is not stored."""
    return hashlib.sha256(license_id.encode()).hexdigest()[:16]


def _read_points_limit_cache(license_id: str) -> int | None:
    """Return the cached points limit for ``license_id``, or None on miss.

    A missing, corrupt, or expired cache entry is a miss, never an error.
    """
    try:
        data = json.loads(_POINTS_LIMIT_CACHE_FILE.read_text())
        entry = data[_points_cache_key(license_id)]
        # A future fetched_at (clock skew, tampering) is invalid, not fresh
        if 0 <= time.time() - entry["fetched_at"] < _POINTS_LIMIT_CACHE_TTL:
            return int(entry["points_limit"])
    except (OSError, ValueError, KeyError, TypeError):
        pass
    return None


def _write_points_limit_cache(license_id: str, points_limit: int) -> None:
    """Store the points limit for ``license_id``; failures only log."""
    try:
        try:
            data = json.loads(_POINTS_LIMIT_CACHE_FILE.read_text())
            if not isinstance(data, dict):
                data = {}
        except (OSError, ValueError):
            data = {}
        data[_points_cache_key(license_id)] = {
            "points_limit": points_limit,
            "fetched_at": time.time(),
        }
        _POINTS_LIMIT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
        # Atomic write: parallel workers starting together (the scenario the
        # cache exists for) must not tear the file — a torn cache reads as a
        # miss and would silently re-create the per-run server traffic
        tmp_file = _POINTS_LIMIT_CACHE_FILE.with_name(
            f"{_POINTS_LIMIT_CACHE_FILE.name}.tmp-{os.getpid()}-{uuid.uuid4().hex[:8]}"
        )
        try:
            tmp_file.write_text(json.dumps(data))
            os.replace(tmp_file, _POINTS_LIMIT_CACHE_FILE)
        except OSError:
            tmp_file.unlink(missing_ok=True)
            raise
    except OSError:
        logger.debug("Could not write the points-limit cache", exc_info=True)


class LicensingLocalCfg(BaseModel):
    license_type: Literal["local"] = "local"
    host: str
    port: int = Field(34523, ge=0, le=65535)

    @property
    def _url(self) -> str:
        return f"http://{self.host}:{self.port}"

    def check_server_status(self):
        """Check whether the local license server is reachable.

        Returns
        -------
        tuple of (str, str)
            Status ("ok" or "unreachable") and a detail message.
        """
        return _check_server_status(self._url)


class LicensingRemoteCfg(BaseModel):
    license_type: Literal["keygen_cloud"] = "keygen_cloud"
    redirect_host: str | None = None
    license_id: uuid.UUID
    activation_token: str
    root_ca_filepath: Path | None = None

    # Use computed_field instead of private attribute
    model_config = ConfigDict(arbitrary_types_allowed=True)

    @property
    def _url(self) -> str:
        """Construct the base URL for the Keygen API. Handles redirection if specified."""
        if self.redirect_host is None:
            host = "api.keygen.sh"
        else:
            host = self.redirect_host
        return f"https://{host}"

    @property
    def _verify(self) -> bool | str:
        """TLS verification argument for requests: the configured CA bundle or True."""
        return True if self.root_ca_filepath is None else str(self.root_ca_filepath)

    def _redact(self, text: str | None) -> str:
        """Remove the license id and activation token (secrets) from message text.

        Server-derived text and low-level request errors can echo request
        headers (e.g. ``requests.InvalidHeader`` embeds the full
        ``Authorization`` value), so both secrets are stripped.
        """
        if text is None:
            return "unknown"
        # Case-insensitive: str(uuid.UUID) is lowercase, but a server or
        # library may echo the id uppercased. The token stays exact-match
        # (tokens are case-sensitive).
        text = re.sub(
            re.escape(str(self.license_id)),
            "<license-id>",
            text,
            flags=re.IGNORECASE,
        )
        if self.activation_token:
            text = text.replace(self.activation_token, "<activation-token>")
        return text

    @field_serializer("license_id")
    def serialize_license_id(self, license_id: uuid.UUID) -> str:
        """Serialize the UUID license ID to a string for API requests."""
        return str(license_id)

    @field_validator("root_ca_filepath")
    @classmethod
    def validate_root_ca_filepath(cls, root_ca_filepath: Path | None) -> Path | None:
        """The configured root CA file must exist (``~`` is expanded)."""
        if root_ca_filepath is None:
            return None
        root_ca_filepath = root_ca_filepath.expanduser()
        if not root_ca_filepath.exists():
            raise ValueError(
                f"Root CA file does not exist at specified path: {root_ca_filepath}"
            )
        return root_ca_filepath

    @field_serializer("root_ca_filepath")
    def serialize_root_ca_filepath(self, root_ca_filepath: Path | None) -> str | None:
        """Serialize the root CA file path to a string for API requests."""
        if root_ca_filepath is None:
            return None
        return str(root_ca_filepath)

    def _request_validation(self) -> dict:
        """Send a request to the license server to validate the license. Returns the response as a dictionary.
        Including information about the license status, such as whether it is active, the number of runs used,
        the runs limit, and the expiry date.

        Only transient failures (network errors, malformed responses, and 5xx
        responses) are retried, with jittered exponential backoff. A rejection
        (4xx response or an invalid license) is raised immediately: retrying
        it cannot succeed and repeated attempts can get the client
        rate-limited by the license server. A 429 (rate limited) is also not
        retried, and is reported as the server being unavailable rather than
        as a credentials problem.

        Raises
        ------
        LicenseConfigError
            If the server rejects the credentials, the license is not valid,
            or the request cannot be built (e.g. a malformed `redirect_host`).
        LicenseServerUnreachableError
            If the server cannot be reached after all retries, or is rate
            limiting requests.
        LicenseError
            If the server certificate cannot be verified (code 123).
        PywaspError
            On other SSL problems.
        """
        uri_validate = f"{self._url}/v1/accounts/{ACCOUNT}/licenses/{self.license_id}/actions/validate"
        headers_validate = {
            "Content-Type": "application/vnd.api+json",
            "Accept": "application/vnd.api+json",
            "Authorization": f"Bearer {self.activation_token}",
        }
        data_validate = json.dumps({"meta": {"scope": {"product": f"{PRODUCT_ID}"}}})

        sleep_s = 0.5
        last_error = None
        for attempt in range(5):
            if attempt > 0:
                # Jittered exponential backoff so simultaneously started
                # workers do not retry in synchronized waves
                time.sleep(sleep_s * random.uniform(0.75, 1.25))
                sleep_s *= 2

            try:
                res = requests.post(
                    uri_validate,
                    headers=headers_validate,
                    data=data_validate,
                    timeout=10,
                    verify=self._verify,
                )
            except requests.exceptions.SSLError as e:
                if "SSL: CERTIFICATE_VERIFY_FAILED" in str(e):
                    raise LicenseError(123)
                raise PywaspError(f"Problem with connection: {self._redact(str(e))}")
            except (
                requests.exceptions.ConnectionError,
                requests.exceptions.Timeout,
                requests.exceptions.ChunkedEncodingError,
                requests.exceptions.ContentDecodingError,
            ) as e:
                last_error = str(e)
                continue
            except requests.exceptions.RequestException as e:
                # Not transient: the request itself cannot be built/sent
                # (e.g. a malformed redirect_host giving InvalidURL)
                raise LicenseConfigError(
                    f"Could not send a valid request to the license server: "
                    f"{self._redact(str(e))}. Check the `redirect_host` in "
                    "your configuration."
                )

            if res.status_code == 200:
                try:
                    res_json = res.json()
                    constant = res_json["meta"]["constant"]
                except (ValueError, KeyError, TypeError) as e:
                    # e.g. an intercepting proxy returning an HTML page
                    last_error = f"Malformed response from license server: {e}"
                    continue
                if constant == "VALID":
                    return res_json
                raise LicenseConfigError(
                    f"The license server rejected the license: "
                    f"{self._redact(str(constant))[:200]}. "
                    "Check your license status with `pywasp status` or contact "
                    "DTU if you believe this is wrong."
                )
            if res.status_code == 429:
                raise LicenseServerUnreachableError(
                    "The license server is rate limiting requests (HTTP 429); "
                    f"{_retry_after_detail(res)}."
                )
            if 400 <= res.status_code < 500:
                raise LicenseConfigError(
                    f"The license server is reachable but rejected your credentials "
                    f"(HTTP {res.status_code}). Check the `license_id` and "
                    "`activation_token` in your configuration; run `pywasp configure` "
                    "in a terminal to update them."
                )
            last_error = f"HTTP {res.status_code}: {res.text[:500]}"

        raise LicenseServerUnreachableError(
            f"Could not reach the license server at {self._url} after 5 attempts. "
            f"Last error: {self._redact(last_error)}"
        )

    def get_points_limit(self) -> int:
        """Get the maximum points allowed by the license.

        The limit is fixed for the lifetime of a license (one tier per
        license), so it is cached on disk for 24 hours, keyed by the license
        id. This keeps repeated runs and parallel workers from hammering the
        license server.
        """
        points_limit = _read_points_limit_cache(str(self.license_id))
        if points_limit is not None:
            return points_limit

        response_dict = self._request_validation()
        try:
            policy_id = response_dict["data"]["relationships"]["policy"]["data"]["id"]
            points_limit = MAX_PTS[policy_id]
        except KeyError as e:
            # e.g. a newly introduced license tier this pywasp version does
            # not know about: a valid license must not fail hard on this
            raise _UnknownPolicyError(
                f"Unknown policy ID in license response: {self._redact(str(e))[:200]}"
            )

        _write_points_limit_cache(str(self.license_id), points_limit)
        return points_limit

    def get_license_status(self) -> dict:
        """Get the status of the license.

        Returns
        -------
        dict:
            A dictionary containing the license status, including whether it is active,
            the number of runs used, the runs limit, and the expiry date.

        Raises
        ------
        LicenseConfigError
            If the license server rejects the configured credentials.
        LicenseServerUnreachableError
            If the license server cannot be reached.
        """
        # Typed errors carry actionable messages (bad credentials, server
        # down) and must reach the user, e.g. via `pywasp status`.
        response_dict = self._request_validation()
        try:
            return {
                "active": True,
                "runs_used": int(response_dict["data"]["attributes"]["uses"]),
                "runs_limit": int(response_dict["data"]["attributes"]["maxUses"]),
                "expiry": pd.to_datetime(
                    response_dict["data"]["attributes"]["expiry"]
                ).date(),
            }
        except Exception as e:
            logger.error(f"Error getting license status: {e}")
            return {
                "active": None,
                "runs_used": None,
                "runs_limit": None,
                "expiry": None,
            }


class _PywaspSettings(BaseSettings):
    """Settings from ``pywasp_*`` environment variables, ``.env`` and the config file.

    Environment variables take precedence over the file.
    """

    model_config = SettingsConfigDict(
        toml_file=_CONFIG_FILE,
        env_prefix="pywasp_",
        extra="ignore",
        env_nested_delimiter="__",
        env_file=".env",
    )

    # Add toml settings to end of default 4 sources
    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            file_secret_settings,
            TomlConfigSettingsSource(settings_cls),
        )


class _DownloadSettings(_PywaspSettings):
    """The ``[download]`` section alone, readable without a licensing section."""

    download: DownloadCfg = DownloadCfg()


class Settings(_PywaspSettings):
    download: DownloadCfg
    licensing: Annotated[
        Union[LicensingLocalCfg, LicensingRemoteCfg],
        Discriminator("license_type"),
    ]

    # Session-level state for the point-limit check: the limit is fetched at
    # most once per process, and a check disabled by an unreachable server
    # stays disabled for the rest of the session.
    _points_limit: int | None = PrivateAttr(default=None)
    _points_check_disabled: bool = PrivateAttr(default=False)

    def save(self):
        # Atomic write: sibling workers (processes and threads) and the
        # licensing library, which reads this file directly, must not see a
        # half-written file. The temp name is unique per writer (pid alone
        # would collide across threads), and it is only unlinked on the
        # failure path so one writer can never delete another's live temp
        # file. Created 0600 from the start (not chmod'ed after) because it
        # contains the activation token. Written as UTF-8 explicitly: TOML
        # must be UTF-8, and without an explicit encoding Windows writes the
        # locale code page (e.g. cp1252), which the Rust licensing library
        # cannot read back when paths contain non-ASCII characters.
        _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
        tmp_file = _CONFIG_FILE.with_name(
            f"{_CONFIG_FILE.name}.tmp-{os.getpid()}-{uuid.uuid4().hex[:8]}"
        )
        try:
            fd = os.open(tmp_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                toml.dump(self.model_dump(exclude_none=True), f)
            os.replace(tmp_file, _CONFIG_FILE)
        except BaseException:
            tmp_file.unlink(missing_ok=True)
            raise
        logger.info(f"Configuration saved to {_CONFIG_FILE}.")

    def get_license_status(self) -> dict | None:
        """Get the status of the license.

        Returns
        -------
        dict:
            A dictionary containing the license status, including whether it is active,
            the number of runs used, the runs limit, and the expiry date.
        """
        if isinstance(self.licensing, LicensingLocalCfg):
            return None
        elif isinstance(self.licensing, LicensingRemoteCfg):
            return self.licensing.get_license_status()
        else:
            raise PywaspError(
                f"Unknown license type: {self.licensing.license_type}. Cannot get license status."
            )

    def check_points_within_limit(self, n_points: int) -> None:
        """Check that the number of points is within the license limit.

        Local licenses have no limit. For remote licenses the limit is
        fetched at most once per session (backed by a 24-hour on-disk cache).
        If the license server cannot be reached, or reports a policy this
        pywasp version does not know, a single warning is emitted and the
        check is skipped for the rest of the session; the WAsP core still
        enforces the license itself.

        Parameters
        ----------
        n_points : int
            The number of points to check.

        Raises
        ------
        PointsLimitError
            If the number of points exceeds the license limit.
        LicenseConfigError
            If the license server rejects the configured credentials.
        LicenseError
            If the server certificate cannot be verified (code 123).
        """
        if isinstance(self.licensing, LicensingLocalCfg):
            return  # No limit for local licenses
        if not isinstance(self.licensing, LicensingRemoteCfg):
            raise PywaspError(
                f"Unknown license type: {self.licensing.license_type}. Cannot check point limit."
            )

        if self._points_check_disabled:
            return

        if self._points_limit is None:
            try:
                self._points_limit = self.licensing.get_points_limit()
            except (LicenseServerUnreachableError, _UnknownPolicyError) as e:
                self._points_check_disabled = True
                warnings.warn(
                    "Could not determine the license point limit; the check "
                    "is skipped for the rest of this session. The license "
                    f"itself is still enforced. Details: {e}",
                    UserWarning,
                    stacklevel=_find_user_stacklevel(),
                )
                return

        if n_points > self._points_limit:
            raise PointsLimitError(n_points, self._points_limit)


# The configuration is resolved lazily: importing pywasp never contacts the
# license server and never warns about a missing/invalid configuration.
# `_config` is populated on the first call to `get_config()` (or replaced by
# `create_config_interactively`).
_config = None


def get_config() -> Settings:
    """Return the PyWAsP configuration, loading it on first use.

    The configuration is read from the configuration file and the
    ``pywasp_*`` environment variables. No connection to the license server
    is made.

    Returns
    -------
    Settings
        The loaded configuration.

    Raises
    ------
    LicenseConfigError
        If no configuration exists or it fails validation. The message
        explains how to fix it.
    """
    global _config
    if _config is None:
        try:
            _config = Settings.model_validate({})
        # SettingsError is defensive: pydantic-settings currently wraps a
        # malformed TOML file in a ValidationError, but that is its wrapping
        # behavior, not a contract — a raw parser error here must never
        # reach the user as a bare traceback
        except (ValidationError, OSError, SettingsError) as e:
            if _CONFIG_FILE.exists():
                sources = (
                    f"the configuration file '{_CONFIG_FILE}' and any "
                    "`pywasp_*` environment variables (environment variables "
                    "take precedence over the file)"
                )
            else:
                sources = (
                    "`pywasp_*` environment variables (no configuration file "
                    f"was found at '{_CONFIG_FILE}')"
                )
            if isinstance(e, ValidationError):
                # Format without input values: str(e) embeds input_value=...,
                # which would leak the activation token into the message
                details = "\n".join(
                    f"  {'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
                    for err in e.errors(include_input=False, include_url=False)
                )
            else:
                details = f"  Could not read the configuration file: {e}"
            raise LicenseConfigError(
                f"The PyWAsP configuration is missing or invalid. Settings are "
                f"read from {sources}. Run `pywasp configure` in a terminal to "
                "create a configuration file, or fix the failing fields:\n"
                f"{details}"
            ) from e
    return _config


def _get_download_cfg() -> DownloadCfg:
    """Return the ``[download]`` settings, or their defaults.

    Read from the ``pywasp_download__*`` environment variables and the
    ``[download]`` section of the configuration file only, so they apply
    even when there is no (valid) licensing configuration. Defaults are used
    when neither source sets them; an invalid section is reported with a
    warning and also falls back to the defaults.
    """
    try:
        return _DownloadSettings.model_validate({}).download
    except (ValidationError, OSError, SettingsError) as e:
        logger.warning(
            "Ignoring invalid [download] settings and using the defaults "
            "(download allowed, prompt in a terminal): %s",
            e,
        )
        return DownloadCfg()


def _license_disabled() -> bool:
    """Whether license checks are disabled via the PW_LICENSE environment variable.

    ``PW_LICENSE=False`` disables license validation in the WAsP core (used
    for testing); the Python-side checks mirror it.
    """
    return os.environ.get("PW_LICENSE", "True").lower() in ("false", "0")


def _read_config_file() -> dict | None:
    """Return the parsed configuration file, or None when missing/unreadable."""
    if not _CONFIG_FILE.exists():
        return None
    try:
        return toml.load(_CONFIG_FILE)
    except (OSError, toml.TomlDecodeError):
        # Unreadable (e.g. permissions) or unparseable: treat as differing
        return None


def _sync_config_to_disk(config: Settings) -> bool:
    """Sync the PyWAsP settings to disk for the WAsP core license check.

    The core reads ``pywasp_config.toml`` only (not the ``pywasp_*``
    environment variables), so the file is written whenever the effective
    settings differ from what is on disk.

    Returns
    -------
    bool
        True when the file matches the effective settings on return (it
        already did, or it was written now); False when it could not be
        written.
    """
    global _config
    on_disk = _read_config_file()
    if on_disk == config.model_dump(exclude_none=True):
        return True
    # The file differs from the in-memory settings. It may have been fixed
    # or updated externally (e.g. `pywasp configure` run in a terminal while
    # this session was open), so re-resolve from the sources instead of
    # trusting the cached object — otherwise a stale session would overwrite
    # the user's fix with old settings. Environment variables still take
    # precedence over the file, by pydantic source order.
    try:
        fresh = Settings.model_validate({})
        if fresh.licensing == config.licensing:
            # Same licensing config: carry the session points-check state
            # over, so a disabled check stays disabled for the session and
            # the limit is not re-fetched. A changed licensing config gets
            # a fresh fetch instead — the old state may not apply to it.
            fresh._points_limit = config._points_limit
            fresh._points_check_disabled = config._points_check_disabled
        config = fresh
        _config = config
    except (ValidationError, OSError, SettingsError):
        # The external change is invalid; keep the known-good session
        # config and restore the file from it below
        pass
    if on_disk != config.model_dump(exclude_none=True):
        try:
            config.save()
        except OSError as e:
            # e.g. a read-only home directory in a container: proceed — the
            # write only matters if the on-disk file must differ from the
            # effective settings, and the core reports its own error if so.
            warnings.warn(
                f"Could not write the configuration file at '{_CONFIG_FILE}' "
                f"({e}). If calculations fail with a license error, ensure the "
                "file exists and matches your environment settings.",
                UserWarning,
                stacklevel=_find_user_stacklevel(),
            )
            return False
    return True


def _requires_license(func):
    """Mark a function as requiring a valid PyWAsP license.

    Performs no network I/O itself. On call, two purely local steps run:
    the configuration is resolved (raising
    :class:`~pywasp._errors.LicenseConfigError` with instructions when it is
    missing or invalid) and synced to disk. The sync is required because the
    licensing library reads only ``pywasp_config.toml`` — it does not read
    the ``pywasp_*`` environment variables, so overrides supplied through
    them must land in the file. The WAsP core performs the actual license
    validation during the calculation.
    """

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not _license_disabled():
            _sync_config_to_disk(get_config())
        return func(*args, **kwargs)

    # Introspectable marker so tests can cross-check the gated set
    wrapper.__pywasp_requires_license__ = True
    return wrapper


def _check_points_within_limit(n_points):
    """Check ``n_points`` against the license point limit.

    No-op when licensing is disabled via ``PW_LICENSE=False``.
    """
    if _license_disabled():
        return
    get_config().check_points_within_limit(n_points)


def _prompt_download_settings() -> DownloadCfg:
    """Prompt for download preferences."""
    print(
        "Some functions need the global mesoclimate files (ERA5/CFSR mean "
        "atmospheric fields), fetched on first use (or with "
        "`pywasp mesoclimate download`)."
    )
    download_prompt = (
        input("Ask before downloading, when a terminal is available? (y/N): ")
        .strip()
        .lower()
    )
    download_global = input("Allow PyWAsP to download them? (Y/n): ").strip().lower()
    return DownloadCfg(
        download_prompt=download_prompt in ["y", "yes", "true"],
        download_global_nc_files=download_global not in ["n", "no", "false"],
    )


def _prompt_save_anyway() -> bool:
    """Ask whether to save the configuration despite a failed check."""
    save = input("Save configuration anyway? (y/N): ").strip().lower()
    if save in ["y", "yes"]:
        return True
    print("Configuration creation cancelled.")
    return False


def _prompt_local_licensing() -> LicensingLocalCfg | None:
    """Prompt for local license server settings; None if the user cancels."""
    print("\nConfiguring local license server...")

    while True:
        host = input("License server host: ").strip()
        if host:
            break
        print("Please enter a valid host.")

    while True:
        port_input = input("License server port: ").strip()
        if port_input:
            break
        print("Please enter a valid port.")

    try:
        port = int(port_input)
        if not (0 <= port <= 65535):
            raise ValueError("Port must be between 0 and 65535")
    except ValueError as e:
        print(f"Invalid port: {e}")
        return None

    licensing_cfg = LicensingLocalCfg(host=host, port=port)

    print(f"Testing connection to {host}:{port}...")
    status, detail = licensing_cfg.check_server_status()
    if status == "ok":
        print("Connection successful!")
    else:
        print(f"Connection failed: {detail}")
        if not _prompt_save_anyway():
            return None
    return licensing_cfg


def _prompt_remote_licensing() -> LicensingRemoteCfg | None:
    """Prompt for remote (Keygen cloud) license settings; None if the user cancels."""
    print("\nConfiguring remote license (Keygen cloud)...")

    license_id_input = input("License ID (UUID): ").strip()
    try:
        license_id = uuid.UUID(license_id_input)
    except ValueError:
        print("Invalid UUID format for license ID.")
        return None

    activation_token = input("Activation token: ").strip()
    if not activation_token:
        print("Activation token is required.")
        return None

    redirect = input("Redirect host through license.windenergy.dtu.dk? (y/N): ").strip()
    redirect_host = (
        "license.windenergy.dtu.dk"
        if redirect.lower() in ["y", "yes", "true"]
        else None
    )

    # Re-prompt on a nonexistent path: constructing the config below
    # validates existence and would abort the whole session otherwise
    while True:
        root_ca_path = input(
            "Root CA file path (optional, press Enter to skip): "
        ).strip()
        if not root_ca_path:
            root_ca_filepath = None
            break
        root_ca_filepath = Path(root_ca_path).expanduser()
        if root_ca_filepath.exists():
            break
        print(
            f"CA file not found at {root_ca_filepath}. "
            "Enter a valid path, or press Enter to skip."
        )

    licensing_cfg = LicensingRemoteCfg(
        license_id=license_id,
        activation_token=activation_token,
        redirect_host=redirect_host,
        root_ca_filepath=root_ca_filepath,
    )

    print("Testing license validation...")
    try:
        licensing_cfg._request_validation()
        print("License validated!")
    except Exception as e:
        print(f"License validation failed: {e}")
        if not _prompt_save_anyway():
            return None
    return licensing_cfg


[docs] def create_config_interactively(): """ Interactively prompt the user to create a pywasp_config.toml file. This function guides the user through setting up their PyWAsP configuration by asking for licensing and download preferences. The configuration is saved to the default config file location. Returns ------- Settings or None The created configuration settings object, or None if the user cancelled the process or the provided input was invalid. """ global _config print(f"Creating PyWAsP configuration file at: {_CONFIG_FILE}") print("=" * 60) try: if _CONFIG_FILE.exists(): overwrite = ( input( f"Configuration file already exists at {_CONFIG_FILE}. Overwrite? (y/N): " ) .strip() .lower() ) if overwrite not in ["y", "yes"]: print("Configuration creation cancelled.") return None print("\n1. Download Settings") print("-" * 20) download_cfg = _prompt_download_settings() print("\n2. Licensing Settings") print("-" * 20) print("Choose license type:") print("1. Remote license (Keygen cloud)") print("2. Local license server") while True: license_choice = input("Enter choice (1 or 2): ").strip() if license_choice in ["1", "2"]: break print("Please enter 1 or 2.") if license_choice == "2": licensing_cfg = _prompt_local_licensing() else: licensing_cfg = _prompt_remote_licensing() if licensing_cfg is None: return None config = Settings(download=download_cfg, licensing=licensing_cfg) config.save() _config = config print(f"\nConfiguration saved successfully to: {_CONFIG_FILE}") print("\nConfiguration summary:") print(f" Download prompts: {download_cfg.download_prompt}") print(f" Auto-download global files: {download_cfg.download_global_nc_files}") print(f" License type: {licensing_cfg.license_type}") if isinstance(licensing_cfg, LicensingLocalCfg): print(f" License server: {licensing_cfg.host}:{licensing_cfg.port}") else: print(f" License ID: {licensing_cfg.license_id}") print(f" Redirect host: {licensing_cfg.redirect_host or 'None'}") return config except (KeyboardInterrupt, EOFError): # EOFError covers Ctrl+Z on Windows and closed/non-interactive stdin. print("\n\nConfiguration creation cancelled by user.") return None except Exception as e: print(f"\nError creating configuration: {e}") return None