From 1abcae40507edd6415f983e17c92bd5c5b135006 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sun, 14 Sep 2025 15:45:19 +0200 Subject: [PATCH] added redis adapter --- README.md | 14 ++ pyproject.toml | 20 ++- python_repositories/__init__.py | 5 + python_repositories/adapters/__init__.py | 5 + python_repositories/adapters/redis_adapter.py | 149 ++++++++++++++++++ python_repositories/interfaces/__init__.py | 7 + .../interfaces/connection_aware_interface.py | 23 +++ .../interfaces/context_aware_interface.py | 24 +++ uv.lock | 45 +++++- 9 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 python_repositories/__init__.py create mode 100644 python_repositories/adapters/__init__.py create mode 100644 python_repositories/adapters/redis_adapter.py create mode 100644 python_repositories/interfaces/__init__.py create mode 100644 python_repositories/interfaces/connection_aware_interface.py create mode 100644 python_repositories/interfaces/context_aware_interface.py diff --git a/README.md b/README.md index f0afc24..03037ab 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ # python-repositories Various python repository interfaces exposed as a python package. + +## Optional dependencies + +This package supports interacting with multiple different backends: + +- Redis +- Mongo +- MinIO + +To add support for a specific backend install this package with one or more of these optional packages: + +```bash +uv add python-repositories[redis, mongo, minio] +``` diff --git a/pyproject.toml b/pyproject.toml index 2a96a5c..c03655c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,13 +13,29 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dependencies = [] +dependencies = [ + "python-utils>=0.1.0", + "structlog>=25.4.0", +] + +[project.optional-dependencies] +redis = [ + "redis>=6.4.0", +] + +[tool.uv.sources] +python-utils = { index = "gitea" } [[tool.uv.index]] -name = "private-cache" +name = "threadripper-proxpi-cache" url = "http://10.0.0.2:5001/index/" default = true +[[tool.uv.index]] +name = "gitea" +url = "https://gitea.gt-proj.com/api/packages/brian/pypi/simple/" +explicit = true + [tool.mypy] python_version = "3.10" warn_return_any = true # nudge to use stricter types diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py new file mode 100644 index 0000000..ac7e780 --- /dev/null +++ b/python_repositories/__init__.py @@ -0,0 +1,5 @@ +"""python_repositories: Unified repository interfaces and adapters.""" +from . import adapters +from . import interfaces + +__all__ = ['adapters', 'interfaces'] diff --git a/python_repositories/adapters/__init__.py b/python_repositories/adapters/__init__.py new file mode 100644 index 0000000..89240ca --- /dev/null +++ b/python_repositories/adapters/__init__.py @@ -0,0 +1,5 @@ +from .redis_adapter import RedisAdapter as RedisAdapter + +__all__ = [ + "RedisAdapter", +] diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py new file mode 100644 index 0000000..fb35d8c --- /dev/null +++ b/python_repositories/adapters/redis_adapter.py @@ -0,0 +1,149 @@ +"""Definition of RedisAdapter class.""" + +from __future__ import annotations + +import os +import structlog + +import redis +from redis.commands.json.path import Path as RedisPath + +from python_utils import check_env + +from python_repositories.interfaces import ( + ContextAwareInterface, + ConnectionAwareInterface, +) + + +class RedisAdapter( + ContextAwareInterface, + ConnectionAwareInterface, +): + """Redis adapter exposing basic CRUD functionality.""" + + uri_env_var_name: str = "REDIS_URI" + path: str = RedisPath.root_path() + encoding: str = "UTF-8" + + def __init__(self) -> None: + # Setup logger + self.logger = structlog.get_logger( + self.__class__.__name__, + ) + # Check environment variables + check_env(self.uri_env_var_name) + # Prepare internal variables + self._client: redis.Redis | None = None + + def __enter__(self) -> RedisAdapter: + """Enter the context.""" + self.connect() + return self + + def __exit__( + self, + exc_type: type | None, + exc_val: object | None, + exc_tb: object | None + ) -> None: + """Exit the context.""" + ctx_info = { + 'exc_type': exc_type, + 'exc_val': exc_val, + 'exc_tb': exc_tb + } + if any( + ( + exc_type is not None, + exc_val is not None, + exc_tb is not None, + ), + ): + self.logger.error("Error while exiting context", **ctx_info) + self.disconnect() + + def connect(self) -> None: + """Connect to the Redis server.""" + # Prepare arguments + uri = str(os.getenv(self.uri_env_var_name)) + # Connect client + client = redis.Redis.from_url( + url=uri, + socket_connect_timeout=10, + ) + if not client.ping(): + raise ConnectionError(f"Could not connect to Redis at {uri}") + # Persist client + self._client = client + + def disconnect(self) -> None: + """Disconnect from the Redis server.""" + # Close connection + if self._client is not None: + self._client.close() + # Reset client + self._client = None + + @property + def is_connected(self) -> bool: + """Check if connected to Redis server.""" + res = bool(isinstance(self._client, redis.Redis)) + self.logger.debug(res) + return res + + def _set(self, key: str, data: dict) -> None: + """Set a JSON object in Redis.""" + # Check input + if not isinstance(key, str) or len(key) == 0: + raise ValueError("Key must be a non-empty string") + if not isinstance(data, dict) or len(data) == 0: + raise ValueError("Data must be a non-empty dictionary") + # Check connection + if not self.is_connected: + raise ConnectionError("Not connected to Redis") + # Set data + self._client.json().set(key, self.path, data) + self.logger.debug(f"Set {key} to {data}") + + def _get(self, key: str) -> dict | None: + """Get a JSON object from Redis.""" + # Check input + if not isinstance(key, str) or len(key) == 0: + raise ValueError("Key must be a non-empty string") + # Check connection + if not self.is_connected: + raise ConnectionError("Not connected to Redis") + # Get data + data = self._client.json().get(key) + self.logger.debug(f"Got {data} from {key}") + return data + + def _delete(self, key: str) -> None: + """Delete data from Redis.""" + # Check input + if not isinstance(key, str) or len(key) == 0: + raise ValueError("Key must be a non-empty string") + # Check connection + if not self.is_connected: + raise ConnectionError("Not connected to Redis") + # Delete data + self._client.json().delete(key) + self.logger.debug(f"Deleted {key}") + + def _list_keys(self, pattern: str) -> list[str]: + """List keys in Redis matching a pattern.""" + # Check input + if not isinstance(pattern, str) or len(pattern) == 0: + raise ValueError("Pattern must be a non-empty string") + # Check connection + if not self.is_connected: + raise ConnectionError("Not connected to Redis") + # List keys + keys: list[str] = [ + key.decode(self.encoding) + for key + in self._client.keys(pattern) + ] + self.logger.debug(f"Got {keys} matching {pattern}") + return keys diff --git a/python_repositories/interfaces/__init__.py b/python_repositories/interfaces/__init__.py new file mode 100644 index 0000000..5819794 --- /dev/null +++ b/python_repositories/interfaces/__init__.py @@ -0,0 +1,7 @@ +from .connection_aware_interface import ConnectionAwareInterface as ConnectionAwareInterface +from .context_aware_interface import ContextAwareInterface as ContextAwareInterface + +__all__ = [ + "ConnectionAwareInterface", + "ContextAwareInterface", +] diff --git a/python_repositories/interfaces/connection_aware_interface.py b/python_repositories/interfaces/connection_aware_interface.py new file mode 100644 index 0000000..58ea48c --- /dev/null +++ b/python_repositories/interfaces/connection_aware_interface.py @@ -0,0 +1,23 @@ +"""Definition of ConnectionAwareInterface abstract base class.""" + +from abc import ABC, abstractmethod + + +class ConnectionAwareInterface(ABC): + """Interface that defines connection-related methods.""" + + @abstractmethod + def connect(self) -> None: + """Connect to resource.""" + raise NotImplementedError() + + @abstractmethod + def disconnect(self) -> None: + """Disconnect from resource.""" + raise NotImplementedError() + + @property + @abstractmethod + def is_connected(self) -> bool: + """Check if connected to resource.""" + raise NotImplementedError() diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py new file mode 100644 index 0000000..fa14fb6 --- /dev/null +++ b/python_repositories/interfaces/context_aware_interface.py @@ -0,0 +1,24 @@ +"""Definition of ConnectionAwareInterface abstract base class.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class ContextAwareInterface(ABC): + """Interface that defined context-related methods.""" + + @abstractmethod + def __enter__(self) -> ContextAwareInterface: + """Enter the context.""" + raise NotImplementedError() + + @abstractmethod + def __exit__( + self, + exc_type: type | None, + exc_val: object | None, + exc_tb: object | None + ) -> None: + """Exit the context.""" + raise NotImplementedError() diff --git a/uv.lock b/uv.lock index e195077..145d6bf 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] @@ -592,6 +593,15 @@ wheels = [ name = "python-repositories" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "python-utils" }, + { name = "structlog" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] [package.dev-dependencies] dev = [ @@ -604,6 +614,12 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "python-utils", specifier = ">=0.1.0", index = "https://gitea.gt-proj.com/api/packages/brian/pypi/simple/" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=6.4.0" }, + { name = "structlog", specifier = ">=25.4.0" }, +] +provides-extras = ["redis"] [package.metadata.requires-dev] dev = [ @@ -615,6 +631,15 @@ dev = [ { name = "safety", specifier = ">=3.6.0" }, ] +[[package]] +name = "python-utils" +version = "0.1.0" +source = { registry = "https://gitea.gt-proj.com/api/packages/brian/pypi/simple/" } +sdist = { url = "https://gitea.gt-proj.com/api/packages/brian/pypi/files/python-utils/0.1.0/python_utils-0.1.0.tar.gz", hash = "sha256:1c1c6a885a47d65bfc4542b4be98412f7444d832d1171b02d135e7eacb7989c6" } +wheels = [ + { url = "https://gitea.gt-proj.com/api/packages/brian/pypi/files/python-utils/0.1.0/python_utils-0.1.0-py3-none-any.whl", hash = "sha256:a2b7c55bb308a0ff39e1ef8fa28c71f32afc59f6d865cd0ce6da1a1a7a507d3d" }, +] + [[package]] name = "pyupgrade" version = "3.20.0" @@ -653,6 +678,15 @@ wheels = [ { url = "http://10.0.0.2:5001/index/pyyaml/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563" }, ] +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "http://10.0.0.2:5001/index/" } +sdist = { url = "http://10.0.0.2:5001/index/redis/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010" } +wheels = [ + { url = "http://10.0.0.2:5001/index/redis/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f" }, +] + [[package]] name = "regex" version = "2025.8.29" @@ -868,6 +902,15 @@ wheels = [ { url = "http://10.0.0.2:5001/index/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, ] +[[package]] +name = "structlog" +version = "25.4.0" +source = { registry = "http://10.0.0.2:5001/index/" } +sdist = { url = "http://10.0.0.2:5001/index/structlog/structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4" } +wheels = [ + { url = "http://10.0.0.2:5001/index/structlog/structlog-25.4.0-py3-none-any.whl", hash = "sha256:fe809ff5c27e557d14e613f45ca441aabda051d119ee5a0102aaba6ce40eed2c" }, +] + [[package]] name = "tenacity" version = "9.1.2"