diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..3933570 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +branch = True +source = ondewo/utils +omit = + ondewo/version.py + +[report] +show_missing = True +exclude_lines = + pragma: no cover + @abstractmethod + raise NotImplementedError diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6efb127 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,52 @@ +# Runs the test suite (with 100% coverage gate) on every push and pull request. +name: tests + +on: + push: + branches: + - "**" + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install . + + - name: Run tests with coverage + run: python -m pytest + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v5 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + if-no-files-found: ignore diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf33c28 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +`ondewo-client-utils-python` (distribution name `ondewo-client-utils`, importable as `ondewo.utils`) is a small +library of shared base classes and utilities for ONDEWO gRPC Python clients. Other ONDEWO client packages (nlu, s2t, +t2s, vtsi, …) depend on it. It is deliberately minimal — keep it that way. + +Public building blocks (all under `ondewo/utils/`): + +- `base_client.py` — `BaseClient`, the abstract base for synchronous clients (`connect` / `disconnect`, holds a + `services` container). +- `async_base_client.py` — `AsyncBaseClient`, the `async` counterpart. +- `base_services_interface.py` — `BaseServicesInterface` plus channel helpers (`get_secure_channel`, + `_get_grpc_channel`, `MAX_MESSAGE_LENGTH`) and the constant gRPC channel options. +- `async_base_services_interface.py` — `AsyncBaseServicesInterface`, the `grpc.aio` counterpart. +- `base_client_config.py` — `BaseClientConfig`, a frozen `dataclass_json` config (`host`, `port`, `grpc_cert`). +- `base_service_container.py` — `BaseServicesContainer`, the dataclass that concrete clients subclass to enumerate + their services. +- `helpers.py` — `get_struct_from_dict`, `get_attr_recursive`, `set_attr_recursive`. +- `text.py` — `TextHelper.from_camel_to_snake_case`. + +The default gRPC channel options (`_DEFAULT_GRPC_OPTIONS` / `_SERVICE_CONFIG_JSON`) are assembled once at import time. +Keep them module-level constants — do not move the `json.dumps` / options-dict construction back into `__init__`, since +a client with N services would otherwise rebuild them N times per connection. + +## Development + +Python `>=3.9`. + +- **Install:** `pip install -r requirements.txt -r requirements-dev.txt && pip install .` +- **Run tests:** `python -m pytest` — configuration lives in `setup.cfg` (`asyncio_mode = auto`, branch coverage, and a + `--cov-fail-under=100` gate). Any new runtime line needs a covering test or the suite fails. +- **Lint:** `make flake8` (or `flake8 .`); max line length is 120. +- **Types:** `make mypy` (or `pre-commit run mypy --all-files`); configuration in `mypy.ini`. +- **All hooks:** `pre-commit run --all-files`. +- **Docker parity:** `make run_tests` and `make run_code_checks` reproduce CI locally. + +CI (`.github/workflows/tests.yml`) runs the test suite on every push and pull request across Python 3.9–3.12. Tests live +in `tests/`; name new files `test_*.py` (enforced by the `name-tests-test` hook). + +## Working Principles + +Behavioral guidelines to reduce common mistakes. They bias toward caution over speed; for trivial tasks, use judgment. + +### Think before coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them — don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +### Simplicity first + +Minimum code that solves the problem. Nothing speculative. + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### Surgical changes + +Touch only what you must. Clean up only your own mess. + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it — don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that _your_ changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: every changed line should trace directly to the user's request. + +### Goal-driven execution + +Define success criteria. Loop until verified. + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +```text +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and +clarifying questions come before implementation rather than after mistakes. + +## Logging + +This library uses the Python standard-library `logging` module (there is no `loguru` dependency): + +```python +from logging import warning +``` + +- **Levels:** `debug()`, `info()`, `warning()`, `error()`, `exception()`. Choose by hotness/verbosity — `debug` for + routine method entry/exit, `info` for notable lifecycle events, `warning` / `error` / `exception` for problems. +- **Interpolate with f-strings.** Use `f"…{value}"`; only add the `f` prefix when the string actually interpolates + (`"START: …"` with no params stays a plain string). +- **`START:` / `DONE:` bracketing.** For a notable operation, wrap it with a `START:` line at entry and a `DONE:` line + at exit, both naming `ClassName: method_name` (append `: param={value}` context where useful). +- **Timing uses `perf_counter()`, rendered `:.5f`.** Measure elapsed time with `time.perf_counter()` captured as a start + value and subtracted at the `DONE:` line: + + ```python + from time import perf_counter + + start_time: float = perf_counter() + ... + log.info(f"DONE: BaseClient: connect. Elapsed time: {perf_counter() - start_time:.5f}") + ``` + + Never measure a duration with `time.time()` — reserve `time.time()` for wall-clock timestamps. `perf_counter()` has an + undefined epoch and must not be stored or compared across processes. + +## Docstrings + +Google-style, triple double-quotes: + +```python +""" +Short imperative summary line. + +Args: + param_name (type): + Description of the parameter. + +Returns: + type: + Description of the return value. + +Raises: + ExceptionType: + When this exception is raised. +""" +``` + +## Git Commits + +- **Never include Claude as author or co-author** in commit messages, PR descriptions, or any other text. Do not add + `Co-Authored-By: Claude…` trailers, "Generated with Claude Code" footers, or any similar attribution. +- The user's own git author identity (already configured in git) is the only identity that should appear on commits. +- This rule overrides the default Claude Code commit-template guidance. +- **Never prepend the JIRA ticket ID** (e.g. `[OND211-2418]`) to the commit subject yourself. The `giticket` pre-commit + hook reads the ticket from the branch name and prepends `[]` automatically. This repo's regex is + `(?:(?:feature|bugfix|support|hotfix)/)?(OND[0-9]{3}-[0-9]{1,5})[_-][\w-]+`, so the `feature/` … prefix is optional — + a branch such as `OND211-2418-add-keycloak-for-2-fa` is matched directly and yields `[OND211-2418] `. Writing the + prefix manually produces a duplicate like `[OND211-2418] [OND211-2418] …`. Write the subject as a plain message and + let the hook add the prefix on commit. + +## General Principles + +- Follow existing patterns before introducing new abstractions. +- Keep changes minimal and consistent with surrounding code. +- Validate inputs early with descriptive, context-rich error messages. +- Use context managers for files, sockets, and thread pools. +- Prefer region comments for grouping methods in files that already use them. +- End edited Markdown and YAML files with a trailing newline. diff --git a/Makefile b/Makefile index 8572629..00c4fc3 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,16 @@ +# Makefile for ondewo-client-utils-python +# +# Documentation conventions used in this file: +# - Any target with a trailing `## ` comment is listed by `make help`. +# - Section banners (`####...`) are listed by `make makefile_chapters`. +# +# Common developer targets: +# make run_tests Build the pytest docker image and run the test suite +# make run_code_checks Run flake8 + mypy inside the code-checks docker image +# make flake8 / make mypy Run the linters directly in the current environment +# make setup_developer_environment_locally Install pre-commit hooks and local dependencies +# make release Run the full automated release (branch, tag, GitHub, PyPI) + PACKAGE_FOLDER := ondewo-client-utils TESTFILE := ondewo CODE_CHECK_IMAGE := code_check_image_${TESTFILE} @@ -27,7 +40,7 @@ export # MUST BE THE SAME AS API in Mayor and Minor Version Number # example: API 2.9.0 --> Client 2.9.X -ONDEWO_PACKAGE_VERSION=$(shell cat ondewo/version.py | sed "s:__version__ = '::" | sed "s:'::") +ONDEWO_PACKAGE_VERSION=$(shell sed -nE "s/^__version__[^=]*= *'([^']+)'.*/\1/p" ondewo/version.py) PYPI_USERNAME?=ENTER_HERE_YOUR_PYPI_USERNAME @@ -56,7 +69,7 @@ IMAGE_UTILS_NAME=ondewo-client-utils-python:${ONDEWO_PACKAGE_VERSION} # ONDEWO Standard Make Targets ######################################################## -setup_developer_environment_locally: install_precommit_hooks install_dependencies_locally +setup_developer_environment_locally: install_precommit_hooks install_dependencies_locally ## Set up the local dev environment (pre-commit hooks + dependencies) install_precommit_hooks: ## Installs pre-commit hooks and sets them up for the ondewo-csi-client repo conda install -y pre-commit @@ -76,9 +89,12 @@ flake8: ## Runs flake8 mypy: ## Run mypy static code checking pre-commit run mypy --all-files +cythonize: ## Compile the pure-Python ondewo modules into native extensions (skips dataclasses) + python cython_compile.py build_ext --inplace + help: ## Print usage info about help targets # (first comment after target starting with double hashes ##) - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' makefile_chapters: ## Shows all sections of Makefile @echo `cat Makefile| grep "########################################################" -A 1 | grep -v "########################################################"` @@ -124,11 +140,11 @@ push_to_pypi_via_docker_image: ## Push source code to pypi via docker ${IMAGE_UTILS_NAME} make push_to_pypi rm -rf dist -show_pypi: build_package +show_pypi: build_package ## Build the package and print the contents of the resulting sdist tar xvfz dist/ondewo-client-utils-${ONDEWO_PACKAGE_VERSION}.tar.gz tree ondewo-client-utils-${ONDEWO_PACKAGE_VERSION} -show_pypi_via_docker_image: build_utils_docker_image ## Push source code to pypi via docker +show_pypi_via_docker_image: build_utils_docker_image ## Show the contents of the pypi package via docker [ -d $(OUTPUT_DIR) ] || mkdir -p $(OUTPUT_DIR) docker run --rm \ -v ${shell pwd}/dist:/home/ondewo/dist \ @@ -138,10 +154,10 @@ show_pypi_via_docker_image: build_utils_docker_image ## Push source code to pypi rm -rf dist -push_to_pypi: build_package upload_package clear_package_data +push_to_pypi: build_package upload_package clear_package_data ## Build, upload to PyPI and clean up (run inside the release docker image) @echo 'YAY - Pushed to pypi : )' -push_to_gh: login_to_gh build_gh_release +push_to_gh: login_to_gh build_gh_release ## Log in to GitHub and create the GitHub release (run inside the release docker image) @echo 'Released to Github' release_to_github_via_docker_image: ## Release to Github via docker @@ -149,14 +165,14 @@ release_to_github_via_docker_image: ## Release to Github via docker -e GITHUB_GH_TOKEN=${GITHUB_GH_TOKEN} \ ${IMAGE_UTILS_NAME} make push_to_gh -build_package: +build_package: ## Build the sdist and wheel into dist/ python setup.py sdist bdist_wheel chmod a+rw dist -R -upload_package: +upload_package: ## Upload the built dist/* artifacts to PyPI with twine twine upload --verbose -r pypi dist/* -u${PYPI_USERNAME} -p${PYPI_PASSWORD} -clear_package_data: +clear_package_data: ## Remove build artifacts (build/, dist/, *.egg-info) rm -rf build dist/* ondewo-client-utils.egg-info ondewo_release: spc clone_devops_accounts run_release_with_devops ## Release with credentials from devops-accounts repo @@ -169,13 +185,13 @@ clone_devops_accounts: ## Clones devops-accounts repo DEVOPS_ACCOUNT_GIT="ondewo-devops-accounts" DEVOPS_ACCOUNT_DIR="./${DEVOPS_ACCOUNT_GIT}" -TEST: +TEST: ## Debug: echo the resolved GitHub/PyPI credentials and current release notes @echo ${GITHUB_GH_TOKEN} @echo ${PYPI_USERNAME} @echo ${PYPI_PASSWORD} @echo ${CURRENT_RELEASE_NOTES} -run_release_with_devops: +run_release_with_devops: ## Load credentials from the devops-accounts repo and run the full release $(eval info:= $(shell cat ${DEVOPS_ACCOUNT_DIR}/account_github.env | grep GITHUB_GH & cat ${DEVOPS_ACCOUNT_DIR}/account_pypi.env | grep PYPI_USERNAME & cat ${DEVOPS_ACCOUNT_DIR}/account_pypi.env | grep PYPI_PASSWORD)) make release $(info) diff --git a/RELEASE.md b/RELEASE.md index 8ca5c2e..838f51d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,6 +2,22 @@ ***************** +## Release ONDEWO CLIENT UTILS PYTHON 3.2.0 + +### New Features + +* Added a test suite with 100% code coverage +* Added a GitHub Actions workflow that runs the tests on every push and pull request + +### Improvements + +* Serialize the gRPC service config and default channel options once at import instead of on every service construction to reduce client construction latency +* Enabled gRPC keepalive to keep long-lived streaming RPCs warm and detect half-open connections +* Updated GitHub Actions to their Node 24 releases to remove the Node 20 deprecation warnings +* Documented all Makefile targets and added a CLAUDE.md with repository and development guidance + +***************** + ## Release ONDEWO CLIENT UTILS PYTHON 3.1.0 ### Improvements diff --git a/cython_compile.py b/cython_compile.py new file mode 100644 index 0000000..56daa20 --- /dev/null +++ b/cython_compile.py @@ -0,0 +1,99 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Cythonize the pure-Python ``ondewo`` modules into native C extensions. + +Every ``ondewo`` module is compiled to a C extension, except for files that cannot +(or should not) be cythonized: + +* files that define a ``@dataclass`` — Cython does not support dataclasses, so + (following the ONDEWO cythonization standard) they are left as plain ``.py`` files; +* package markers and tooling-read files (``__init__.py``, ``version.py``) — the latter + is parsed as text by ``setup.py`` and the ``Makefile`` and therefore must stay ``.py``. + +Cython's annotation-based typing is disabled (``annotation_typing=False``) so that the +PEP 484 type hints added for static analysis are treated as pure Python annotations and +do not alter the compiled behaviour. + +Usage: + python cython_compile.py build_ext --inplace +""" + +import os +from typing import ( + List, + Set, +) + +from Cython.Build import cythonize +from setuptools import setup + +#: File names that must never be cythonized (package markers / tooling-read files). +SKIP_FILENAMES: Set[str] = {"__init__.py", "version.py"} + +#: Root package directory that is walked for cythonizable modules. +PACKAGE_ROOT: str = "ondewo" + + +def find_modules(root: str = PACKAGE_ROOT) -> List[str]: + """ + Collect the pure-Python modules that are safe to cythonize. + + Walks ``root`` recursively and returns every ``.py`` file that is neither a + skipped file name (:data:`SKIP_FILENAMES`) nor a module defining a ``@dataclass``. + + Args: + root (str): + Directory to search for modules. Defaults to :data:`PACKAGE_ROOT`. + + Returns: + List[str]: + Sorted list of module file paths to hand to Cython. + """ + modules: List[str] = [] + for dirpath, _dirnames, filenames in os.walk(root): + for filename in filenames: + if not filename.endswith(".py") or filename in SKIP_FILENAMES: + continue + path: str = os.path.join(dirpath, filename) + with open(path, encoding="utf-8") as handle: + if "@dataclass" in handle.read(): + # Cython does not support dataclasses; keep the module as pure Python. + continue + modules.append(path) + return sorted(modules) + + +def main() -> None: + """ + Cythonize and build the discovered modules in place. + + Returns: + None + """ + modules: List[str] = find_modules() + print("Cythonizing:\n " + "\n ".join(modules)) + setup( + name="ondewo-client-utils-cython", + ext_modules=cythonize( + modules, + language_level="3", + compiler_directives={"annotation_typing": False}, + ), + zip_safe=False, + ) + + +if __name__ == "__main__": + main() diff --git a/dockerfiles/pytest.Dockerfile b/dockerfiles/pytest.Dockerfile index 37bac55..268a3b4 100644 --- a/dockerfiles/pytest.Dockerfile +++ b/dockerfiles/pytest.Dockerfile @@ -5,7 +5,7 @@ WORKDIR /home/ondewo COPY requirements.txt . RUN \ pip3 install -U pip && \ - pip3 install pytest && \ + pip3 install pytest pytest-cov pytest-asyncio && \ pip3 install -r requirements.txt ARG TESTFILE diff --git a/mypy.ini b/mypy.ini index 0b159a1..58906f9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,13 +1,18 @@ # Global options: [mypy] -python_version = 3.9 +# The package supports Python >=3.9, but current mypy releases no longer accept +# python_version = 3.9 ("must be 3.10 or higher"), so 3.10 is the lowest target +# available. The library uses no 3.10-only syntax, so this does not weaken checks. +python_version = 3.10 warn_return_any = True warn_unused_configs = True # Per-module options: [mypy-setuptools] ignore_missing_imports = True +[mypy-Cython.*] +ignore_missing_imports = True [mypy-grpc] ignore_missing_imports = True [mypy-regex] diff --git a/ondewo/__init__.py b/ondewo/__init__.py index d55ccad..b3b6afc 100644 --- a/ondewo/__init__.py +++ b/ondewo/__init__.py @@ -1 +1,7 @@ +"""Namespace package root for the ``ondewo`` distribution family. + +Extends ``__path__`` via :func:`pkgutil.extend_path` so that sibling ``ondewo.*`` +distributions installed in separate locations share the single ``ondewo`` namespace. +""" + __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore diff --git a/ondewo/utils/__init__.py b/ondewo/utils/__init__.py index 5f644e7..aef33c7 100644 --- a/ondewo/utils/__init__.py +++ b/ondewo/utils/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,3 +11,5 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +"""Utilities and abstract base classes shared by ONDEWO gRPC Python clients.""" diff --git a/ondewo/utils/async_base_client.py b/ondewo/utils/async_base_client.py index 7b70303..69c8bc3 100644 --- a/ondewo/utils/async_base_client.py +++ b/ondewo/utils/async_base_client.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 ONDEWO GmbH +# Copyright 2017-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +""" +Abstract base class for asynchronous ONDEWO gRPC Python clients. + +Provides the async counterpart of ``BaseClient`` with awaitable ``connect`` and +``disconnect`` methods that manage the lifecycle of the underlying gRPC channels. +""" + from abc import ( ABC, abstractmethod, @@ -42,6 +49,21 @@ def __init__( use_secure_channel: bool = True, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: + """ + Initialize the async client and its service clients. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. Defaults to ``True``. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. Defaults to ``None``. + + Raises: + ValueError: + If the ``services`` attribute is not defined after initialization. + """ self.services: Optional[BaseServicesContainer] = None self._initialize_services( config=config, @@ -59,6 +81,17 @@ def _initialize_services( use_secure_channel: bool, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: + """ + Initialize the service clients. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. Defaults to ``None``. + """ pass async def connect( @@ -67,6 +100,21 @@ async def connect( use_secure_channel: bool, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: + """ + Establish a connection to the services. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. Defaults to ``None``. + + Raises: + ConnectionError: + If a connection is already established. + """ if self.services: raise ConnectionError("The current client already has an open connection.") @@ -77,11 +125,21 @@ async def connect( ) async def disconnect(self) -> None: + """ + Asynchronously close all gRPC channels and clear the services. + + Awaits the graceful shutdown of each service's gRPC channel before + discarding the services container. + + Raises: + AttributeError: + If the ``services`` attribute is not defined. + """ if not self.services: raise AttributeError("The attribute `services` is not defined.") for service_name in self.services.__annotations__.keys(): service: AsyncBaseServicesInterface = self.services.__getattribute__(service_name) - await service.grpc_channel.close() + await service.grpc_channel.close(grace=None) self.services = None diff --git a/ondewo/utils/async_base_services_interface.py b/ondewo/utils/async_base_services_interface.py index b049093..6486162 100644 --- a/ondewo/utils/async_base_services_interface.py +++ b/ondewo/utils/async_base_services_interface.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,6 +11,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +"""Async gRPC service interface base classes and channel factory helpers. + +Async counterpart of ``base_services_interface`` built on ``grpc.aio``: it +provides channel factory helpers and the abstract ``AsyncBaseServicesInterface``. +""" import json import struct @@ -26,21 +31,92 @@ Optional, Set, Tuple, + Union, ) import grpc from ondewo.utils.base_client_config import BaseClientConfig -MAX_MESSAGE_LENGTH = 2 ** (struct.Struct("i").size * 8 - 1) - 1 +MAX_MESSAGE_LENGTH: int = 2 ** (struct.Struct("i").size * 8 - 1) - 1 + +# The gRPC service config and default channel options are constant. They are +# serialized/assembled once at import time instead of on every service +# construction so that building a client with many services stays cheap +# (ultra low latency): a client with N services would otherwise run +# ``json.dumps`` and rebuild the options dict N times per connection. +_SERVICE_CONFIG_JSON: str = json.dumps( + { + "methodConfig": [ + { + "name": [{}], + "retryPolicy": { + "maxAttempts": 10, + "initialBackoff": "0.1s", + "maxBackoff": "3s", + "backoffMultiplier": 2, + "retryableStatusCodes": [ + grpc.StatusCode.CANCELLED.name, + grpc.StatusCode.UNKNOWN.name, + grpc.StatusCode.DEADLINE_EXCEEDED.name, + grpc.StatusCode.NOT_FOUND.name, + grpc.StatusCode.RESOURCE_EXHAUSTED.name, + grpc.StatusCode.ABORTED.name, + grpc.StatusCode.INTERNAL.name, + grpc.StatusCode.UNAVAILABLE.name, + grpc.StatusCode.DATA_LOSS.name, + ], + }, + } + ] + } +) + +_DEFAULT_GRPC_OPTIONS: Dict[str, Any] = { + "grpc.max_send_message_length": MAX_MESSAGE_LENGTH, + "grpc.max_receive_message_length": MAX_MESSAGE_LENGTH, + # Keepalive keeps long-lived streaming RPCs warm and detects half-open + # connections. Pings fire only during active calls (permit_without_calls + # stays False) to avoid a server "too_many_pings" GOAWAY on idle channels; + # max_pings_without_data=0 lets pings continue through silent stream gaps. + "grpc.keepalive_time_ms": 30000, + "grpc.keepalive_timeout_ms": 60000, + "grpc.keepalive_permit_without_calls": False, + "grpc.http2.max_pings_without_data": 0, + "grpc.dns_enable_srv_queries": 1, + "grpc.enable_retries": 1, + "grpc.service_config": _SERVICE_CONFIG_JSON, +} + +# Pre-materialized list of the default options for the common case where no +# per-client overrides are supplied. +_DEFAULT_GRPC_OPTIONS_ITEMS: List[Tuple[str, Any]] = list(_DEFAULT_GRPC_OPTIONS.items()) def get_secure_channel( host: str, - cert: str, + cert: Union[str, bytes], options: Optional[List[Tuple[str, Any]]] = None, ) -> grpc.aio.Channel: - credentials = grpc.ssl_channel_credentials(root_certificates=cert) + """ + Create a secure asynchronous gRPC channel to the given host. + + Args: + host (str): + Target address in the form "host:port" to connect to. + cert (Union[str, bytes]): + Root certificate used to establish the TLS connection. A ``str`` is encoded to + ``bytes`` before being handed to gRPC; ``BaseClientConfig.__post_init__`` already + supplies ``bytes``. + options (Optional[List[Tuple[str, Any]]]): + Optional list of gRPC channel options as (key, value) tuples. + + Returns: + grpc.aio.Channel: + A secure asynchronous gRPC channel connected to the target host. + """ + root_certificates: bytes = cert.encode() if isinstance(cert, str) else cert + credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(root_certificates=root_certificates) return grpc.aio.secure_channel( target=host, credentials=credentials, @@ -53,6 +129,26 @@ def _get_grpc_channel( use_secure_channel: bool, options: Optional[List[Tuple[str, Any]]] = None, ) -> grpc.aio.Channel: + """ + Build an asynchronous gRPC channel, secure or insecure, from a client config. + + Args: + config (BaseClientConfig): + Client configuration providing the host, port and optional certificate. + use_secure_channel (bool): + Whether to create a secure (TLS) channel. If False an insecure channel + is created instead. + options (Optional[List[Tuple[str, Any]]]): + Optional list of gRPC channel options as (key, value) tuples. + + Returns: + grpc.aio.Channel: + A secure or insecure asynchronous gRPC channel. + + Raises: + ValueError: + If a secure channel is requested but the config has no gRPC certificate. + """ if not use_secure_channel: warning("Using insecure grpc channel.") return grpc.aio.insecure_channel(target=config.host_and_port, options=options) @@ -68,56 +164,39 @@ def _get_grpc_channel( class AsyncBaseServicesInterface(ABC): + """ + Abstract base class for async ONDEWO gRPC service interfaces. + + Attributes: + grpc_channel (grpc.aio.Channel): + The asynchronous gRPC channel used to communicate with the service. + """ + def __init__( self, config: BaseClientConfig, use_secure_channel: bool, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: - - service_config_json: str = json.dumps( - { - "methodConfig": [ - { - "name": [{}], - "retryPolicy": { - "maxAttempts": 10, - "initialBackoff": "0.1s", - "maxBackoff": "3s", - "backoffMultiplier": 2, - "retryableStatusCodes": [ - grpc.StatusCode.CANCELLED.name, - grpc.StatusCode.UNKNOWN.name, - grpc.StatusCode.DEADLINE_EXCEEDED.name, - grpc.StatusCode.NOT_FOUND.name, - grpc.StatusCode.RESOURCE_EXHAUSTED.name, - grpc.StatusCode.ABORTED.name, - grpc.StatusCode.INTERNAL.name, - grpc.StatusCode.UNAVAILABLE.name, - grpc.StatusCode.DATA_LOSS.name, - ], - }, - } - ] - } - ) - - default_options: Dict[str, Any] = { - "grpc.max_send_message_length": MAX_MESSAGE_LENGTH, - "grpc.max_receive_message_length": MAX_MESSAGE_LENGTH, - "grpc.keepalive_time_ms": 2 ** 31 - 1, - "grpc.keepalive_timeout_ms": 60000, - "grpc.keepalive_permit_without_calls": False, - "grpc.http2.max_pings_without_data": 2, - "grpc.dns_enable_srv_queries": 1, - "grpc.enable_retries": 1, - "grpc.service_config": service_config_json, - } + """ + Initialize the async service interface and open its gRPC channel. + + Args: + config (BaseClientConfig): + Client configuration providing the host, port and optional certificate. + use_secure_channel (bool): + Whether to create a secure (TLS) channel. + options (Optional[Set[Tuple[str, Any]]]): + Optional set of gRPC channel options as (key, value) tuples that + override the default options. + """ if options: - default_options.update(dict(options)) - - updated_options: List[Tuple[str, Any]] = list(default_options.items()) + merged_options: Dict[str, Any] = dict(_DEFAULT_GRPC_OPTIONS) + merged_options.update(dict(options)) + updated_options: List[Tuple[str, Any]] = list(merged_options.items()) + else: + updated_options = _DEFAULT_GRPC_OPTIONS_ITEMS self.grpc_channel: grpc.aio.Channel = _get_grpc_channel( config=config, @@ -128,4 +207,11 @@ def __init__( @property @abstractmethod def stub(self) -> Any: + """ + Return the concrete gRPC stub used to issue RPC calls. + + Returns: + Any: + The gRPC service stub implemented by the concrete subclass. + """ pass diff --git a/ondewo/utils/base_client.py b/ondewo/utils/base_client.py index 2106e89..9d96725 100644 --- a/ondewo/utils/base_client.py +++ b/ondewo/utils/base_client.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 ONDEWO GmbH +# Copyright 2017-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Abstract base class providing the synchronous scaffolding for ONDEWO gRPC clients.""" + from abc import ( ABC, abstractmethod, @@ -33,7 +35,9 @@ class BaseClient(ABC): Abstract base class for ONDEWO clients. Attributes: - services: A container for the service clients initialized by the client. + services (Optional[BaseServicesContainer]): + A container for the service clients initialized by the client, or ``None`` when the + client is not connected. """ def __init__( @@ -42,6 +46,21 @@ def __init__( use_secure_channel: bool = True, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: + """ + Initialize the client and its service clients. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. Defaults to ``True``. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. Defaults to ``None``. + + Raises: + ValueError: + If ``_initialize_services`` does not populate the ``services`` attribute. + """ self.services: Optional[BaseServicesContainer] = None self._initialize_services( config=config, diff --git a/ondewo/utils/base_client_config.py b/ondewo/utils/base_client_config.py index 346fe66..869ad2d 100644 --- a/ondewo/utils/base_client_config.py +++ b/ondewo/utils/base_client_config.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Data class holding the host, port and gRPC certificate configuration for ONDEWO gRPC clients.""" + from dataclasses import dataclass from typing import Optional @@ -39,14 +41,26 @@ class BaseClientConfig: grpc_cert: Optional[str] = None def __post_init__(self) -> None: + """ + Encode the gRPC certificate to bytes after the frozen dataclass is initialised. + + The certificate is provided as a ``str`` on construction and is transparently encoded to + ``bytes`` here using ``object.__setattr__`` (required because the dataclass is frozen). If + ``grpc_cert`` is ``None`` it is left unchanged. + + Returns: + None: + This method mutates the instance in place and returns nothing. + """ object.__setattr__(self, "grpc_cert", self.grpc_cert.encode() if self.grpc_cert else self.grpc_cert) @property def host_and_port(self) -> str: """ - Returns the host and port as a single string. + Return the host and port combined into a single connection string. Returns: - str: The host and port in the format "host:port". + str: + The host and port in the format ``"host:port"``. """ return f"{self.host}:{self.port}" diff --git a/ondewo/utils/base_service_container.py b/ondewo/utils/base_service_container.py index 7d8a7b1..13cda28 100644 --- a/ondewo/utils/base_service_container.py +++ b/ondewo/utils/base_service_container.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +"""Base dataclass that concrete ONDEWO gRPC clients subclass to enumerate their service stubs.""" from dataclasses import dataclass @@ -19,7 +19,15 @@ @dataclass class BaseServicesContainer: """ - Common class to maintain a consistent interface + Provide a common base for grouping gRPC service stubs behind a consistent interface. + + Concrete ONDEWO gRPC clients subclass this dataclass and declare one typed + attribute per gRPC service they expose (for example ``users`` or ``agents``), + giving every client a uniform way of holding and accessing its service stubs. + + This base class intentionally declares no attributes and has an empty body; + it exists solely to be extended by concrete containers. The ``@dataclass`` + decorator is retained so that subclasses inherit dataclass semantics. """ pass diff --git a/ondewo/utils/base_services_interface.py b/ondewo/utils/base_services_interface.py index 80d912f..b80f63a 100644 --- a/ondewo/utils/base_services_interface.py +++ b/ondewo/utils/base_services_interface.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,6 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +"""Synchronous gRPC channel helpers and the base service interface for ONDEWO clients. + +Provides secure and insecure ``grpc.Channel`` factory helpers, the shared default channel +options (maximum message sizes, keepalive settings and the retry policy), and the +:class:`BaseServicesInterface` abstract base class from which every synchronous ONDEWO +gRPC service client derives. +""" import json import struct from abc import ( @@ -25,21 +32,100 @@ Optional, Set, Tuple, + Union, ) import grpc from ondewo.utils.base_client_config import BaseClientConfig -MAX_MESSAGE_LENGTH = 2 ** (struct.Struct("i").size * 8 - 1) - 1 +MAX_MESSAGE_LENGTH: int = 2 ** (struct.Struct("i").size * 8 - 1) - 1 + +# The gRPC service config and default channel options are constant. They are +# serialized/assembled once at import time instead of on every service +# construction so that building a client with many services stays cheap +# (ultra low latency): a client with N services would otherwise run +# ``json.dumps`` and rebuild the options dict N times per connection. +# https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto +_SERVICE_CONFIG_JSON: str = json.dumps( + { + "methodConfig": [ + { + "name": [ + # To apply retry to all methods, put [{}] in the "name" field + {} + # For a specific set of services and endpoint calls + # {"service": ".", "method": ""} + # For example: + # {"service": "ondewo.nlu.Users", "method": "Login"} + ], + "retryPolicy": { + "maxAttempts": 10, + "initialBackoff": "0.1s", + "maxBackoff": "3s", + "backoffMultiplier": 2, + "retryableStatusCodes": [ + grpc.StatusCode.CANCELLED.name, + grpc.StatusCode.UNKNOWN.name, + grpc.StatusCode.DEADLINE_EXCEEDED.name, + grpc.StatusCode.NOT_FOUND.name, + grpc.StatusCode.RESOURCE_EXHAUSTED.name, + grpc.StatusCode.ABORTED.name, + grpc.StatusCode.INTERNAL.name, + grpc.StatusCode.UNAVAILABLE.name, + grpc.StatusCode.DATA_LOSS.name, + ], + }, + } + ] + } +) + +_DEFAULT_GRPC_OPTIONS: Dict[str, Any] = { + "grpc.max_send_message_length": MAX_MESSAGE_LENGTH, + "grpc.max_receive_message_length": MAX_MESSAGE_LENGTH, + # Keepalive keeps long-lived streaming RPCs warm and detects half-open + # connections. Pings fire only during active calls (permit_without_calls + # stays False) to avoid a server "too_many_pings" GOAWAY on idle channels; + # max_pings_without_data=0 lets pings continue through silent stream gaps. + "grpc.keepalive_time_ms": 30000, + "grpc.keepalive_timeout_ms": 60000, + "grpc.keepalive_permit_without_calls": False, + "grpc.http2.max_pings_without_data": 0, + "grpc.dns_enable_srv_queries": 1, + "grpc.enable_retries": 1, + "grpc.service_config": _SERVICE_CONFIG_JSON, +} + +# Pre-materialized list of the default options for the common case where no +# per-client overrides are supplied. +_DEFAULT_GRPC_OPTIONS_ITEMS: List[Tuple[str, Any]] = list(_DEFAULT_GRPC_OPTIONS.items()) def get_secure_channel( host: str, - cert: str, + cert: Union[str, bytes], options: Optional[List[Tuple[str, Any]]] = None, ) -> grpc.Channel: - credentials = grpc.ssl_channel_credentials(root_certificates=cert) + """ + Create a secure (TLS) gRPC channel to the given host. + + Args: + host (str): + Target host in ``"host:port"`` form to connect to. + cert (Union[str, bytes]): + Root certificate used to verify the server. A ``str`` is encoded to ``bytes`` + before being handed to gRPC; ``BaseClientConfig.__post_init__`` already supplies + ``bytes``. + options (Optional[List[Tuple[str, Any]]]): + Optional gRPC channel options as ``(key, value)`` pairs. Defaults to ``None``. + + Returns: + grpc.Channel: + A secure channel configured with the supplied credentials and options. + """ + root_certificates: bytes = cert.encode() if isinstance(cert, str) else cert + credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(root_certificates=root_certificates) return grpc.secure_channel( target=host, credentials=credentials, @@ -52,6 +138,25 @@ def _get_grpc_channel( use_secure_channel: bool, options: Optional[List[Tuple[str, Any]]] = None, ) -> grpc.Channel: + """ + Build a gRPC channel for the given client configuration. + + Args: + config (BaseClientConfig): + Client configuration providing the host, port and optional gRPC certificate. + use_secure_channel (bool): + If ``True`` build a secure (TLS) channel; if ``False`` build an insecure channel. + options (Optional[List[Tuple[str, Any]]]): + Optional gRPC channel options as ``(key, value)`` pairs. Defaults to ``None``. + + Returns: + grpc.Channel: + A secure or insecure channel depending on ``use_secure_channel``. + + Raises: + ValueError: + If a secure channel is requested but ``config.grpc_cert`` is not set. + """ if not use_secure_channel: warning("Using insecure grpc channel.") return grpc.insecure_channel(target=config.host_and_port, options=options) @@ -67,64 +172,45 @@ def _get_grpc_channel( class BaseServicesInterface(ABC): + """ + Abstract base class for synchronous ONDEWO gRPC service clients. + + Sets up the shared ``grpc.Channel`` used to talk to a service and requires subclasses + to expose the concrete service ``stub``. + + Attributes: + grpc_channel (grpc.Channel): + The gRPC channel connecting to the configured service host. + """ + def __init__( self, config: BaseClientConfig, use_secure_channel: bool, options: Optional[Set[Tuple[str, Any]]] = None, ) -> None: - - # https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto - service_config_json: str = json.dumps( - { - "methodConfig": [ - { - "name": [ - # To apply retry to all methods, put [{}] in the "name" field - {} - # For a specific set of services and endpoint calls - # {"service": ".", "method": ""} - # For example: - # {"service": "ondewo.nlu.Users", "method": "Login"} - ], - "retryPolicy": { - "maxAttempts": 10, - "initialBackoff": "0.1s", - "maxBackoff": "3s", - "backoffMultiplier": 2, - "retryableStatusCodes": [ - grpc.StatusCode.CANCELLED.name, - grpc.StatusCode.UNKNOWN.name, - grpc.StatusCode.DEADLINE_EXCEEDED.name, - grpc.StatusCode.NOT_FOUND.name, - grpc.StatusCode.RESOURCE_EXHAUSTED.name, - grpc.StatusCode.ABORTED.name, - grpc.StatusCode.INTERNAL.name, - grpc.StatusCode.UNAVAILABLE.name, - grpc.StatusCode.DATA_LOSS.name, - ], - }, - } - ] - } - ) - - default_options: Dict[str, Any] = { - "grpc.max_send_message_length": MAX_MESSAGE_LENGTH, - "grpc.max_receive_message_length": MAX_MESSAGE_LENGTH, - "grpc.keepalive_time_ms": 2 ** 31 - 1, - "grpc.keepalive_timeout_ms": 60000, - "grpc.keepalive_permit_without_calls": False, - "grpc.http2.max_pings_without_data": 2, - "grpc.dns_enable_srv_queries": 1, - "grpc.enable_retries": 1, - "grpc.service_config": service_config_json, - } - + """ + Initialize the interface and open the underlying gRPC channel. + + Args: + config (BaseClientConfig): + Client configuration providing the host, port and optional gRPC certificate. + use_secure_channel (bool): + If ``True`` open a secure (TLS) channel; if ``False`` open an insecure channel. + options (Optional[Set[Tuple[str, Any]]]): + Optional gRPC channel option overrides as ``(key, value)`` pairs merged on top of + the default options. Defaults to ``None``, in which case the shared default options + are used unchanged. + + Returns: + None + """ if options: - default_options.update(dict(options)) - - updated_options: List[Tuple[str, Any]] = list(default_options.items()) + merged_options: Dict[str, Any] = dict(_DEFAULT_GRPC_OPTIONS) + merged_options.update(dict(options)) + updated_options: List[Tuple[str, Any]] = list(merged_options.items()) + else: + updated_options = _DEFAULT_GRPC_OPTIONS_ITEMS self.grpc_channel: grpc.Channel = _get_grpc_channel( config=config, @@ -135,4 +221,11 @@ def __init__( @property @abstractmethod def stub(self) -> Any: + """ + Return the concrete gRPC service stub. + + Returns: + Any: + The service-specific gRPC stub used to issue RPCs. + """ pass diff --git a/ondewo/utils/helpers.py b/ondewo/utils/helpers.py index 4e87436..b306826 100644 --- a/ondewo/utils/helpers.py +++ b/ondewo/utils/helpers.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 ONDEWO GmbH +# Copyright 2020-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Helper utilities for building protobuf Structs and for recursive attribute access.""" import functools from typing import ( @@ -24,12 +25,21 @@ def get_struct_from_dict(d: Dict) -> Struct: # type: ignore """ - create a protobuf Struct from some dict + Create a protobuf Struct from a dictionary. + Args: d (Dict): + The dictionary whose key/value pairs populate the resulting ``Struct``. + May be ``None``, in which case an empty ``Struct`` is returned. Returns: + Struct: + A protobuf ``Struct`` populated with the entries of ``d`` (empty when + ``d`` is ``None``). + Raises: + AssertionError: + If ``d`` is neither a ``dict`` nor ``None``. """ assert isinstance(d, dict) or d is None, "parameter must be a dict or None" @@ -44,16 +54,44 @@ def get_struct_from_dict(d: Dict) -> Struct: # type: ignore def get_attr_recursive(obj: Any, attr: str, *args: Any) -> Any: """ + Recursively read a (possibly nested) attribute from an object. + from https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-subobjects-chained-properties + + Args: + obj (Any): + The object to read the attribute from. + attr (str): + The dot-separated attribute path, e.g. ``"a.b.c"``. + *args (Any): + An optional single default value returned when an attribute along the + path does not exist. + + Returns: + Any: + The value of the nested attribute, or the provided default when given. """ return functools.reduce(lambda obj_, attr_: getattr(obj_, attr_, *args), [obj] + attr.split(".")) def set_attr_recursive(obj: Any, attr: str, value: Any) -> None: """ + Recursively set a (possibly nested) attribute on an object. + from https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-subobjects-chained-properties + + Args: + obj (Any): + The object on which to set the attribute. + attr (str): + The dot-separated attribute path, e.g. ``"a.b.c"``. + value (Any): + The value to assign to the (nested) attribute. + + Returns: + None """ pre, _, post = attr.rpartition(".") return setattr(get_attr_recursive(obj, pre) if pre else obj, post, value) diff --git a/ondewo/utils/text.py b/ondewo/utils/text.py index d34bbc9..21a0bdc 100644 --- a/ondewo/utils/text.py +++ b/ondewo/utils/text.py @@ -1,4 +1,4 @@ -# Copyright 2021 ONDEWO GmbH +# Copyright 2021-2026 ONDEWO GmbH # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Text manipulation helpers for converting between naming conventions.""" from typing import Pattern @@ -19,10 +20,30 @@ class TextHelper: + """ + Provide static helpers for transforming text between naming conventions. - FROM_CAMEL_TO_SNAKE_PATTERN: Pattern = regex.compile(r"((?<=[a-z])[A-Z]|(?!^)[A-Z](?=[a-z]))") + Attributes: + FROM_CAMEL_TO_SNAKE_PATTERN (Pattern[str]): + Compiled regular expression matching the boundaries inside a + camelCase or PascalCase string where an underscore must be + inserted to produce snake_case. + """ + + FROM_CAMEL_TO_SNAKE_PATTERN: Pattern[str] = regex.compile(r"((?<=[a-z])[A-Z]|(?!^)[A-Z](?=[a-z]))") @classmethod def from_camel_to_snake_case(cls, text: str) -> str: + """ + Convert a camelCase or PascalCase string to snake_case. + + Args: + text (str): + The camelCase or PascalCase string to convert. + + Returns: + str: + The lower-cased snake_case representation of ``text``. + """ snaked: str = cls.FROM_CAMEL_TO_SNAKE_PATTERN.sub(r"_\1", text).lower() return snaked diff --git a/ondewo/version.py b/ondewo/version.py index 7f5601d..1eb3791 100644 --- a/ondewo/version.py +++ b/ondewo/version.py @@ -1 +1,3 @@ -__version__ = '3.1.0' +"""Single source of truth for the version of the ``ondewo-client-utils`` package.""" + +__version__: str = '3.2.0' diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b1c7882 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +addopts = + --cov=ondewo.utils + --cov-report=term-missing + --cov-report=xml + --cov-fail-under=100 diff --git a/requirements-dev.txt b/requirements-dev.txt index a3c476e..062c946 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,15 @@ + +# build +Cython>=3.0.0 flake8>=7.1.0 mypy>=1.11.0 pre-commit +# testing +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=5.0.0 + # mypy types types-mock types-protobuf diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ca3a07f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test suite for the ``ondewo.utils`` package.""" diff --git a/tests/test_async_base_client.py b/tests/test_async_base_client.py new file mode 100644 index 0000000..4bc9dc7 --- /dev/null +++ b/tests/test_async_base_client.py @@ -0,0 +1,172 @@ +# Copyright 2017-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Async pytest suite for :class:`AsyncBaseClient`. + +Exercises service initialisation, the connection/disconnection guards, and gRPC channel +teardown using lightweight in-memory stub clients backed by :mod:`unittest.mock`. +""" + +from dataclasses import dataclass +from typing import ( + Any, + Optional, + Set, + Tuple, +) +from unittest import mock + +import pytest + +from ondewo.utils.async_base_client import AsyncBaseClient +from ondewo.utils.base_client_config import BaseClientConfig +from ondewo.utils.base_service_container import BaseServicesContainer + + +@dataclass +class _Services(BaseServicesContainer): + """ + Minimal services container exposing a single stub service. + + Attributes: + svc (Any): + The stubbed service client, or ``None`` when left unset. + """ + + svc: Any = None + + +def _make_service() -> Any: + """ + Build a stub service whose gRPC channel closes via an async mock. + + Returns: + Any: + A :class:`unittest.mock.MagicMock` service whose ``grpc_channel.close`` + attribute is an awaitable :class:`unittest.mock.AsyncMock`. + """ + service: Any = mock.MagicMock() + service.grpc_channel = mock.MagicMock() + service.grpc_channel.close = mock.AsyncMock() + return service + + +class _AsyncClient(AsyncBaseClient): + """Concrete async client whose services are initialised with a stub.""" + + def _initialize_services( + self, + config: BaseClientConfig, + use_secure_channel: bool, + options: Optional[Set[Tuple[str, Any]]] = None, + ) -> None: + """ + Populate ``self.services`` with a single stub service. + + Args: + config (BaseClientConfig): + Client configuration (unused by this stub). + use_secure_channel (bool): + Whether a secure channel would be used (unused by this stub). + options (Optional[Set[Tuple[str, Any]]]): + Optional gRPC channel options (unused by this stub). + + Returns: + None + """ + self.services = _Services(svc=_make_service()) + + +class _EmptyAsyncClient(AsyncBaseClient): + """Concrete async client that never initialises ``services``.""" + + def _initialize_services( + self, + config: BaseClientConfig, + use_secure_channel: bool, + options: Optional[Set[Tuple[str, Any]]] = None, + ) -> None: + """ + Return without setting ``self.services`` to exercise the guard clause. + + Args: + config (BaseClientConfig): + Client configuration (unused by this stub). + use_secure_channel (bool): + Whether a secure channel would be used (unused by this stub). + options (Optional[Set[Tuple[str, Any]]]): + Optional gRPC channel options (unused by this stub). + + Returns: + None + """ + # deliberately leaves ``self.services`` unset to hit the guard clause + return + + +def _config() -> BaseClientConfig: + """ + Build a throwaway client configuration pointing at localhost. + + Returns: + BaseClientConfig: + A configuration with host ``"localhost"`` and port ``"50051"``. + """ + return BaseClientConfig(host="localhost", port="50051") + + +def test_init_sets_services() -> None: + """Verify that constructing a client populates ``services``.""" + client: _AsyncClient = _AsyncClient(config=_config()) + assert client.services is not None + + +def test_init_without_services_raises() -> None: + """Verify that a client leaving ``services`` unset raises ``ValueError``.""" + with pytest.raises(ValueError, match="must be defined"): + _EmptyAsyncClient(config=_config()) + + +async def test_connect_when_already_connected_raises() -> None: + """Verify that connecting an already-connected client raises ``ConnectionError``.""" + client: _AsyncClient = _AsyncClient(config=_config()) + with pytest.raises(ConnectionError, match="already has an open connection"): + await client.connect(config=_config(), use_secure_channel=True) + + +async def test_disconnect_closes_channels_and_clears() -> None: + """Verify that disconnecting closes each gRPC channel and clears ``services``.""" + client: _AsyncClient = _AsyncClient(config=_config()) + service: Any = client.services.svc # type: ignore[union-attr] + await client.disconnect() + service.grpc_channel.close.assert_awaited_once_with(grace=None) + assert client.services is None + + +async def test_disconnect_without_services_raises() -> None: + """Verify that disconnecting with ``services`` unset raises ``AttributeError``.""" + client: _AsyncClient = _AsyncClient(config=_config()) + client.services = None + with pytest.raises(AttributeError, match="is not defined"): + await client.disconnect() + + +async def test_connect_after_disconnect_reinitializes() -> None: + """Verify that a client can reconnect after a disconnect.""" + client: _AsyncClient = _AsyncClient(config=_config()) + await client.disconnect() + assert client.services is None + await client.connect(config=_config(), use_secure_channel=True) + assert client.services is not None diff --git a/tests/test_async_base_services_interface.py b/tests/test_async_base_services_interface.py new file mode 100644 index 0000000..318bb58 --- /dev/null +++ b/tests/test_async_base_services_interface.py @@ -0,0 +1,175 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async unit tests for :class:`ondewo.utils.async_base_services_interface.AsyncBaseServicesInterface`.""" + +from typing import ( + Any, + Dict, +) +from unittest import mock + +import pytest + +from ondewo.utils import async_base_services_interface as absi +from ondewo.utils.async_base_services_interface import ( + MAX_MESSAGE_LENGTH, + AsyncBaseServicesInterface, + get_secure_channel, +) +from ondewo.utils.base_client_config import BaseClientConfig + + +class _ConcreteAsyncService(AsyncBaseServicesInterface): + """ + Minimal concrete :class:`AsyncBaseServicesInterface` used for testing. + + The abstract base class cannot be instantiated directly, so this subclass + provides a trivial :pyattr:`stub` implementation to exercise the asynchronous + channel construction performed in ``AsyncBaseServicesInterface.__init__``. + """ + + @property + def stub(self) -> Any: + """ + Return a placeholder stub identifying this concrete test service. + + Returns: + Any: + The constant sentinel string ``"the-async-stub"``. + """ + return "the-async-stub" + + +def _config(cert: Any = None) -> BaseClientConfig: + """ + Build a :class:`BaseClientConfig` pointing at a local test endpoint. + + Args: + cert (Any): + Optional gRPC certificate forwarded to ``grpc_cert``. Defaults to ``None``. + + Returns: + BaseClientConfig: + A config for ``localhost:50051`` carrying the supplied certificate. + """ + return BaseClientConfig(host="localhost", port="50051", grpc_cert=cert) + + +def test_max_message_length_is_int32_max() -> None: + """ + Verify that ``MAX_MESSAGE_LENGTH`` equals the signed 32-bit integer maximum. + + Returns: + None: + This test returns nothing; it asserts on the constant value. + """ + assert MAX_MESSAGE_LENGTH == 2 ** 31 - 1 + + +def test_keepalive_enabled_only_during_active_calls() -> None: + """ + Verify keepalive is configured to ping only while a call is active. + + Returns: + None: + This test returns nothing; it asserts on the default gRPC options. + """ + # Keepalive pings are on (long-lived streams stay warm, half-open sockets + # get detected) but only while a call is active, so idle channels never + # trigger a server "too_many_pings" GOAWAY. + options: Dict[str, Any] = absi._DEFAULT_GRPC_OPTIONS + assert options["grpc.keepalive_time_ms"] == 30000 + assert options["grpc.keepalive_permit_without_calls"] is False + assert options["grpc.http2.max_pings_without_data"] == 0 + + +async def test_insecure_channel_without_options() -> None: + """ + Verify an insecure channel is built when ``use_secure_channel`` is ``False``. + + Returns: + None: + This test returns nothing; it asserts the channel and stub are set. + """ + service: _ConcreteAsyncService = _ConcreteAsyncService(config=_config(), use_secure_channel=False) + assert service.grpc_channel is not None + assert service.stub == "the-async-stub" + await service.grpc_channel.close(grace=None) + + +async def test_insecure_channel_with_options_merges_defaults() -> None: + """ + Verify custom options are merged with the defaults on an insecure channel. + + Returns: + None: + This test returns nothing; it asserts the merged channel is built. + """ + service: _ConcreteAsyncService = _ConcreteAsyncService( + config=_config(), + use_secure_channel=False, + options={("grpc.max_send_message_length", 123)}, + ) + assert service.grpc_channel is not None + await service.grpc_channel.close(grace=None) + + +def test_get_secure_channel_builds_credentials() -> None: + """ + Verify ``get_secure_channel`` builds SSL credentials and a secure channel. + + Returns: + None: + This test returns nothing; it asserts on the mocked gRPC calls. + """ + with mock.patch.object(absi.grpc, "ssl_channel_credentials") as creds, mock.patch.object( + absi.grpc.aio, "secure_channel" + ) as secure_channel: + channel = get_secure_channel(host="localhost:50051", cert="cert-bytes", options=[]) + # a str cert is normalized to bytes before being handed to gRPC + creds.assert_called_once_with(root_certificates=b"cert-bytes") + secure_channel.assert_called_once() + assert channel is secure_channel.return_value + + +def test_secure_channel_via_init() -> None: + """ + Verify a secure channel is created during init when a certificate is present. + + Returns: + None: + This test returns nothing; it asserts the channel is the secure one. + """ + with mock.patch.object(absi.grpc, "ssl_channel_credentials"), mock.patch.object( + absi.grpc.aio, "secure_channel" + ) as secure_channel: + service: _ConcreteAsyncService = _ConcreteAsyncService(config=_config(cert="my-cert"), use_secure_channel=True) + assert service.grpc_channel is secure_channel.return_value + + +def test_secure_channel_missing_cert_raises() -> None: + """ + Verify init raises ``ValueError`` when a secure channel lacks a certificate. + + Returns: + None: + This test returns nothing; it asserts a ``ValueError`` is raised. + + Raises: + AssertionError: + If the expected ``ValueError`` is not raised. + """ + with pytest.raises(ValueError, match="No grpc certificate"): + _ConcreteAsyncService(config=_config(cert=None), use_secure_channel=True) diff --git a/tests/test_base_client.py b/tests/test_base_client.py new file mode 100644 index 0000000..cd9060f --- /dev/null +++ b/tests/test_base_client.py @@ -0,0 +1,178 @@ +# Copyright 2017-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for the ``BaseClient`` abstract base class. + +Exercises service initialization, connection, and disconnection behavior using +lightweight concrete subclasses backed by mock services. +""" + +from dataclasses import dataclass +from typing import ( + Any, + Optional, + Set, + Tuple, +) +from unittest import mock + +import pytest + +from ondewo.utils.base_client import BaseClient +from ondewo.utils.base_client_config import BaseClientConfig +from ondewo.utils.base_service_container import BaseServicesContainer + + +@dataclass +class _Services(BaseServicesContainer): + """ + Concrete services container used by the test clients. + + Attributes: + svc (Any): + A single mock service exposing a ``grpc_channel`` attribute. + """ + + svc: Any = None + + +def _make_service() -> Any: + """ + Create a mock service with a mock gRPC channel. + + Returns: + Any: + A mock service whose ``grpc_channel`` attribute is itself a mock, + allowing assertions on channel interactions such as ``close``. + """ + service: mock.MagicMock = mock.MagicMock() + service.grpc_channel = mock.MagicMock() + return service + + +class _Client(BaseClient): + """ + Concrete ``BaseClient`` subclass that populates ``services`` on initialization. + """ + + def _initialize_services( + self, + config: BaseClientConfig, + use_secure_channel: bool, + options: Optional[Set[Tuple[str, Any]]] = None, + ) -> None: + """ + Initialize ``services`` with a single mock service. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. + + Returns: + None + """ + self.services = _Services(svc=_make_service()) + + +class _EmptyClient(BaseClient): + """ + Concrete ``BaseClient`` subclass that never sets ``services``. + + Used to exercise the guard clause that raises when ``services`` remains undefined. + """ + + def _initialize_services( + self, + config: BaseClientConfig, + use_secure_channel: bool, + options: Optional[Set[Tuple[str, Any]]] = None, + ) -> None: + """ + Deliberately leave ``services`` unset to hit the guard clause. + + Args: + config (BaseClientConfig): + Configuration for the client. + use_secure_channel (bool): + Whether to use a secure gRPC channel. + options (Optional[Set[Tuple[str, Any]]]): + Additional options for the gRPC channel. + + Returns: + None + """ + # deliberately leaves ``self.services`` unset to hit the guard clause + return + + +@pytest.fixture +def config() -> BaseClientConfig: + """ + Provide a ``BaseClientConfig`` pointing at a local host and port. + + Returns: + BaseClientConfig: + Configuration targeting ``localhost:50051``. + """ + return BaseClientConfig(host="localhost", port="50051") + + +def test_init_sets_services(config: BaseClientConfig) -> None: + """Verify that constructing a client initializes its ``services`` attribute.""" + client: _Client = _Client(config=config) + assert client.services is not None + + +def test_init_without_services_raises(config: BaseClientConfig) -> None: + """Verify that a client leaving ``services`` unset raises ``ValueError``.""" + with pytest.raises(ValueError, match="must be defined"): + _EmptyClient(config=config) + + +def test_connect_when_already_connected_raises(config: BaseClientConfig) -> None: + """Verify that connecting while already connected raises ``ConnectionError``.""" + client: _Client = _Client(config=config) + with pytest.raises(ConnectionError, match="already has an open connection"): + client.connect(config=config, use_secure_channel=True) + + +def test_disconnect_closes_channels_and_clears(config: BaseClientConfig) -> None: + """Verify that disconnecting closes each gRPC channel and clears ``services``.""" + client: _Client = _Client(config=config) + service: Any = client.services.svc # type: ignore[union-attr] + client.disconnect() + service.grpc_channel.close.assert_called_once_with() + assert client.services is None + + +def test_disconnect_without_services_raises(config: BaseClientConfig) -> None: + """Verify that disconnecting without ``services`` raises ``AttributeError``.""" + client: _Client = _Client(config=config) + client.services = None + with pytest.raises(AttributeError, match="is not defined"): + client.disconnect() + + +def test_connect_after_disconnect_reinitializes(config: BaseClientConfig) -> None: + """Verify that connecting after a disconnect re-initializes ``services``.""" + client: _Client = _Client(config=config) + client.disconnect() + assert client.services is None + client.connect(config=config, use_secure_channel=True) + assert client.services is not None diff --git a/tests/test_base_client_config.py b/tests/test_base_client_config.py new file mode 100644 index 0000000..c904342 --- /dev/null +++ b/tests/test_base_client_config.py @@ -0,0 +1,87 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for :class:`ondewo.utils.base_client_config.BaseClientConfig`.""" + +from ondewo.utils.base_client_config import BaseClientConfig + + +class TestBaseClientConfig: + """ + Test suite verifying the behaviour of :class:`BaseClientConfig`. + + The tests cover host/port composition, gRPC certificate encoding, dataclass + immutability (``frozen=True``) and the ``dataclass_json`` serialization helpers. + """ + + def test_host_and_port(self) -> None: + """Verify that ``host_and_port`` joins host and port with a colon. + + Returns: + None: + This test returns nothing; it asserts on the composed value. + """ + config: BaseClientConfig = BaseClientConfig(host="localhost", port="50051") + assert config.host_and_port == "localhost:50051" + + def test_cert_none_stays_none(self) -> None: + """Verify that an unset ``grpc_cert`` remains ``None`` after init. + + Returns: + None: + This test returns nothing; it asserts on the certificate value. + """ + config: BaseClientConfig = BaseClientConfig(host="localhost", port="50051") + assert config.grpc_cert is None + + def test_cert_is_encoded_to_bytes(self) -> None: + """Verify that a provided ``grpc_cert`` string is encoded to ``bytes``. + + Returns: + None: + This test returns nothing; it asserts on the encoded certificate. + """ + config: BaseClientConfig = BaseClientConfig(host="localhost", port="50051", grpc_cert="my-cert") + assert config.grpc_cert == b"my-cert" + + def test_is_frozen(self) -> None: + """Verify that the frozen dataclass forbids attribute assignment. + + Returns: + None: + This test returns nothing; it asserts a ``FrozenInstanceError`` is raised. + + Raises: + AssertionError: + If assigning to an attribute does not raise ``FrozenInstanceError``. + """ + config: BaseClientConfig = BaseClientConfig(host="localhost", port="50051") + # dataclass(frozen=True) forbids attribute assignment + try: + config.host = "other" # type: ignore[misc] + except Exception as exc: # dataclasses raises FrozenInstanceError + assert exc.__class__.__name__ == "FrozenInstanceError" + else: # pragma: no cover + raise AssertionError("expected the config to be frozen") + + def test_dataclass_json_roundtrip(self) -> None: + """Verify that ``dataclass_json`` exposes a working ``to_dict`` helper. + + Returns: + None: + This test returns nothing; it asserts on the serialized ``host`` field. + """ + config: BaseClientConfig = BaseClientConfig(host="localhost", port="50051") + # dataclass_json adds to_json / from_dict helpers + assert config.to_dict()["host"] == "localhost" # type: ignore[attr-defined] diff --git a/tests/test_base_service_container.py b/tests/test_base_service_container.py new file mode 100644 index 0000000..4651bc4 --- /dev/null +++ b/tests/test_base_service_container.py @@ -0,0 +1,35 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for :class:`BaseServicesContainer`. + +Verify that ``BaseServicesContainer`` is a dataclass and can be instantiated. +""" + +import dataclasses + +from ondewo.utils.base_service_container import BaseServicesContainer + + +def test_is_dataclass_and_instantiable() -> None: + """ + Verify that ``BaseServicesContainer`` is a dataclass and can be instantiated. + + Returns: + None: + This test returns nothing; it asserts on the container instead. + """ + container: BaseServicesContainer = BaseServicesContainer() + assert dataclasses.is_dataclass(container) diff --git a/tests/test_base_services_interface.py b/tests/test_base_services_interface.py new file mode 100644 index 0000000..ce85248 --- /dev/null +++ b/tests/test_base_services_interface.py @@ -0,0 +1,141 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for :mod:`ondewo.utils.base_services_interface`. + +Covers secure and insecure gRPC channel construction, the shared default +channel options, and the keepalive configuration using mocked ``grpc`` +primitives. +""" + +from typing import ( + Any, + Dict, +) +from unittest import mock + +import grpc +import pytest + +from ondewo.utils import base_services_interface as bsi +from ondewo.utils.base_client_config import BaseClientConfig +from ondewo.utils.base_services_interface import ( + MAX_MESSAGE_LENGTH, + BaseServicesInterface, + get_secure_channel, +) + + +class _ConcreteService(BaseServicesInterface): + """ + Minimal concrete :class:`BaseServicesInterface` used to exercise the base class. + + The abstract ``stub`` property is implemented with a sentinel string so the + otherwise-abstract base class can be instantiated within the tests. + """ + + @property + def stub(self) -> Any: + """ + Return the service stub sentinel. + + Returns: + Any: + A placeholder stub value used by the tests. + """ + return "the-stub" + + +def _config(cert: Any = None) -> BaseClientConfig: + """ + Build a :class:`BaseClientConfig` pointing at a local test host. + + Args: + cert (Any): + Optional gRPC certificate passed through to the config. Defaults to + ``None`` for the insecure-channel tests. + + Returns: + BaseClientConfig: + Configuration targeting ``localhost:50051`` with the given certificate. + """ + return BaseClientConfig(host="localhost", port="50051", grpc_cert=cert) + + +def test_max_message_length_is_int32_max() -> None: + """Verify ``MAX_MESSAGE_LENGTH`` equals the signed 32-bit integer maximum.""" + assert MAX_MESSAGE_LENGTH == 2 ** 31 - 1 + + +def test_keepalive_enabled_only_during_active_calls() -> None: + """Verify keepalive pings are enabled but only while a call is active.""" + # Keepalive pings are on (long-lived streams stay warm, half-open sockets + # get detected) but only while a call is active, so idle channels never + # trigger a server "too_many_pings" GOAWAY. + options: Dict[str, Any] = bsi._DEFAULT_GRPC_OPTIONS + assert options["grpc.keepalive_time_ms"] == 30000 + assert options["grpc.keepalive_permit_without_calls"] is False + assert options["grpc.http2.max_pings_without_data"] == 0 + + +def test_insecure_channel_without_options() -> None: + """Verify an insecure channel is built and the stub is accessible without options.""" + service: _ConcreteService = _ConcreteService(config=_config(), use_secure_channel=False) + assert service.grpc_channel is not None + assert service.stub == "the-stub" + + +def test_insecure_channel_with_options_merges_defaults() -> None: + """Verify user-supplied options merge with the defaults for an insecure channel.""" + service: _ConcreteService = _ConcreteService( + config=_config(), + use_secure_channel=False, + options={("grpc.max_send_message_length", 123)}, + ) + assert service.grpc_channel is not None + + +def test_default_options_are_shared_and_not_reserialized() -> None: + """Verify the pre-materialized default options and service config are reused.""" + # The pre-materialized default options list is reused for the no-options path. + assert bsi._DEFAULT_GRPC_OPTIONS_ITEMS[0][0].startswith("grpc.") + assert isinstance(bsi._SERVICE_CONFIG_JSON, str) + + +def test_get_secure_channel_builds_credentials() -> None: + """Verify ``get_secure_channel`` builds SSL credentials and a secure channel.""" + with mock.patch.object(bsi.grpc, "ssl_channel_credentials") as creds, mock.patch.object( + bsi.grpc, "secure_channel" + ) as secure_channel: + channel: grpc.Channel = get_secure_channel(host="localhost:50051", cert="cert-bytes", options=[]) + # a str cert is normalized to bytes before being handed to gRPC + creds.assert_called_once_with(root_certificates=b"cert-bytes") + secure_channel.assert_called_once() + assert channel is secure_channel.return_value + + +def test_secure_channel_via_init() -> None: + """Verify :class:`BaseServicesInterface` builds a secure channel from a certificate.""" + with mock.patch.object(bsi.grpc, "ssl_channel_credentials"), mock.patch.object( + bsi.grpc, "secure_channel" + ) as secure_channel: + service: _ConcreteService = _ConcreteService(config=_config(cert="my-cert"), use_secure_channel=True) + assert service.grpc_channel is secure_channel.return_value + + +def test_secure_channel_missing_cert_raises() -> None: + """Verify a missing certificate raises ``ValueError`` for a secure channel.""" + with pytest.raises(ValueError, match="No grpc certificate"): + _ConcreteService(config=_config(cert=None), use_secure_channel=True) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..2182e41 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,98 @@ +# Copyright 2020-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the helper functions in ``ondewo.utils.helpers``. + +Covers ``get_struct_from_dict``, ``get_attr_recursive`` and ``set_attr_recursive``. +""" + +from types import SimpleNamespace + +import pytest +from google.protobuf.struct_pb2 import Struct + +from ondewo.utils.helpers import ( + get_attr_recursive, + get_struct_from_dict, + set_attr_recursive, +) + + +class TestGetStructFromDict: + """Test suite for :func:`ondewo.utils.helpers.get_struct_from_dict`.""" + + def test_returns_struct_with_values(self) -> None: + """Verify that a populated dict is converted into a matching protobuf Struct.""" + struct: Struct = get_struct_from_dict({"a": "b", "n": 1, "flag": True}) + assert isinstance(struct, Struct) + assert struct["a"] == "b" + assert struct["n"] == 1 + assert struct["flag"] is True + + def test_none_returns_empty_struct(self) -> None: + """Verify that ``None`` produces an empty protobuf Struct.""" + struct: Struct = get_struct_from_dict(None) # type: ignore[arg-type] + assert isinstance(struct, Struct) + assert len(struct.fields) == 0 + + def test_empty_dict_returns_empty_struct(self) -> None: + """Verify that an empty dict produces an empty protobuf Struct.""" + struct: Struct = get_struct_from_dict({}) + assert len(struct.fields) == 0 + + def test_non_dict_raises_assertion_error(self) -> None: + """Verify that a non-dict, non-None argument raises an ``AssertionError``.""" + with pytest.raises(AssertionError): + get_struct_from_dict("not-a-dict") # type: ignore[arg-type] + + +class TestGetAttrRecursive: + """Test suite for :func:`ondewo.utils.helpers.get_attr_recursive`.""" + + def test_nested_attribute(self) -> None: + """Verify that a dotted path resolves a deeply nested attribute value.""" + obj: SimpleNamespace = SimpleNamespace(a=SimpleNamespace(b=SimpleNamespace(c=42))) + assert get_attr_recursive(obj, "a.b.c") == 42 + + def test_single_level_attribute(self) -> None: + """Verify that a single attribute name resolves a top-level attribute value.""" + obj: SimpleNamespace = SimpleNamespace(x=7) + assert get_attr_recursive(obj, "x") == 7 + + def test_missing_attribute_with_default(self) -> None: + """Verify that a missing attribute returns the supplied default value.""" + obj: SimpleNamespace = SimpleNamespace(a=SimpleNamespace()) + assert get_attr_recursive(obj, "a.missing", "fallback") == "fallback" + + def test_missing_attribute_without_default_raises(self) -> None: + """Verify that a missing attribute without a default raises ``AttributeError``.""" + obj: SimpleNamespace = SimpleNamespace() + with pytest.raises(AttributeError): + get_attr_recursive(obj, "does_not_exist") + + +class TestSetAttrRecursive: + """Test suite for :func:`ondewo.utils.helpers.set_attr_recursive`.""" + + def test_nested_attribute(self) -> None: + """Verify that a dotted path sets a deeply nested attribute value.""" + obj: SimpleNamespace = SimpleNamespace(a=SimpleNamespace(b=SimpleNamespace(c=0))) + set_attr_recursive(obj, "a.b.c", 99) + assert obj.a.b.c == 99 + + def test_single_level_attribute(self) -> None: + """Verify that a single attribute name sets a top-level attribute value.""" + obj: SimpleNamespace = SimpleNamespace(x=0) + set_attr_recursive(obj, "x", "new") + assert obj.x == "new" diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..577cec7 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,47 @@ +# Copyright 2021-2026 ONDEWO GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Parametrized tests for :meth:`TextHelper.from_camel_to_snake_case`.""" + +import pytest + +from ondewo.utils.text import TextHelper + + +@pytest.mark.parametrize( + "text, expected", + [ + ("CamelCase", "camel_case"), + ("simpleTest", "simple_test"), + ("already_snake", "already_snake"), + ("HTTPResponseCode", "http_response_code"), + ("lowercase", "lowercase"), + ("", ""), + ], +) +def test_from_camel_to_snake_case(text: str, expected: str) -> None: + """ + Verify that camelCase and PascalCase strings convert to snake_case. + + Args: + text (str): + The input string to be converted to snake case. + expected (str): + The expected snake case result of the conversion. + + Returns: + None: + This function returns nothing; it asserts on the conversion result. + """ + assert TextHelper.from_camel_to_snake_case(text) == expected