From 9e3ed3f4a7209d0b9a25cad250b4b9ef2831c9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Thu, 16 Apr 2026 08:49:10 +0200 Subject: [PATCH] [client-python] feat(pyoaev): add multi-tenancy (#205) --- pyoaev/client.py | 12 ++++++++++-- pyoaev/configuration/settings_loader.py | 6 ++++++ pyoaev/daemons/base_daemon.py | 6 ++++-- pyoaev/helpers.py | 1 + pyoaev/utils.py | 11 +++++++++-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/pyoaev/client.py b/pyoaev/client.py index a86a76b..450e99d 100644 --- a/pyoaev/client.py +++ b/pyoaev/client.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union from urllib import parse +from uuid import UUID import requests @@ -23,6 +24,7 @@ def __init__( pagination: Optional[str] = None, order_by: Optional[str] = None, ssl_verify: Union[bool, str] = True, + tenant_id: Optional[UUID] = None, **kwargs: Any, ) -> None: @@ -32,6 +34,7 @@ def __init__( raise ValueError("A TOKEN must be set") self.url = url + self.tenant_id = tenant_id self.timeout = timeout #: Headers that will be used in request to OpenAEV self.headers = { @@ -109,9 +112,14 @@ def _build_url(self, path: str) -> str: Returns: The full URL """ - if path.startswith("http://") or path.startswith("https://"): + if parse.urlparse(path).scheme in ("http", "https"): return path - return f"{self.url}/api{path}" + base_url = self.url.rstrip("/") + normalized_path = path.lstrip("/") + if self.tenant_id: + return f"{base_url}/api/tenants/{self.tenant_id}/{normalized_path}" + else: + return f"{base_url}/api/{normalized_path}" def _get_session_opts(self) -> Dict[str, Any]: return { diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 8d41e4f..8e4dcdc 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -3,6 +3,7 @@ from datetime import timedelta from pathlib import Path from typing import Annotated, Literal +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PlainSerializer from pydantic_settings import ( @@ -99,6 +100,11 @@ class ConfigLoaderOAEV(BaseConfigModel): token: str = Field( description="The token for the OpenAEV platform.", ) + tenant_id: UUID | None = Field( + default=None, + description="Identifier of the tenant within the OpenAEV platform. Used in multi-tenant environments to scope " + "API requests and ensure data isolation between different tenants.", + ) class ConfigLoaderCollector(BaseConfigModel): diff --git a/pyoaev/daemons/base_daemon.py b/pyoaev/daemons/base_daemon.py index 7dcc4e4..9728a87 100644 --- a/pyoaev/daemons/base_daemon.py +++ b/pyoaev/daemons/base_daemon.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from inspect import signature from types import FunctionType +from uuid import UUID from pyoaev.client import OpenAEV from pyoaev.configuration import Configuration @@ -37,6 +38,7 @@ def __init__( self.api = api_client or BaseDaemon.__get_default_api_client( url=self._configuration.get("openaev_url"), token=self._configuration.get("openaev_token"), + tenant_id=self._configuration.get("openaev_tenant_id"), ) # logging @@ -131,8 +133,8 @@ def get_id(self): ) @classmethod - def __get_default_api_client(cls, url, token): - return OpenAEV(url=url, token=token) + def __get_default_api_client(cls, url, token, tenant_id: UUID | None): + return OpenAEV(url=url, token=token, tenant_id=tenant_id) @classmethod def __get_default_logger(cls, log_level, name): diff --git a/pyoaev/helpers.py b/pyoaev/helpers.py index a422151..a85971e 100644 --- a/pyoaev/helpers.py +++ b/pyoaev/helpers.py @@ -322,6 +322,7 @@ def __init__(self, config: OpenAEVConfigHelper, icon) -> None: self.api = OpenAEV( url=config.get_conf("openaev_url"), token=config.get_conf("openaev_token"), + tenant_id=config.get_conf("openaev_tenant_id"), ) # Get the mq configuration from api self.config = { diff --git a/pyoaev/utils.py b/pyoaev/utils.py index c620152..5d922f2 100644 --- a/pyoaev/utils.py +++ b/pyoaev/utils.py @@ -186,6 +186,7 @@ def __init__(self, api, config, logger, ping_type) -> None: threading.Thread.__init__(self) self.ping_type = ping_type self.api = api + self.tenant_id = getattr(self.api, "tenant_id", None) self.config = config self.logger = logger self.in_error = False @@ -203,9 +204,15 @@ def ping(self) -> None: self.exit_event.wait(40) def run(self) -> None: - self.logger.info("Starting PingAlive thread") + self.logger.info( + "Starting PingAlive thread", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.ping() def stop(self) -> None: - self.logger.info("Preparing PingAlive for clean shutdown") + self.logger.info( + "Preparing PingAlive for clean shutdown", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.exit_event.set()