From 7869717d19cf567db0deea55fe9e0fcfceb38695 Mon Sep 17 00:00:00 2001 From: Jochen Mattes Date: Sun, 30 Nov 2025 10:53:04 +0100 Subject: [PATCH 1/3] improves websocket connection --- examples/websocket_connection_management.py | 89 ++++++++ pyproject.toml | 4 + tests/test_cli_status_command.py | 10 + tests/test_websocket_connection_management.py | 84 +++++++ werk24/models/v1/material.py | 5 +- werk24/models/v1/property/abbe_number.py | 5 +- werk24/models/v1/property/base.py | 5 +- werk24/models/v1/property/refractive_index.py | 8 +- werk24/models/v1/tolerance.py | 5 +- werk24/models/v1/typed_model.py | 43 ++-- werk24/models/v1/value.py | 12 +- werk24/techread.py | 206 +++++++++++++++--- 12 files changed, 412 insertions(+), 64 deletions(-) create mode 100644 examples/websocket_connection_management.py create mode 100644 tests/test_websocket_connection_management.py diff --git a/examples/websocket_connection_management.py b/examples/websocket_connection_management.py new file mode 100644 index 00000000..fd8bdf8f --- /dev/null +++ b/examples/websocket_connection_management.py @@ -0,0 +1,89 @@ +""" +Example demonstrating WebSocket connection management features. + +This example shows how to use the enhanced WebSocket connection management +features including heartbeat, auto-reconnect, and graceful shutdown. +""" + +import asyncio + +from werk24 import Werk24Client +from werk24.models.v2.asks import AskBalloons + + +async def main(): + """ + Demonstrate WebSocket connection management with custom configuration. + """ + # Create client with custom connection management settings + client = Werk24Client( + token="your_token_here", + region="eu-central-1", + ping_interval=30.0, # Send ping every 30 seconds (built-in websockets feature) + ping_timeout=10.0, # Wait 10 seconds for pong response + max_reconnect_attempts=3, # Try to reconnect up to 3 times + reconnect_delay=1.0, # Initial delay of 1 second, with exponential backoff + ) + + # The client automatically manages the WebSocket connection + async with client: + # Connection is established with retry logic + # Ping/pong heartbeat is handled automatically by websockets library + + # Read a drawing - connection will auto-reconnect if it drops + with open("path/to/drawing.pdf", "rb") as f: + drawing_bytes = f.read() + + asks = [AskBalloons()] + + async for message in client.read_drawing(drawing_bytes, asks): + print(f"Received: {message.message_type}") + + # If connection drops during processing, it will automatically + # reconnect and continue receiving messages + + # When exiting the context, graceful shutdown is performed: + # WebSocket connection is closed cleanly + + +async def example_with_default_settings(): + """ + Use default connection management settings. + """ + # Default settings: + # - ping_interval: 30.0 seconds + # - ping_timeout: 10.0 seconds + # - max_reconnect_attempts: 3 + # - reconnect_delay: 1.0 second (with exponential backoff) + + client = Werk24Client( + token="your_token_here", + region="eu-central-1", + ) + + async with client: + # Connection management happens automatically + pass + + +async def example_aggressive_reconnect(): + """ + Configure aggressive reconnection for unstable networks. + """ + client = Werk24Client( + token="your_token_here", + region="eu-central-1", + ping_interval=10.0, # More frequent heartbeats + ping_timeout=5.0, # Shorter timeout + max_reconnect_attempts=5, # More retry attempts + reconnect_delay=0.5, # Faster initial retry + ) + + async with client: + # Will reconnect more aggressively if connection drops + pass + + +if __name__ == "__main__": + # Run the main example + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index b1c3cc15..89f7a7a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,3 +78,7 @@ changelog = "https://github.com/W24-Service-GmbH/werk24-python/releases" [project.scripts] werk24 = "werk24.cli.werk24:app" + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" diff --git a/tests/test_cli_status_command.py b/tests/test_cli_status_command.py index 4fb70799..b2c4704a 100644 --- a/tests/test_cli_status_command.py +++ b/tests/test_cli_status_command.py @@ -1,3 +1,4 @@ +import pytest from typer.testing import CliRunner from werk24 import SystemStatus @@ -15,7 +16,16 @@ async def mock_get_status(): ) +@pytest.mark.filterwarnings("ignore::ResourceWarning") def test_status_cli(monkeypatch): + """Test the status CLI command. + + Note: ResourceWarning is suppressed because asyncio.run() (used in the CLI command) + creates and properly closes an event loop, but pytest's garbage collector timing + can trigger a false positive warning. The event loop is correctly managed by + asyncio.run() - this is a known pytest + asyncio.run() interaction issue. + See: https://github.com/pytest-dev/pytest/issues/5502 + """ monkeypatch.setattr(status_cmd.Werk24Client, "get_system_status", mock_get_status) runner = CliRunner() result = runner.invoke(status_cmd.app, []) diff --git a/tests/test_websocket_connection_management.py b/tests/test_websocket_connection_management.py new file mode 100644 index 00000000..a81db7ae --- /dev/null +++ b/tests/test_websocket_connection_management.py @@ -0,0 +1,84 @@ +""" +Tests for WebSocket connection management features. + +This module tests: +- Auto-reconnect on disconnect +- Graceful shutdown +- Configuration options +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from werk24.techread import Werk24Client +from werk24.utils.exceptions import ServerException, UnauthorizedException + +# Import websockets exceptions - they moved in version 14.0 +try: + from websockets.exceptions import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, + InvalidStatus, + ) +except ImportError: + # websockets 14+ moved exceptions to the main module + from websockets import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, + InvalidStatus, + ) + + +@pytest.fixture +def client(): + """Create a Werk24Client instance with test configuration.""" + return Werk24Client( + token="test_token", + region="eu-central-1", # Required for license validation + ping_interval=0.1, # Fast ping for testing + ping_timeout=0.05, # Short timeout for testing + max_reconnect_attempts=3, + reconnect_delay=0.1, + ) + + +@pytest.mark.asyncio +async def test_ping_interval_configured(client): + """Test that ping_interval is properly configured.""" + assert client._ping_interval == 0.1 + assert client._ping_timeout == 0.05 + + +@pytest.mark.asyncio +async def test_max_reconnect_attempts_configured(client): + """Test that max_reconnect_attempts is properly configured.""" + assert client._max_reconnect_attempts == 3 + assert client._reconnect_delay == 0.1 + + +@pytest.mark.asyncio +async def test_no_reconnect_during_shutdown(client): + """Test that reconnect is skipped during shutdown.""" + client._is_shutting_down = True + + with patch.object(client, "_connect_with_retry") as mock_connect: + await client._reconnect() + + # Should not attempt to connect during shutdown + mock_connect.assert_not_called() + + +@pytest.mark.asyncio +async def test_shutdown_flag(client): + """Test that shutdown flag prevents reconnection.""" + client._is_shutting_down = True + + # Reconnect should do nothing when shutting down + await client._reconnect() + + # No exception should be raised + assert client._is_shutting_down diff --git a/werk24/models/v1/material.py b/werk24/models/v1/material.py index 06c9e675..d69930f1 100644 --- a/werk24/models/v1/material.py +++ b/werk24/models/v1/material.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Any, List, Literal, Optional, Tuple, Union -from pydantic import Field +from pydantic import ConfigDict, Field from .base_feature import BaseModel, W24BaseFeatureModel from .property.glass_homogeneity import W24PropertyGlassHomogeneityType @@ -525,8 +525,7 @@ class W24MaterialCategory3(str, Enum): class W24MaterialConditionBase(W24TypedModel): - class Config: - discriminators: Tuple[str, ...] = ("condition_type",) + model_config = ConfigDict(discriminator="condition_type") condition_type: str blurb: str diff --git a/werk24/models/v1/property/abbe_number.py b/werk24/models/v1/property/abbe_number.py index 172d2527..9575d1d5 100644 --- a/werk24/models/v1/property/abbe_number.py +++ b/werk24/models/v1/property/abbe_number.py @@ -1,7 +1,7 @@ from decimal import Decimal from typing import Any, Literal, Optional, Union -from pydantic import Field +from pydantic import ConfigDict, Field from ..typed_model import W24TypedModel from .base import W24Property @@ -9,8 +9,7 @@ class W24PropertyAbbeTolerance(W24TypedModel): - class Config: - discriminators = ("abbe_tolerance_type",) + model_config = ConfigDict(discriminator="abbe_tolerance_type") blurb: str abbe_tolerance_type: Any diff --git a/werk24/models/v1/property/base.py b/werk24/models/v1/property/base.py index a88cab2a..aa7c35d8 100644 --- a/werk24/models/v1/property/base.py +++ b/werk24/models/v1/property/base.py @@ -1,11 +1,12 @@ from typing import Any +from pydantic import ConfigDict + from ..typed_model import W24TypedModel class W24Property(W24TypedModel): - class Config: - discriminators = ("property_type", "property_subtype") + model_config = ConfigDict(discriminator="property_type") property_type: Any property_subtype: Any diff --git a/werk24/models/v1/property/refractive_index.py b/werk24/models/v1/property/refractive_index.py index 2969d1ab..8d6a0f50 100644 --- a/werk24/models/v1/property/refractive_index.py +++ b/werk24/models/v1/property/refractive_index.py @@ -1,7 +1,7 @@ from decimal import Decimal from typing import Any, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from ..property.base import W24Property from ..property.fraunhofer import W24FraunhoferLine @@ -9,8 +9,7 @@ class W24PropertyRefractiveTolerance(W24TypedModel): - class Config: - discriminators = ("refractive_tolerance_type",) + model_config = ConfigDict(discriminator="refractive_tolerance_type") refractive_tolerance_type: Any @@ -38,8 +37,7 @@ class W24PropertyRefractiveToleranceStep(W24PropertyRefractiveTolerance): class W24PropertyRefractiveVariation(BaseModel): - class Config: - discriminators = ("variation_type",) + model_config = ConfigDict(discriminator="variation_type") variation_type: Any diff --git a/werk24/models/v1/tolerance.py b/werk24/models/v1/tolerance.py index 17d4a020..298628f8 100644 --- a/werk24/models/v1/tolerance.py +++ b/werk24/models/v1/tolerance.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from .base_feature import W24BaseFeatureModel from .gender import W24Gender @@ -20,8 +20,7 @@ class W24Tolerance(W24TypedModel): deserialization """ - class Config: - discriminators = ("toleration_type",) + model_config = ConfigDict(discriminator="toleration_type") blurb: str toleration_type: str diff --git a/werk24/models/v1/typed_model.py b/werk24/models/v1/typed_model.py index 62c34024..78dbe7c2 100644 --- a/werk24/models/v1/typed_model.py +++ b/werk24/models/v1/typed_model.py @@ -29,7 +29,7 @@ class MetaDataDesignation(MetaData): """ from pint import Quantity -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pydantic_core import PydanticUndefined @@ -46,20 +46,19 @@ class W24TypedModel(BaseModel): __subtypes__ = {} - class Config: - arbitrary_types_allowed = True - - """Have the custom encoders here. - This is not the nicest solution, but more - a workaround until Pydantic 2.0 is ready. - See: https://github.com/pydantic/pydantic/issues/2277 - """ - json_encoders = { + model_config = ConfigDict( + arbitrary_types_allowed=True, + # Have the custom encoders here. + # This is not the nicest solution, but more + # a workaround until Pydantic 2.0 is ready. + # See: https://github.com/pydantic/pydantic/issues/2277 + json_encoders={ # NOTE: specify a custom validator to make # sure that the serialization is done correctly. # See validator for details. Quantity: lambda v: str(v) - } + }, + ) @classmethod def __pydantic_init_subclass__( @@ -69,6 +68,15 @@ def __pydantic_init_subclass__( Registers the class locally. """ + # Get discriminators from model_config if available + discriminators = [] + if hasattr(cls, "model_config") and isinstance(cls.model_config, dict): + discriminator = cls.model_config.get("discriminator") + if discriminator: + discriminators = ( + [discriminator] if isinstance(discriminator, str) else discriminator + ) + key_ = tuple( [cls._first_child()] + [ @@ -77,7 +85,7 @@ def __pydantic_init_subclass__( if cls.model_fields[disc].default == PydanticUndefined else cls.model_fields[disc].default ) - for disc in cls.Config.discriminators + for disc in discriminators ] ) cls.__subtypes__[key_] = cls @@ -96,8 +104,17 @@ def _first_child(cls): @classmethod def _convert_to_real_type_(cls, data): """Convert the data to the correct subtype.""" + # Get discriminators from model_config if available + discriminators = [] + if hasattr(cls, "model_config") and isinstance(cls.model_config, dict): + discriminator = cls.model_config.get("discriminator") + if discriminator: + discriminators = ( + [discriminator] if isinstance(discriminator, str) else discriminator + ) + # get the key from the data. - key_ = tuple([cls] + [data.get(disc) for disc in cls.Config.discriminators]) + key_ = tuple([cls] + [data.get(disc) for disc in discriminators]) # check whether the subtype actually exists. # Be careful with updates here. diff --git a/werk24/models/v1/value.py b/werk24/models/v1/value.py index 35fc946f..e033094c 100644 --- a/werk24/models/v1/value.py +++ b/werk24/models/v1/value.py @@ -3,7 +3,14 @@ from pint import Quantity as PintQuantity from pint import UnitRegistry -from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, WithJsonSchema +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PlainSerializer, + WithJsonSchema, +) from .tolerance import W24Tolerance @@ -24,8 +31,7 @@ class W24PhysicalQuantity(BaseModel): Physical Quantity with a value, unit and tolerance. """ - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) blurb: str = Field( title="blurb", diff --git a/werk24/techread.py b/werk24/techread.py index 8da6ac72..468e530e 100644 --- a/werk24/techread.py +++ b/werk24/techread.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import io import json import ssl @@ -79,6 +80,23 @@ except Exception: USE_EXTRA_HEADERS = False +# Import websockets exceptions - they moved in version 14.0 +try: + from websockets.exceptions import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, + InvalidStatus, + ) +except ImportError: + # websockets 14+ moved exceptions to the main module + from websockets import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, + InvalidStatus, + ) + class Werk24Client: @@ -88,6 +106,10 @@ def __init__( https_server=settings.http_server, token: Optional[str] = None, region: Optional[str] = None, + ping_interval: float = 30.0, + ping_timeout: float = 10.0, + max_reconnect_attempts: int = 3, + reconnect_delay: float = 1.0, ): self.license = find_license(token, region) self._wss_server = str(wss_server) @@ -98,6 +120,14 @@ def __init__( # certificate chain is properly verified. self._ssl_context = ssl.create_default_context(cafile=certifi.where()) + # WebSocket connection management + self._ping_interval = ping_interval + self._ping_timeout = ping_timeout + self._max_reconnect_attempts = max_reconnect_attempts + self._reconnect_delay = reconnect_delay + self._is_shutting_down = False + self._reconnect_attempts = 0 + @staticmethod def validate_asks(asks: List[AskV2]) -> None: """ @@ -193,45 +223,131 @@ def _create_websocket_session( extra_headers=headers, close_timeout=wss_close_timeout, ssl=self._ssl_context, + ping_interval=self._ping_interval, + ping_timeout=self._ping_timeout, ) return websockets.connect( self._wss_server, additional_headers=headers, close_timeout=wss_close_timeout, ssl=self._ssl_context, + ping_interval=self._ping_interval, + ping_timeout=self._ping_timeout, ) - async def __aenter__(self): + async def _reconnect(self): + """ + Attempt to reconnect the WebSocket connection. + + This method is called when the connection is lost or becomes unresponsive. + It closes the existing connection and attempts to establish a new one. + + Raises: + ------ + - ServerException: If reconnection fails after all retry attempts. + """ + if self._is_shutting_down: + logger.debug("Skipping reconnect during shutdown") + return + + logger.info("Attempting to reconnect WebSocket") + + # Close existing connection if any + if self._wss_session: + try: + await self._wss_session.close() + except Exception as exc: + logger.debug("Error closing old connection: %s", exc) + + # Attempt to reconnect with retry logic try: - self._wss_session = await self._create_websocket_session() - - # ----------------------------------------------------------- - # Handle the error codes - # ----------------------------------------------------------- - except websockets.exceptions.InvalidStatus as exc: - match exc.response.status_code: - case 403: - raise UnauthorizedException( - "Invalid status when connecting to the server" - ) from exc - - case _: - raise ServerException( - f"Invalid status when connecting to the server: {exc.response.status_code}" - ) from exc - - # ----------------------------------------------------------- - # Handle remaining exceptions - # ----------------------------------------------------------- + await self._connect_with_retry() except Exception as exc: - logger.error("Failed to establish a connection with the server: %s", exc) - raise ServerException(details=str(exc)) from exc + logger.error("Failed to reconnect: %s", exc) + raise + + async def __aenter__(self): + await self._connect_with_retry() return self + async def _connect_with_retry(self): + """ + Establish WebSocket connection with retry logic. + + Raises: + ------ + - UnauthorizedException: If authentication fails (403). + - ServerException: If connection fails after all retry attempts. + """ + self._reconnect_attempts = 0 + + while self._reconnect_attempts < self._max_reconnect_attempts: + try: + self._wss_session = await self._create_websocket_session() + self._reconnect_attempts = 0 # Reset on successful connection + logger.info("WebSocket connection established successfully") + return + + # ----------------------------------------------------------- + # Handle the error codes + # ----------------------------------------------------------- + except InvalidStatus as exc: + match exc.response.status_code: + case 403: + raise UnauthorizedException( + "Invalid status when connecting to the server" + ) from exc + + case _: + raise ServerException( + f"Invalid status when connecting to the server: {exc.response.status_code}" + ) from exc + + # ----------------------------------------------------------- + # Handle remaining exceptions + # ----------------------------------------------------------- + except Exception as exc: + self._reconnect_attempts += 1 + if self._reconnect_attempts >= self._max_reconnect_attempts: + logger.error( + "Failed to establish connection after %d attempts: %s", + self._max_reconnect_attempts, + exc, + ) + raise ServerException(details=str(exc)) from exc + + # Exponential backoff + delay = self._reconnect_delay * (2 ** (self._reconnect_attempts - 1)) + logger.warning( + "Connection attempt %d/%d failed, retrying in %.1fs: %s", + self._reconnect_attempts, + self._max_reconnect_attempts, + delay, + exc, + ) + await asyncio.sleep(delay) + async def __aexit__(self, exc_type, exc_value, traceback): - logger.debug(f"Exiting the session with the server {self._wss_server}") + await self._graceful_shutdown() + + async def _graceful_shutdown(self): + """ + Perform graceful shutdown of WebSocket connection. + + This method: + 1. Sets shutdown flag to prevent reconnection + 2. Closes WebSocket connection cleanly + """ + logger.debug(f"Starting graceful shutdown for server {self._wss_server}") + self._is_shutting_down = True + + # Close WebSocket connection if self._wss_session is not None: - await self._wss_session.close() + try: + await self._wss_session.close() + logger.info("WebSocket connection closed successfully") + except Exception as exc: + logger.warning("Error during WebSocket close: %s", exc) async def read_drawing_with_hooks( self, @@ -486,11 +602,25 @@ async def _recv_message(self) -> TechreadMessage: "You need to call enter the profile before receiving command" ) - # wait for the websocket to say something and interpret the message - message_raw = str(await self._wss_session.recv()) - logger.debug("Received message: %s", message_raw) - message = self._parse_message(message_raw) - return message + try: + # wait for the websocket to say something and interpret the message + message_raw = str(await self._wss_session.recv()) + logger.debug("Received message: %s", message_raw) + message = self._parse_message(message_raw) + return message + except ( + ConnectionClosedError, + ConnectionClosedOK, + ) as exc: + logger.warning("Connection closed while receiving message: %s", exc) + if not self._is_shutting_down: + await self._reconnect() + # After reconnect, try to receive again + message_raw = str(await self._wss_session.recv()) + logger.debug("Received message after reconnect: %s", message_raw) + message = self._parse_message(message_raw) + return message + raise async def _send_command(self, action: str, message: str = "{}") -> None: """ @@ -525,8 +655,20 @@ async def _send_command(self, action: str, message: str = "{}") -> None: command = TechreadCommand(action=action, message=message) logger.debug("Sending command: %s", command.model_dump_json()) - # Send the serialized command to the websocket server - await self._wss_session.send(command.model_dump_json()) + try: + # Send the serialized command to the websocket server + await self._wss_session.send(command.model_dump_json()) + except ( + ConnectionClosedError, + ConnectionClosedOK, + ) as exc: + logger.warning("Connection closed while sending command: %s", exc) + if not self._is_shutting_down: + await self._reconnect() + # Retry sending after reconnect + await self._wss_session.send(command.model_dump_json()) + else: + raise @staticmethod def _parse_message(message_raw: str) -> TechreadMessage: From 4a527d21e591508459aa6fa882867805495722df Mon Sep 17 00:00:00 2001 From: Jochen Mattes Date: Sun, 30 Nov 2025 11:03:42 +0100 Subject: [PATCH 2/3] code quality --- .github/ISSUE_TEMPLATE/bug_report.md | 7 +- .github/ISSUE_TEMPLATE/feature_request.md | 7 +- .github/workflows/codeql-analysis.yml | 70 +++++++++---------- .github/workflows/python-publish.yml | 38 +++++----- .github/workflows/python-test.yml | 14 ++-- CONTRIBUTING.md | 3 + examples/ask_validation_example.py | 10 +-- examples/exception_handling.py | 7 +- test_pydantic_validation.py | 6 +- tests/test_ask_custom.py | 1 - tests/test_ask_validation_integration.py | 2 +- tests/test_client.py | 15 ++-- tests/test_exception_types.py | 9 ++- tests/test_new_exceptions.py | 2 - tests/test_websocket_connection_management.py | 21 +----- werk24/__init__.py | 4 +- werk24/models/v2/asks.py | 2 +- werk24/techread.py | 2 - 18 files changed, 97 insertions(+), 123 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index cd0bd22e..44c59e67 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,9 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Describe the bug** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d..2bc5d5f7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,10 +1,9 @@ --- name: Feature request about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Is your feature request related to a problem? Please describe.** diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4aa0ab75..ca5fb1eb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,7 +12,7 @@ on: # The branches below must be a subset of the branches above branches: [main] schedule: - - cron: '0 5 * * 5' + - cron: "0 5 * * 5" jobs: analyze: @@ -24,48 +24,48 @@ jobs: matrix: # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['python'] + language: ["python"] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # โ„น๏ธ Command-line programs to run using the OS shell. - # ๐Ÿ“š https://git.io/JvXDl + # โ„น๏ธ Command-line programs to run using the OS shell. + # ๐Ÿ“š https://git.io/JvXDl - # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - #- run: | - # make bootstrap - # make release + #- run: | + # make bootstrap + # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 37643dee..82705a0c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -14,22 +14,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python -m build - twine upload dist/* + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build + twine upload dist/* diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index af06e071..6d19443c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -1,13 +1,13 @@ name: ๐Ÿงช Tests (CPython 3.10 โ€“ 3.13) on: - push: # run on every push to any branch + push: # run on every push to any branch defaults: run: - shell: bash # makes multi-line run steps a little tidier + shell: bash # makes multi-line run steps a little tidier -concurrency: # cancel superseded runs on the same ref +concurrency: # cancel superseded runs on the same ref group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -17,16 +17,16 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip + cache: pip - name: Install dependencies run: | @@ -37,6 +37,6 @@ jobs: - name: Run test suite env: W24TECHREAD_AUTH_REGION: ${{ secrets.W24TECHREAD_AUTH_REGION }} - W24TECHREAD_AUTH_TOKEN: ${{ secrets.W24TECHREAD_AUTH_TOKEN }} + W24TECHREAD_AUTH_TOKEN: ${{ secrets.W24TECHREAD_AUTH_TOKEN }} run: | pytest -ra --color=yes # -ra gives a concise test summary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a71635..0ec6ad5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,17 +3,20 @@ We appreciate your interest in improving the Werk24 Python Client! ## Getting Started + - Fork this repository. - Create a feature branch: `git checkout -b feature-name`. - Install dependencies: `pip install -r requirements.txt`. ## Testing + - Ensure all tests run successfully: ```bash pytest ``` ## Pull Requests + - Describe the motivation and impact of your changes. - Reference any relevant issues. - The team will review your contribution as soon as possible. diff --git a/examples/ask_validation_example.py b/examples/ask_validation_example.py index 404652b2..38d6a101 100644 --- a/examples/ask_validation_example.py +++ b/examples/ask_validation_example.py @@ -59,7 +59,7 @@ class InvalidAsk(BaseModel): Werk24Client.validate_asks(asks) print("โœ“ All ask types are valid!") except BadRequestException as e: - print(f"โœ— Validation failed:") + print("โœ— Validation failed:") print(f" {e}") print() @@ -77,7 +77,7 @@ async def example_empty_asks(): Werk24Client.validate_asks(asks) print("โœ“ All ask types are valid!") except BadRequestException as e: - print(f"โœ— Validation failed:") + print("โœ— Validation failed:") print(f" {e}") print() @@ -106,7 +106,7 @@ class InvalidAsk(BaseModel): async for message in client.read_drawing(drawing, [InvalidAsk()]): print(f"Received message: {message}") except BadRequestException as e: - print(f"โœ“ Validation caught the error before sending to API:") + print("โœ“ Validation caught the error before sending to API:") print(f" {e}") except Exception as e: print(f"Other error: {e}") @@ -135,8 +135,8 @@ class InvalidAsk2(BaseModel): except BadRequestException as e: error_msg = str(e) print("Error message includes:") - print(f" - Invalid ask types: WRONG_TYPE_1, WRONG_TYPE_2") - print(f" - List of all valid ask types") + print(" - Invalid ask types: WRONG_TYPE_1, WRONG_TYPE_2") + print(" - List of all valid ask types") print() print("Full error message (truncated):") print(f" {error_msg[:200]}...") diff --git a/examples/exception_handling.py b/examples/exception_handling.py index 20d36c24..cfd2e035 100644 --- a/examples/exception_handling.py +++ b/examples/exception_handling.py @@ -12,7 +12,6 @@ W24RateLimitError, W24ServerError, W24ValidationError, - Werk24Client, ) @@ -155,7 +154,7 @@ def example_error_handling_with_retry(): except W24RateLimitError as e: retry_count += 1 if retry_count >= max_retries: - print(f"Max retries reached. Giving up.") + print("Max retries reached. Giving up.") raise wait_time = e.retry_after or 5 @@ -168,7 +167,7 @@ def example_error_handling_with_retry(): if e.is_transient: retry_count += 1 if retry_count >= max_retries: - print(f"Max retries reached. Giving up.") + print("Max retries reached. Giving up.") raise wait_time = e.error_details.get("retry_after", 5) @@ -178,7 +177,7 @@ def example_error_handling_with_retry(): time.sleep(wait_time) else: # Non-transient error, don't retry - print(f"Permanent server error. Not retrying.") + print("Permanent server error. Not retrying.") raise diff --git a/test_pydantic_validation.py b/test_pydantic_validation.py index efc46b4d..1537fee6 100644 --- a/test_pydantic_validation.py +++ b/test_pydantic_validation.py @@ -26,9 +26,9 @@ class InvalidAsk(BaseModel): try: request = TechreadRequest(asks=[InvalidAsk()], max_pages=5) - print(f"โœ— Invalid ask was accepted (shouldn't happen)") + print("โœ— Invalid ask was accepted (shouldn't happen)") except ValidationError as e: - print(f"โœ“ Pydantic caught the invalid ask:") + print("โœ“ Pydantic caught the invalid ask:") print(f" {e.errors()[0]['msg']}") print() @@ -37,6 +37,6 @@ class InvalidAsk(BaseModel): print("Test 3: Empty asks list") try: request = TechreadRequest(asks=[], max_pages=5) - print(f"โœ“ Empty asks list accepted (Pydantic doesn't validate list length)") + print("โœ“ Empty asks list accepted (Pydantic doesn't validate list length)") except ValidationError as e: print(f"โœ— Validation error: {e}") diff --git a/tests/test_ask_custom.py b/tests/test_ask_custom.py index 3da31064..bcec2660 100644 --- a/tests/test_ask_custom.py +++ b/tests/test_ask_custom.py @@ -16,4 +16,3 @@ def test_postprocessor_slot_enum_value(): def test_postprocessor_slot_invalid_value(): with pytest.raises(ValueError): AskCustom(custom_id="foo", postprocessor_slot="red") - diff --git a/tests/test_ask_validation_integration.py b/tests/test_ask_validation_integration.py index 314a871b..4f735871 100644 --- a/tests/test_ask_validation_integration.py +++ b/tests/test_ask_validation_integration.py @@ -6,7 +6,7 @@ """ import io -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest diff --git a/tests/test_client.py b/tests/test_client.py index 474a49a9..1736f0ac 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,8 @@ import os import uuid +from unittest.mock import AsyncMock, Mock import pytest -from unittest.mock import AsyncMock, Mock from werk24 import ( AskMetaData, @@ -25,10 +25,7 @@ ) requires_license = pytest.mark.skipif( - not ( - os.getenv("W24TECHREAD_AUTH_TOKEN") - and os.getenv("W24TECHREAD_AUTH_REGION") - ), + not (os.getenv("W24TECHREAD_AUTH_TOKEN") and os.getenv("W24TECHREAD_AUTH_REGION")), reason="Werk24 license credentials not provided", ) @@ -77,9 +74,7 @@ async def test_read_drawing_with_hooks(drawing_bytes): await client.read_drawing_with_hooks(drawing_bytes, hooks) hook.assert_called_once() - assert ( - hook.call_args.args[0].message_type == TechreadMessageType.ASK - ) + assert hook.call_args.args[0].message_type == TechreadMessageType.ASK @requires_license @@ -144,9 +139,7 @@ def test_get_hook_function_for_message_no_match(): message_type=TechreadMessageType.PROGRESS, message_subtype=TechreadMessageSubtype.PROGRESS_STARTED, ) - assert ( - Werk24Client._get_hook_function_for_message(message, [hook]) is None - ) + assert Werk24Client._get_hook_function_for_message(message, [hook]) is None @pytest.mark.asyncio diff --git a/tests/test_exception_types.py b/tests/test_exception_types.py index 8dd52021..ef738e0d 100644 --- a/tests/test_exception_types.py +++ b/tests/test_exception_types.py @@ -3,5 +3,10 @@ def test_configuration_incorrect_enum_present(): - assert TechreadExceptionType.CONFIGURATION_INCORRECT.value == "CONFIGURATION_INCORRECT" - assert W24TechreadExceptionType.CONFIGURATION_INCORRECT.value == "CONFIGURATION_INCORRECT" + assert ( + TechreadExceptionType.CONFIGURATION_INCORRECT.value == "CONFIGURATION_INCORRECT" + ) + assert ( + W24TechreadExceptionType.CONFIGURATION_INCORRECT.value + == "CONFIGURATION_INCORRECT" + ) diff --git a/tests/test_new_exceptions.py b/tests/test_new_exceptions.py index 17a4bcd1..b07a4464 100644 --- a/tests/test_new_exceptions.py +++ b/tests/test_new_exceptions.py @@ -5,8 +5,6 @@ they properly handle error details and provide useful information. """ -import pytest - from werk24.utils.exceptions import ( W24AuthenticationError, W24RateLimitError, diff --git a/tests/test_websocket_connection_management.py b/tests/test_websocket_connection_management.py index a81db7ae..4bad490d 100644 --- a/tests/test_websocket_connection_management.py +++ b/tests/test_websocket_connection_management.py @@ -7,30 +7,11 @@ - Configuration options """ -import asyncio -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import patch import pytest from werk24.techread import Werk24Client -from werk24.utils.exceptions import ServerException, UnauthorizedException - -# Import websockets exceptions - they moved in version 14.0 -try: - from websockets.exceptions import ( - ConnectionClosed, - ConnectionClosedError, - ConnectionClosedOK, - InvalidStatus, - ) -except ImportError: - # websockets 14+ moved exceptions to the main module - from websockets import ( - ConnectionClosed, - ConnectionClosedError, - ConnectionClosedOK, - InvalidStatus, - ) @pytest.fixture diff --git a/werk24/__init__.py b/werk24/__init__.py index 8b2c41a7..6225b638 100644 --- a/werk24/__init__.py +++ b/werk24/__init__.py @@ -1,4 +1,4 @@ -from werk24._version import __version__ -from werk24.models import * +from werk24._version import __version__ as __version__ # noqa: F401 +from werk24.models import * # noqa: F403 from werk24.techread import Werk24Client as Werk24Client from werk24.utils import * # noqa: F401, F403 diff --git a/werk24/models/v2/asks.py b/werk24/models/v2/asks.py index dc7f2b86..ca4c7c49 100644 --- a/werk24/models/v2/asks.py +++ b/werk24/models/v2/asks.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field from werk24.models.v1.ask import W24Ask -from werk24.models.v2.enums import AskType, ThumbnailFileFormat, PostprocessorSlot +from werk24.models.v2.enums import AskType, PostprocessorSlot, ThumbnailFileFormat from werk24.models.v2.models import RedactionKeyword diff --git a/werk24/techread.py b/werk24/techread.py index 468e530e..4c91842f 100644 --- a/werk24/techread.py +++ b/werk24/techread.py @@ -83,7 +83,6 @@ # Import websockets exceptions - they moved in version 14.0 try: from websockets.exceptions import ( - ConnectionClosed, ConnectionClosedError, ConnectionClosedOK, InvalidStatus, @@ -91,7 +90,6 @@ except ImportError: # websockets 14+ moved exceptions to the main module from websockets import ( - ConnectionClosed, ConnectionClosedError, ConnectionClosedOK, InvalidStatus, From 5ab258353b5ad97fba4f304488f600b444ef75a5 Mon Sep 17 00:00:00 2001 From: Jochen Mattes Date: Sun, 30 Nov 2025 11:07:23 +0100 Subject: [PATCH 3/3] cq --- .github/workflows/codeql-analysis.yml | 8 ++++---- .github/workflows/python-publish.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ca5fb1eb..f4aa02cb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -43,7 +43,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # โ„น๏ธ Command-line programs to run using the OS shell. # ๐Ÿ“š https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 82705a0c..75900f0d 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.12"