diff --git a/pyproject.toml b/pyproject.toml index a6262c1..38ba54e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["wheel", "setuptools"] [project] name = "werk24" -version = "2.4.4" +version = "2.5.0" description = "AI-powered platform for extracting and analyzing data from technical drawings / CAD drawings to enhance manufacturing workflows." readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE.txt" } diff --git a/tests/test_client.py b/tests/test_client.py index 1736f0a..9d30921 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -93,23 +93,34 @@ async def test_read_drawing_with_callback( @pytest.mark.asyncio async def test_invalid_token(): """ - Test that an empty token raises an UnauthorizedException. + Test that a bogus token raises an UnauthorizedException. """ with pytest.raises(UnauthorizedException): - async with Werk24Client(token="", region="eu-central-1"): + async with Werk24Client(token="not-a-valid-token", region="eu-central-1"): ... @pytest.mark.asyncio -async def test_invalid_region(): +async def test_empty_token(): """ - Test that an empty region raises an InvalidLicenseException. + Test that an empty token raises an InvalidLicenseException. """ with pytest.raises(InvalidLicenseException): async with Werk24Client(token="", region=None): ... +@pytest.mark.asyncio +async def test_token_only_license(): + """ + Test that a client can be created with a token and no region, as issued + during registration. + """ + client = Werk24Client(token="some-token") + assert client.license.token == "some-token" # nosec + assert client.license.region is None # nosec + + def test_run_preflight_checks_invalid_type(): with pytest.raises(UnsupportedMediaType): Werk24Client.run_preflight_checks("not-bytes") diff --git a/tests/test_utils.py b/tests/test_utils.py index 0087d1d..5ac0ce3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -20,6 +20,7 @@ "W24TECHREAD_AUTH_TOKEN=valid_token\nW24TECHREAD_AUTH_REGION=valid_region\n" ) INVALID_LICENSE_TEXT = "INVALID_KEY=missing_token\n" +BARE_TOKEN_TEXT = "valid_token\n" @pytest.fixture @@ -55,6 +56,26 @@ def test_parse_license_text(valid_license): assert license == valid_license # nosec +def test_parse_license_text_bare_token(): + """Test parsing a bare token (the format issued during registration).""" + license = parse_license_text(BARE_TOKEN_TEXT) + assert license.token == "valid_token" # nosec + assert license.region is None # nosec + + +def test_parse_license_text_bare_token_with_whitespace(): + """Test parsing a bare token that has surrounding whitespace/blank lines.""" + license = parse_license_text("\n valid_token \n\n") + assert license.token == "valid_token" # nosec + assert license.region is None # nosec + + +def test_parse_license_text_empty(): + """Test parsing empty text raises an exception.""" + with pytest.raises(InvalidLicenseException): + parse_license_text(" \n") + + def test_parse_license_text_invalid(): """Test parsing invalid license text raises an exception.""" with pytest.raises(InvalidLicenseException): diff --git a/werk24/cli/commands/init.py b/werk24/cli/commands/init.py index 84fcf8c..19a4d6b 100644 --- a/werk24/cli/commands/init.py +++ b/werk24/cli/commands/init.py @@ -20,7 +20,7 @@ @app.command() def init(): - """Initialize Werk24 by providing or creating a license.""" + """Initialize Werk24 by providing or creating a license token.""" try: find_license() console.print( @@ -30,22 +30,22 @@ def init(): ) return except InvalidLicenseException: - pass # Continue to ask user to create a license file + pass # Continue to ask user to provide a token ask_user_to_create_license() def ask_user_to_create_license(): - """Guide the user to provide or create a license.""" + """Guide the user to provide or create a license token.""" CREATE_A_LICENSE_FILE_TEXT = """ - To use Werk24, you need a valid license file. - If you don't have one, you can create a trial license to get started. + To use Werk24, you need a valid token. + If you don't have one, you can sign up to get a license. """ console.print( Panel(Text(CREATE_A_LICENSE_FILE_TEXT, style="bold red"), title="License Setup") ) console.print("[blue]Choose an option:[/blue]") - console.print("[yellow]1.[/yellow] Provide a license file") - console.print("[yellow]2.[/yellow] Create a trial license") + console.print("[yellow]1.[/yellow] Provide a token") + console.print("[yellow]2.[/yellow] Sign up to get a license") while True: try: @@ -54,7 +54,7 @@ def ask_user_to_create_license(): accept_license_from_terminal() break elif choice == 2: - create_trial_license() + sign_up_for_license() break else: raise ValueError("Invalid choice") @@ -63,9 +63,14 @@ def ask_user_to_create_license(): def accept_license_from_terminal(): - """Accept license text from the user.""" + """Accept a token from the user. + + The token you receive during registration can be pasted directly. For + backwards compatibility, a legacy license block (``W24TECHREAD_AUTH_TOKEN=...``) + is also accepted. + """ console.print( - "[blue]Please paste your license text below and press [bold]Enter[/bold] twice when done:[/blue]" + "[blue]Please paste your token below and press [bold]Enter[/bold] twice when done:[/blue]" ) license_text = "" @@ -82,17 +87,17 @@ def accept_license_from_terminal(): try: license = parse_license_text(license_text) save_license_file(license) - console.print(Panel("[bold green]License successfully saved![/bold green]")) + console.print(Panel("[bold green]Token successfully saved![/bold green]")) except InvalidLicenseException: - console.print("[red]Invalid license text. Please try again.[/red]") + console.print("[red]Invalid token. Please try again.[/red]") accept_license_from_terminal() # Retry on failure -def create_trial_license(): - """Guide the user to create a trial license.""" - console.print("[blue]To create a trial license, visit the following URL:[/blue]") - console.print(f"[bold cyan]{settings.trial_license_url}[/bold cyan]") - console.print("[blue]Once you have the license text, paste it below.[/blue]") +def sign_up_for_license(): + """Guide the user to sign up for a license and obtain a token.""" + console.print("[blue]To sign up for a license, visit the following URL:[/blue]") + console.print(f"[bold cyan]{settings.signup_url}[/bold cyan]") + console.print("[blue]Once you have your token, paste it below.[/blue]") accept_license_from_terminal() diff --git a/werk24/utils/defaults.py b/werk24/utils/defaults.py index 017e4b4..656049c 100644 --- a/werk24/utils/defaults.py +++ b/werk24/utils/defaults.py @@ -11,7 +11,7 @@ class Settings(BaseSettings): Attributes: ---------- - - trial_license_url (HttpUrl): URL to access the trial license. + - signup_url (HttpUrl): URL to sign up and obtain a license token. Default is "https://werk24.io/trial-license". - wss_server (AnyUrl): WebSocket server URL for connecting to the Werk24 API. Default is "wss://ws-api.w24.co/v2". @@ -33,8 +33,8 @@ class Settings(BaseSettings): """ - trial_license_url: HttpUrl = "https://werk24.io/trial-license" - """URL for obtaining a trial license.""" + signup_url: HttpUrl = "https://werk24.io/trial-license" + """URL for signing up and obtaining a license token.""" http_server: AnyUrl = "https://api.w24.co" wss_server: AnyUrl = "wss://ws-api.w24.co/v2" diff --git a/werk24/utils/exceptions.py b/werk24/utils/exceptions.py index 1ebdb0d..6585931 100644 --- a/werk24/utils/exceptions.py +++ b/werk24/utils/exceptions.py @@ -129,7 +129,7 @@ class InvalidLicenseException(TechreadException): cli_message_header: str = "Invalid License" cli_message_body: str = ( "The provided license is invalid or has expired.\n\n" - "Please ensure that you provide a token AND a region." + "Please ensure that you provide a valid token." ) diff --git a/werk24/utils/license.py b/werk24/utils/license.py index 57ff2d2..222274d 100644 --- a/werk24/utils/license.py +++ b/werk24/utils/license.py @@ -3,7 +3,7 @@ from typing import Optional import dotenv -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from werk24.utils.exceptions import InvalidLicenseException @@ -20,6 +20,12 @@ # Expand user paths once at import time SEARCH_PATHS = [os.path.expanduser(p) for p in _RAW_SEARCH_PATHS] +# Name of the environment variable / dotenv key that holds the auth token. +TOKEN_ENV_KEY = "W24TECHREAD_AUTH_TOKEN" + +# Name of the environment variable / dotenv key that holds the (legacy) region. +REGION_ENV_KEY = "W24TECHREAD_AUTH_REGION" + # Initialize logger logger = get_logger() @@ -27,7 +33,18 @@ # Define License Model class License(BaseModel): token: str - region: str + + # The region is a legacy field. Registration now only issues a token, so the + # region is optional and kept for backwards compatibility with existing + # license files and environment variables. + region: Optional[str] = None + + @field_validator("token") + @classmethod + def _token_must_not_be_empty(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("The license token must not be empty.") + return value.strip() def find_license(token: Optional[str] = None, region: Optional[str] = None) -> License: @@ -48,15 +65,13 @@ def find_license(token: Optional[str] = None, region: Optional[str] = None) -> L """ # ----------------------------------------------------------- - # Check if token and region are provided + # Check if a token is provided (the region is optional) # ----------------------------------------------------------- if token is not None: try: return License(token=token, region=region) except ValueError as e: - raise InvalidLicenseException( - "The license requires a token and a region" - ) from e + raise InvalidLicenseException("The license requires a valid token") from e # ----------------------------------------------------------- # If not provided, search for a valid license @@ -103,9 +118,9 @@ def find_license_in_envs() -> Optional[License]: - License: A valid License object if found. None: If no valid license is found in the environment variables. """ - token = os.environ.get("W24TECHREAD_AUTH_TOKEN") - region = os.environ.get("W24TECHREAD_AUTH_REGION") - if token and region: + token = os.environ.get(TOKEN_ENV_KEY) + region = os.environ.get(REGION_ENV_KEY) + if token: logger.debug("License found in environment variables.") return License(token=token, region=region) logger.debug("Required environment variables not set.") @@ -145,9 +160,17 @@ def parse_license_text(text: str) -> License: """ Parse license text and validate its format. + Two formats are supported: + + 1. A dotenv style block containing ``W24TECHREAD_AUTH_TOKEN`` (and + optionally the legacy ``W24TECHREAD_AUTH_REGION``). This is the format of + older license files. + 2. A bare token, as issued during registration. In this case the whole + text is treated as the token. + Args: ---- - - text (str): The content of the license file. + - text (str): The content of the license file or the raw token. Returns: ------- @@ -158,17 +181,36 @@ def parse_license_text(text: str) -> License: - InvalidLicenseException: If the license text is invalid. """ logger.debug("Parsing license text...") - try: - vars = dotenv.dotenv_values(stream=io.StringIO(text)) - token = vars.get("W24TECHREAD_AUTH_TOKEN") - region = vars.get("W24TECHREAD_AUTH_REGION") - if not (token and region): - raise KeyError("missing token or region") - logger.debug("License text parsed successfully.") - return License(token=token, region=region) - except (ValueError, KeyError) as e: - logger.error(f"Missing key in license text: {e}") - raise InvalidLicenseException("Invalid license text format.") from e + + # ----------------------------------------------------------- + # Legacy dotenv format: recognised by the presence of the token key. + # ----------------------------------------------------------- + if TOKEN_ENV_KEY in text: + try: + vars = dotenv.dotenv_values(stream=io.StringIO(text)) + token = vars.get(TOKEN_ENV_KEY) + region = vars.get(REGION_ENV_KEY) + if not token: + raise KeyError("missing token") + logger.debug("License text parsed successfully (dotenv format).") + return License(token=token, region=region) + except (ValueError, KeyError) as e: + logger.error(f"Missing key in license text: {e}") + raise InvalidLicenseException("Invalid license text format.") from e + + # ----------------------------------------------------------- + # New format: a bare token, as issued during registration. Use the first + # non-empty line so trailing whitespace or blank lines from a copy/paste do + # not break parsing. A line containing "=" is a malformed dotenv block + # rather than a token, and is rejected. + # ----------------------------------------------------------- + token = next((line.strip() for line in text.splitlines() if line.strip()), "") + if not token or "=" in token: + logger.error("No valid token found in license text.") + raise InvalidLicenseException("Invalid license text format.") + + logger.debug("License text parsed successfully (bare token).") + return License(token=token) def save_license_file(license: License): @@ -182,8 +224,10 @@ def save_license_file(license: License): license_path = SEARCH_PATHS[0] try: with open(license_path, "w+") as file: - file.write(f"W24TECHREAD_AUTH_TOKEN={license.token}\n") - file.write(f"W24TECHREAD_AUTH_REGION={license.region}\n") + file.write(f"{TOKEN_ENV_KEY}={license.token}\n") + # Only persist the region if one is present (legacy licenses). + if license.region: + file.write(f"{REGION_ENV_KEY}={license.region}\n") logger.info(f"License saved successfully at {license_path}") except Exception as e: logger.error(f"Error saving license file: {e}")