diff --git a/.github/workflows/integration-package-tests.yaml b/.github/workflows/integration-package-tests.yaml index 639e0d9920df..28ba822277b3 100644 --- a/.github/workflows/integration-package-tests.yaml +++ b/.github/workflows/integration-package-tests.yaml @@ -90,6 +90,15 @@ jobs: if: matrix.package == 'prefect-docker' run : prefect dev build-image + - name: Start redis + if: matrix.package == 'prefect-redis' + run: > + docker run + --name "redis" + --detach + --publish 6379:6379 + redis:latest + - name: Run tests if: matrix.package != 'prefect-ray' env: diff --git a/src/integrations/prefect-redis/LICENSE b/src/integrations/prefect-redis/LICENSE new file mode 100644 index 000000000000..53c2097390ee --- /dev/null +++ b/src/integrations/prefect-redis/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Prefect Technologies, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/src/integrations/prefect-redis/MANIFEST.in b/src/integrations/prefect-redis/MANIFEST.in new file mode 100644 index 000000000000..0c0087308843 --- /dev/null +++ b/src/integrations/prefect-redis/MANIFEST.in @@ -0,0 +1,14 @@ +# Things to always exclude +global-exclude .git* +global-exclude .ipynb_checkpoints +global-exclude *.py[co] +global-exclude __pycache__/** + +# Top-level Config +include versioneer.py +include prefect_redis/_version.py +include LICENSE +include MANIFEST.in +include setup.cfg +include requirements.txt +include requirements-dev.txt diff --git a/src/integrations/prefect-redis/README.md b/src/integrations/prefect-redis/README.md new file mode 100644 index 000000000000..363a4b3a3ed5 --- /dev/null +++ b/src/integrations/prefect-redis/README.md @@ -0,0 +1,74 @@ +# prefect-redis + +

+ + PyPI + + +

+ +## Welcome! + +Prefect integrations for working with Redis + +## Getting Started + +### Python setup + +Requires an installation of Python 3.9+. + +We recommend using a Python virtual environment manager such as pipenv, conda or virtualenv. + +These tasks are designed to work with Prefect 2.0. For more information about how to use Prefect, please refer to the [Prefect documentation](https://docs.prefect.io/). + +### Installation + +Install `prefect-redis` with `pip`: + +```bash +pip install prefect-redis +``` + +Then, register to view the block on Prefect Cloud: + +```bash +prefect block register -m prefect_redis.credentials +``` + +Note, to use the `load` method on Blocks, you must already have a block document [saved through code](https://docs.prefect.io/concepts/blocks/#saving-blocks) or [saved through the UI](https://docs.prefect.io/ui/blocks/). + +### Write and run a flow + +```python +from prefect import flow +from prefect_redis import ( + RedisCredentials, + redis_set, + redis_get, +) + + +@flow +def example_flow(): + + # Load credentials-block + credentials = RedisCredentials.load("my-redis-store") + + # Set a redis-key - Supports any object that is not a live connection + redis_set(credentials, "mykey", {"foo": "bar"}) + + # Get a redis key + val = redis_get(credentials, "mykey") + + print(val) + +example_flow() +``` + +## Resources + +If you encounter any bugs while using `prefect-redis`, feel free to open an issue in the [prefect-redis](https://github.com/C4IROcean/prefect-redis) repository. + +If you have any questions or issues while using `prefect-redis`, you can find help in either the [Prefect Discourse forum](https://discourse.prefect.io/) or the [Prefect Slack community](https://prefect.io/slack). + + diff --git a/src/integrations/prefect-redis/prefect_redis/__init__.py b/src/integrations/prefect-redis/prefect_redis/__init__.py new file mode 100644 index 000000000000..16dc6817375b --- /dev/null +++ b/src/integrations/prefect-redis/prefect_redis/__init__.py @@ -0,0 +1,11 @@ +from .credentials import RedisCredentials +from .redis import ( + redis_set, + redis_get, + redis_set_binary, + redis_get_binary, + redis_execute, +) +from . import _version + +__version__ = _version.__version__ diff --git a/src/integrations/prefect-redis/prefect_redis/credentials.py b/src/integrations/prefect-redis/prefect_redis/credentials.py new file mode 100644 index 000000000000..80b7a7c3c8a7 --- /dev/null +++ b/src/integrations/prefect-redis/prefect_redis/credentials.py @@ -0,0 +1,158 @@ +"""Redis credentials handling""" + +from typing import Optional, Union + +import redis.asyncio as redis +from pydantic import Field +from pydantic.types import SecretStr + +from prefect.filesystems import WritableFileSystem + +DEFAULT_PORT = 6379 + + +class RedisCredentials(WritableFileSystem): + """ + Block used to manage authentication with Redis + + Attributes: + host (str): The value to store. + port (int): The value to store. + db (int): The value to store. + username (str): The value to store. + password (str): The value to store. + connection_string (str): The value to store. + + Example: + Create a new block from hostname, username and password: + ```python + from prefect_redis import RedisBlock + + block = RedisBlock.from_host( + host="myredishost.com", username="redis", password="SuperSecret") + block.save("BLOCK_NAME") + ``` + + Create a new block from a connection string + ```python + from prefect_redis import RedisBlock + block = RedisBlock.from_url(""redis://redis:SuperSecret@myredishost.com:6379") + block.save("BLOCK_NAME") + ``` + + Get Redis client in order to interact directly with Redis + ```python + from prefect_redis import RedisBlock + block = RedisBlock.load("BLOCK_NAME") + redis_client = block.get_client() + ``` + """ + + _logo_url = "https://stprododpcmscdnendpoint.azureedge.net/assets/icons/redis.png" + + host: Optional[str] = Field(default=None, description="Redis hostname") + port: int = Field(default=DEFAULT_PORT, description="Redis port") + db: int = Field(default=0, description="Redis DB index") + username: Optional[SecretStr] = Field(default=None, description="Redis username") + password: Optional[SecretStr] = Field(default=None, description="Redis password") + connection_string: Optional[SecretStr] = Field( + default=None, description="Redis connection string" + ) + + def block_initialization(self) -> None: + """Validate parameters""" + + if self.connection_string: + return + if not self.host: + raise ValueError("Missing hostname") + if self.username and not self.password: + raise ValueError("Missing password") + + async def read_path(self, path: str) -> bytes: + """Read a redis key + + Args: + path: Redis key to read from + + Returns: + Contents at key as bytes + """ + client = self.get_client() + ret = await client.get(path) + + await client.close() + return ret + + async def write_path(self, path: str, content: bytes) -> None: + """Write to a redis key + + Args: + path: Redis key to write to + content: Binary object to write + """ + client = self.get_client() + ret = await client.set(path, content) + + await client.close() + return ret + + def get_client(self) -> redis.Redis: + """Get Redis Client + + Returns: + An initialized Redis async client + """ + if self.connection_string: + return redis.Redis.from_url(self.connection_string.get_secret_value()) + return redis.Redis( + host=self.host, + port=self.port, + username=self.username.get_secret_value() if self.username else None, + password=self.password.get_secret_value() if self.password else None, + db=self.db, + ) + + @classmethod + def from_host( + cls, + host: str, + username: Union[None, str, SecretStr], + password: Union[None, str, SecretStr], + port: int = DEFAULT_PORT, + ) -> "RedisCredentials": + """Create block from hostname, username and password + + Args: + host: Redis hostname + username: Redis username + password: Redis password + port: Redis port + + Returns: + `RedisCredentials` instance + """ + return cls(host=host, username=username, password=password, port=port) + + @classmethod + def from_connection_string( + cls, connection_string: Union[str, SecretStr] + ) -> "RedisCredentials": + """Create block from a Redis connection string + + Supports the following URL schemes: + - `redis://` creates a TCP socket connection + - `rediss://` creates a SSL wrapped TCP socket connection + - `unix://` creates a Unix Domain Socket connection + + See [Redis docs](https://redis.readthedocs.io/en/stable/examples + /connection_examples.html#Connecting-to-Redis-instances-by-specifying-a-URL + -scheme.) for more info. + + Args: + connection_string: Redis connection string + + Returns: + `RedisCredentials` instance + """ + return cls(connection_string=connection_string) diff --git a/src/integrations/prefect-redis/prefect_redis/redis.py b/src/integrations/prefect-redis/prefect_redis/redis.py new file mode 100644 index 000000000000..9023dbe6c60d --- /dev/null +++ b/src/integrations/prefect-redis/prefect_redis/redis.py @@ -0,0 +1,132 @@ +"""Redis tasks""" +from typing import TYPE_CHECKING, Any, Optional + +import cloudpickle + +from prefect import task + +if TYPE_CHECKING: + from .credentials import RedisCredentials + + +@task +async def redis_set( + credentials: "RedisCredentials", + key: str, + value: Any, + ex: Optional[float] = None, + px: Optional[float] = None, + nx: bool = False, + xx: bool = False, +) -> None: + """Set a Redis key to a any value. Will use cloudpickle to convert `value` to + binary representation. + + Args: + credentials: Redis credential block + key: Key to be set + value: Value to be set to `key`. Does not accept open connections such as + database-connections + ex: If provided, sets an expire flag in seconds on `key` set + px: If provided, sets an expire flag in milliseconds on `key` set + nx: If set to `True`, set the value at `key` to `value` only if it does not + already exist + xx: If set tot `True`, set the value at `key` to `value` only if it already + exists + """ + return await redis_set_binary.fn( + credentials, key, cloudpickle.dumps(value), ex, px, nx, xx + ) + + +@task +async def redis_set_binary( + credentials: "RedisCredentials", + key: str, + value: bytes, + ex: Optional[float] = None, + px: Optional[float] = None, + nx: bool = False, + xx: bool = False, +) -> None: + """Set a Redis key to a binary value + + Args: + credentials: Redis credential block + key: Key to be set + value: Value to be set to `key`. Must be bytes + ex: If provided, sets an expire flag in seconds on `key` set + px: If provided, sets an expire flag in milliseconds on `key` set + nx: If set to `True`, set the value at `key` to `value` only if it does not + already exist + xx: If set tot `True`, set the value at `key` to `value` only if it already + exists + """ + client = credentials.get_client() + + await client.set(key, value, ex=ex, px=px, nx=nx, xx=xx) + await client.close() + + +@task +async def redis_get( + credentials: "RedisCredentials", + key: str, +) -> Any: + """Get an object stored at a redis key. Will use cloudpickle to reconstruct + the object. + + Args: + credentials: Redis credential block + key: Key to get + + Returns: + Fully reconstructed object, decoded brom bytes in redis + """ + binary_obj = await redis_get_binary.fn(credentials, key) + + return cloudpickle.loads(binary_obj) + + +@task +async def redis_get_binary( + credentials: "RedisCredentials", + key: str, +) -> bytes: + """Get an bytes stored at a redis key + + Args: + credentials: Redis credential block + key: Key to get + + Returns: + Bytes from `key` in Redis + """ + client = credentials.get_client() + + ret = await client.get(key) + + await client.close() + return ret + + +@task +async def redis_execute( + credentials: "RedisCredentials", + cmd: str, +) -> str: + """Execute Redis command + + Args: + credentials: Redis credential block + cmd: Command to be executed + + Returns: + Command response + """ + client = credentials.get_client() + + ret = await client.execute_command(cmd) + await client.close() + + return ret diff --git a/src/integrations/prefect-redis/pyproject.toml b/src/integrations/prefect-redis/pyproject.toml new file mode 100644 index 000000000000..b78c0b0c99cd --- /dev/null +++ b/src/integrations/prefect-redis/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "prefect-redis" +description = "Prefect integrations with Redis." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache License 2.0" } +keywords = ["prefect"] +authors = [{ name = "Prefect Technologies, Inc.", email = "help@prefect.io" }] +classifiers = [ + "Natural Language :: English", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] +dependencies = ["prefect>=3.0.0rc1", "redis>=5.0.1"] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "coverage", + "interrogate", + "mkdocs-gen-files", + "mkdocs-material", + "mkdocs", + "mkdocstrings[python]", + "mypy", + "pillow", + "pre-commit", + "pytest-asyncio", + "pytest", + "pytest-env", + "pytest-xdist", +] + +[project.urls] +Homepage = "https://github.com/PrefectHQ/prefect/tree/main/src/integrations/prefect-redis" + +[project.entry-points."prefect.collections"] +prefect_redis = "prefect_redis" + +[tool.setuptools_scm] +version_file = "prefect_redis/_version.py" +root = "../../.." +tag_regex = "^prefect-redis-(?P\\d+\\.\\d+\\.\\d+(?:[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)*)?)$" +fallback_version = "0.2.0" +git_describe_command = 'git describe --dirty --tags --long --match "prefect-redis-*[0-9]*"' + +[tool.interrogate] +ignore-init-module = true +ignore_init_method = true +exclude = ["prefect_redis/_version.py", "tests"] +fail-under = 95 +omit-covered-files = true + +[tool.coverage.run] +omit = ["tests/*", "prefect_redis/_version.py"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +env = [ + "PREFECT_TEST_MODE=1", +] diff --git a/src/integrations/prefect-redis/tests/test_tasks.py b/src/integrations/prefect-redis/tests/test_tasks.py new file mode 100644 index 000000000000..6ab02b32176e --- /dev/null +++ b/src/integrations/prefect-redis/tests/test_tasks.py @@ -0,0 +1,125 @@ +"""Test Redis tasks""" + +import os +import random +import string +from typing import Dict + +import pytest +from prefect_redis import ( + RedisCredentials, + redis_execute, + redis_get, + redis_get_binary, + redis_set, + redis_set_binary, +) + + +@pytest.fixture +def environ_credentials() -> Dict: + """Get redis credentials from environment + + Returns: + Redis credentials as a dict, can be piped directly into `RedisCredentials` + """ + return { + "host": os.environ.get("TEST_REDIS_HOST", "localhost"), + "port": int(os.environ.get("TEST_REDIS_PORT", 6379)), + "db": int(os.environ.get("TEST_REDIS_DB", 0)), + "username": os.environ.get("TEST_REDIS_USERNAME"), + "password": os.environ.get("TEST_REDIS_PASSWORD"), + } + + +@pytest.fixture +def redis_credentials(environ_credentials: Dict) -> RedisCredentials: + """Get `RedisCredentials` object from environment + + Returns: + `RedisCredentials` object + """ + return RedisCredentials(**environ_credentials) + + +@pytest.fixture +def random_key() -> str: + """Generate a random key + + Returns: + A random string of length 10 + """ + return "".join(random.sample(string.ascii_lowercase, 10)) + + +@pytest.mark.asyncio +async def test_from_credentials(redis_credentials: RedisCredentials): + """Test instantiating credentials""" + client = redis_credentials.get_client() + await client.ping() + + await client.close() + + +@pytest.mark.asyncio +async def test_from_connection_string(environ_credentials: Dict): + """Test instantiating from connection string""" + + connection_string = "redis://@{host}:{port}/{db}".format(**environ_credentials) + redis_credentials = RedisCredentials.from_connection_string(connection_string) + + client = redis_credentials.get_client() + await client.ping() + + await client.close() + + +@pytest.mark.asyncio +async def test_set_get_bytes(redis_credentials: RedisCredentials, random_key: str): + """Test writing and reading back a byte-string""" + + ref_string = b"hello world" + + await redis_set_binary.fn(redis_credentials, random_key, ref_string, ex=60) + test_value = await redis_get_binary.fn(redis_credentials, random_key) + + assert test_value == ref_string + + +async def test_set_get(redis_credentials: RedisCredentials, random_key: str): + """Test writing and reading back a string""" + + ref_string = "hello world" + + await redis_set.fn(redis_credentials, random_key, ref_string, ex=60) + test_value = await redis_get.fn(redis_credentials, random_key) + + assert test_value == ref_string + + +async def test_set_obj(redis_credentials: RedisCredentials, random_key: str): + """Test writing and reading back an object""" + + ref_obj = ("foobar", 123, {"hello": "world"}) + + await redis_set.fn(redis_credentials, random_key, ref_obj, ex=60) + test_value = await redis_get.fn(redis_credentials, random_key) + + assert type(ref_obj) == type(test_value) + assert len(ref_obj) == len(test_value) + + assert ref_obj[0] == test_value[0] + assert ref_obj[1] == test_value[1] + + ref_dct = ref_obj[2] + test_dct = test_value[2] + + for ref_key, test_key in zip(ref_dct, test_dct): + assert ref_key == test_key + assert ref_dct[ref_key] == test_dct[test_key] + + +async def test_execute(redis_credentials: RedisCredentials): + """Test executing a command""" + + await redis_execute.fn(redis_credentials, "ping")