From f46fa710549704d5d854b81c6cef1f15517d827f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 13:46:34 -0800 Subject: [PATCH 01/17] update: move file --- src/py/mat3ra/api_client/__init__.py | 34 +++-- src/py/mat3ra/api_client/client.py | 221 --------------------------- 2 files changed, 21 insertions(+), 234 deletions(-) delete mode 100644 src/py/mat3ra/api_client/client.py diff --git a/src/py/mat3ra/api_client/__init__.py b/src/py/mat3ra/api_client/__init__.py index 43e090a..62414ad 100644 --- a/src/py/mat3ra/api_client/__init__.py +++ b/src/py/mat3ra/api_client/__init__.py @@ -1,18 +1,26 @@ -# ruff: noqa: F401 try: from ._version import version as __version__ except ModuleNotFoundError: __version__ = None -from mat3ra.api_client.endpoints.bank_materials import BankMaterialEndpoints -from mat3ra.api_client.endpoints.bank_workflows import BankWorkflowEndpoints -from mat3ra.api_client.endpoints.jobs import JobEndpoints -from mat3ra.api_client.endpoints.login import LoginEndpoint -from mat3ra.api_client.endpoints.logout import LogoutEndpoint -from mat3ra.api_client.endpoints.materials import MaterialEndpoints -from mat3ra.api_client.endpoints.metaproperties import MetaPropertiesEndpoints -from mat3ra.api_client.endpoints.projects import ProjectEndpoints -from mat3ra.api_client.endpoints.properties import PropertiesEndpoints -from mat3ra.api_client.endpoints.workflows import WorkflowEndpoints - -from mat3ra.api_client.client import APIClient +from ..api_client.client import ( + ACCESS_TOKEN_ENV_VAR, + APIClient, + APIEnv, + Account, + AuthContext, + AuthEnv, + CLIENT_ID, + SCOPE, + build_oidc_base_url, +) +from ..api_client.endpoints.bank_materials import BankMaterialEndpoints +from ..api_client.endpoints.bank_workflows import BankWorkflowEndpoints +from ..api_client.endpoints.jobs import JobEndpoints +from ..api_client.endpoints.login import LoginEndpoint +from ..api_client.endpoints.logout import LogoutEndpoint +from ..api_client.endpoints.materials import MaterialEndpoints +from ..api_client.endpoints.metaproperties import MetaPropertiesEndpoints +from ..api_client.endpoints.projects import ProjectEndpoints +from ..api_client.endpoints.properties import PropertiesEndpoints +from ..api_client.endpoints.workflows import WorkflowEndpoints diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py deleted file mode 100644 index fd9867d..0000000 --- a/src/py/mat3ra/api_client/client.py +++ /dev/null @@ -1,221 +0,0 @@ -import os -from typing import Any, Optional, Tuple - -import requests -from pydantic import BaseModel, ConfigDict, Field - -from mat3ra.api_client.endpoints.bank_materials import BankMaterialEndpoints -from mat3ra.api_client.endpoints.bank_workflows import BankWorkflowEndpoints -from mat3ra.api_client.endpoints.jobs import JobEndpoints -from mat3ra.api_client.endpoints.materials import MaterialEndpoints -from mat3ra.api_client.endpoints.metaproperties import MetaPropertiesEndpoints -from mat3ra.api_client.endpoints.projects import ProjectEndpoints -from mat3ra.api_client.endpoints.properties import PropertiesEndpoints -from mat3ra.api_client.endpoints.workflows import WorkflowEndpoints - -# Default API Configuration -DEFAULT_API_HOST = "platform-new.mat3ra.com" -DEFAULT_API_PORT = 443 -DEFAULT_API_VERSION = "2018-10-01" -DEFAULT_API_SECURE = True - -# Environment Variable Names -ACCESS_TOKEN_ENV_VAR = "OIDC_ACCESS_TOKEN" -API_HOST_ENV_VAR = "API_HOST" -API_PORT_ENV_VAR = "API_PORT" -API_VERSION_ENV_VAR = "API_VERSION" -API_SECURE_ENV_VAR = "API_SECURE" -ACCOUNT_ID_ENV_VAR = "ACCOUNT_ID" -AUTH_TOKEN_ENV_VAR = "AUTH_TOKEN" - -# Default OIDC Configuration -CLIENT_ID = "cli-device-client" -SCOPE = "openid profile email" - -# API Paths -USERS_ME_PATH = "/api/v1/users/me" - - -class AuthContext(BaseModel): - access_token: Optional[str] = None - account_id: Optional[str] = None - auth_token: Optional[str] = None - - -class APIEnv(BaseModel): - host: str = Field(default=DEFAULT_API_HOST, validation_alias=API_HOST_ENV_VAR) - port: int = Field(default=DEFAULT_API_PORT, validation_alias=API_PORT_ENV_VAR) - version: str = Field(default=DEFAULT_API_VERSION, validation_alias=API_VERSION_ENV_VAR) - secure: bool = Field(default=DEFAULT_API_SECURE, validation_alias=API_SECURE_ENV_VAR) - - @classmethod - def from_env(cls) -> "APIEnv": - return cls.model_validate(os.environ) - - -class AuthEnv(BaseModel): - access_token: Optional[str] = Field(None, validation_alias=ACCESS_TOKEN_ENV_VAR) - account_id: Optional[str] = Field(None, validation_alias=ACCOUNT_ID_ENV_VAR) - auth_token: Optional[str] = Field(None, validation_alias=AUTH_TOKEN_ENV_VAR) - - @classmethod - def from_env(cls) -> "AuthEnv": - return cls.model_validate(os.environ) - - -class Account(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) - - client: Any = Field(exclude=True, repr=False) - id_cache: Optional[str] = None - - @property - def id(self) -> str: - if self.id_cache: - return self.id_cache - self.id_cache = self.client._resolve_account_id() - return self.id_cache - - -def _build_base_url(host: str, port: int, secure: bool, path: str) -> str: - protocol = "https" if secure else "http" - port_str = f":{port}" if port not in (80, 443) else "" - return f"{protocol}://{host}{port_str}{path}" - -# Used in API-examples utils -def build_oidc_base_url(host: str, port: int, secure: bool) -> str: - return _build_base_url(host, port, secure, "/oidc") - -class APIClient(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow", validate_assignment=True) - - host: str - port: int - version: str - secure: bool - auth: AuthContext - timeout_seconds: int = 60 - - def model_post_init(self, __context: Any) -> None: - self.my_account = Account(client=self) - self.account = self.my_account - self._init_endpoints(self.timeout_seconds) - - @classmethod - def env(cls) -> APIEnv: - return APIEnv.from_env() - - @classmethod - def auth_env(cls) -> AuthEnv: - return AuthEnv.from_env() - - def _init_endpoints(self, timeout_seconds: int) -> None: - kwargs = {"timeout": timeout_seconds, "auth": self.auth} - account_id = self.auth.account_id or "" - auth_token = self.auth.auth_token or "" - self._init_core_endpoints(kwargs, account_id, auth_token) - self._init_bank_endpoints(kwargs, account_id, auth_token) - - def _init_core_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: - self.materials = MaterialEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.workflows = WorkflowEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.jobs = JobEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.projects = ProjectEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.properties = PropertiesEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.metaproperties = MetaPropertiesEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - - def _init_bank_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: - self.bank_materials = BankMaterialEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.bank_workflows = BankWorkflowEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - - @staticmethod - def _resolve_config( - host: Optional[str], - port: Optional[int], - version: Optional[str], - secure: Optional[bool], - env: APIEnv, - ) -> Tuple[str, int, str, bool]: - return ( - host if host is not None else env.host, - port if port is not None else env.port, - version if version is not None else env.version, - secure if secure is not None else env.secure, - ) - - @classmethod - def _auth_from_env( - cls, - *, - access_token: Optional[str], - account_id: Optional[str], - auth_token: Optional[str], - ) -> AuthContext: - env = cls.auth_env() - return AuthContext( - access_token=access_token if access_token is not None else env.access_token, - account_id=account_id if account_id is not None else env.account_id, - auth_token=auth_token if auth_token is not None else env.auth_token, - ) - - @staticmethod - def _validate_auth(auth: AuthContext) -> None: - if auth.access_token: - return - if auth.account_id and auth.auth_token: - return - raise ValueError("Missing auth. Provide OIDC_ACCESS_TOKEN or ACCOUNT_ID and AUTH_TOKEN.") - - @classmethod - def authenticate( - cls, - *, - host: Optional[str] = None, - port: Optional[int] = None, - version: Optional[str] = None, - secure: Optional[bool] = None, - access_token: Optional[str] = None, - account_id: Optional[str] = None, - auth_token: Optional[str] = None, - timeout_seconds: int = 60, - ) -> "APIClient": - host_value, port_value, version_value, secure_value = cls._resolve_config(host, port, version, secure, - cls.env()) - auth = cls._auth_from_env(access_token=access_token, account_id=account_id, auth_token=auth_token) - cls._validate_auth(auth) - return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, - timeout_seconds=timeout_seconds) - - def _resolve_account_id(self) -> str: - account_id = self.auth.account_id or os.environ.get(ACCOUNT_ID_ENV_VAR) - if account_id: - self.auth.account_id = account_id - return account_id - - access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) - if not access_token: - raise ValueError("ACCOUNT_ID is not set and no OIDC access token is available.") - - url = _build_base_url(self.host, self.port, self.secure, USERS_ME_PATH) - response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) - response.raise_for_status() - account_id = response.json()["data"]["user"]["entity"]["defaultAccountId"] - os.environ[ACCOUNT_ID_ENV_VAR] = account_id - self.auth.account_id = account_id - return account_id From 3c4c320d177e70fcb803e56d4a09ba1120374a2e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 13:46:46 -0800 Subject: [PATCH 02/17] update: separate logically --- src/py/mat3ra/api_client/client/__init__.py | 4 + src/py/mat3ra/api_client/client/client.py | 132 +++++++++++++++++++ src/py/mat3ra/api_client/client/constants.py | 20 +++ src/py/mat3ra/api_client/client/models.py | 67 ++++++++++ 4 files changed, 223 insertions(+) create mode 100644 src/py/mat3ra/api_client/client/__init__.py create mode 100644 src/py/mat3ra/api_client/client/client.py create mode 100644 src/py/mat3ra/api_client/client/constants.py create mode 100644 src/py/mat3ra/api_client/client/models.py diff --git a/src/py/mat3ra/api_client/client/__init__.py b/src/py/mat3ra/api_client/client/__init__.py new file mode 100644 index 0000000..9a33020 --- /dev/null +++ b/src/py/mat3ra/api_client/client/__init__.py @@ -0,0 +1,4 @@ +from .client import APIClient +from .constants import ACCESS_TOKEN_ENV_VAR, CLIENT_ID, SCOPE, build_oidc_base_url +from .models import Account, APIEnv, AuthContext, AuthEnv + diff --git a/src/py/mat3ra/api_client/client/client.py b/src/py/mat3ra/api_client/client/client.py new file mode 100644 index 0000000..e2d3850 --- /dev/null +++ b/src/py/mat3ra/api_client/client/client.py @@ -0,0 +1,132 @@ +from typing import Any, Optional, Tuple + +from pydantic import BaseModel, ConfigDict + +from ..endpoints.bank_materials import BankMaterialEndpoints +from ..endpoints.bank_workflows import BankWorkflowEndpoints +from ..endpoints.jobs import JobEndpoints +from ..endpoints.materials import MaterialEndpoints +from ..endpoints.metaproperties import MetaPropertiesEndpoints +from ..endpoints.projects import ProjectEndpoints +from ..endpoints.properties import PropertiesEndpoints +from ..endpoints.workflows import WorkflowEndpoints + +from .models import Account, APIEnv, AuthContext, AuthEnv + + +class APIClient(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow", validate_assignment=True) + + host: str + port: int + version: str + secure: bool + auth: AuthContext + timeout_seconds: int = 60 + + def model_post_init(self, __context: Any) -> None: + self.my_account = Account(client=self) + self.account = self.my_account + self._init_endpoints(self.timeout_seconds) + + @classmethod + def env(cls) -> APIEnv: + return APIEnv.from_env() + + @classmethod + def auth_env(cls) -> AuthEnv: + return AuthEnv.from_env() + + def _init_endpoints(self, timeout_seconds: int) -> None: + kwargs = {"timeout": timeout_seconds, "auth": self.auth} + account_id = self.auth.account_id or "" + auth_token = self.auth.auth_token or "" + self._init_core_endpoints(kwargs, account_id, auth_token) + self._init_bank_endpoints(kwargs, account_id, auth_token) + + def _init_core_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: + self.materials = MaterialEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.workflows = WorkflowEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.jobs = JobEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.projects = ProjectEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.properties = PropertiesEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.metaproperties = MetaPropertiesEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + + def _init_bank_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: + self.bank_materials = BankMaterialEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + self.bank_workflows = BankWorkflowEndpoints( + self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs + ) + + @staticmethod + def _resolve_config( + host: Optional[str], + port: Optional[int], + version: Optional[str], + secure: Optional[bool], + env: APIEnv, + ) -> Tuple[str, int, str, bool]: + return ( + host if host is not None else env.host, + port if port is not None else env.port, + version if version is not None else env.version, + secure if secure is not None else env.secure, + ) + + @classmethod + def _auth_from_env( + cls, + *, + access_token: Optional[str], + account_id: Optional[str], + auth_token: Optional[str], + ) -> AuthContext: + env = cls.auth_env() + return AuthContext( + access_token=access_token if access_token is not None else env.access_token, + account_id=account_id if account_id is not None else env.account_id, + auth_token=auth_token if auth_token is not None else env.auth_token, + ) + + @staticmethod + def _validate_auth(auth: AuthContext) -> None: + if auth.access_token: + return + if auth.account_id and auth.auth_token: + return + raise ValueError("Missing auth. Provide OIDC_ACCESS_TOKEN or ACCOUNT_ID and AUTH_TOKEN.") + + @classmethod + def authenticate( + cls, + *, + host: Optional[str] = None, + port: Optional[int] = None, + version: Optional[str] = None, + secure: Optional[bool] = None, + access_token: Optional[str] = None, + account_id: Optional[str] = None, + auth_token: Optional[str] = None, + timeout_seconds: int = 60, + ) -> "APIClient": + host_value, port_value, version_value, secure_value = cls._resolve_config(host, port, version, secure, + cls.env()) + auth = cls._auth_from_env(access_token=access_token, account_id=account_id, auth_token=auth_token) + cls._validate_auth(auth) + return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, + timeout_seconds=timeout_seconds) + diff --git a/src/py/mat3ra/api_client/client/constants.py b/src/py/mat3ra/api_client/client/constants.py new file mode 100644 index 0000000..b22b5ff --- /dev/null +++ b/src/py/mat3ra/api_client/client/constants.py @@ -0,0 +1,20 @@ +# Environment Variable Names - Exported for external use (e.g., api-examples) +ACCESS_TOKEN_ENV_VAR = "OIDC_ACCESS_TOKEN" +ACCOUNT_ID_ENV_VAR = "ACCOUNT_ID" +AUTH_TOKEN_ENV_VAR = "AUTH_TOKEN" + +# OIDC Configuration - Exported for external use +CLIENT_ID = "cli-device-client" +SCOPE = "openid profile email" + + +def _build_base_url(host: str, port: int, secure: bool, path: str) -> str: + protocol = "https" if secure else "http" + port_str = f":{port}" if port not in (80, 443) else "" + return f"{protocol}://{host}{port_str}{path}" + + +def build_oidc_base_url(host: str, port: int, secure: bool) -> str: + """Used in api-examples utils.""" + return _build_base_url(host, port, secure, "/oidc") + diff --git a/src/py/mat3ra/api_client/client/models.py b/src/py/mat3ra/api_client/client/models.py new file mode 100644 index 0000000..290c6c9 --- /dev/null +++ b/src/py/mat3ra/api_client/client/models.py @@ -0,0 +1,67 @@ +import os +from typing import Any, Optional + +import requests +from pydantic import BaseModel, ConfigDict, Field + +from .constants import ACCESS_TOKEN_ENV_VAR, ACCOUNT_ID_ENV_VAR, AUTH_TOKEN_ENV_VAR, _build_base_url + + +class AuthContext(BaseModel): + access_token: Optional[str] = None + account_id: Optional[str] = None + auth_token: Optional[str] = None + + +class APIEnv(BaseModel): + host: str = Field(default="platform-new.mat3ra.com", validation_alias="API_HOST") + port: int = Field(default=443, validation_alias="API_PORT") + version: str = Field(default="2018-10-01", validation_alias="API_VERSION") + secure: bool = Field(default=True, validation_alias="API_SECURE") + + @classmethod + def from_env(cls) -> "APIEnv": + return cls.model_validate(os.environ) + + +class AuthEnv(BaseModel): + access_token: Optional[str] = Field(None, validation_alias=ACCESS_TOKEN_ENV_VAR) + account_id: Optional[str] = Field(None, validation_alias=ACCOUNT_ID_ENV_VAR) + auth_token: Optional[str] = Field(None, validation_alias=AUTH_TOKEN_ENV_VAR) + + @classmethod + def from_env(cls) -> "AuthEnv": + return cls.model_validate(os.environ) + + +class Account(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + + client: Any = Field(exclude=True, repr=False) + id_cache: Optional[str] = None + + @property + def id(self) -> str: + if self.id_cache: + return self.id_cache + self.id_cache = self._resolve_account_id() + return self.id_cache + + def _resolve_account_id(self) -> str: + account_id = self.client.auth.account_id or os.environ.get(ACCOUNT_ID_ENV_VAR) + if account_id: + self.client.auth.account_id = account_id + return account_id + + access_token = self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) + if not access_token: + raise ValueError("ACCOUNT_ID is not set and no OIDC access token is available.") + + url = _build_base_url(self.client.host, self.client.port, self.client.secure, "/api/v1/users/me") + response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) + response.raise_for_status() + account_id = response.json()["data"]["user"]["entity"]["defaultAccountId"] + os.environ[ACCOUNT_ID_ENV_VAR] = account_id + self.client.auth.account_id = account_id + return account_id + From ca0b470645e38a6f1f07319aa5a353f5bc64b13b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 15:45:06 -0800 Subject: [PATCH 03/17] chore: flatten the structure --- src/py/mat3ra/api_client/__init__.py | 34 +++++++------------ .../mat3ra/api_client/{client => }/client.py | 18 +++++----- src/py/mat3ra/api_client/client/__init__.py | 4 --- .../api_client/{client => }/constants.py | 0 .../mat3ra/api_client/{client => }/models.py | 0 5 files changed, 21 insertions(+), 35 deletions(-) rename src/py/mat3ra/api_client/{client => }/client.py (91%) delete mode 100644 src/py/mat3ra/api_client/client/__init__.py rename src/py/mat3ra/api_client/{client => }/constants.py (100%) rename src/py/mat3ra/api_client/{client => }/models.py (100%) diff --git a/src/py/mat3ra/api_client/__init__.py b/src/py/mat3ra/api_client/__init__.py index 62414ad..c469dad 100644 --- a/src/py/mat3ra/api_client/__init__.py +++ b/src/py/mat3ra/api_client/__init__.py @@ -3,24 +3,16 @@ except ModuleNotFoundError: __version__ = None -from ..api_client.client import ( - ACCESS_TOKEN_ENV_VAR, - APIClient, - APIEnv, - Account, - AuthContext, - AuthEnv, - CLIENT_ID, - SCOPE, - build_oidc_base_url, -) -from ..api_client.endpoints.bank_materials import BankMaterialEndpoints -from ..api_client.endpoints.bank_workflows import BankWorkflowEndpoints -from ..api_client.endpoints.jobs import JobEndpoints -from ..api_client.endpoints.login import LoginEndpoint -from ..api_client.endpoints.logout import LogoutEndpoint -from ..api_client.endpoints.materials import MaterialEndpoints -from ..api_client.endpoints.metaproperties import MetaPropertiesEndpoints -from ..api_client.endpoints.projects import ProjectEndpoints -from ..api_client.endpoints.properties import PropertiesEndpoints -from ..api_client.endpoints.workflows import WorkflowEndpoints +from .client import APIClient +from .constants import ACCESS_TOKEN_ENV_VAR, CLIENT_ID, SCOPE, build_oidc_base_url +from .models import Account, APIEnv, AuthContext, AuthEnv +from .endpoints.bank_materials import BankMaterialEndpoints +from .endpoints.bank_workflows import BankWorkflowEndpoints +from .endpoints.jobs import JobEndpoints +from .endpoints.login import LoginEndpoint +from .endpoints.logout import LogoutEndpoint +from .endpoints.materials import MaterialEndpoints +from .endpoints.metaproperties import MetaPropertiesEndpoints +from .endpoints.projects import ProjectEndpoints +from .endpoints.properties import PropertiesEndpoints +from .endpoints.workflows import WorkflowEndpoints diff --git a/src/py/mat3ra/api_client/client/client.py b/src/py/mat3ra/api_client/client.py similarity index 91% rename from src/py/mat3ra/api_client/client/client.py rename to src/py/mat3ra/api_client/client.py index e2d3850..b9c58c0 100644 --- a/src/py/mat3ra/api_client/client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -2,15 +2,14 @@ from pydantic import BaseModel, ConfigDict -from ..endpoints.bank_materials import BankMaterialEndpoints -from ..endpoints.bank_workflows import BankWorkflowEndpoints -from ..endpoints.jobs import JobEndpoints -from ..endpoints.materials import MaterialEndpoints -from ..endpoints.metaproperties import MetaPropertiesEndpoints -from ..endpoints.projects import ProjectEndpoints -from ..endpoints.properties import PropertiesEndpoints -from ..endpoints.workflows import WorkflowEndpoints - +from .endpoints.bank_materials import BankMaterialEndpoints +from .endpoints.bank_workflows import BankWorkflowEndpoints +from .endpoints.jobs import JobEndpoints +from .endpoints.materials import MaterialEndpoints +from .endpoints.metaproperties import MetaPropertiesEndpoints +from .endpoints.projects import ProjectEndpoints +from .endpoints.properties import PropertiesEndpoints +from .endpoints.workflows import WorkflowEndpoints from .models import Account, APIEnv, AuthContext, AuthEnv @@ -129,4 +128,3 @@ def authenticate( cls._validate_auth(auth) return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, timeout_seconds=timeout_seconds) - diff --git a/src/py/mat3ra/api_client/client/__init__.py b/src/py/mat3ra/api_client/client/__init__.py deleted file mode 100644 index 9a33020..0000000 --- a/src/py/mat3ra/api_client/client/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .client import APIClient -from .constants import ACCESS_TOKEN_ENV_VAR, CLIENT_ID, SCOPE, build_oidc_base_url -from .models import Account, APIEnv, AuthContext, AuthEnv - diff --git a/src/py/mat3ra/api_client/client/constants.py b/src/py/mat3ra/api_client/constants.py similarity index 100% rename from src/py/mat3ra/api_client/client/constants.py rename to src/py/mat3ra/api_client/constants.py diff --git a/src/py/mat3ra/api_client/client/models.py b/src/py/mat3ra/api_client/models.py similarity index 100% rename from src/py/mat3ra/api_client/client/models.py rename to src/py/mat3ra/api_client/models.py From 7fec2697649ff3bc28c97d7402366f975f86f2f5 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 15:50:21 -0800 Subject: [PATCH 04/17] chore: rename get -> build --- src/py/mat3ra/api_client/endpoints/jobs.py | 6 +++--- src/py/mat3ra/api_client/endpoints/properties.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/py/mat3ra/api_client/endpoints/jobs.py b/src/py/mat3ra/api_client/endpoints/jobs.py index d5b703c..e9e84dd 100644 --- a/src/py/mat3ra/api_client/endpoints/jobs.py +++ b/src/py/mat3ra/api_client/endpoints/jobs.py @@ -54,7 +54,7 @@ def terminate(self, id_): """ self.request("POST", "/".join((self.name, id_, "submit")), headers=self.headers) - def get_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, is_multi_material=False): + def build_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, is_multi_material=False): """ Returns a job config based on the given parameters. @@ -85,7 +85,7 @@ def get_config(self, material_ids, workflow_id, project_id, owner_id, name, comp config.update({"_material": {"_id": material_ids[0]}}) return config - def get_compute(self, cluster, ppn=1, nodes=1, queue="D", time_limit="01:00:00", notify="abe"): + def build_compute_config(self, cluster, ppn=1, nodes=1, queue="D", time_limit="01:00:00", notify="abe"): """ Returns job compute configuration. @@ -128,7 +128,7 @@ def create_by_ids(self, materials, workflow_id, project_id, prefix, owner_id=Non jobs = [] for material in materials: job_name = " ".join((prefix, material["formula"])) - job_config = self.get_config([material["_id"]], workflow_id, project_id, owner_id, job_name, compute) + job_config = self.build_config([material["_id"]], workflow_id, project_id, owner_id, job_name, compute) jobs.append(self.create(job_config)) return jobs diff --git a/src/py/mat3ra/api_client/endpoints/properties.py b/src/py/mat3ra/api_client/endpoints/properties.py index d6208cb..6eaade0 100644 --- a/src/py/mat3ra/api_client/endpoints/properties.py +++ b/src/py/mat3ra/api_client/endpoints/properties.py @@ -3,11 +3,11 @@ class BasePropertiesEndpoints(EntityEndpoint): - def get_property_selector(self, job_id, unit_flowchart_id, property_name): + def build_property_selector(self, job_id, unit_flowchart_id, property_name): return {"source.info.jobId": job_id, "source.info.unitId": unit_flowchart_id, "data.name": property_name} def get_property(self, job_id, unit_flowchart_id, property_name): - selector = self.get_property_selector(job_id, unit_flowchart_id, property_name) + selector = self.build_property_selector(job_id, unit_flowchart_id, property_name) return self.list(query=selector)[0] def get_band_gap_by_type(self, job_id, unit_flowchart_id, type): From 1468619fab3aa3876f5a503e56af0c48f24623b4 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:08:04 -0800 Subject: [PATCH 05/17] update: add my_org --- src/py/mat3ra/api_client/client.py | 52 +++++++++++++++++++++- src/py/mat3ra/api_client/endpoints/jobs.py | 3 +- src/py/mat3ra/api_client/models.py | 6 ++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index b9c58c0..90fcfa9 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -1,7 +1,10 @@ -from typing import Any, Optional, Tuple +import os +from typing import Any, List, Optional, Tuple +import requests from pydantic import BaseModel, ConfigDict +from .constants import ACCESS_TOKEN_ENV_VAR from .endpoints.bank_materials import BankMaterialEndpoints from .endpoints.bank_workflows import BankWorkflowEndpoints from .endpoints.jobs import JobEndpoints @@ -26,8 +29,15 @@ class APIClient(BaseModel): def model_post_init(self, __context: Any) -> None: self.my_account = Account(client=self) self.account = self.my_account + self._my_organization: Optional[Account] = None self._init_endpoints(self.timeout_seconds) + @property + def my_organization(self) -> Optional[Account]: + if self._my_organization is None: + self._my_organization = self.get_default_organization() + return self._my_organization + @classmethod def env(cls) -> APIEnv: return APIEnv.from_env() @@ -128,3 +138,43 @@ def authenticate( cls._validate_auth(auth) return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, timeout_seconds=timeout_seconds) + + def _fetch_user_accounts(self) -> List[dict]: + access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) + if not access_token: + raise ValueError("Access token is required to fetch accounts") + + protocol = "https" if self.secure else "http" + port_str = f":{self.port}" if self.port not in (80, 443) else "" + url = f"{protocol}://{self.host}{port_str}/api/v1/users/me" + + response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) + response.raise_for_status() + return response.json()["data"]["user"].get("accounts", []) + + def list_accounts(self) -> List[dict]: + accounts = self._fetch_user_accounts() + return [ + { + "id": acc["entity"]["_id"], + "name": acc["entity"].get("name", ""), + "type": acc["entity"].get("type", "user"), + "isDefault": acc.get("isDefault", False), + } + for acc in accounts + ] + + def get_default_organization(self) -> Optional[Account]: + accounts = self._fetch_user_accounts() + organizations = [acc for acc in accounts if acc["entity"].get("type") == "organization"] + + if not organizations: + return None + + # Try to find default organization first + for org in organizations: + if org.get("isDefault"): + return Account(client=self, account_entity_id=org["entity"]["_id"]) + + # If no default, return first organization + return Account(client=self, account_entity_id=organizations[0]["entity"]["_id"]) diff --git a/src/py/mat3ra/api_client/endpoints/jobs.py b/src/py/mat3ra/api_client/endpoints/jobs.py index e9e84dd..0001695 100644 --- a/src/py/mat3ra/api_client/endpoints/jobs.py +++ b/src/py/mat3ra/api_client/endpoints/jobs.py @@ -54,7 +54,8 @@ def terminate(self, id_): """ self.request("POST", "/".join((self.name, id_, "submit")), headers=self.headers) - def build_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, is_multi_material=False): + def build_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, + is_multi_material=False): """ Returns a job config based on the given parameters. diff --git a/src/py/mat3ra/api_client/models.py b/src/py/mat3ra/api_client/models.py index 290c6c9..30c2562 100644 --- a/src/py/mat3ra/api_client/models.py +++ b/src/py/mat3ra/api_client/models.py @@ -39,9 +39,12 @@ class Account(BaseModel): client: Any = Field(exclude=True, repr=False) id_cache: Optional[str] = None + account_entity_id: Optional[str] = None @property def id(self) -> str: + if self.account_entity_id: + return self.account_entity_id if self.id_cache: return self.id_cache self.id_cache = self._resolve_account_id() @@ -60,7 +63,8 @@ def _resolve_account_id(self) -> str: url = _build_base_url(self.client.host, self.client.port, self.client.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - account_id = response.json()["data"]["user"]["entity"]["defaultAccountId"] + user_data = response.json()["data"]["user"] + account_id = user_data["entity"]["defaultAccountId"] os.environ[ACCOUNT_ID_ENV_VAR] = account_id self.client.auth.account_id = account_id return account_id From 2ba0c3262f030e10e3000cb8e906e975a3edc9d6 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:09:20 -0800 Subject: [PATCH 06/17] update: cleanup --- src/py/mat3ra/api_client/client.py | 6 ++---- src/py/mat3ra/api_client/models.py | 6 +----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 90fcfa9..b42c737 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -171,10 +171,8 @@ def get_default_organization(self) -> Optional[Account]: if not organizations: return None - # Try to find default organization first for org in organizations: if org.get("isDefault"): - return Account(client=self, account_entity_id=org["entity"]["_id"]) + return Account(client=self, id_cache=org["entity"]["_id"]) - # If no default, return first organization - return Account(client=self, account_entity_id=organizations[0]["entity"]["_id"]) + return Account(client=self, id_cache=organizations[0]["entity"]["_id"]) diff --git a/src/py/mat3ra/api_client/models.py b/src/py/mat3ra/api_client/models.py index 30c2562..290c6c9 100644 --- a/src/py/mat3ra/api_client/models.py +++ b/src/py/mat3ra/api_client/models.py @@ -39,12 +39,9 @@ class Account(BaseModel): client: Any = Field(exclude=True, repr=False) id_cache: Optional[str] = None - account_entity_id: Optional[str] = None @property def id(self) -> str: - if self.account_entity_id: - return self.account_entity_id if self.id_cache: return self.id_cache self.id_cache = self._resolve_account_id() @@ -63,8 +60,7 @@ def _resolve_account_id(self) -> str: url = _build_base_url(self.client.host, self.client.port, self.client.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - user_data = response.json()["data"]["user"] - account_id = user_data["entity"]["defaultAccountId"] + account_id = response.json()["data"]["user"]["entity"]["defaultAccountId"] os.environ[ACCOUNT_ID_ENV_VAR] = account_id self.client.auth.account_id = account_id return account_id From 9affdf439a132d842181101769104c5d997ec251 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:19:01 -0800 Subject: [PATCH 07/17] update: add helper to get account by name/idx --- src/py/mat3ra/api_client/client.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index b42c737..0ba6f37 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -1,4 +1,5 @@ import os +import re from typing import Any, List, Optional, Tuple import requests @@ -164,15 +165,36 @@ def list_accounts(self) -> List[dict]: for acc in accounts ] + def get_account(self, name: Optional[str] = None, index: Optional[int] = None) -> Account: + """Get account by name (partial regex match) or index from the list of user accounts.""" + if name is None and index is None: + raise ValueError("Either 'name' or 'index' must be provided") + + accounts = self._fetch_user_accounts() + + if index is not None: + return Account(client=self, id_cache=accounts[index]["entity"]["_id"]) + + pattern = re.compile(name, re.IGNORECASE) + matches = [acc for acc in accounts if pattern.search(acc["entity"].get("name", ""))] + + if not matches: + raise ValueError(f"No account found matching '{name}'") + if len(matches) > 1: + names = [acc["entity"].get("name", "") for acc in matches] + raise ValueError(f"Multiple accounts match '{name}': {names}") + + return Account(client=self, id_cache=matches[0]["entity"]["_id"]) + def get_default_organization(self) -> Optional[Account]: accounts = self._fetch_user_accounts() organizations = [acc for acc in accounts if acc["entity"].get("type") == "organization"] - + if not organizations: return None - + for org in organizations: if org.get("isDefault"): return Account(client=self, id_cache=org["entity"]["_id"]) - + return Account(client=self, id_cache=organizations[0]["entity"]["_id"]) From e6a425b4021027945080d56a819b5ff7961713f4 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:22:47 -0800 Subject: [PATCH 08/17] update: add test for client --- tests/py/unit/test_client.py | 188 +++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index 59306ad..f3496cc 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -17,6 +17,28 @@ ME_ACCOUNT_ID = "my-account-id" USERS_ME_RESPONSE = {"data": {"user": {"entity": {"defaultAccountId": ME_ACCOUNT_ID}}}} +ACCOUNTS_RESPONSE = { + "data": { + "user": { + "entity": {"defaultAccountId": ME_ACCOUNT_ID}, + "accounts": [ + { + "entity": {"_id": "user-acc-1", "name": "John Doe", "type": "user"}, + "isDefault": True, + }, + { + "entity": {"_id": "org-acc-1", "name": "Acme Corp", "type": "organization"}, + "isDefault": True, + }, + { + "entity": {"_id": "org-acc-2", "name": "Beta Industries", "type": "organization"}, + "isDefault": False, + }, + ], + } + } +} + class APIClientUnitTest(EndpointBaseUnitTest): def _base_env(self): @@ -71,3 +93,169 @@ def test_my_account_id_fetches_and_caches(self, mock_get): self.assertEqual(mock_get.call_args[1]["headers"]["Authorization"], f"Bearer {OIDC_ACCESS_TOKEN}") self.assertEqual(mock_get.call_args[1]["timeout"], 30) self.assertEqual(os.environ.get("ACCOUNT_ID"), ME_ACCOUNT_ID) + + @mock.patch("requests.get") + def test_list_accounts(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + accounts = client.list_accounts() + + self.assertEqual(len(accounts), 3) + self.assertEqual(accounts[0]["id"], "user-acc-1") + self.assertEqual(accounts[0]["name"], "John Doe") + self.assertEqual(accounts[0]["type"], "user") + self.assertTrue(accounts[0]["isDefault"]) + self.assertEqual(accounts[1]["id"], "org-acc-1") + self.assertEqual(accounts[1]["name"], "Acme Corp") + self.assertEqual(accounts[1]["type"], "organization") + + @mock.patch("requests.get") + def test_get_account_by_index(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + account = client.get_account(index=1) + self.assertEqual(account.id_cache, "org-acc-1") + + @mock.patch("requests.get") + def test_get_account_by_name(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + account = client.get_account(name="Acme") + self.assertEqual(account.id_cache, "org-acc-1") + + @mock.patch("requests.get") + def test_get_account_by_name_regex(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + account = client.get_account(name="Beta.*") + self.assertEqual(account.id_cache, "org-acc-2") + + @mock.patch("requests.get") + def test_get_account_no_params_raises(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + client = APIClient.authenticate() + with self.assertRaises(ValueError) as ctx: + client.get_account() + self.assertIn("Either 'name' or 'index' must be provided", str(ctx.exception)) + + @mock.patch("requests.get") + def test_get_account_no_match_raises(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + with self.assertRaises(ValueError) as ctx: + client.get_account(name="NonExistent") + self.assertIn("No account found matching 'NonExistent'", str(ctx.exception)) + + @mock.patch("requests.get") + def test_get_account_multiple_matches_raises(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + with self.assertRaises(ValueError) as ctx: + client.get_account(name=".*") + self.assertIn("Multiple accounts match", str(ctx.exception)) + + @mock.patch("requests.get") + def test_my_organization_returns_default(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + mock_resp = mock.Mock() + mock_resp.json.return_value = ACCOUNTS_RESPONSE + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + org = client.my_organization + self.assertEqual(org.id_cache, "org-acc-1") + + @mock.patch("requests.get") + def test_my_organization_returns_first_if_no_default(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + response_no_default = { + "data": { + "user": { + "entity": {"defaultAccountId": ME_ACCOUNT_ID}, + "accounts": [ + { + "entity": {"_id": "org-acc-1", "name": "Org 1", "type": "organization"}, + "isDefault": False, + }, + { + "entity": {"_id": "org-acc-2", "name": "Org 2", "type": "organization"}, + "isDefault": False, + }, + ], + } + } + } + mock_resp = mock.Mock() + mock_resp.json.return_value = response_no_default + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + org = client.my_organization + self.assertEqual(org.id_cache, "org-acc-1") + + @mock.patch("requests.get") + def test_my_organization_returns_none_if_no_orgs(self, mock_get): + env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + with mock.patch.dict("os.environ", env, clear=True): + response_no_orgs = { + "data": { + "user": { + "entity": {"defaultAccountId": ME_ACCOUNT_ID}, + "accounts": [ + { + "entity": {"_id": "user-acc-1", "name": "User", "type": "user"}, + "isDefault": True, + }, + ], + } + } + } + mock_resp = mock.Mock() + mock_resp.json.return_value = response_no_orgs + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + client = APIClient.authenticate() + org = client.my_organization + self.assertIsNone(org) From c12ae6dedc81d3587d7dae3f147b99e5feb4a578 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:29:04 -0800 Subject: [PATCH 09/17] update: cleanup test --- tests/py/unit/test_client.py | 151 +++-------------------------------- 1 file changed, 11 insertions(+), 140 deletions(-) diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index f3496cc..e155002 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -49,9 +49,9 @@ def _base_env(self): "API_SECURE": API_SECURE_FALSE, } - def _mock_users_me(self, mock_get): + def _mock_users_me(self, mock_get, response=None): mock_resp = mock.Mock() - mock_resp.json.return_value = USERS_ME_RESPONSE + mock_resp.json.return_value = response or USERS_ME_RESPONSE mock_resp.raise_for_status.return_value = None mock_get.return_value = mock_resp @@ -98,11 +98,7 @@ def test_my_account_id_fetches_and_caches(self, mock_get): def test_list_accounts(self, mock_get): env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - + self._mock_users_me(mock_get, ACCOUNTS_RESPONSE) client = APIClient.authenticate() accounts = client.list_accounts() @@ -116,146 +112,21 @@ def test_list_accounts(self, mock_get): self.assertEqual(accounts[1]["type"], "organization") @mock.patch("requests.get") - def test_get_account_by_index(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - account = client.get_account(index=1) - self.assertEqual(account.id_cache, "org-acc-1") - - @mock.patch("requests.get") - def test_get_account_by_name(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - account = client.get_account(name="Acme") - self.assertEqual(account.id_cache, "org-acc-1") - - @mock.patch("requests.get") - def test_get_account_by_name_regex(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - account = client.get_account(name="Beta.*") - self.assertEqual(account.id_cache, "org-acc-2") - - @mock.patch("requests.get") - def test_get_account_no_params_raises(self, mock_get): + def test_get_account(self, mock_get): env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} with mock.patch.dict("os.environ", env, clear=True): + self._mock_users_me(mock_get, ACCOUNTS_RESPONSE) client = APIClient.authenticate() - with self.assertRaises(ValueError) as ctx: - client.get_account() - self.assertIn("Either 'name' or 'index' must be provided", str(ctx.exception)) + + self.assertEqual(client.get_account(index=1).id_cache, "org-acc-1") + self.assertEqual(client.get_account(name="Acme").id_cache, "org-acc-1") + self.assertEqual(client.get_account(name="Beta.*").id_cache, "org-acc-2") @mock.patch("requests.get") - def test_get_account_no_match_raises(self, mock_get): + def test_my_organization(self, mock_get): env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - with self.assertRaises(ValueError) as ctx: - client.get_account(name="NonExistent") - self.assertIn("No account found matching 'NonExistent'", str(ctx.exception)) - - @mock.patch("requests.get") - def test_get_account_multiple_matches_raises(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - with self.assertRaises(ValueError) as ctx: - client.get_account(name=".*") - self.assertIn("Multiple accounts match", str(ctx.exception)) - - @mock.patch("requests.get") - def test_my_organization_returns_default(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - mock_resp = mock.Mock() - mock_resp.json.return_value = ACCOUNTS_RESPONSE - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - + self._mock_users_me(mock_get, ACCOUNTS_RESPONSE) client = APIClient.authenticate() org = client.my_organization self.assertEqual(org.id_cache, "org-acc-1") - - @mock.patch("requests.get") - def test_my_organization_returns_first_if_no_default(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - response_no_default = { - "data": { - "user": { - "entity": {"defaultAccountId": ME_ACCOUNT_ID}, - "accounts": [ - { - "entity": {"_id": "org-acc-1", "name": "Org 1", "type": "organization"}, - "isDefault": False, - }, - { - "entity": {"_id": "org-acc-2", "name": "Org 2", "type": "organization"}, - "isDefault": False, - }, - ], - } - } - } - mock_resp = mock.Mock() - mock_resp.json.return_value = response_no_default - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - org = client.my_organization - self.assertEqual(org.id_cache, "org-acc-1") - - @mock.patch("requests.get") - def test_my_organization_returns_none_if_no_orgs(self, mock_get): - env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} - with mock.patch.dict("os.environ", env, clear=True): - response_no_orgs = { - "data": { - "user": { - "entity": {"defaultAccountId": ME_ACCOUNT_ID}, - "accounts": [ - { - "entity": {"_id": "user-acc-1", "name": "User", "type": "user"}, - "isDefault": True, - }, - ], - } - } - } - mock_resp = mock.Mock() - mock_resp.json.return_value = response_no_orgs - mock_resp.raise_for_status.return_value = None - mock_get.return_value = mock_resp - - client = APIClient.authenticate() - org = client.my_organization - self.assertIsNone(org) From e4990b3408692d75eae40832c541a99efdfe640e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:35:01 -0800 Subject: [PATCH 10/17] chore: lint fix --- src/py/mat3ra/api_client/endpoints/jobs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/mat3ra/api_client/endpoints/jobs.py b/src/py/mat3ra/api_client/endpoints/jobs.py index e9e84dd..0001695 100644 --- a/src/py/mat3ra/api_client/endpoints/jobs.py +++ b/src/py/mat3ra/api_client/endpoints/jobs.py @@ -54,7 +54,8 @@ def terminate(self, id_): """ self.request("POST", "/".join((self.name, id_, "submit")), headers=self.headers) - def build_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, is_multi_material=False): + def build_config(self, material_ids, workflow_id, project_id, owner_id, name, compute=None, + is_multi_material=False): """ Returns a job config based on the given parameters. From 0c0f00a9896d075d109bb4c906d3dc4fd79f2ff2 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 18:41:12 -0800 Subject: [PATCH 11/17] chore: cleanup url creation --- src/py/mat3ra/api_client/client.py | 16 ++++++++-------- src/py/mat3ra/api_client/models.py | 12 ++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 0ba6f37..dc8dc82 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -5,7 +5,7 @@ import requests from pydantic import BaseModel, ConfigDict -from .constants import ACCESS_TOKEN_ENV_VAR +from .constants import ACCESS_TOKEN_ENV_VAR, _build_base_url from .endpoints.bank_materials import BankMaterialEndpoints from .endpoints.bank_workflows import BankWorkflowEndpoints from .endpoints.jobs import JobEndpoints @@ -140,18 +140,18 @@ def authenticate( return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, timeout_seconds=timeout_seconds) - def _fetch_user_accounts(self) -> List[dict]: + def _fetch_user_data(self) -> dict: access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) if not access_token: - raise ValueError("Access token is required to fetch accounts") - - protocol = "https" if self.secure else "http" - port_str = f":{self.port}" if self.port not in (80, 443) else "" - url = f"{protocol}://{self.host}{port_str}/api/v1/users/me" + raise ValueError("Access token is required to fetch user data") + url = _build_base_url(self.host, self.port, self.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - return response.json()["data"]["user"].get("accounts", []) + return response.json()["data"]["user"] + + def _fetch_user_accounts(self) -> List[dict]: + return self._fetch_user_data().get("accounts", []) def list_accounts(self) -> List[dict]: accounts = self._fetch_user_accounts() diff --git a/src/py/mat3ra/api_client/models.py b/src/py/mat3ra/api_client/models.py index 290c6c9..a4c00d3 100644 --- a/src/py/mat3ra/api_client/models.py +++ b/src/py/mat3ra/api_client/models.py @@ -1,10 +1,9 @@ import os from typing import Any, Optional -import requests from pydantic import BaseModel, ConfigDict, Field -from .constants import ACCESS_TOKEN_ENV_VAR, ACCOUNT_ID_ENV_VAR, AUTH_TOKEN_ENV_VAR, _build_base_url +from .constants import ACCESS_TOKEN_ENV_VAR, ACCOUNT_ID_ENV_VAR, AUTH_TOKEN_ENV_VAR class AuthContext(BaseModel): @@ -53,14 +52,11 @@ def _resolve_account_id(self) -> str: self.client.auth.account_id = account_id return account_id - access_token = self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) - if not access_token: + if not (self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR)): raise ValueError("ACCOUNT_ID is not set and no OIDC access token is available.") - url = _build_base_url(self.client.host, self.client.port, self.client.secure, "/api/v1/users/me") - response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) - response.raise_for_status() - account_id = response.json()["data"]["user"]["entity"]["defaultAccountId"] + user_data = self.client._fetch_user_data() + account_id = user_data["entity"]["defaultAccountId"] os.environ[ACCOUNT_ID_ENV_VAR] = account_id self.client.auth.account_id = account_id return account_id From 9a320501c32b25d4169134ed2335cba8b757a1bc Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 21 Jan 2026 19:10:32 -0800 Subject: [PATCH 12/17] update: cleanup --- src/py/mat3ra/api_client/client.py | 66 +++++++++++------------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index dc8dc82..26e50c4 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -48,39 +48,17 @@ def auth_env(cls) -> AuthEnv: return AuthEnv.from_env() def _init_endpoints(self, timeout_seconds: int) -> None: - kwargs = {"timeout": timeout_seconds, "auth": self.auth} - account_id = self.auth.account_id or "" - auth_token = self.auth.auth_token or "" - self._init_core_endpoints(kwargs, account_id, auth_token) - self._init_bank_endpoints(kwargs, account_id, auth_token) - - def _init_core_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: - self.materials = MaterialEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.workflows = WorkflowEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.jobs = JobEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.projects = ProjectEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.properties = PropertiesEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.metaproperties = MetaPropertiesEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - - def _init_bank_endpoints(self, kwargs: dict, account_id: str, auth_token: str) -> None: - self.bank_materials = BankMaterialEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) - self.bank_workflows = BankWorkflowEndpoints( - self.host, self.port, account_id, auth_token, version=self.version, secure=self.secure, **kwargs - ) + base_args = (self.host, self.port, self.auth.account_id or "", self.auth.auth_token or "") + base_kwargs = {"version": self.version, "secure": self.secure, "timeout": timeout_seconds, "auth": self.auth} + + self.materials = MaterialEndpoints(*base_args, **base_kwargs) + self.workflows = WorkflowEndpoints(*base_args, **base_kwargs) + self.jobs = JobEndpoints(*base_args, **base_kwargs) + self.projects = ProjectEndpoints(*base_args, **base_kwargs) + self.properties = PropertiesEndpoints(*base_args, **base_kwargs) + self.metaproperties = MetaPropertiesEndpoints(*base_args, **base_kwargs) + self.bank_materials = BankMaterialEndpoints(*base_args, **base_kwargs) + self.bank_workflows = BankWorkflowEndpoints(*base_args, **base_kwargs) @staticmethod def _resolve_config( @@ -133,12 +111,19 @@ def authenticate( auth_token: Optional[str] = None, timeout_seconds: int = 60, ) -> "APIClient": - host_value, port_value, version_value, secure_value = cls._resolve_config(host, port, version, secure, - cls.env()) + host_value, port_value, version_value, secure_value = cls._resolve_config( + host, port, version, secure, cls.env() + ) auth = cls._auth_from_env(access_token=access_token, account_id=account_id, auth_token=auth_token) cls._validate_auth(auth) - return cls(host=host_value, port=port_value, version=version_value, secure=secure_value, auth=auth, - timeout_seconds=timeout_seconds) + return cls( + host=host_value, + port=port_value, + version=version_value, + secure=secure_value, + auth=auth, + timeout_seconds=timeout_seconds, + ) def _fetch_user_data(self) -> dict: access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) @@ -193,8 +178,5 @@ def get_default_organization(self) -> Optional[Account]: if not organizations: return None - for org in organizations: - if org.get("isDefault"): - return Account(client=self, id_cache=org["entity"]["_id"]) - - return Account(client=self, id_cache=organizations[0]["entity"]["_id"]) + default_org = next((org for org in organizations if org.get("isDefault")), organizations[0]) + return Account(client=self, id_cache=default_org["entity"]["_id"]) From ddb46fae50e9533e394cb17d313e752ab9dc9b6a Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 22 Jan 2026 20:04:10 -0800 Subject: [PATCH 13/17] update: fecth data correctly --- src/py/mat3ra/api_client/client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 26e50c4..fa2453a 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -50,7 +50,7 @@ def auth_env(cls) -> AuthEnv: def _init_endpoints(self, timeout_seconds: int) -> None: base_args = (self.host, self.port, self.auth.account_id or "", self.auth.auth_token or "") base_kwargs = {"version": self.version, "secure": self.secure, "timeout": timeout_seconds, "auth": self.auth} - + self.materials = MaterialEndpoints(*base_args, **base_kwargs) self.workflows = WorkflowEndpoints(*base_args, **base_kwargs) self.jobs = JobEndpoints(*base_args, **base_kwargs) @@ -125,7 +125,7 @@ def authenticate( timeout_seconds=timeout_seconds, ) - def _fetch_user_data(self) -> dict: + def _fetch_data(self) -> dict: access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) if not access_token: raise ValueError("Access token is required to fetch user data") @@ -133,10 +133,11 @@ def _fetch_user_data(self) -> dict: url = _build_base_url(self.host, self.port, self.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - return response.json()["data"]["user"] + + return response.json()["data"] def _fetch_user_accounts(self) -> List[dict]: - return self._fetch_user_data().get("accounts", []) + return self._fetch_data().get("accounts", []) def list_accounts(self) -> List[dict]: accounts = self._fetch_user_accounts() From 3e0e2c2dda9fc8a98eaf7179938691b5f9ab4c43 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 22 Jan 2026 20:06:14 -0800 Subject: [PATCH 14/17] update: adjustments --- src/py/mat3ra/api_client/client.py | 21 ++++++++++----------- tests/py/unit/test_client.py | 8 ++++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index fa2453a..3f6f8cd 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -125,7 +125,7 @@ def authenticate( timeout_seconds=timeout_seconds, ) - def _fetch_data(self) -> dict: + def _fetch_user_data(self) -> dict: access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) if not access_token: raise ValueError("Access token is required to fetch user data") @@ -133,22 +133,21 @@ def _fetch_data(self) -> dict: url = _build_base_url(self.host, self.port, self.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - - return response.json()["data"] + return response.json()["data"]["user"] def _fetch_user_accounts(self) -> List[dict]: - return self._fetch_data().get("accounts", []) + return self._fetch_user_data().get("accounts", []) def list_accounts(self) -> List[dict]: accounts = self._fetch_user_accounts() return [ { - "id": acc["entity"]["_id"], - "name": acc["entity"].get("name", ""), - "type": acc["entity"].get("type", "user"), - "isDefault": acc.get("isDefault", False), + "id": account["entity"]["_id"], + "name": account["entity"].get("name", ""), + "type": account["entity"].get("type", "personal"), + "isDefault": account.get("isDefault", False), } - for acc in accounts + for account in accounts ] def get_account(self, name: Optional[str] = None, index: Optional[int] = None) -> Account: @@ -162,7 +161,7 @@ def get_account(self, name: Optional[str] = None, index: Optional[int] = None) - return Account(client=self, id_cache=accounts[index]["entity"]["_id"]) pattern = re.compile(name, re.IGNORECASE) - matches = [acc for acc in accounts if pattern.search(acc["entity"].get("name", ""))] + matches = [account for account in accounts if pattern.search(account["entity"].get("name", ""))] if not matches: raise ValueError(f"No account found matching '{name}'") @@ -174,7 +173,7 @@ def get_account(self, name: Optional[str] = None, index: Optional[int] = None) - def get_default_organization(self) -> Optional[Account]: accounts = self._fetch_user_accounts() - organizations = [acc for acc in accounts if acc["entity"].get("type") == "organization"] + organizations = [account for account in accounts if account["entity"].get("type") in ("organization", "enterprise")] if not organizations: return None diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index e155002..c1bf7e1 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -23,11 +23,11 @@ "entity": {"defaultAccountId": ME_ACCOUNT_ID}, "accounts": [ { - "entity": {"_id": "user-acc-1", "name": "John Doe", "type": "user"}, + "entity": {"_id": "user-acc-1", "name": "John Doe", "type": "personal"}, "isDefault": True, }, { - "entity": {"_id": "org-acc-1", "name": "Acme Corp", "type": "organization"}, + "entity": {"_id": "org-acc-1", "name": "Acme Corp", "type": "enterprise"}, "isDefault": True, }, { @@ -105,11 +105,11 @@ def test_list_accounts(self, mock_get): self.assertEqual(len(accounts), 3) self.assertEqual(accounts[0]["id"], "user-acc-1") self.assertEqual(accounts[0]["name"], "John Doe") - self.assertEqual(accounts[0]["type"], "user") + self.assertEqual(accounts[0]["type"], "personal") self.assertTrue(accounts[0]["isDefault"]) self.assertEqual(accounts[1]["id"], "org-acc-1") self.assertEqual(accounts[1]["name"], "Acme Corp") - self.assertEqual(accounts[1]["type"], "organization") + self.assertEqual(accounts[1]["type"], "enterprise") @mock.patch("requests.get") def test_get_account(self, mock_get): From 6f18170829ba5f838c1713ceecf5107410fae372 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 22 Jan 2026 20:09:54 -0800 Subject: [PATCH 15/17] update: adjustments --- src/py/mat3ra/api_client/client.py | 6 +++--- src/py/mat3ra/api_client/models.py | 4 ++-- tests/py/unit/test_client.py | 32 +++++++++++++++--------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 3f6f8cd..3d11fbc 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -125,7 +125,7 @@ def authenticate( timeout_seconds=timeout_seconds, ) - def _fetch_user_data(self) -> dict: + def _fetch_data(self) -> dict: access_token = self.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR) if not access_token: raise ValueError("Access token is required to fetch user data") @@ -133,10 +133,10 @@ def _fetch_user_data(self) -> dict: url = _build_base_url(self.host, self.port, self.secure, "/api/v1/users/me") response = requests.get(url, headers={"Authorization": f"Bearer {access_token}"}, timeout=30) response.raise_for_status() - return response.json()["data"]["user"] + return response.json()["data"] def _fetch_user_accounts(self) -> List[dict]: - return self._fetch_user_data().get("accounts", []) + return self._fetch_data().get("accounts", []) def list_accounts(self) -> List[dict]: accounts = self._fetch_user_accounts() diff --git a/src/py/mat3ra/api_client/models.py b/src/py/mat3ra/api_client/models.py index a4c00d3..4d1cc5b 100644 --- a/src/py/mat3ra/api_client/models.py +++ b/src/py/mat3ra/api_client/models.py @@ -55,8 +55,8 @@ def _resolve_account_id(self) -> str: if not (self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR)): raise ValueError("ACCOUNT_ID is not set and no OIDC access token is available.") - user_data = self.client._fetch_user_data() - account_id = user_data["entity"]["defaultAccountId"] + data = self.client._fetch_data() + account_id = data["user"]["entity"]["defaultAccountId"] os.environ[ACCOUNT_ID_ENV_VAR] = account_id self.client.auth.account_id = account_id return account_id diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index c1bf7e1..e1ee43a 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -20,22 +20,22 @@ ACCOUNTS_RESPONSE = { "data": { "user": { - "entity": {"defaultAccountId": ME_ACCOUNT_ID}, - "accounts": [ - { - "entity": {"_id": "user-acc-1", "name": "John Doe", "type": "personal"}, - "isDefault": True, - }, - { - "entity": {"_id": "org-acc-1", "name": "Acme Corp", "type": "enterprise"}, - "isDefault": True, - }, - { - "entity": {"_id": "org-acc-2", "name": "Beta Industries", "type": "organization"}, - "isDefault": False, - }, - ], - } + "entity": {"defaultAccountId": ME_ACCOUNT_ID} + }, + "accounts": [ + { + "entity": {"_id": "user-acc-1", "name": "John Doe", "type": "personal"}, + "isDefault": True, + }, + { + "entity": {"_id": "org-acc-1", "name": "Acme Corp", "type": "enterprise"}, + "isDefault": True, + }, + { + "entity": {"_id": "org-acc-2", "name": "Beta Industries", "type": "organization"}, + "isDefault": False, + }, + ], } } From fc324626794ccf1c4472c77defd9f4db24636160 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 22 Jan 2026 21:02:14 -0800 Subject: [PATCH 16/17] update: add name property --- src/py/mat3ra/api_client/client.py | 6 ++--- src/py/mat3ra/api_client/models.py | 42 ++++++++++++++++++++++-------- tests/py/unit/test_client.py | 22 ++++++++++++---- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 3d11fbc..8193995 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -158,7 +158,7 @@ def get_account(self, name: Optional[str] = None, index: Optional[int] = None) - accounts = self._fetch_user_accounts() if index is not None: - return Account(client=self, id_cache=accounts[index]["entity"]["_id"]) + return Account(client=self, entity_cache=accounts[index]["entity"]) pattern = re.compile(name, re.IGNORECASE) matches = [account for account in accounts if pattern.search(account["entity"].get("name", ""))] @@ -169,7 +169,7 @@ def get_account(self, name: Optional[str] = None, index: Optional[int] = None) - names = [acc["entity"].get("name", "") for acc in matches] raise ValueError(f"Multiple accounts match '{name}': {names}") - return Account(client=self, id_cache=matches[0]["entity"]["_id"]) + return Account(client=self, entity_cache=matches[0]["entity"]) def get_default_organization(self) -> Optional[Account]: accounts = self._fetch_user_accounts() @@ -179,4 +179,4 @@ def get_default_organization(self) -> Optional[Account]: return None default_org = next((org for org in organizations if org.get("isDefault")), organizations[0]) - return Account(client=self, id_cache=default_org["entity"]["_id"]) + return Account(client=self, entity_cache=default_org["entity"]) diff --git a/src/py/mat3ra/api_client/models.py b/src/py/mat3ra/api_client/models.py index 4d1cc5b..e834ed8 100644 --- a/src/py/mat3ra/api_client/models.py +++ b/src/py/mat3ra/api_client/models.py @@ -37,27 +37,47 @@ class Account(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) client: Any = Field(exclude=True, repr=False) - id_cache: Optional[str] = None + entity_cache: Optional[dict] = None @property def id(self) -> str: - if self.id_cache: - return self.id_cache - self.id_cache = self._resolve_account_id() - return self.id_cache + if not self.entity_cache: + self._get_entity() + return self.entity_cache["_id"] - def _resolve_account_id(self) -> str: + @property + def name(self) -> str: + if not self.entity_cache: + self._get_entity() + return self.entity_cache.get("name", "") + + def _get_entity(self) -> None: + account_id, accounts = self._get_account_id_and_accounts() + self.entity_cache = self._find_account_entity(account_id, accounts) + + def _get_account_id_and_accounts(self) -> tuple[str, Optional[list]]: account_id = self.client.auth.account_id or os.environ.get(ACCOUNT_ID_ENV_VAR) + if account_id: - self.client.auth.account_id = account_id - return account_id - + return account_id, None + if not (self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR)): raise ValueError("ACCOUNT_ID is not set and no OIDC access token is available.") - + data = self.client._fetch_data() account_id = data["user"]["entity"]["defaultAccountId"] os.environ[ACCOUNT_ID_ENV_VAR] = account_id self.client.auth.account_id = account_id - return account_id + return account_id, data.get("accounts", []) + + def _find_account_entity(self, account_id: str, accounts: Optional[list]) -> dict: + if accounts is None and (self.client.auth.access_token or os.environ.get(ACCESS_TOKEN_ENV_VAR)): + accounts = self.client._fetch_user_accounts() + + if accounts: + for account in accounts: + if account["entity"]["_id"] == account_id: + return account["entity"] + + return {"_id": account_id} diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index e1ee43a..6be476b 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -83,8 +83,16 @@ def test_my_account_id_uses_existing_account_id(self, mock_get): @mock.patch("requests.get") def test_my_account_id_fetches_and_caches(self, mock_get): env = self._base_env() | {"OIDC_ACCESS_TOKEN": OIDC_ACCESS_TOKEN} + response_with_account = { + "data": { + "user": {"entity": {"defaultAccountId": ME_ACCOUNT_ID}}, + "accounts": [ + {"entity": {"_id": ME_ACCOUNT_ID, "name": "Test User", "type": "personal"}, "isDefault": True} + ], + } + } with mock.patch.dict("os.environ", env, clear=True): - self._mock_users_me(mock_get) + self._mock_users_me(mock_get, response_with_account) client = APIClient.authenticate() self.assertEqual(client.my_account.id, ME_ACCOUNT_ID) self.assertEqual(client.my_account.id, ME_ACCOUNT_ID) @@ -118,9 +126,12 @@ def test_get_account(self, mock_get): self._mock_users_me(mock_get, ACCOUNTS_RESPONSE) client = APIClient.authenticate() - self.assertEqual(client.get_account(index=1).id_cache, "org-acc-1") - self.assertEqual(client.get_account(name="Acme").id_cache, "org-acc-1") - self.assertEqual(client.get_account(name="Beta.*").id_cache, "org-acc-2") + account = client.get_account(index=1) + self.assertEqual(account.id, "org-acc-1") + self.assertEqual(account.name, "Acme Corp") + + self.assertEqual(client.get_account(name="Acme").id, "org-acc-1") + self.assertEqual(client.get_account(name="Beta.*").id, "org-acc-2") @mock.patch("requests.get") def test_my_organization(self, mock_get): @@ -129,4 +140,5 @@ def test_my_organization(self, mock_get): self._mock_users_me(mock_get, ACCOUNTS_RESPONSE) client = APIClient.authenticate() org = client.my_organization - self.assertEqual(org.id_cache, "org-acc-1") + self.assertEqual(org.id, "org-acc-1") + self.assertEqual(org.name, "Acme Corp") From b07cb35e2a0ca30b369255e0689401f402d51e13 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 22 Jan 2026 21:07:00 -0800 Subject: [PATCH 17/17] chore: lint fix --- src/py/mat3ra/api_client/client.py | 5 +++-- tests/py/unit/test_client.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/py/mat3ra/api_client/client.py b/src/py/mat3ra/api_client/client.py index 8193995..00914aa 100644 --- a/src/py/mat3ra/api_client/client.py +++ b/src/py/mat3ra/api_client/client.py @@ -142,7 +142,7 @@ def list_accounts(self) -> List[dict]: accounts = self._fetch_user_accounts() return [ { - "id": account["entity"]["_id"], + "_id": account["entity"]["_id"], "name": account["entity"].get("name", ""), "type": account["entity"].get("type", "personal"), "isDefault": account.get("isDefault", False), @@ -173,7 +173,8 @@ def get_account(self, name: Optional[str] = None, index: Optional[int] = None) - def get_default_organization(self) -> Optional[Account]: accounts = self._fetch_user_accounts() - organizations = [account for account in accounts if account["entity"].get("type") in ("organization", "enterprise")] + organizations = [account for account in accounts if + account["entity"].get("type") in ("organization", "enterprise")] if not organizations: return None diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index 6be476b..b19aa41 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -111,11 +111,11 @@ def test_list_accounts(self, mock_get): accounts = client.list_accounts() self.assertEqual(len(accounts), 3) - self.assertEqual(accounts[0]["id"], "user-acc-1") + self.assertEqual(accounts[0]["_id"], "user-acc-1") self.assertEqual(accounts[0]["name"], "John Doe") self.assertEqual(accounts[0]["type"], "personal") self.assertTrue(accounts[0]["isDefault"]) - self.assertEqual(accounts[1]["id"], "org-acc-1") + self.assertEqual(accounts[1]["_id"], "org-acc-1") self.assertEqual(accounts[1]["name"], "Acme Corp") self.assertEqual(accounts[1]["type"], "enterprise")