├── test ├── __init__.py ├── ext │ └── FastAPI │ │ ├── test_ext_fastapi.py │ │ └── test_ext_fastapi_crudrouter.py ├── conftest.py ├── test_auto_save.py ├── test_abstract.py ├── test_nested_aio.py ├── test_union_typing.py ├── test_model.py └── test_pydantic_aioredis.py ├── pydantic_aioredis ├── ext │ ├── __init__.py │ └── FastAPI │ │ ├── __init__.py │ │ ├── model.py │ │ └── crudrouter.py ├── __init__.py ├── config.py ├── types.py ├── store.py ├── abstract.py ├── model.py └── utils.py ├── .gitattributes ├── .prettierignore ├── docs ├── requirements.txt ├── module.rst ├── Makefile ├── _templates │ ├── custom-class-template.rst │ └── custom-module-template.rst ├── index.rst ├── development.rst ├── extras.rst ├── conf.py ├── serialization.rst ├── automatic_saving.rst └── quickstart.rst ├── examples ├── benchmarks │ ├── requirements.txt │ ├── pytest.ini │ ├── Makefile │ ├── conftest.py │ ├── test_benchmarks.py │ └── README.md ├── fastapi │ ├── requirements.txt │ ├── Makefile │ ├── README.md │ └── fastapi_example.py ├── asyncio │ ├── Makefile │ ├── README.md │ └── asyncio_example.py └── serializer │ ├── Makefile │ ├── README.md │ └── custom_serializer.py ├── bandit.yml ├── .github ├── workflows │ ├── constraints.txt │ ├── release-please.yml │ ├── dependabot-auto-merge.yml │ ├── labeler.yml │ ├── issues.yml │ ├── release.yml │ ├── pre-release.yml │ └── tests.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release-drafter.yml └── labels.yml ├── codecov.yml ├── .gitignore ├── .flake8 ├── .readthedocs.yaml ├── Makefile ├── LICENSE ├── .pre-commit-config.yaml ├── .all-contributorsrc ├── pyproject.toml ├── CONTRIBUTING.rst ├── CODE_OF_CONDUCT.rst ├── noxfile.py ├── CHANGELOG.md └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydantic_aioredis/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/* 2 | CHANGELOG.md 3 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo==2024.5.6 2 | sphinx==7.3.7 3 | -------------------------------------------------------------------------------- /examples/benchmarks/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest-benchmark==3.4.1 2 | -------------------------------------------------------------------------------- /examples/fastapi/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.109.1 2 | uvicorn 3 | -------------------------------------------------------------------------------- /bandit.yml: -------------------------------------------------------------------------------- 1 | exclude_dirs: ["test", "examples"] 2 | assert_used: 3 | skips: ["*/test_*.py", "noxfile.py"] 4 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==24.0.0 2 | nox==2024.4.15 3 | nox-poetry==1.0.3 4 | poetry==1.8.3 5 | virtualenv==20.26.2 6 | toml==0.10.2 7 | -------------------------------------------------------------------------------- /examples/benchmarks/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --benchmark-min-rounds=5 --benchmark-disable-gc --benchmark-autosave --benchmark-save-data --benchmark-compare 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: "100" 7 | patch: 8 | default: 9 | target: "100" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | /.coverage 3 | /.coverage.* 4 | coverage.xml 5 | /.nox/ 6 | /.python-version 7 | /.pytype/ 8 | /dist/ 9 | /docs/_build/ 10 | /src/*.egg-info/ 11 | __pycache__/ 12 | examples/benchmarks/.benchmarks 13 | examples/benchmarks/.coverage 14 | .hypothesis 15 | docs/_autosummary/* 16 | -------------------------------------------------------------------------------- /examples/asyncio/Makefile: -------------------------------------------------------------------------------- 1 | start-redis: ## Runs a copy of redis in docker 2 | docker run -it -d --rm --name pydantic-aioredis-example -p 6379:6379 -e REDIS_PASSWORD=password bitnami/redis || echo "$(REDIS_CONTAINER_NAME) is either running or failed" 3 | 4 | stop-redis: ## Stops the redis in docker 5 | docker stop pydantic-aioredis-example 6 | -------------------------------------------------------------------------------- /examples/fastapi/Makefile: -------------------------------------------------------------------------------- 1 | start-redis: ## Runs a copy of redis in docker 2 | docker run -it -d --rm --name pydantic-aioredis-example -p 6379:6379 -e REDIS_PASSWORD=password bitnami/redis || echo "$(REDIS_CONTAINER_NAME) is either running or failed" 3 | 4 | stop-redis: ## Stops the redis in docker 5 | docker stop pydantic-aioredis-example 6 | -------------------------------------------------------------------------------- /examples/serializer/Makefile: -------------------------------------------------------------------------------- 1 | start-redis: ## Runs a copy of redis in docker 2 | docker run -it -d --rm --name pydantic-aioredis-example -p 6379:6379 -e REDIS_PASSWORD=password bitnami/redis || echo "$(REDIS_CONTAINER_NAME) is either running or failed" 3 | 4 | stop-redis: ## Stops the redis in docker 5 | docker stop pydantic-aioredis-example 6 | -------------------------------------------------------------------------------- /docs/module.rst: -------------------------------------------------------------------------------- 1 | ============================== 2 | Generated Module Documentation 3 | ============================== 4 | 5 | This documentation is automatically generated from python docstrings. 6 | 7 | .. autosummary:: 8 | :toctree: _autosummary 9 | :template: custom-module-template.rst 10 | :recursive: 11 | 12 | pydantic_aioredis 13 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | # Eventually would add D and DAR 4 | select = B,B9,C,E,F,N,RST,S,W 5 | ignore = E203,E501,RST201,RST203,RST301,W503,B902,N805,DAR402 6 | max-line-length = 119 7 | max-complexity = 10 8 | docstring-convention = google 9 | per-file-ignores = tests/*:S101 10 | rst-roles = class,const,func,meth,mod,ref 11 | rst-directives = deprecated 12 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release-please 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: google-github-actions/release-please-action@v4 11 | with: 12 | token: ${{ secrets.THIS_PAT }} 13 | release-type: python 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Set the version of Python and other tools you might need 4 | build: 5 | os: ubuntu-20.04 6 | tools: 7 | python: "3.11" 8 | 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | - path: . 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependabot 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 12 | with: 13 | target: minor 14 | github-token: ${{ secrets.THIS_PAT }} 15 | -------------------------------------------------------------------------------- /examples/benchmarks/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' 5 | 6 | 7 | run-benchmark: ## Run the benchmarking suite 8 | pytest test_benchmarks.py -n 0 9 | 10 | setup: ## Setup extra benchmarking requirements 11 | pip install -r requirements.txt 12 | -------------------------------------------------------------------------------- /pydantic_aioredis/__init__.py: -------------------------------------------------------------------------------- 1 | """Entry point for pydantic-aioredis""" 2 | 3 | # set by poetry-dynamic-versioning 4 | __version__ = "1.4.0" # noqa: E402 5 | 6 | from .config import RedisConfig # noqa: F401 7 | from .model import Model # noqa: F401 8 | from .model import AutoModel # noqa: F401 9 | from .store import Store # noqa: F401 10 | 11 | __all__ = ["RedisConfig", "Model", "AutoModel", "Store"] 12 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | labeler: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Run Labeler 16 | uses: crazy-max/ghaction-github-labeler@v5.0.0 17 | with: 18 | skip-delete: true 19 | -------------------------------------------------------------------------------- /examples/benchmarks/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | import redislite 3 | 4 | 5 | @pytest_asyncio.fixture() 6 | def redis_server(unused_tcp_port): 7 | """Sets up a fake redis server we can use for tests""" 8 | try: 9 | instance = redislite.Redis(serverconfig={"port": unused_tcp_port}) 10 | yield unused_tcp_port 11 | finally: 12 | instance.close() 13 | instance.shutdown() 14 | -------------------------------------------------------------------------------- /pydantic_aioredis/ext/FastAPI/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | # if we have crudrouter, the crudrouter will work 3 | import fastapi_crudrouter as _crudrouter # noqa: F401 4 | from .crudrouter import PydanticAioredisCRUDRouter # noqa: F401 5 | except ImportError: # pragma: no cover 6 | pass 7 | 8 | try: 9 | # if we have fastapi, fastapi model will work 10 | from fastapi import FastAPI as _ # noqa: F401 11 | from .model import FastAPIModel # noqa: F401 12 | except ImportError: # pragma: no cover 13 | pass 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' 5 | 6 | setup: ## Setup a dev environment for working in this repo. Assumes in a venv or other isolation 7 | pip install -r .github/workflows/constraints.txt 8 | poetry install 9 | 10 | build: setup ## build python packages 11 | pip install twine build 12 | python -m build --sdist --wheel --outdir dist/ 13 | twine check dist/* 14 | 15 | test: setup ## Run unit tests 16 | pytest 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | commit-message: 8 | prefix: ci 9 | - package-ecosystem: pip 10 | directory: "/.github/workflows" 11 | schedule: 12 | interval: daily 13 | commit-message: 14 | prefix: ci 15 | - package-ecosystem: pip 16 | directory: "/docs" 17 | schedule: 18 | interval: daily 19 | commit-message: 20 | prefix: docs 21 | - package-ecosystem: pip 22 | directory: "/" 23 | schedule: 24 | interval: daily 25 | commit-message: 26 | prefix: deps 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = pydantic_aioredis 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: Issues to Discord 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | - reopened 7 | - deleted 8 | - closed 9 | jobs: 10 | issue-to-discord: 11 | name: issue-to-discord 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Post to discord 15 | uses: Ilshidur/action-discord@0.3.2 16 | env: 17 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ISSUES }} 18 | ACTION: ${{ github.event.action }} 19 | REPO: ${{ github.repository }} 20 | ISSUE_URL: ${{ github.event.issue.html_url }} 21 | ISSUE_USER: ${{ github.event.issue.user.login }} 22 | with: 23 | args: "{{ REPO }} had an issue {{ ACTION }} by {{ ISSUE_USER }} at {{ ISSUE_URL }}." 24 | -------------------------------------------------------------------------------- /docs/_templates/custom-class-template.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :show-inheritance: 8 | :inherited-members: 9 | 10 | {% block methods %} 11 | .. automethod:: __init__ 12 | 13 | {% if methods %} 14 | .. rubric:: {{ _('Methods') }} 15 | 16 | .. autosummary:: 17 | {% for item in methods %} 18 | ~{{ name }}.{{ item }} 19 | {%- endfor %} 20 | {% endif %} 21 | {% endblock %} 22 | 23 | {% block attributes %} 24 | {% if attributes %} 25 | .. rubric:: {{ _('Attributes') }} 26 | 27 | .. autosummary:: 28 | {% for item in attributes %} 29 | ~{{ name }}.{{ item }} 30 | {%- endfor %} 31 | {% endif %} 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pydantic-aioredis 2 | ============================================= 3 | A declarative ORM for Redis, using redis-py in async mode. Use your Pydantic 4 | models like an ORM, storing data in Redis. 5 | 6 | Inspired by 7 | `pydantic-redis `_ by 8 | `Martin Ahindura `_ 9 | 10 | Dependencies 11 | ----------------- 12 | 13 | * `Python +3.7 `_ 14 | * `redis-py <4.2 `_ 15 | * `pydantic `_ 16 | 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | quickstart 22 | serialization 23 | automatic_saving 24 | extras 25 | development 26 | module 27 | 28 | Indices and tables 29 | ================== 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | -------------------------------------------------------------------------------- /pydantic_aioredis/config.py: -------------------------------------------------------------------------------- 1 | """Module containing the main config classes""" 2 | 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class RedisConfig(BaseModel): 9 | """A config object for connecting to redis""" 10 | 11 | host: str = "localhost" 12 | port: int = 6379 13 | db: int = 0 14 | password: Optional[str] = None 15 | ssl: bool = False 16 | encoding: Optional[str] = "utf-8" 17 | 18 | @property 19 | def redis_url(self) -> str: 20 | """Returns a redis url to connect to""" 21 | proto = "rediss" if self.ssl else "redis" 22 | if self.password is None: 23 | return f"{proto}://{self.host}:{self.port}/{self.db}" 24 | return f"{proto}://:{self.password}@{self.host}:{self.port}/{self.db}" 25 | 26 | class Config: 27 | """Pydantic schema config""" 28 | 29 | orm_mode = True 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to pypi 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/pydantic-aioredis 14 | permissions: 15 | id-token: write 16 | steps: 17 | - name: Check out the repository 18 | uses: actions/checkout@v4.1.4 19 | with: 20 | fetch-depth: 2 21 | - name: Set up Python 22 | uses: actions/setup-python@v5.1.0 23 | with: 24 | python-version: "3.11" 25 | - name: Install pip and poetry 26 | run: | 27 | pip install --upgrade --constraint .github/workflows/constraints.txt pip poetry 28 | pip --version 29 | poetry --version 30 | - name: Build package 31 | run: | 32 | poetry build 33 | - name: Publish package distributions to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /pydantic_aioredis/ext/FastAPI/model.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import List 3 | from typing import Optional 4 | 5 | from fastapi import HTTPException 6 | from pydantic_aioredis import Model 7 | 8 | 9 | class FastAPIModel(Model): 10 | """ 11 | Useful with fastapi. 12 | Offers extra class methods specific to using pydantic_aioredis 13 | with Fastapi 14 | """ 15 | 16 | @classmethod 17 | async def select_or_404( 18 | cls, 19 | columns: Optional[List[str]] = None, 20 | ids: Optional[List[Any]] = None, 21 | custom_exception: Optional[Exception] = None, 22 | ): 23 | """ 24 | Selects given rows or sets of rows in the table. 25 | Raises a HTTPException with a 404 if there is no result 26 | """ 27 | result = await cls.select(columns=columns, ids=ids) 28 | if result is None: 29 | raise ( 30 | HTTPException(status_code=404, detail=f"{cls.__name__} not found") 31 | if custom_exception is None 32 | else custom_exception 33 | ) 34 | return result 35 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release to pypi 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repository 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 2 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5.1.0 19 | with: 20 | python-version: "3.10" 21 | 22 | - name: Upgrade pip 23 | run: | 24 | pip install --constraint=.github/workflows/constraints.txt pip 25 | pip --version 26 | 27 | - name: Install Poetry 28 | run: | 29 | pip install --constraint=.github/workflows/constraints.txt poetry poetry-dynamic-versioning 30 | poetry --version 31 | 32 | - name: Build package 33 | run: | 34 | poetry build --ansi 35 | 36 | - name: Publish package on TestPyPI 37 | uses: pypa/gh-action-pypi-publish@v1.5.0 38 | with: 39 | user: __token__ 40 | password: ${{ secrets.TEST_PYPI_TOKEN }} 41 | repository_url: https://test.pypi.org/legacy/ 42 | -------------------------------------------------------------------------------- /pydantic_aioredis/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from ipaddress import IPv4Address 3 | from ipaddress import IPv4Network 4 | from ipaddress import IPv6Address 5 | from ipaddress import IPv6Network 6 | from uuid import UUID 7 | 8 | from pydantic.fields import SHAPE_DEFAULTDICT 9 | from pydantic.fields import SHAPE_DICT 10 | from pydantic.fields import SHAPE_FROZENSET 11 | from pydantic.fields import SHAPE_LIST 12 | from pydantic.fields import SHAPE_MAPPING 13 | from pydantic.fields import SHAPE_SEQUENCE 14 | from pydantic.fields import SHAPE_SET 15 | from pydantic.fields import SHAPE_TUPLE 16 | from pydantic.fields import SHAPE_TUPLE_ELLIPSIS 17 | 18 | # JSON_DUMP_SHAPES are object types that are serialized to JSON using json.dumps 19 | JSON_DUMP_SHAPES = ( 20 | SHAPE_LIST, 21 | SHAPE_SET, 22 | SHAPE_MAPPING, 23 | SHAPE_TUPLE, 24 | SHAPE_TUPLE_ELLIPSIS, 25 | SHAPE_SEQUENCE, 26 | SHAPE_FROZENSET, 27 | SHAPE_DICT, 28 | SHAPE_DEFAULTDICT, 29 | Enum, 30 | ) 31 | 32 | # STR_DUMP_SHAPES are object types that are serialized to strings using str(obj) 33 | # They are stored in redis as strings and rely on pydantic to deserialize them 34 | STR_DUMP_SHAPES = (IPv4Address, IPv4Network, IPv6Address, IPv6Network, UUID) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright for portions of project are held by 2020 Martin Ahindura from https://github.com/sopherapps/pydantic-redis 4 | pydantic-aioredis Copyright (c) 2021 Andrew Herrington https://github.com/andrewthetechie/pydantic-aioredis 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/ext/FastAPI/test_ext_fastapi.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest_asyncio 4 | from fastapi import FastAPI 5 | from httpx import AsyncClient 6 | from pydantic_aioredis.ext.FastAPI import FastAPIModel 7 | 8 | 9 | class Model(FastAPIModel): 10 | _primary_key_field = "name" 11 | name: str 12 | 13 | 14 | @pytest_asyncio.fixture() 15 | async def test_app(redis_store): 16 | redis_store.register_model(Model) 17 | 18 | app = FastAPI() 19 | 20 | @app.get("/", response_model=List[Model]) 21 | async def get_endpoint(): 22 | return await Model.select_or_404() 23 | 24 | yield redis_store, app 25 | await redis_store.redis_store.close() 26 | 27 | 28 | async def test_select_or_404_404(test_app): 29 | """Tests that select_or_404 will raise a 404 error on an empty return""" 30 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 31 | response = await client.get("/") 32 | 33 | assert response.status_code == 404 34 | 35 | 36 | async def test_select_or_404_200(test_app): 37 | """Tests that select_or_404 will return a model when that model exists""" 38 | await Model.insert(Model(name="test")) 39 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 40 | response = await client.get("/") 41 | 42 | assert response.status_code == 200 43 | assert len(response.json()) == 1 44 | assert response.json()[0]["name"] == "test" 45 | -------------------------------------------------------------------------------- /docs/_templates/custom-module-template.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. automodule:: {{ fullname }} 4 | 5 | {% block attributes %} 6 | {% if attributes %} 7 | .. rubric:: Module Attributes 8 | 9 | .. autosummary:: 10 | :toctree: 11 | {% for item in attributes %} 12 | {{ item }} 13 | {%- endfor %} 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block functions %} 18 | {% if functions %} 19 | .. rubric:: {{ _('Functions') }} 20 | 21 | .. autosummary:: 22 | :toctree: 23 | {% for item in functions %} 24 | {{ item }} 25 | {%- endfor %} 26 | {% endif %} 27 | {% endblock %} 28 | 29 | {% block classes %} 30 | {% if classes %} 31 | .. rubric:: {{ _('Classes') }} 32 | 33 | .. autosummary:: 34 | :toctree: 35 | :template: custom-class-template.rst 36 | {% for item in classes %} 37 | {{ item }} 38 | {%- endfor %} 39 | {% endif %} 40 | {% endblock %} 41 | 42 | {% block exceptions %} 43 | {% if exceptions %} 44 | .. rubric:: {{ _('Exceptions') }} 45 | 46 | .. autosummary:: 47 | :toctree: 48 | {% for item in exceptions %} 49 | {{ item }} 50 | {%- endfor %} 51 | {% endif %} 52 | {% endblock %} 53 | 54 | {% block modules %} 55 | {% if modules %} 56 | .. rubric:: Modules 57 | 58 | .. autosummary:: 59 | :toctree: 60 | :template: custom-module-template.rst 61 | :recursive: 62 | {% for item in modules %} 63 | {{ item }} 64 | {%- endfor %} 65 | {% endif %} 66 | {% endblock %} 67 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | import pytest 5 | import pytest_asyncio 6 | from fakeredis.aioredis import FakeRedis 7 | from hypothesis import settings 8 | from hypothesis import Verbosity 9 | from pydantic_aioredis.config import RedisConfig 10 | from pydantic_aioredis.store import Store 11 | 12 | 13 | @pytest_asyncio.fixture() 14 | async def redis_store(): 15 | """Sets up a redis store using the redis_server fixture and adds the book model to it""" 16 | store = Store( 17 | name="sample", 18 | redis_config=RedisConfig(port=1024, db=1), # nosec 19 | life_span_in_seconds=3600, 20 | ) 21 | store.redis_store = FakeRedis(decode_responses=True) 22 | yield store 23 | await store.redis_store.close() 24 | 25 | 26 | def pytest_configure(config): 27 | """Configure our markers""" 28 | config.addinivalue_line("markers", "union_test: Tests for union types") 29 | config.addinivalue_line("markers", "hypothesis: Tests that use hypothesis") 30 | 31 | 32 | @pytest.hookimpl(trylast=True) 33 | def pytest_collection_modifyitems(config, items): 34 | """Tags all async tests with the asyncio marker""" 35 | for item in items: 36 | if inspect.iscoroutinefunction(item.function): 37 | item.add_marker(pytest.mark.asyncio) 38 | 39 | 40 | settings.register_profile("ci", max_examples=5000) 41 | settings.register_profile("dev", max_examples=100) 42 | settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose) 43 | settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev")) 44 | -------------------------------------------------------------------------------- /examples/asyncio/README.md: -------------------------------------------------------------------------------- 1 | # asyncio_example 2 | 3 | This is a working example using python-aioredis with asyncio. 4 | 5 | # Requirements 6 | 7 | This example requires a running redis server. You can change the RedisConfig on line 28 in the example to match connecting to your running redis. 8 | 9 | For your ease of use, we've provided a Makefile in this directory that can start and stop a redis using docker. 10 | 11 | `make start-redis` 12 | 13 | `make stop-redis` 14 | 15 | The example is configured to connect to this dockerized redis automatically 16 | 17 | # Expected Output 18 | 19 | This is a working example. If you try to run it and find it broken, first check your local env. If you are unable to get the 20 | example running, please raise an Issue 21 | 22 | ```bash 23 | python asyncio_example.py 24 | [Book(title='Jane Eyre', author='Charles Dickens', published_on=datetime.date(1225, 6, 4), in_stock=False), Book(title='Great Expectations', author='Charles Dickens', published_on=datetime.date(1220, 4, 4), in_stock=True), Book(title='Wuthering Heights', author='Jane Austen', published_on=datetime.date(1600, 4, 4), in_stock=True), Book(title='Oliver Twist', author='Charles Dickens', published_on=datetime.date(1215, 4, 4), in_stock=False)] 25 | [Book(title='Oliver Twist', author='Charles Dickens', published_on=datetime.date(1215, 4, 4), in_stock=False), Book(title='Jane Eyre', author='Charles Dickens', published_on=datetime.date(1225, 6, 4), in_stock=False)] 26 | [{'author': 'Charles Dickens', 'in_stock': 'False'}, {'author': 'Charles Dickens', 'in_stock': 'True'}, {'author': 'Jane Austen', 'in_stock': 'True'}, {'author': 'Charles Dickens', 'in_stock': 'False'}] 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | The `Makefile <./makefile>`_ has useful targets to help setup your 5 | development encironment. We suggest using pyenv to have access to 6 | multiple python versions easily. 7 | 8 | Environment Setup 9 | ^^^^^^^^^^^^^^^^^ 10 | 11 | 12 | * 13 | Clone the repo and enter its root folder 14 | 15 | .. code-block:: 16 | 17 | git clone https://github.com/andrewthetechie/pydantic-aioredis.git && cd pydantic-aioredis 18 | 19 | * 20 | Create a python 3.10 virtual environment and activate it. We suggest 21 | using `pyenv `_ to easily setup 22 | multiple python environments on multiple versions. 23 | 24 | * 25 | Install the dependencies 26 | 27 | .. code-block:: 28 | 29 | make setup 30 | 31 | How to Run Tests 32 | ^^^^^^^^^^^^^^^^ 33 | 34 | 35 | * 36 | Run the test command to run tests on only python 3.9 37 | 38 | .. code-block:: 39 | 40 | pytest 41 | 42 | * 43 | Run the tox command to run all python version tests 44 | 45 | .. code-block:: 46 | 47 | tox 48 | 49 | Test Requirements 50 | ^^^^^^^^^^^^^^^^^ 51 | 52 | Prs should always have tests to cover the change being made. Code 53 | coverage goals for this project are 100% coverage. 54 | 55 | Code Linting 56 | ^^^^^^^^^^^^ 57 | 58 | All code should pass Flake8 and be blackened. If you install and setup 59 | pre-commit (done automatically by environment setup), pre-commit will 60 | lint your code for you. 61 | 62 | You can run the linting manually with make 63 | 64 | .. code-block:: 65 | 66 | make lint 67 | 68 | CI 69 | -- 70 | 71 | CI is run via Github Actions on all PRs and pushes to the main branch. 72 | 73 | Releases are automatically released by Github Actions to Pypi. 74 | -------------------------------------------------------------------------------- /pydantic_aioredis/store.py: -------------------------------------------------------------------------------- 1 | """Module containing the store classes""" 2 | 3 | from typing import Any 4 | from typing import Dict 5 | from typing import Optional 6 | 7 | from pydantic_aioredis.abstract import _AbstractStore 8 | from pydantic_aioredis.config import RedisConfig 9 | from pydantic_aioredis.model import Model 10 | from redis import asyncio as aioredis 11 | 12 | 13 | class Store(_AbstractStore): 14 | """ 15 | A store that allows a declarative way of querying for data in redis 16 | """ 17 | 18 | models: Dict[str, type(Model)] = {} 19 | 20 | def __init__( 21 | self, 22 | name: str, 23 | redis_config: RedisConfig, 24 | redis_store: Optional[aioredis.Redis] = None, 25 | life_span_in_seconds: Optional[int] = None, 26 | **data: Any, 27 | ): 28 | super().__init__( 29 | name=name, 30 | redis_config=redis_config, 31 | redis_store=redis_store, 32 | life_span_in_seconds=life_span_in_seconds, 33 | **data, 34 | ) 35 | self.redis_store = aioredis.from_url( 36 | self.redis_config.redis_url, 37 | encoding=self.redis_config.encoding, 38 | decode_responses=True, 39 | ) 40 | 41 | def register_model(self, model_class: type(Model)): 42 | """Registers the model to this store""" 43 | if not isinstance(model_class.get_primary_key_field(), str): 44 | raise NotImplementedError(f"{model_class.__name__} should have a _primary_key_field") 45 | 46 | model_class._store = self 47 | self.models[model_class.__name__.lower()] = model_class 48 | 49 | def model(self, name: str) -> Model: 50 | """Gets a model by name: case insensitive""" 51 | return self.models[name.lower()] 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".*tests\/fixtures.*" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.6.0 5 | hooks: 6 | - id: check-yaml 7 | - id: debug-statements 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | # Ruff version. 12 | rev: v0.4.3 13 | hooks: 14 | # Run the linter. 15 | - id: ruff 16 | args: [ --fix ] 17 | # Run the formatter. 18 | - id: ruff-format 19 | - repo: https://github.com/rhysd/actionlint 20 | rev: v1.6.27 21 | hooks: 22 | - id: actionlint-docker 23 | name: Actionlint 24 | - repo: local 25 | hooks: 26 | - id: bandit 27 | name: bandit 28 | entry: bandit 29 | language: system 30 | types: [python] 31 | require_serial: true 32 | args: ["-c", "pyproject.toml"] 33 | - id: check-added-large-files 34 | name: Check for added large files 35 | entry: check-added-large-files 36 | language: system 37 | - id: check-toml 38 | name: Check Toml 39 | entry: check-toml 40 | language: system 41 | types: [toml] 42 | - id: check-yaml 43 | name: Check Yaml 44 | entry: check-yaml 45 | language: system 46 | types: [yaml] 47 | - id: end-of-file-fixer 48 | name: Fix End of Files 49 | entry: end-of-file-fixer 50 | language: system 51 | types: [text] 52 | stages: [commit, push, manual] 53 | - id: pyupgrade 54 | name: pyupgrade 55 | description: Automatically upgrade syntax for newer versions. 56 | entry: pyupgrade 57 | language: system 58 | types: [python] 59 | args: [--py38-plus] 60 | - id: trailing-whitespace 61 | name: Trim Trailing Whitespace 62 | entry: trailing-whitespace-fixer 63 | language: system 64 | types: [text] 65 | stages: [commit, push, manual] 66 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /test/test_auto_save.py: -------------------------------------------------------------------------------- 1 | """Tests for the AutoModel""" 2 | 3 | from datetime import date 4 | 5 | import pytest_asyncio 6 | from fakeredis.aioredis import FakeRedis 7 | from pydantic_aioredis.config import RedisConfig 8 | from pydantic_aioredis.model import AutoModel 9 | from pydantic_aioredis.store import Store 10 | 11 | 12 | class Book(AutoModel): 13 | _primary_key_field: str = "title" 14 | title: str 15 | author: str 16 | published_on: date 17 | in_stock: bool = True 18 | 19 | 20 | @pytest_asyncio.fixture() 21 | async def redis_store(): 22 | """Sets up a redis store using the redis_server fixture and adds the book model to it""" 23 | store = Store( 24 | name="sample", 25 | redis_config=RedisConfig(port=1024, db=1), # nosec 26 | life_span_in_seconds=3600, 27 | ) 28 | store.redis_store = FakeRedis(decode_responses=True) 29 | store.register_model(Book) 30 | yield store 31 | await store.redis_store.flushall() 32 | 33 | 34 | async def test_auto_save(redis_store): 35 | """Tests that the auto save feature works""" 36 | book = Book( 37 | title="Oliver Twist", 38 | author="Charles Dickens", 39 | published_on=date(year=1215, month=4, day=4), 40 | in_stock=False, 41 | ) 42 | book_in_redis = await Book.select(ids=["Oliver Twist"]) 43 | assert book == book_in_redis[0] 44 | 45 | 46 | async def test_auto_sync(redis_store): 47 | """Tests that the auto sync feature works""" 48 | book = Book( 49 | title="Oliver Twist", 50 | author="Charles Dickens", 51 | published_on=date(year=1215, month=4, day=4), 52 | in_stock=False, 53 | ) 54 | key = f"book:{getattr(book, type(book)._primary_key_field)}" 55 | 56 | book_in_redis = await redis_store.redis_store.hgetall(name=key) 57 | book_deser = Book(**Book.deserialize_partially(book_in_redis)) 58 | assert book == book_deser 59 | 60 | book.in_stock = True 61 | book_in_redis = await redis_store.redis_store.hgetall(name=key) 62 | book_deser = Book(**Book.deserialize_partially(book_in_redis)) 63 | assert book_deser.in_stock 64 | -------------------------------------------------------------------------------- /docs/extras.rst: -------------------------------------------------------------------------------- 1 | Extras 2 | ====== 3 | pydantic-aioredis works well with other python modules in the pydantic ecosystem. There are some extras offered to make 4 | those integrations tighter 5 | 6 | FastAPI 7 | ------- 8 | The `FastAPI `_ extra adds a new base model called FastAPIModel. It has a single additional classmethod, select_or_404. 9 | 10 | Usage 11 | ^^^^^ 12 | .. code-block:: python 13 | 14 | from pydantic_aioredis.config import RedisConfig 15 | from pydantic_aioredis.store import Store 16 | from pydantic_aioredis.ext.FastAPI import FastAPIModel 17 | 18 | class Model(FastAPIModel): 19 | _primary_key_field = "name" 20 | name: str 21 | 22 | store = Store( 23 | name="sample", 24 | redis_config=RedisConfig() 25 | ) 26 | store.register_model(Model) 27 | app = FastAPI() 28 | 29 | @app.get("/", response_model=List[Model]) 30 | async def get_endpoint(): 31 | return await Model.select_or_404() 32 | 33 | Module 34 | ^^^^^^ 35 | .. automodule:: pydantic_aioredis.ext.FastAPI.model 36 | ::member:: 37 | 38 | FastAPI Crudrouter 39 | ------------------ 40 | `FastAPI Crud Router `_ extra adds a CRUD generator for use with FastAPI Crud Router. 41 | You can use your pydantic-aioredis models with fastapi-crudrouter to automatically generate crud routes. 42 | 43 | Usage 44 | ^^^^^ 45 | 46 | .. code-block:: python 47 | 48 | 49 | from pydantic_aioredis.config import RedisConfig 50 | from pydantic_aioredis.store import Store 51 | from pydantic_aioredis.ext.FastAPI import PydanticAioredisCRUDRouter 52 | from pydantic_aioredis import Model 53 | 54 | class Model(FastAPIModel): 55 | _primary_key_field = "name" 56 | name: str 57 | 58 | store = Store( 59 | name="sample", 60 | redis_config=RedisConfig() 61 | ) 62 | store.register_model(Model) 63 | app = FastAPI() 64 | 65 | router = PydanticAioredisCRUDRouter(schema=Model, store=store) 66 | app.include_router(router) 67 | 68 | Module 69 | ^^^^^^ 70 | .. automodule:: pydantic_aioredis.ext.FastAPI.crudrouter 71 | ::member:: 72 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | import os 12 | import sys 13 | 14 | sys.path.insert(0, os.path.abspath("..")) 15 | 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = "pydantic-aioredis" 20 | copyright = "2021, Andrew Herrington" 21 | author = "Andrew Herrington" 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [ 30 | "sphinx.ext.autodoc", 31 | "sphinx.ext.coverage", 32 | "sphinx.ext.napoleon", 33 | "sphinx.ext.autosummary", 34 | ] 35 | 36 | autosummary_generate = True 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = [] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "furo" 53 | 54 | # Add any paths that contain custom static files (such as style sheets) here, 55 | # relative to this directory. They are copied after the builtin static files, 56 | # so a file named "default.css" will overwrite the builtin "default.css". 57 | html_static_path = ["_static"] 58 | -------------------------------------------------------------------------------- /examples/fastapi/README.md: -------------------------------------------------------------------------------- 1 | # asyncio_example 2 | 3 | This is a working example using python-aioredis with FastAPI. 4 | 5 | # Requirements 6 | 7 | ## Fastapi and Uvicorn 8 | 9 | This example requires Fastapi and Uvicorn to run. You can install them from the requirements.txt in this directory 10 | 11 | ```bash 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## Redis Server 16 | 17 | This example requires a running redis server. You can change the RedisConfig on line 28 in the example to match connecting to your running redis. 18 | 19 | For your ease of use, we've provided a Makefile in this directory that can start and stop a redis using docker. 20 | 21 | `make start-redis` 22 | 23 | `make stop-redis` 24 | 25 | The example is configured to connect to this dockerized redis automatically 26 | 27 | # Expected Output 28 | 29 | This is a working example. If you try to run it and find it broken, first check your local env. If you are unable to get the 30 | example running, please raise an Issue 31 | 32 | ```bash 33 | python fastapi_example.py 34 | INFO: Started server process [122453] 35 | INFO: Waiting for application startup. 36 | INFO: Application startup complete. 37 | INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit) 38 | ``` 39 | 40 | In another terminal 41 | 42 | ```bash 43 | curl locahost:8080/books 44 | [{"title":"Jane Eyre","author":"Charles Dickens","published_on":"1225-06-04","in_stock":false},{"title":"Great Expectations","author":"Charles Dickens","published_on":"1220-04-04","in_stock":true},{"title":"Wuthering Heights","author":"Jane Austen","published_on":"1600-04-04","in_stock":true},{"title":"Oliver Twist","author":"Charles Dickens","published_on":"1215-04-04","in_stock":false}] 45 | 46 | curl localhost:8080/libraries 47 | [{"name":"Christian Library","address":"Buhimba, Hoima, Uganda"},{"name":"The Grand Library","address":"Kinogozi, Hoima, Uganda"}] 48 | 49 | curl localhost:8080/book/Jane%20Eyre 50 | [{"title":"Jane Eyre","author":"Charles Dickens","published_on":"1225-06-04","in_stock":false}] 51 | 52 | curl -v localhost:8080/book/Not%20a%20book 53 | < HTTP/1.1 404 Not Found 54 | < date: Sat, 07 Aug 2021 17:20:45 GMT 55 | < server: uvicorn 56 | < content-length: 27 57 | < content-type: application/json 58 | {"detail":"Book not found"} 59 | ``` 60 | -------------------------------------------------------------------------------- /examples/serializer/README.md: -------------------------------------------------------------------------------- 1 | # asyncio_example 2 | 3 | This is a working example using python-aioredis with asyncio and a custom serializer for a python object BookCover. 4 | 5 | Book.json_default is used to serialize the BookCover object to a dictionary that json.dumps can dump to a string and store in redis. 6 | Book.json_object_hook can convert a dict from redis back to a BookCover object. 7 | 8 | # Requirements 9 | 10 | This example requires a running redis server. You can change the RedisConfig on line 28 in the example to match connecting to your running redis. 11 | 12 | For your ease of use, we've provided a Makefile in this directory that can start and stop a redis using docker. 13 | 14 | `make start-redis` 15 | 16 | `make stop-redis` 17 | 18 | The example is configured to connect to this dockerized redis automatically 19 | 20 | # Expected Output 21 | 22 | This is a working example. If you try to run it and find it broken, first check your local env. If you are unable to get the 23 | example running, please raise an Issue 24 | 25 | ```bash 26 | python custom_serializer.py 27 | [Book(title='Great Expectations', author='Charles Dickens', published_on=datetime.date(1220, 4, 4), cover=<__main__.BookCover object at 0x10410c4c0>), Book(title='Jane Eyre', author='Charlotte Bronte', published_on=datetime.date(1225, 6, 4), cover=<__main__.BookCover object at 0x10410d4e0>), Book(title='Moby Dick', author='Herman Melville', published_on=datetime.date(1851, 10, 18), cover=<__main__.BookCover object at 0x10410d060>), Book(title='Oliver Twist', author='Charles Dickens', published_on=datetime.date(1215, 4, 4), cover=<__main__.BookCover object at 0x10410c760>), Book(title='Wuthering Heights', author='Emily Bronte', published_on=datetime.date(1600, 4, 4), cover=<__main__.BookCover object at 0x10410d690>)] 28 | [Book(title='Jane Eyre', author='Charlotte Bronte', published_on=datetime.date(1225, 6, 4), cover=<__main__.BookCover object at 0x10410cdc0>), Book(title='Oliver Twist', author='Charles Dickens', published_on=datetime.date(1215, 4, 4), cover=<__main__.BookCover object at 0x10410d7e0>)] 29 | [{'author': 'Charles Dickens', 'cover': <__main__.BookCover object at 0x10410d7b0>}, {'author': 'Charlotte Bronte', 'cover': <__main__.BookCover object at 0x10410d8d0>}, {'author': 'Herman Melville', 'cover': <__main__.BookCover object at 0x10410d840>}, {'author': 'Charles Dickens', 'cover': <__main__.BookCover object at 0x10410d960>}, {'author': 'Emily Bronte', 'cover': <__main__.BookCover object at 0x10410d900>}] 30 | ``` 31 | -------------------------------------------------------------------------------- /test/test_abstract.py: -------------------------------------------------------------------------------- 1 | """Test methods in abstract.py. Uses hypothesis""" 2 | 3 | from datetime import date 4 | from datetime import datetime 5 | from ipaddress import IPv4Address 6 | from ipaddress import IPv6Address 7 | from typing import Dict 8 | from typing import List 9 | from typing import Tuple 10 | from typing import Union 11 | 12 | import pytest 13 | from hypothesis import given 14 | from hypothesis import strategies as st 15 | from pydantic_aioredis.model import Model 16 | 17 | 18 | class SimpleModel(Model): 19 | _primary_key_field: str = "test_str" 20 | test_str: str 21 | test_int: int 22 | test_float: float 23 | test_date: date 24 | test_datetime: datetime 25 | test_ip_v4: IPv4Address 26 | test_ip_v6: IPv6Address 27 | test_list: List 28 | test_dict: Dict[str, Union[int, float]] 29 | test_tuple: Tuple[str] 30 | 31 | 32 | def test_serialize_partially_skip_missing_field(): 33 | serialized = SimpleModel.serialize_partially({"unknown": "test"}) 34 | assert serialized["unknown"] == "test" 35 | 36 | 37 | parameters = [ 38 | (st.text, [], {}, "test_str", str, False), 39 | (st.dates, [], {}, "test_date", str, False), 40 | (st.datetimes, [], {}, "test_datetime", str, False), 41 | (st.ip_addresses, [], {"v": 4}, "test_ip_v4", str, False), 42 | (st.ip_addresses, [], {"v": 6}, "test_ip_v4", str, False), 43 | (st.lists, [st.tuples(st.integers(), st.floats())], {}, "test_list", str, False), 44 | ( 45 | st.dictionaries, 46 | [st.text(), st.tuples(st.integers(), st.floats())], 47 | {}, 48 | "test_dict", 49 | str, 50 | False, 51 | ), 52 | (st.tuples, [st.text()], {}, "test_tuple", str, False), 53 | (st.floats, [], {"allow_nan": False}, "test_float", float, True), 54 | (st.integers, [], {}, "test_int", int, True), 55 | ] 56 | 57 | 58 | @pytest.mark.flaky(retruns=3) 59 | @pytest.mark.parametrize( 60 | "strategy, strategy_args, strategy_kwargs, model_field, expected_type, equality_expected", 61 | parameters, 62 | ) 63 | @given(st.data()) 64 | def test_serialize_partially( 65 | strategy, 66 | strategy_args, 67 | strategy_kwargs, 68 | model_field, 69 | expected_type, 70 | equality_expected, 71 | data, 72 | ): 73 | value = data.draw(strategy(*strategy_args, **strategy_kwargs)) 74 | serialized = SimpleModel.serialize_partially({model_field: value}) 75 | assert isinstance(serialized.get(model_field), expected_type) 76 | if equality_expected: 77 | assert serialized.get(model_field) == value 78 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "andrewthetechie", 11 | "name": "Andrew", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/1377314?v=4", 13 | "profile": "https://github.com/andrewthetechie", 14 | "contributions": [ 15 | "code", 16 | "doc" 17 | ] 18 | }, 19 | { 20 | "login": "Tinitto", 21 | "name": "Martin Ahindura", 22 | "avatar_url": "https://avatars.githubusercontent.com/u/21363733??v=4", 23 | "profile": "https://github.com/Tinitto", 24 | "contributions": [ 25 | "code", 26 | "ideas" 27 | ] 28 | }, 29 | { 30 | "login": "david-wahlstedt", 31 | "name": "david-wahlstedt", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/66391333?v=4", 33 | "profile": "https://github.com/david-wahlstedt", 34 | "contributions": [ 35 | "test", 36 | "doc", 37 | "review" 38 | ] 39 | }, 40 | { 41 | "login": "gtmanfred", 42 | "name": "Daniel Wallace", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/732321?v=4", 44 | "profile": "https://blog.gtmanfred.com", 45 | "contributions": [ 46 | "test" 47 | ] 48 | }, 49 | { 50 | "login": "ceteri", 51 | "name": "Paco Nathan", 52 | "avatar_url": "https://avatars.githubusercontent.com/u/57973?v=4", 53 | "profile": "https://derwen.ai/paco", 54 | "contributions": [ 55 | "example" 56 | ] 57 | }, 58 | { 59 | "login": "AndreasPB", 60 | "name": "Andreas Brodersen", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/43907402?v=4", 62 | "profile": "https://www.linkedin.com/in/andreas-brodersen-1b955b100/", 63 | "contributions": [ 64 | "doc" 65 | ] 66 | }, 67 | { 68 | "login": "kraczak", 69 | "name": "kraczak", 70 | "avatar_url": "https://avatars.githubusercontent.com/u/58468064?v=4", 71 | "profile": "https://github.com/kraczak", 72 | "contributions": [ 73 | "doc" 74 | ] 75 | }, 76 | { 77 | "login": "CharlieJiangXXX", 78 | "name": "CharlieJiangXXX", 79 | "avatar_url": "https://avatars.githubusercontent.com/u/45895922?v=4", 80 | "profile": "https://github.com/CharlieJiangXXX", 81 | "contributions": [ 82 | "code" 83 | ] 84 | } 85 | ], 86 | "contributorsPerLine": 7, 87 | "skipCi": true, 88 | "repoType": "github", 89 | "repoHost": "https://github.com", 90 | "projectName": "pydantic-aioredis", 91 | "projectOwner": "andrewthetechie", 92 | "commitType": "docs" 93 | } 94 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | tests: 8 | name: ${{ matrix.session }} ${{ matrix.python }} / ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | include: 14 | - { python: "3.11", os: "ubuntu-latest", session: "pre-commit" } 15 | - { python: "3.11", os: "ubuntu-latest", session: "safety" } 16 | - { python: "3.12", os: "ubuntu-latest", session: "tests" } 17 | - { python: "3.11", os: "ubuntu-latest", session: "tests" } 18 | - { python: "3.10", os: "ubuntu-latest", session: "tests" } 19 | - { python: "3.9", os: "ubuntu-latest", session: "tests" } 20 | - { python: "3.8", os: "ubuntu-latest", session: "tests" } 21 | - { python: "3.11", os: "ubuntu-latest", session: "docs-build" } 22 | 23 | env: 24 | NOXSESSION: ${{ matrix.session }} 25 | FORCE_COLOR: "1" 26 | PRE_COMMIT_COLOR: "always" 27 | 28 | steps: 29 | - name: Check out the repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Python ${{ matrix.python }} 33 | uses: actions/setup-python@v5.1.0 34 | with: 35 | python-version: ${{ matrix.python }} 36 | 37 | - name: Install needed tools 38 | run: | 39 | pip install --upgrade pip nox poetry virtualenv toml --constraint=.github/workflows/constraints.txt 40 | pip --version 41 | # separate out nox-poetry until it gets a released version with support for 1.8 42 | pip install --upgrade nox-poetry --constraint=.github/workflows/constraints.txt 43 | 44 | - name: Compute pre-commit cache key 45 | if: matrix.session == 'pre-commit' 46 | id: pre-commit-cache 47 | shell: python 48 | run: | 49 | import hashlib 50 | import sys 51 | import os 52 | 53 | python = "py{}.{}".format(*sys.version_info[:2]) 54 | payload = sys.version.encode() + sys.executable.encode() 55 | digest = hashlib.sha256(payload).hexdigest() 56 | result = "${{ runner.os }}-{}-{}-pre-commit".format(python, digest[:8]) 57 | 58 | with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: 59 | fh.write(f"result={result}\n") 60 | 61 | - name: Restore pre-commit cache 62 | uses: actions/cache@v4.0.2 63 | if: matrix.session == 'pre-commit' 64 | with: 65 | path: ~/.cache/pre-commit 66 | key: ${{ steps.pre-commit-cache.outputs.result }}-${{ hashFiles('.pre-commit-config.yaml') }} 67 | restore-keys: | 68 | ${{ steps.pre-commit-cache.outputs.result }}- 69 | 70 | - name: Run Nox 71 | run: | 72 | nox --force-color --python=${{ matrix.python }} 73 | -------------------------------------------------------------------------------- /docs/serialization.rst: -------------------------------------------------------------------------------- 1 | Serialization 2 | ============= 3 | 4 | Data in Redis 5 | ############# 6 | pydantic-aioredis uses Redis Hashes to store data. The ```_primary_key_field``` of each Model is used as the key of the hash. 7 | 8 | Because Redis only supports string values as the fields of a hash, data types have to be serialized. 9 | 10 | Simple data types 11 | ################# 12 | Simple python datatypes that can be represented as a string and natively converted by pydantic are converted to strings and stored. Examples 13 | are ints, floats, strs, bools, and Nonetypes. 14 | 15 | Unintentional Type Casting with Union Types 16 | ******************************************* 17 | 18 | When using Union types, pydantic will cast the value to the first type in the Union. This can cause unintended type casting. For example, if you have a field 19 | of type Union[float, int], and you set the value to 1, pydantic will cast the value to a float 1.0. In the other direction (i.e. `x: Union[int, float]`) will result in 20 | the value being casted as an int and rounded. If you used a value of x = 1.99, it would get cast as an int and rounded to 1. 21 | 22 | This is an issue with Pydantic. More info can be found in 23 | `this issue `_ reported by `david-wahlstedt `_ and `this issue in Pydantic `_. 24 | 25 | There are some `test cases `_ in pydantic_aioredis that illustrate this problem. 26 | 27 | Complex data types 28 | ################## 29 | Complex data types are dumped to json with json.dumps(). 30 | 31 | Custom serialization is possible using `json_default `_ and `json_object_hook `_. 32 | 33 | These methods are part of the `abstract model `_ and can be overridden in your 34 | model to dump custom objects to json and then back to objects. An example is available in `examples `_ 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pydantic-aioredis" 3 | version = "1.4.0" 4 | description = "Use your pydantic models as an ORM, storing data in Redis." 5 | authors = ["Andrew Herrington "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/andrewthetechie/pydantic-aioredis" 9 | repository = "https://github.com/andrewthetechie/pydantic-aioredis" 10 | documentation = "https://pydantic-aioredis.readthedocs.io/en/latest/" 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.7", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Framework :: AsyncIO" 22 | ] 23 | 24 | [tool.poetry.urls] 25 | Changelog = "https://github.com/andrewthetechie/pydantic-aioredis/releases" 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.8" 29 | pydantic = "^1.10.2" 30 | redis = ">=4.4.4,<6.0.0" 31 | anyio = ">=3.6.2,<5.0.0" 32 | fastapi = {version = ">=0.110", optional = true} 33 | fastapi-crudrouter = {version = "^0.8.6", optional = true} 34 | 35 | [tool.poetry.extras] 36 | FastAPI= ['fastapi'] 37 | fastapi-crudrouter=['fastapi-crudrouter'] 38 | 39 | 40 | [tool.poetry.group.dev.dependencies] 41 | pytest = "^7.2.0" 42 | coverage = {extras = ["toml"], version = ">=6.5,<8.0"} 43 | safety = ">=2.3.1,<4.0.0" 44 | mypy = ">=0.991,<1.11" 45 | xdoctest = {extras = ["colors"], version = "^1.1.0"} 46 | sphinx = ">=4.3.2,<8.0.0" 47 | sphinx-autobuild = ">=2021.3.14" 48 | pre-commit = ">=2.12.1" 49 | pep8-naming = ">=0.13.2,<0.15.0" 50 | reorder-python-imports = "^3.9.0" 51 | pre-commit-hooks = "^4.2.0" 52 | Pygments = "^2.13.0" 53 | pyupgrade = "^3.3.1" 54 | furo = ">=2021.11.12" 55 | pytest-cov = ">=4,<6" 56 | types-croniter = ">=1.3.2,<3.0.0" 57 | pytest_async = "^0.1.1" 58 | pytest-asyncio = ">=0.20.1,<0.22.0" 59 | pytest-mock = "^3.10.0" 60 | pytest-lazy-fixture = "^0.6.3" 61 | fastapi = ">=0.6.3" 62 | fastapi-crudrouter = ">=0.8.4" 63 | httpx = ">=0.23,<0.28" 64 | pytest-env = ">=0.8.1,<1.2.0" 65 | pytest-xdist = "^3.1.0" 66 | bandit = "^1.7.8" 67 | fakeredis = {extras = ["json"], version = "2.23.2"} 68 | hypothesis = "^6.61.0" 69 | pytest-rerunfailures = ">=11.1,<15.0" 70 | ruff = "^0.4.2" 71 | 72 | [tool.mypy] 73 | strict = true 74 | warn_unreachable = true 75 | pretty = true 76 | show_column_numbers = true 77 | show_error_codes = true 78 | show_error_context = true 79 | 80 | [build-system] 81 | requires = ["poetry-core>=1.0.0"] 82 | build-backend = "poetry.core.masonry.api" 83 | 84 | [tool.bandit] 85 | exclude_dirs = ["test", "noxfile.py", ".github/scripts", "dist", "examples/*"] 86 | 87 | [tool.pytest.ini_options] 88 | addopts = "-n 4 --ignore examples --cov=pydantic_aioredis --cov-report xml:.coverage.xml --cov-report=term-missing --cov-fail-under 85" 89 | 90 | [tool.ruff] 91 | line-length = 120 92 | target-version = "py38" 93 | -------------------------------------------------------------------------------- /examples/fastapi/fastapi_example.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List 3 | 4 | import uvicorn 5 | from fastapi import FastAPI 6 | from fastapi import HTTPException 7 | from pydantic_aioredis import Model 8 | from pydantic_aioredis import RedisConfig 9 | from pydantic_aioredis import Store 10 | 11 | 12 | # Create models as you would create pydantic models i.e. using typings 13 | class Book(Model): 14 | _primary_key_field: str = "title" 15 | title: str 16 | author: str 17 | published_on: date 18 | in_stock: bool = True 19 | 20 | 21 | # Do note that there is no concept of relationships here 22 | class Library(Model): 23 | # the _primary_key_field is mandatory 24 | _primary_key_field: str = "name" 25 | name: str 26 | address: str 27 | 28 | 29 | app = FastAPI() 30 | 31 | 32 | @app.on_event("startup") 33 | async def redis_setup(): 34 | # Redisconfig. Change this configuration to match your redis server 35 | redis_config = RedisConfig(db=5, host="localhost", password="password", ssl=False, port=6379) 36 | 37 | # Create the store and register your models 38 | store = Store(name="some_name", redis_config=redis_config, life_span_in_seconds=3600) 39 | store.register_model(Book) 40 | store.register_model(Library) 41 | 42 | # Sample books. You can create as many as you wish anywhere in the code 43 | books = [ 44 | Book( 45 | title="Oliver Twist", 46 | author="Charles Dickens", 47 | published_on=date(year=1215, month=4, day=4), 48 | in_stock=False, 49 | ), 50 | Book( 51 | title="Great Expectations", 52 | author="Charles Dickens", 53 | published_on=date(year=1220, month=4, day=4), 54 | ), 55 | Book( 56 | title="Jane Eyre", 57 | author="Charles Dickens", 58 | published_on=date(year=1225, month=6, day=4), 59 | in_stock=False, 60 | ), 61 | Book( 62 | title="Wuthering Heights", 63 | author="Jane Austen", 64 | published_on=date(year=1600, month=4, day=4), 65 | ), 66 | ] 67 | # Some library objects 68 | libraries = [ 69 | Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), 70 | Library(name="Christian Library", address="Buhimba, Hoima, Uganda"), 71 | ] 72 | 73 | await Book.insert(books) 74 | await Library.insert(libraries) 75 | 76 | 77 | @app.get("/book/{title}", response_model=List[Book]) 78 | async def get_book(title: str) -> Book: 79 | response = await Book.select(ids=[title]) 80 | if response is None: 81 | raise HTTPException(status_code=404, detail="Book not found") 82 | return response 83 | 84 | 85 | @app.get("/books", response_model=List[Book]) 86 | async def get_books(): 87 | return await Book.select() 88 | 89 | 90 | @app.get("/libraries", response_model=List[Library]) 91 | async def get_libraries(): 92 | return await Library.select() 93 | 94 | 95 | if __name__ == "__main__": 96 | uvicorn.run(app, host="127.0.0.1", port=8080) 97 | -------------------------------------------------------------------------------- /test/test_nested_aio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import unittest 4 | 5 | from pydantic_aioredis.utils import NestedAsyncIO 6 | 7 | 8 | def exception_handler(loop, context): 9 | print("Exception:", context) 10 | 11 | 12 | class NestTest(unittest.TestCase): 13 | def setUp(self): 14 | self.loop = asyncio.new_event_loop() 15 | NestedAsyncIO().apply(self.loop) 16 | asyncio.set_event_loop(self.loop) 17 | self.loop.set_debug(True) 18 | self.loop.set_exception_handler(exception_handler) 19 | 20 | def tearDown(self): 21 | NestedAsyncIO().revert() 22 | self.assertIsNone(asyncio._get_running_loop()) 23 | self.loop.close() 24 | del self.loop 25 | 26 | async def coro(self): 27 | await asyncio.sleep(0.01) 28 | return 42 29 | 30 | def test_nesting(self): 31 | async def f1(): 32 | result = self.loop.run_until_complete(self.coro()) 33 | self.assertEqual(result, await self.coro()) 34 | return result 35 | 36 | async def f2(): 37 | result = self.loop.run_until_complete(f1()) 38 | self.assertEqual(result, await f1()) 39 | return result 40 | 41 | result = self.loop.run_until_complete(f2()) 42 | self.assertEqual(result, 42) 43 | 44 | def test_ensure_future_with_run_until_complete(self): 45 | async def f(): 46 | task = asyncio.ensure_future(self.coro()) 47 | return self.loop.run_until_complete(task) 48 | 49 | result = self.loop.run_until_complete(f()) 50 | self.assertEqual(result, 42) 51 | 52 | def test_ensure_future_with_run_until_complete_with_wait(self): 53 | async def f(): 54 | task = asyncio.ensure_future(self.coro()) 55 | done, pending = self.loop.run_until_complete(asyncio.wait([task], return_when=asyncio.ALL_COMPLETED)) 56 | task = done.pop() 57 | return task.result() 58 | 59 | result = self.loop.run_until_complete(f()) 60 | self.assertEqual(result, 42) 61 | 62 | def test_timeout(self): 63 | async def f1(): 64 | await asyncio.sleep(0.1) 65 | 66 | async def f2(): 67 | asyncio.run(asyncio.wait_for(f1(), 0.01)) 68 | 69 | with self.assertRaises(asyncio.TimeoutError): 70 | self.loop.run_until_complete(f2()) 71 | 72 | def test_two_run_until_completes_in_one_outer_loop(self): 73 | async def f1(): 74 | self.loop.run_until_complete(asyncio.sleep(0.02)) 75 | return 4 76 | 77 | async def f2(): 78 | self.loop.run_until_complete(asyncio.sleep(0.01)) 79 | return 2 80 | 81 | result = self.loop.run_until_complete(asyncio.gather(f1(), f2())) 82 | self.assertEqual(result, [4, 2]) 83 | 84 | @unittest.skipIf(sys.version_info < (3, 7, 0), "No contextvars module") 85 | def test_contextvars(self): 86 | from contextvars import ContextVar 87 | 88 | var = ContextVar("var") 89 | var.set(0) 90 | 91 | async def set_val(): 92 | var.set(42) 93 | 94 | async def coro(): 95 | await set_val() 96 | await asyncio.sleep(0.01) 97 | return var.get() 98 | 99 | result = self.loop.run_until_complete(coro()) 100 | self.assertEqual(result, 42) 101 | 102 | 103 | if __name__ == "__main__": 104 | unittest.main() 105 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributor Guide 2 | ================= 3 | 4 | Thank you for your interest in improving this project. 5 | This project is open-source under the `MIT license`_ and 6 | welcomes contributions in the form of bug reports, feature requests, and pull requests. 7 | 8 | Here is a list of important resources for contributors: 9 | 10 | - `Source Code`_ 11 | - `Documentation`_ 12 | - `Issue Tracker`_ 13 | - `Code of Conduct`_ 14 | 15 | .. _MIT license: https://opensource.org/licenses/MIT 16 | .. _Source Code: https://github.com/andrewthetechie/pydantic-aioredis 17 | .. _Documentation: https://pydantic-aioredis.readthedocs.io/ 18 | .. _Issue Tracker: https://github.com/andrewthetechie/pydantic-aioredis/issues 19 | 20 | How to report a bug 21 | ------------------- 22 | 23 | Report bugs on the `Issue Tracker`_. 24 | 25 | When filing an issue, make sure to answer these questions: 26 | 27 | - Which operating system and Python version are you using? 28 | - Which version of this project are you using? 29 | - What did you do? 30 | - What did you expect to see? 31 | - What did you see instead? 32 | 33 | The best way to get your bug fixed is to provide a test case, 34 | and/or steps to reproduce the issue. 35 | 36 | 37 | How to request a feature 38 | ------------------------ 39 | 40 | Request features on the `Issue Tracker`_. 41 | 42 | 43 | How to set up your development environment 44 | ------------------------------------------ 45 | 46 | You need Python 3.7+ and the following tools: 47 | 48 | - Poetry_ 49 | - Nox_ 50 | - nox-poetry_ 51 | 52 | Install the package with development requirements: 53 | 54 | .. code:: console 55 | 56 | $ poetry install 57 | 58 | You can now run an interactive Python session, 59 | or the command-line interface: 60 | 61 | .. code:: console 62 | 63 | $ poetry run python 64 | 65 | .. _Poetry: https://python-poetry.org/ 66 | .. _Nox: https://nox.thea.codes/ 67 | .. _nox-poetry: https://nox-poetry.readthedocs.io/ 68 | 69 | 70 | How to test the project 71 | ----------------------- 72 | 73 | Run the full test suite: 74 | 75 | .. code:: console 76 | 77 | $ nox 78 | 79 | List the available Nox sessions: 80 | 81 | .. code:: console 82 | 83 | $ nox --list-sessions 84 | 85 | You can also run a specific Nox session. 86 | For example, invoke the unit test suite like this: 87 | 88 | .. code:: console 89 | 90 | $ nox --session=tests 91 | 92 | Unit tests are located in the ``tests`` directory, 93 | and are written using the pytest_ testing framework. 94 | 95 | .. _pytest: https://pytest.readthedocs.io/ 96 | 97 | 98 | How to submit changes 99 | --------------------- 100 | 101 | Open a `pull request`_ to submit changes to this project. 102 | 103 | Your pull request needs to meet the following guidelines for acceptance: 104 | 105 | - The Nox test suite must pass without errors and warnings. 106 | - Include unit tests. This project maintains 100% code coverage. 107 | - If your changes add functionality, update the documentation accordingly. 108 | 109 | Feel free to submit early, though—we can always iterate on this. 110 | 111 | To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: 112 | 113 | .. code:: console 114 | 115 | $ nox --session=pre-commit -- install 116 | 117 | It is recommended to open an issue before starting work on anything. 118 | This will allow a chance to talk it over with the owners and validate your approach. 119 | 120 | .. _pull request: https://github.com/andrewthetechie/pydantic-aioredis/pulls 121 | .. github-only 122 | .. _Code of Conduct: CODE_OF_CONDUCT.rst 123 | -------------------------------------------------------------------------------- /docs/automatic_saving.rst: -------------------------------------------------------------------------------- 1 | Automatic Saving 2 | ================ 3 | 4 | By default, a pydantic-aioredis model is only saved to Redis when its .save() method is called or when it is inserted(). This is to prevent unnecessary writes to Redis. 5 | 6 | pydantic-aioredis has two options you can tweak for automatic saving: 7 | * _auto_save: Used to determine if a model is saved to redis on instantiate 8 | * _auto_sync: Used to determine if a change to a model is saved on setattr 9 | 10 | These options can be set on a model or on a per instance basis. 11 | 12 | .. code-block:: 13 | 14 | import asyncio 15 | from pydantic_aioredis import RedisConfig, Model, Store 16 | 17 | class Book(Model): 18 | _primary_key_field: str = 'title' 19 | title: str 20 | author: str 21 | 22 | _auto_save: bool = True 23 | _auto_sync: bool = True 24 | 25 | 26 | class Movie(Model): 27 | _primary_key_field: str = 'title' 28 | title: str 29 | director: str 30 | 31 | _auto_sync: bool = True 32 | 33 | 34 | 35 | # Create the store and register your models 36 | store = Store(name='some_name', redis_config=RedisConfig(db=5, host='localhost', port=6379), life_span_in_seconds=3600) 37 | store.register_model(Book) 38 | 39 | async def autol(): 40 | my_book = Book(title='The Hobbit', author='J.R.R. Tolkien') 41 | # my_book is already in redis 42 | book_from_redis = await Book.select(ids['The Hobbit']) 43 | assert book_from_redis[0] == my_book 44 | 45 | # _auto_save means that changing a field will automatically save the model 46 | my_book.author = 'J.R.R. Tolkien II' 47 | book_from_redis = await Book.select(ids['The Hobbit']) 48 | assert book_from_redis[0] == my_book 49 | 50 | my_movie = Movie(title='The Lord of the Rings', director='Peter Jackson') 51 | # my_move is not in redis until its inserted 52 | await Movie.insert(my_movie) 53 | 54 | # _auto_sync means that changing a field will automatically save the model 55 | my_movie.director = 'Peter Jackson II' 56 | movie_from_redis = await Movie.select(ids['The Hobbit']) 57 | assert movie_from_redis[0] == my_movie 58 | 59 | # _auto_sync and _auto_save can be set on a per instance basis 60 | local_book = Book(title='The Silmarillion', author='J.R.R. Tolkien', _auto_save=False, _auto_sync=False) 61 | # local_book is not automatically saved in redis and won't try to sync, even though the class has _auto_save and _auto_sync set to True 62 | books_in_redis = await Book.select() 63 | assert len(books_in_redis) == 1 64 | 65 | 66 | loop = asyncio.get_event_loop() 67 | loop.run_until_complete(auto()) 68 | 69 | 70 | There is also `AutoModel`, which is a subclass of `Model` that has `_auto_save` and `_auto_sync` set to True by default. 71 | 72 | .. code-block:: 73 | 74 | import asyncio 75 | from pydantic_aioredis import RedisConfig, AutoModel, Store 76 | 77 | class Book(AutoModel): 78 | _primary_key_field: str = 'title' 79 | title: str 80 | 81 | async def auto_model(): 82 | my_book = Book(title='The Hobbit') 83 | # my_book is already in redis 84 | book_from_redis = await Book.select(ids['The Hobbit']) 85 | assert book_from_redis[0] == my_book 86 | 87 | # _auto_save means that changing a field will automatically save the model 88 | my_book.author = 'J.R.R. Tolkien II' 89 | book_from_redis = await Book.select(ids['The Hobbit']) 90 | assert book_from_redis[0] == my_book 91 | 92 | loop = asyncio.get_event_loop() 93 | loop.run_until_complete(auto_model()) 94 | -------------------------------------------------------------------------------- /test/test_union_typing.py: -------------------------------------------------------------------------------- 1 | """Tests from https://github.com/andrewthetechie/pydantic-aioredis/issues/379""" 2 | 3 | from typing import Union 4 | 5 | import pytest 6 | from pydantic import BaseModel 7 | from pydantic_aioredis.model import Model 8 | 9 | 10 | class FloatIntTest(Model): 11 | _primary_key_field: str = "key" 12 | 13 | key: str 14 | float_int: Union[float, int] # fails 15 | # float_int: Optional[Union[float,int]] # passes 16 | # float_int: Union[Optional[float],Optional[int]] # fails 17 | 18 | 19 | class IntFloatTest(Model): 20 | _primary_key_field: str = "key" 21 | 22 | key: str 23 | int_float: Union[int, float] # fails 24 | # int_float: Optional[Union[int,float]] # fails 25 | # int_float: Union[Optional[int],Optional[float]] # fails 26 | 27 | 28 | """Integers gets cast into floats unintentionally""" 29 | 30 | 31 | @pytest.mark.union_test 32 | @pytest.mark.xfail 33 | async def test_float_int_assign_inside(redis_store): 34 | redis_store.register_model(FloatIntTest) 35 | key = "test_float_int" 36 | instance = FloatIntTest( 37 | key=key, 38 | float_int=2, # gets cast to 2.0 39 | ) 40 | await instance.save() # this operation doesn't affect the test outcome 41 | # Fails ! 42 | assert isinstance(instance.float_int, int) 43 | 44 | 45 | @pytest.mark.union_test 46 | @pytest.mark.xfail 47 | async def test_float_int_assign_inside_pydantic_only(): 48 | class FloatIntTestPydantic(BaseModel): 49 | key: str 50 | float_int: Union[float, int] # fails 51 | # float_int: Optional[Union[float,int]] # passes 52 | # float_int: Union[Optional[float],Optional[int]] # fails 53 | 54 | key = "test_float_int" 55 | instance = FloatIntTestPydantic( 56 | key=key, 57 | float_int=2, # gets cast to 2.0 58 | ) 59 | assert isinstance(instance.float_int, int) 60 | 61 | 62 | @pytest.mark.union_test 63 | def test_float_int_assign_after(): 64 | key = "test_float_int_assign_after" 65 | instance = FloatIntTest( 66 | key=key, 67 | float_int=2, # gets cast to 2.0 68 | ) 69 | instance.float_int = 1 70 | # Passes ! 71 | assert isinstance(instance.float_int, int) 72 | 73 | 74 | """Floats gets cast (and truncated) into integers unintentionally""" 75 | 76 | 77 | @pytest.mark.union_test 78 | @pytest.mark.xfail 79 | async def test_int_float_assign_inside(redis_store): 80 | key = "test_int_float_assign_inside" 81 | redis_store.register_model(IntFloatTest) 82 | instance = IntFloatTest( 83 | key=key, 84 | int_float=2.9, # gets cast into and truncated to 2 85 | ) 86 | await instance.save() # this operation doesn't affect the test outcome 87 | # Fails ! 88 | assert isinstance(instance.int_float, float) 89 | 90 | 91 | @pytest.mark.union_test 92 | @pytest.mark.xfail 93 | async def test_int_float_assign_inside_pydantic_only(): 94 | class IntFloatTestPydantic(BaseModel): 95 | key: str 96 | int_float: Union[int, float] # fails 97 | # int_float: Optional[Union[int,float]] # fails 98 | # int_float: Union[Optional[int],Optional[float]] # fails 99 | 100 | key = "test_int_float_assign_inside" 101 | instance = IntFloatTestPydantic( 102 | key=key, 103 | int_float=2.9, # gets cast into and truncated to 2 104 | ) 105 | # Fails ! 106 | assert isinstance(instance.int_float, float) 107 | 108 | 109 | @pytest.mark.union_test 110 | def test_int_float_assign_after(): 111 | key = "test_int_float_assign_after" 112 | instance = IntFloatTest( 113 | key=key, 114 | int_float=2.9, 115 | ) 116 | instance.int_float = 1.9 117 | # Passes ! 118 | assert isinstance(instance.int_float, float) 119 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | Examples 5 | ^^^^^^^^ 6 | 7 | Examples are in the `examples/ `_ directory of this repo. 8 | 9 | Installation 10 | ^^^^^^^^^^^^ 11 | 12 | Install the package 13 | 14 | .. code-block:: 15 | 16 | pip install pydantic-aioredis 17 | 18 | 19 | Quick Usage 20 | ^^^^^^^^^^^ 21 | 22 | Import the ``Store``\ , the ``RedisConfig`` and the ``Model`` classes. 23 | 24 | Store and RedisConfig let you configure and customize the connection to your redis instance. Model is the base class for your ORM models. 25 | 26 | .. code-block:: 27 | 28 | import asyncio 29 | from datetime import date 30 | from pydantic_aioredis import RedisConfig, Model, Store 31 | 32 | # Create models as you would create pydantic models i.e. using typings 33 | class Book(Model): 34 | _primary_key_field: str = 'title' 35 | title: str 36 | author: str 37 | published_on: date 38 | in_stock: bool = True 39 | 40 | # Do note that there is no concept of relationships here 41 | class Library(Model): 42 | # the _primary_key_field is mandatory 43 | _primary_key_field: str = 'name' 44 | name: str 45 | address: str 46 | 47 | # Create the store and register your models 48 | store = Store(name='some_name', redis_config=RedisConfig(db=5, host='localhost', port=6379), life_span_in_seconds=3600) 49 | store.register_model(Book) 50 | store.register_model(Library) 51 | 52 | # Sample books. You can create as many as you wish anywhere in the code 53 | books = [ 54 | Book(title="Oliver Twist", author='Charles Dickens', published_on=date(year=1215, month=4, day=4), 55 | in_stock=False), 56 | Book(title="Great Expectations", author='Charles Dickens', published_on=date(year=1220, month=4, day=4)), 57 | Book(title="Jane Eyre", author='Charles Dickens', published_on=date(year=1225, month=6, day=4), in_stock=False), 58 | Book(title="Wuthering Heights", author='Jane Austen', published_on=date(year=1600, month=4, day=4)), 59 | ] 60 | # Some library objects 61 | libraries = [ 62 | Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), 63 | Library(name="Christian Library", address="Buhimba, Hoima, Uganda") 64 | ] 65 | 66 | async def work_with_orm(): 67 | # Insert them into redis 68 | await Book.insert(books) 69 | await Library.insert(libraries) 70 | 71 | # Select all books to view them. A list of Model instances will be returned 72 | all_books = await Book.select() 73 | print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] 74 | 75 | # Or select some of the books 76 | some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) 77 | print(some_books) # Will return only those two books 78 | 79 | # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances 80 | # The Dictionaries have values in string form so you might need to do some extra work 81 | books_with_few_fields = await Book.select(columns=["author", "in_stock"]) 82 | print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "in_stock": "True"},...] 83 | 84 | 85 | this_book = Book(title="Moby Dick", author='Herman Melvill', published_on=date(year=1851, month=10, day=17)) 86 | await Book.insert(this_book) 87 | # oops, there was a typo. Fix it 88 | # Update is an async context manager and will update redis with all changes in one operations 89 | async with this_book.update(): 90 | this_book.author = "Herman Melville" 91 | this_book.published_on=date(year=1851, month=10, day=18) 92 | this_book_from_redis = await Book.select(ids=["Moby Dick"]) 93 | assert this_book_from_redis[0].author == "Herman Melville" 94 | assert this_book_from_redis[0].author == date(year=1851, month=10, day=18) 95 | 96 | # Delete any number of items 97 | await Library.delete(ids=["The Grand Library"]) 98 | 99 | # Now run these updates 100 | loop = asyncio.get_event_loop() 101 | loop.run_until_complete(work_with_orm()) 102 | -------------------------------------------------------------------------------- /examples/asyncio/asyncio_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import date 3 | from typing import Dict 4 | from typing import List 5 | from typing import Optional 6 | 7 | from pydantic_aioredis import Model 8 | from pydantic_aioredis import RedisConfig 9 | from pydantic_aioredis import Store 10 | 11 | 12 | # Create models as you would create pydantic models i.e. using typings 13 | class Book(Model): 14 | _primary_key_field: str = "title" 15 | title: str 16 | author: str 17 | published_on: date 18 | in_stock: bool = True 19 | locations: Optional[List[str]] 20 | 21 | 22 | # Do note that there is no concept of relationships here 23 | class Library(Model): 24 | # the _primary_key_field is mandatory 25 | _primary_key_field: str = "name" 26 | name: str 27 | address: str 28 | details: Optional[Dict[str, str]] 29 | 30 | 31 | # Redisconfig. Change this configuration to match your redis server 32 | redis_config = RedisConfig(db=5, host="localhost", password="password", ssl=False, port=6379) 33 | 34 | 35 | # Create the store and register your models 36 | store = Store(name="some_name", redis_config=redis_config, life_span_in_seconds=3600) 37 | store.register_model(Book) 38 | store.register_model(Library) 39 | 40 | # Sample books. You can create as many as you wish anywhere in the code 41 | books = [ 42 | Book( 43 | title="Oliver Twist", 44 | author="Charles Dickens", 45 | published_on=date(year=1215, month=4, day=4), 46 | in_stock=False, 47 | ), 48 | Book( 49 | title="Great Expectations", 50 | author="Charles Dickens", 51 | published_on=date(year=1220, month=4, day=4), 52 | ), 53 | Book( 54 | title="Jane Eyre", 55 | author="Charles Dickens", 56 | published_on=date(year=1225, month=6, day=4), 57 | in_stock=False, 58 | ), 59 | Book( 60 | title="Wuthering Heights", 61 | author="Jane Austen", 62 | published_on=date(year=1600, month=4, day=4), 63 | locations=["one", "two", "three"], 64 | ), 65 | ] 66 | # Some library objects 67 | libraries = [ 68 | Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), 69 | Library( 70 | name="Christian Library", 71 | address="Buhimba, Hoima, Uganda", 72 | details={"catalog": "huge", "fun_factor": "over 9000"}, 73 | ), 74 | ] 75 | 76 | 77 | async def work_with_orm(): 78 | # Insert them into redis 79 | await Book.insert(books) 80 | await Library.insert(libraries) 81 | 82 | # Select all books to view them. A list of Model instances will be returned 83 | all_books = await Book.select() 84 | print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", 85 | # published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] 86 | 87 | # Or select some of the books 88 | some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) 89 | print(some_books) # Will return only those two books 90 | 91 | # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances 92 | # The Dictionaries have values in string form so you might need to do some extra work 93 | books_with_few_fields = await Book.select(columns=["author", "in_stock"]) 94 | print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "in_stock": "True"},...] 95 | 96 | # When _auto_sync = True (default), updating any attribute will update that field in Redis too 97 | this_book = Book( 98 | title="Moby Dick", 99 | author="Herman Melvill", 100 | published_on=date(year=1851, month=10, day=18), 101 | ) 102 | await Book.insert(this_book) 103 | # oops, there was a typo. Fix it 104 | this_book.author = "Herman Melville" 105 | this_book_from_redis = await Book.select(ids=["Moby Dick"]) 106 | assert this_book_from_redis[0].author == "Herman Melville" 107 | 108 | # If you have _auto_save set to false on a model, you have to await .save() to update a model in tedis 109 | await this_book.save() 110 | 111 | all_libraries = await Library.select() 112 | print(all_libraries) 113 | # Delete any number of items 114 | await Library.delete(ids=["The Grand Library"]) 115 | after_delete = await Library.select() 116 | print(after_delete) 117 | 118 | 119 | if __name__ == "__main__": 120 | asyncio.run(work_with_orm()) 121 | -------------------------------------------------------------------------------- /pydantic_aioredis/ext/FastAPI/crudrouter.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Callable 3 | from typing import cast 4 | from typing import List 5 | from typing import Optional 6 | from typing import Type 7 | from typing import Union 8 | 9 | from fastapi import HTTPException 10 | from fastapi_crudrouter.core import CRUDGenerator 11 | from fastapi_crudrouter.core import NOT_FOUND 12 | from fastapi_crudrouter.core._types import DEPENDENCIES 13 | from fastapi_crudrouter.core._types import PAGINATION 14 | from fastapi_crudrouter.core._types import PYDANTIC_SCHEMA as SCHEMA 15 | from pydantic_aioredis.store import Store 16 | 17 | 18 | CALLABLE = Callable[..., SCHEMA] 19 | CALLABLE_LIST = Callable[..., List[SCHEMA]] 20 | 21 | INVALID_UPDATE = HTTPException(400, "Invalid Update") 22 | 23 | 24 | class PydanticAioredisCRUDRouter(CRUDGenerator[SCHEMA]): 25 | def __init__( 26 | self, 27 | schema: Type[SCHEMA], 28 | store: Store, 29 | create_schema: Optional[Type[SCHEMA]] = None, 30 | update_schema: Optional[Type[SCHEMA]] = None, 31 | prefix: Optional[str] = None, 32 | tags: Optional[List[str]] = None, 33 | paginate: Optional[int] = None, 34 | get_all_route: Union[bool, DEPENDENCIES] = True, 35 | get_one_route: Union[bool, DEPENDENCIES] = True, 36 | create_route: Union[bool, DEPENDENCIES] = True, 37 | update_route: Union[bool, DEPENDENCIES] = True, 38 | delete_one_route: Union[bool, DEPENDENCIES] = True, 39 | delete_all_route: Union[bool, DEPENDENCIES] = True, 40 | **kwargs: Any, 41 | ) -> None: 42 | super().__init__( 43 | schema=schema, 44 | create_schema=create_schema, 45 | update_schema=update_schema, 46 | prefix=prefix, 47 | tags=tags, 48 | paginate=paginate, 49 | get_all_route=get_all_route, 50 | get_one_route=get_one_route, 51 | create_route=create_route, 52 | update_route=update_route, 53 | delete_one_route=delete_one_route, 54 | delete_all_route=delete_all_route, 55 | **kwargs, 56 | ) 57 | self.store = store 58 | self.store.register_model(self.schema) 59 | 60 | def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 61 | async def route( 62 | pagination: PAGINATION = self.pagination, 63 | ) -> List[SCHEMA]: 64 | skip, limit = pagination.get("skip"), pagination.get("limit") 65 | skip = cast(int, skip) 66 | models = await self.schema.select(skip=skip, limit=limit) 67 | return models if models is not None else [] 68 | 69 | return route 70 | 71 | def _get_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 72 | async def route(item_id: str) -> SCHEMA: 73 | model = await self.schema.select(ids=[item_id]) 74 | if model is None: 75 | raise NOT_FOUND 76 | return model[0] 77 | 78 | return route 79 | 80 | def _create(self, *args: Any, **kwargs: Any) -> CALLABLE: 81 | async def route(model: self.create_schema) -> SCHEMA: # type: ignore 82 | model = self.schema(**model.dict()) 83 | await self.schema.insert(model) 84 | return model 85 | 86 | return route 87 | 88 | def _update(self, *args: Any, **kwargs: Any) -> CALLABLE: 89 | async def route(item_id: str, model: self.update_schema) -> SCHEMA: # type: ignore 90 | item = await self.schema.select(ids=[item_id]) 91 | if item is None: 92 | raise NOT_FOUND 93 | item = item[0] 94 | 95 | async with item.update() as item: 96 | for key, value in model.dict().items(): 97 | setattr(item, key, value) 98 | 99 | return item 100 | 101 | return route 102 | 103 | def _delete_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: 104 | async def route() -> List[SCHEMA]: 105 | await self.schema.delete() 106 | return [] 107 | 108 | return route 109 | 110 | def _delete_one(self, *args: Any, **kwargs: Any) -> CALLABLE: 111 | async def route(item_id: str) -> SCHEMA: 112 | model = await self.schema.select(ids=[item_id]) 113 | if model is None: 114 | raise NOT_FOUND 115 | await self.schema.delete(ids=[item_id]) 116 | return model[0] 117 | 118 | return route 119 | -------------------------------------------------------------------------------- /test/test_model.py: -------------------------------------------------------------------------------- 1 | """Test methods in model.py. Uses hypothesis""" 2 | 3 | from typing import Dict 4 | from typing import List 5 | 6 | from fakeredis.aioredis import FakeRedis 7 | from pydantic_aioredis.config import RedisConfig 8 | from pydantic_aioredis.model import Model 9 | from pydantic_aioredis.store import Store 10 | 11 | 12 | class SimpleModel(Model): 13 | _primary_key_field: str = "test_str" 14 | test_str: str 15 | 16 | 17 | async def test_update_sets_autosync_save(): 18 | """Test that update sets autosync to false""" 19 | model = SimpleModel(test_str="test") 20 | model.set_auto_sync() 21 | async with model.update() as cm: 22 | assert cm._auto_sync is False 23 | assert model.auto_sync 24 | 25 | 26 | def test_set_auto_sync(): 27 | """Test that set_auto_sync sets the autosync attribute""" 28 | model = SimpleModel(test_str="test") 29 | assert model.auto_sync is False 30 | model.set_auto_sync() 31 | assert model.auto_sync 32 | model.set_auto_sync(False) 33 | assert model.auto_sync is False 34 | 35 | 36 | def test_set_auto_save(): 37 | """Test that set_auto_save sets the autosave attribute""" 38 | model = SimpleModel(test_str="test") 39 | assert model.auto_save is False 40 | model.set_auto_save() 41 | assert model.auto_save 42 | model.set_auto_save(False) 43 | assert model.auto_save is False 44 | 45 | 46 | def test_auto_save_in_init(): 47 | """Test that auto_save is set if set in init""" 48 | model = SimpleModel(test_str="test", auto_save=True) 49 | assert model.auto_save 50 | false_model = SimpleModel(test_str="test", auto_save=False) 51 | assert false_model.auto_save is False 52 | 53 | 54 | async def test_update_cm(): 55 | """ """ 56 | test_str = "test" 57 | test_int = 9 58 | update_int = 27 59 | update_int = test_int + update_int 60 | 61 | class UpdateModel(Model): 62 | _primary_key_field: str = "test_str" 63 | test_str: str 64 | test_int: int 65 | 66 | # instead of using a fixture, create it in the function because of hypothesis 67 | # Function-scoped fixtures are not reset between examples generated by 68 | # `@given(...)`, which is often surprising and can cause subtle test bugs. 69 | redis_store = Store( 70 | name="sample", 71 | redis_config=RedisConfig(port=1024, db=1), # nosec 72 | life_span_in_seconds=3600, 73 | ) 74 | redis_store.redis_store = FakeRedis(decode_responses=True) 75 | redis_store.register_model(UpdateModel) 76 | this_model = UpdateModel(test_str=test_str, test_int=test_int) 77 | await UpdateModel.insert(this_model) 78 | 79 | async with this_model.update() as cm: 80 | cm.test_int = update_int 81 | redis_model = await UpdateModel.select(ids=[test_str]) 82 | assert redis_model[0].test_int == test_int 83 | 84 | redis_model = await UpdateModel.select(ids=[test_str]) 85 | assert redis_model[0].test_int == update_int 86 | 87 | 88 | async def test_storing_list(redis_store): 89 | # https://github.com/andrewthetechie/pydantic-aioredis/issues/403 90 | class DataTypeTest(Model): 91 | _primary_key_field: str = "key" 92 | 93 | key: str 94 | value: List[int] 95 | 96 | redis_store.register_model(DataTypeTest) 97 | key = "test_list_storage" 98 | instance = DataTypeTest( 99 | key=key, 100 | value=[1, 2, 3], 101 | ) 102 | await instance.save() 103 | 104 | instance_in_redis = await DataTypeTest.select() 105 | assert instance_in_redis[0].key == instance.key 106 | assert len(instance_in_redis[0].value) == len(instance.value) 107 | for value in instance_in_redis[0].value: 108 | assert value in instance.value 109 | 110 | 111 | async def test_storing_dict(redis_store): 112 | class DataTypeTest(Model): 113 | _primary_key_field: str = "key" 114 | 115 | key: str 116 | value: Dict[str, int] 117 | 118 | redis_store.register_model(DataTypeTest) 119 | key = "test_list_storage" 120 | instance = DataTypeTest( 121 | key=key, 122 | value={"a": 1, "b": 2, "c": 3}, 123 | ) 124 | await instance.save() 125 | 126 | instance_in_redis = await DataTypeTest.select() 127 | assert instance_in_redis[0].key == instance.key 128 | assert len(instance_in_redis[0].value.keys()) == len(instance.value.keys()) 129 | for key in instance_in_redis[0].value.keys(): 130 | assert instance.value[key] == instance_in_redis[0].value[key] 131 | 132 | 133 | async def test_storing_complex_dict(redis_store): 134 | class DataTypeTest(Model): 135 | _primary_key_field: str = "key" 136 | 137 | key: str 138 | value: Dict[str, List[int]] 139 | 140 | redis_store.register_model(DataTypeTest) 141 | key = "test_list_storage" 142 | instance = DataTypeTest( 143 | key=key, 144 | value={"a": [1], "b": [2, 3], "c": [4, 5, 6]}, 145 | ) 146 | await instance.save() 147 | 148 | instance_in_redis = await DataTypeTest.select() 149 | assert instance_in_redis[0].key == instance.key 150 | assert len(instance_in_redis[0].value.keys()) == len(instance.value.keys()) 151 | for key in instance_in_redis[0].value.keys(): 152 | assert instance.value[key] == instance_in_redis[0].value[key] 153 | -------------------------------------------------------------------------------- /pydantic_aioredis/abstract.py: -------------------------------------------------------------------------------- 1 | """Module containing the main base classes""" 2 | 3 | import json 4 | from datetime import date 5 | from datetime import datetime 6 | from typing import Any 7 | from typing import Dict 8 | from typing import List 9 | from typing import Optional 10 | from typing import Union 11 | 12 | from pydantic import BaseModel 13 | from pydantic_aioredis.config import RedisConfig 14 | from redis import asyncio as aioredis 15 | 16 | from .types import JSON_DUMP_SHAPES 17 | from .types import STR_DUMP_SHAPES 18 | 19 | 20 | class _AbstractStore(BaseModel): 21 | """ 22 | An abstract class of a store 23 | """ 24 | 25 | name: str 26 | redis_config: RedisConfig 27 | redis_store: aioredis.Redis = None 28 | life_span_in_seconds: int = None 29 | 30 | class Config: 31 | """Pydantic schema config for _AbstractStore""" 32 | 33 | arbitrary_types_allowed = True 34 | orm_mode = True 35 | 36 | 37 | class _AbstractModel(BaseModel): 38 | """ 39 | An abstract class to help with typings for Model class 40 | """ 41 | 42 | _store: _AbstractStore 43 | _primary_key_field: str 44 | _table_name: Optional[str] = None 45 | _auto_sync: bool = True 46 | 47 | @classmethod 48 | def json_object_hook(cls, obj: dict): 49 | """Can be overridden to handle custom json -> object""" 50 | return obj 51 | 52 | @classmethod 53 | def json_default(cls, obj: Any) -> str: 54 | """ 55 | JSON serializer for objects not serializable by default json library 56 | Currently handles: datetimes -> obj.isoformat, ipaddress and ipnetwork -> str 57 | """ 58 | 59 | if isinstance(obj, (datetime, date)): 60 | return obj.isoformat() 61 | 62 | if isinstance(obj, STR_DUMP_SHAPES): 63 | return str(obj) 64 | 65 | raise TypeError("Type %s not serializable" % type(obj)) 66 | 67 | @classmethod 68 | def serialize_partially(cls, data: Dict[str, Any]): 69 | """Converts data types that are not compatible with Redis into json strings 70 | by looping through the models fields and inspecting its types. 71 | 72 | str, float, int - will be stored in redis as a string field 73 | None - will be converted to the string "None" 74 | More complex data types will be json dumped. 75 | 76 | The json dumper uses class.json_default as its default serializer. 77 | Users can override json_default with a custom json serializer if they chose to. 78 | Users can override serialze paritally and deserialze partially 79 | """ 80 | columns = data.keys() 81 | for field in cls.__fields__: 82 | # if we're updating a few columns, we might not have all fields, skip ones we dont have 83 | if field not in columns: 84 | continue 85 | if cls.__fields__[field].type_ not in [str, float, int]: 86 | data[field] = json.dumps(data[field], default=cls.json_default) 87 | if getattr(cls.__fields__[field], "shape", None) in JSON_DUMP_SHAPES: 88 | data[field] = json.dumps(data[field], default=cls.json_default) 89 | if getattr(cls.__fields__[field], "allow_none", False): 90 | if data[field] is None: 91 | data[field] = "None" 92 | return data 93 | 94 | @classmethod 95 | def deserialize_partially(cls, data: Dict[bytes, Any]): 96 | """Converts model fields back from json strings into python data types. 97 | 98 | Users can override serialze paritally and deserialze partially 99 | """ 100 | columns = data.keys() 101 | for field in cls.__fields__: 102 | # if we're selecting a few columns, we might not have all fields, skip ones we dont have 103 | if field not in columns: 104 | continue 105 | if cls.__fields__[field].type_ not in [str, float, int]: 106 | data[field] = json.loads(data[field], object_hook=cls.json_object_hook) 107 | if getattr(cls.__fields__[field], "shape", None) in JSON_DUMP_SHAPES: 108 | data[field] = json.loads(data[field], object_hook=cls.json_object_hook) 109 | if getattr(cls.__fields__[field], "allow_none", False): 110 | if data[field] == "None": 111 | data[field] = None 112 | return data 113 | 114 | @classmethod 115 | def get_primary_key_field(cls): 116 | """Gets the protected _primary_key_field""" 117 | return cls._primary_key_field 118 | 119 | @classmethod 120 | async def insert( 121 | cls, 122 | data: Union[List["_AbstractModel"], "_AbstractModel"], 123 | life_span_seconds: Optional[int] = None, 124 | ): # pragma: no cover 125 | """Insert into the redis store""" 126 | raise NotImplementedError("insert should be implemented") 127 | 128 | @classmethod 129 | async def delete(cls, ids: Union[Any, List[Any]]): # pragma: no cover 130 | """Delete a key from the redis store""" 131 | raise NotImplementedError("delete should be implemented") 132 | 133 | @classmethod 134 | async def select(cls, columns: Optional[List[str]] = None, ids: Optional[List[Any]] = None): # pragma: no cover 135 | """Select one or more object from the redis store""" 136 | raise NotImplementedError("select should be implemented") 137 | 138 | class Config: 139 | """Pydantic schema config for _AbstractModel""" 140 | 141 | arbitrary_types_allowed = True 142 | -------------------------------------------------------------------------------- /examples/serializer/custom_serializer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import date 3 | from typing import Any 4 | 5 | from pydantic_aioredis import Model 6 | from pydantic_aioredis import RedisConfig 7 | from pydantic_aioredis import Store 8 | 9 | 10 | class BookCover: 11 | def __init__(self, cover_url: int, cover_size_x: int, cover_size_y: int): 12 | self.cover_url = cover_url 13 | self.cover_size_x = cover_size_x 14 | self.cover_size_y = cover_size_y 15 | 16 | @property 17 | def area(self): 18 | return self.cover_size_x * self.cover_size_y 19 | 20 | 21 | # Create models as you would create pydantic models i.e. using typings 22 | class Book(Model): 23 | _primary_key_field: str = "title" 24 | title: str 25 | author: str 26 | published_on: date 27 | cover: BookCover 28 | 29 | @classmethod 30 | def json_default(cls, obj: Any) -> str: 31 | """Since BookCover can't be directly json serialized, we have to write our own 32 | json_default to serialize it methods to handle it.""" 33 | if isinstance(obj, BookCover): 34 | return { 35 | "__BookCover__": True, 36 | "cover_url": obj.cover_url, 37 | "cover_size_x": obj.cover_size_x, 38 | "cover_size_y": obj.cover_size_y, 39 | } 40 | 41 | return super().json_default(obj) 42 | 43 | @classmethod 44 | def json_object_hook(cls, obj: dict): 45 | """Since we're serializing BookCovers above, we need to write an 46 | object hook to turn them back into an Object""" 47 | if obj.get("__BookCover__", False): 48 | return BookCover( 49 | cover_url=obj["cover_url"], 50 | cover_size_x=obj["cover_size_x"], 51 | cover_size_y=obj["cover_size_y"], 52 | ) 53 | super().json_object_hook(obj) 54 | 55 | 56 | # Redisconfig. Change this configuration to match your redis server 57 | redis_config = RedisConfig(db=5, host="localhost", password="password", ssl=False, port=6379) 58 | 59 | 60 | # Create the store and register your models 61 | store = Store(name="some_name", redis_config=redis_config, life_span_in_seconds=3600) 62 | store.register_model(Book) 63 | 64 | 65 | # Sample books. You can create as many as you wish anywhere in the code 66 | books = [ 67 | Book( 68 | title="Oliver Twist", 69 | author="Charles Dickens", 70 | published_on=date(year=1215, month=4, day=4), 71 | cover=BookCover( 72 | "https://images-na.ssl-images-amazon.com/images/I/51SmEM7LUGL._SX342_SY445_QL70_FMwebp_.jpg", 73 | 333, 74 | 499, 75 | ), 76 | ), 77 | Book( 78 | title="Great Expectations", 79 | author="Charles Dickens", 80 | published_on=date(year=1220, month=4, day=4), 81 | cover=BookCover( 82 | "https://images-na.ssl-images-amazon.com/images/I/51i715XqsYL._SX311_BO1,204,203,200_.jpg", 83 | 333, 84 | 499, 85 | ), 86 | ), 87 | Book( 88 | title="Jane Eyre", 89 | author="Charlotte Bronte", 90 | published_on=date(year=1225, month=6, day=4), 91 | cover=BookCover( 92 | "https://images-na.ssl-images-amazon.com/images/I/41saarVx+GL._SX324_BO1,204,203,200_.jpg", 93 | 333, 94 | 499, 95 | ), 96 | ), 97 | Book( 98 | title="Wuthering Heights", 99 | author="Emily Bronte", 100 | published_on=date(year=1600, month=4, day=4), 101 | cover=BookCover( 102 | "https://images-na.ssl-images-amazon.com/images/I/51ZKox7zBKL._SX338_BO1,204,203,200_.jpg", 103 | 333, 104 | 499, 105 | ), 106 | ), 107 | ] 108 | 109 | 110 | async def work_with_orm(): 111 | # Insert them into redis 112 | await Book.insert(books) 113 | 114 | # Select all books to view them. A list of Model instances will be returned 115 | all_books = await Book.select() 116 | print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", 117 | # published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] 118 | 119 | # Or select some of the books 120 | some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) 121 | print(some_books) # Will return only those two books 122 | 123 | # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances 124 | # The Dictionaries have values in string form so you might need to do some extra work 125 | books_with_few_fields = await Book.select(columns=["author", "cover"]) 126 | print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "covker": Cover},...] 127 | 128 | # When _auto_sync = True (default), updating any attribute will update that field in Redis too 129 | this_book = Book( 130 | title="Moby Dick", 131 | author="Herman Melvill", 132 | published_on=date(year=1851, month=10, day=18), 133 | cover=BookCover("https://m.media-amazon.com/images/I/411a8Moy1mL._SY346_.jpg", 333, 499), 134 | ) 135 | await Book.insert(this_book) 136 | # oops, there was a typo. Fix it 137 | this_book.author = "Herman Melville" 138 | this_book_from_redis = await Book.select(ids=["Moby Dick"]) 139 | assert this_book_from_redis[0].author == "Herman Melville" 140 | 141 | # If you have _auto_save set to false on a model, you have to await .save() to update a model in tedis 142 | await this_book.save() 143 | 144 | 145 | if __name__ == "__main__": 146 | asyncio.run(work_with_orm()) 147 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 8 | 9 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 10 | 11 | 12 | Our Standards 13 | ------------- 14 | 15 | Examples of behavior that contributes to a positive environment for our community include: 16 | 17 | - Demonstrating empathy and kindness toward other people 18 | - Being respectful of differing opinions, viewpoints, and experiences 19 | - Giving and gracefully accepting constructive feedback 20 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 21 | - Focusing on what is best not just for us as individuals, but for the overall community 22 | 23 | Examples of unacceptable behavior include: 24 | 25 | - The use of sexualized language or imagery, and sexual attention or 26 | advances of any kind 27 | - Trolling, insulting or derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or email 30 | address, without their explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | Enforcement Responsibilities 35 | ---------------------------- 36 | 37 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 38 | 39 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 40 | 41 | 42 | Scope 43 | ----- 44 | 45 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 46 | 47 | 48 | Enforcement 49 | ----------- 50 | 51 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at andrew@💻.kz. All complaints will be reviewed and investigated promptly and fairly. 52 | 53 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 54 | 55 | 56 | Enforcement Guidelines 57 | ---------------------- 58 | 59 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 60 | 61 | 62 | 1. Correction 63 | ~~~~~~~~~~~~~ 64 | 65 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 66 | 67 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 68 | 69 | 70 | 2. Warning 71 | ~~~~~~~~~~ 72 | 73 | **Community Impact**: A violation through a single incident or series of actions. 74 | 75 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 76 | 77 | 78 | 3. Temporary Ban 79 | ~~~~~~~~~~~~~~~~ 80 | 81 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 82 | 83 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 84 | 85 | 86 | 4. Permanent Ban 87 | ~~~~~~~~~~~~~~~~ 88 | 89 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 90 | 91 | **Consequence**: A permanent ban from any sort of public interaction within the community. 92 | 93 | 94 | Attribution 95 | ----------- 96 | 97 | This Code of Conduct is adapted from the `Contributor Covenant `__, version 2.0, 98 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 99 | 100 | Community Impact Guidelines were inspired by `Mozilla’s code of conduct enforcement ladder `__. 101 | 102 | .. _homepage: https://www.contributor-covenant.org 103 | 104 | For answers to common questions about this code of conduct, see the FAQ at 105 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 106 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | 3 | import os 4 | import shlex 5 | import shutil 6 | import sys 7 | from pathlib import Path 8 | from textwrap import dedent 9 | 10 | import nox 11 | import toml 12 | 13 | try: 14 | from nox_poetry import Session 15 | from nox_poetry import session 16 | except ImportError: 17 | message = f"""\ 18 | Nox failed to import the 'nox-poetry' package. 19 | 20 | Please install it using the following command: 21 | 22 | {sys.executable} -m pip install nox-poetry""" 23 | raise SystemExit(dedent(message)) from None 24 | 25 | 26 | package = "healthchecks_io" 27 | python_versions = ["3.11", "3.12", "3.10", "3.9", "3.8"] 28 | nox.needs_version = ">= 2021.6.6" 29 | nox.options.sessions = ( 30 | "pre-commit", 31 | "safety", 32 | # "mypy", 33 | "tests", 34 | "docs-build", 35 | ) 36 | 37 | pyproject = toml.load("pyproject.toml") 38 | test_requirements = pyproject["tool"]["poetry"]["group"]["dev"]["dependencies"].keys() 39 | 40 | 41 | def activate_virtualenv_in_precommit_hooks(session: Session) -> None: 42 | """Activate virtualenv in hooks installed by pre-commit. 43 | 44 | This function patches git hooks installed by pre-commit to activate the 45 | session's virtual environment. This allows pre-commit to locate hooks in 46 | that environment when invoked from git. 47 | 48 | Args: 49 | session: The Session object. 50 | """ 51 | assert session.bin is not None # noqa: S101 52 | 53 | # Only patch hooks containing a reference to this session's bindir. Support 54 | # quoting rules for Python and bash, but strip the outermost quotes so we 55 | # can detect paths within the bindir, like /python. 56 | bindirs = [ 57 | bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) 58 | ] 59 | 60 | virtualenv = session.env.get("VIRTUAL_ENV") 61 | if virtualenv is None: 62 | return 63 | 64 | headers = { 65 | # pre-commit < 2.16.0 66 | "python": f"""\ 67 | import os 68 | os.environ["VIRTUAL_ENV"] = {virtualenv!r} 69 | os.environ["PATH"] = os.pathsep.join(( 70 | {session.bin!r}, 71 | os.environ.get("PATH", ""), 72 | )) 73 | """, 74 | # pre-commit >= 2.16.0 75 | "bash": f"""\ 76 | VIRTUAL_ENV={shlex.quote(virtualenv)} 77 | PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" 78 | """, 79 | } 80 | 81 | hookdir = Path(".git") / "hooks" 82 | if not hookdir.is_dir(): 83 | return 84 | 85 | for hook in hookdir.iterdir(): 86 | if hook.name.endswith(".sample") or not hook.is_file(): 87 | continue 88 | 89 | if not hook.read_bytes().startswith(b"#!"): 90 | continue 91 | 92 | text = hook.read_text() 93 | 94 | if not any(Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text for bindir in bindirs): 95 | continue 96 | 97 | lines = text.splitlines() 98 | 99 | for executable, header in headers.items(): 100 | if executable in lines[0].lower(): 101 | lines.insert(1, dedent(header)) 102 | hook.write_text("\n".join(lines)) 103 | break 104 | 105 | 106 | @session(name="pre-commit", python=python_versions[0]) 107 | def precommit(session: Session) -> None: 108 | """Lint using pre-commit.""" 109 | args = session.posargs or ["run", "--all-files", "--show-diff-on-failure"] 110 | session.install(*test_requirements) 111 | session.run("pre-commit", *args) 112 | if args and args[0] == "install": 113 | activate_virtualenv_in_precommit_hooks(session) 114 | 115 | 116 | @session(python=python_versions[0]) 117 | def safety(session: Session) -> None: 118 | """Scan dependencies for insecure packages.""" 119 | requirements = session.poetry.export_requirements() 120 | session.install("safety") 121 | # ignore https://github.com/pytest-dev/py/issues/287 122 | # its an irresposnbily filed CVE causing nose 123 | session.run("safety", "check", "--full-report", f"--file={requirements}", "--ignore=51457") 124 | 125 | 126 | @session(python=python_versions) 127 | def mypy(session: Session) -> None: 128 | """Type-check using mypy.""" 129 | args = session.posargs or ["src"] 130 | session.install(".") 131 | session.install("mypy", "pytest") 132 | session.install(*test_requirements) 133 | session.run("mypy", *args) 134 | 135 | 136 | @session(python=python_versions) 137 | def tests(session: Session) -> None: 138 | """Run the test suite.""" 139 | session.install(".") 140 | session.install(*test_requirements) 141 | session.run("poetry", "run", "pytest", *session.posargs) 142 | 143 | 144 | @session(name="docs-build", python=python_versions[0]) 145 | def docs_build(session: Session) -> None: 146 | """Build the documentation.""" 147 | args = session.posargs or ["docs", "docs/_build"] 148 | if not session.posargs and "FORCE_COLOR" in os.environ: 149 | args.insert(0, "--color") 150 | 151 | session.install(".") 152 | session.install("sphinx", "sphinx-click", "furo") 153 | 154 | build_dir = Path("docs", "_build") 155 | if build_dir.exists(): 156 | shutil.rmtree(build_dir) 157 | 158 | session.run("sphinx-build", *args) 159 | 160 | 161 | @session(python=python_versions[0]) 162 | def docs(session: Session) -> None: 163 | """Build and serve the documentation with live reloading on file changes.""" 164 | args = session.posargs or ["--open-browser", "docs", "docs/_build"] 165 | session.install(".") 166 | session.install("sphinx", "sphinx-autobuild", "sphinx-click", "furo") 167 | 168 | build_dir = Path("docs", "_build") 169 | if build_dir.exists(): 170 | shutil.rmtree(build_dir) 171 | 172 | session.run("sphinx-autobuild", *args) 173 | -------------------------------------------------------------------------------- /test/ext/FastAPI/test_ext_fastapi_crudrouter.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | from pydantic_aioredis import Model as PAModel 5 | from pydantic_aioredis.ext.FastAPI import PydanticAioredisCRUDRouter 6 | 7 | 8 | class Model(PAModel): 9 | _primary_key_field = "name" 10 | name: str 11 | value: int 12 | 13 | 14 | class ModelNoSync(PAModel): 15 | _primary_key_field = "name" 16 | name: str 17 | value: int 18 | _auto_sync = False 19 | 20 | 21 | @pytest_asyncio.fixture() 22 | async def test_app(redis_store): 23 | redis_store.register_model(Model) 24 | 25 | app = FastAPI() 26 | 27 | router = PydanticAioredisCRUDRouter(schema=Model, store=redis_store) 28 | app.include_router(router) 29 | yield redis_store, app, Model 30 | await redis_store.redis_store.close() 31 | 32 | 33 | @pytest_asyncio.fixture() 34 | def test_models(): 35 | return [Model(name=f"test{i}", value=i) for i in range(1, 10)] 36 | 37 | 38 | async def test_crudrouter_get_many_200_empty(test_app): 39 | """Tests that select_or_404 will raise a 404 error on an empty return""" 40 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 41 | response = await client.get("/model") 42 | 43 | assert response.status_code == 200 44 | assert response.json() == [] 45 | 46 | 47 | async def test_crudrouter_get_one_404(test_app): 48 | """Tests that select_or_404 will raise a 404 error on an empty return""" 49 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 50 | response = await client.get("/model/test") 51 | 52 | assert response.status_code == 404 53 | 54 | 55 | async def test_crudrouter_get_many_200(test_app, test_models): 56 | """Tests that select_or_404 will raise a 404 error on an empty return""" 57 | await test_app[2].insert(test_models) 58 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 59 | response = await client.get("/model") 60 | 61 | assert response.status_code == 200 62 | result = response.json() 63 | assert len(result) == len(test_models) 64 | 65 | 66 | async def test_crudrouter_get_many_200_pagination(test_app, test_models): 67 | """Tests that select_or_404 will raise a 404 error on an empty return""" 68 | await test_app[2].insert(test_models) 69 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 70 | response = await client.get("/model", params={"skip": 2, "limit": 5}) 71 | 72 | assert response.status_code == 200 73 | result = response.json() 74 | assert len(result) == 5 75 | 76 | 77 | async def test_crudrouter_get_one_200(test_app, test_models): 78 | """Tests that select_or_404 will raise a 404 error on an empty return""" 79 | await test_app[2].insert(test_models) 80 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 81 | response = await client.get(f"/model/{test_models[0].name}") 82 | 83 | assert response.status_code == 200 84 | result = response.json() 85 | assert result["name"] == test_models[0].name 86 | 87 | 88 | async def test_crudrouter_post_200(test_app, test_models): 89 | """Tests that crudrouter will post properly""" 90 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 91 | response = await client.post("/model", json=test_models[0].dict()) 92 | 93 | assert response.status_code == 200 94 | result = response.json() 95 | assert result["name"] == test_models[0].name 96 | 97 | 98 | async def test_crudrouter_post_422(test_app, test_models): 99 | """Tests that crudrouter post will 422 with invalid data""" 100 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 101 | response = await client.post("/model", json={"invalid": "stuff"}) 102 | 103 | assert response.status_code == 422 104 | 105 | 106 | async def test_crudrouter_put_200(test_app, test_models): 107 | """Tests that crudrouter put will 200 on a successful update""" 108 | await test_app[2].insert(test_models) 109 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 110 | response = await client.put( 111 | f"/model/{test_models[0].name}", 112 | json={"name": test_models[0].name, "value": 100}, 113 | ) 114 | 115 | assert response.status_code == 200 116 | result = response.json() 117 | assert result["name"] == test_models[0].name 118 | assert result["value"] == 100 119 | 120 | 121 | async def test_crudrouter_put_200_no_autosync(redis_store): 122 | """Tests that crudrouter put will 404 when no instance exists""" 123 | 124 | redis_store.register_model(ModelNoSync) 125 | 126 | app = FastAPI() 127 | 128 | router = PydanticAioredisCRUDRouter(schema=ModelNoSync, store=redis_store) 129 | app.include_router(router) 130 | await ModelNoSync.insert(ModelNoSync(name="test", value=20)) 131 | async with AsyncClient(app=app, base_url="http://test") as client: 132 | response = await client.put( 133 | "/modelnosync/test", 134 | json={"name": "test", "value": 100}, 135 | ) 136 | 137 | assert response.status_code == 200 138 | result = response.json() 139 | assert result["name"] == "test" 140 | assert result["value"] == 100 141 | 142 | 143 | async def test_crudrouter_put_404(test_app, test_models): 144 | """Tests that crudrouter put will 404 when no instance exists""" 145 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 146 | response = await client.put(f"/model/{test_models[0].name}", json=test_models[0].dict()) 147 | 148 | assert response.status_code == 404 149 | 150 | 151 | async def test_crudrouter_delete_200(test_app, test_models): 152 | """Tests that select_or_404 will raise a 404 error on an empty return""" 153 | await test_app[2].insert(test_models) 154 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 155 | response = await client.delete(f"/model/{test_models[0].name}") 156 | 157 | assert response.status_code == 200 158 | result = response.json() 159 | assert result["name"] == test_models[0].name 160 | 161 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 162 | response = await client.delete("/model") 163 | 164 | assert response.status_code == 200 165 | 166 | 167 | async def test_crudrouter_delete_404(test_app, test_models): 168 | """Tests that select_or_404 will raise a 404 error on an empty return""" 169 | async with AsyncClient(app=test_app[1], base_url="http://test") as client: 170 | response = await client.delete(f"/model/{test_models[0].name}") 171 | 172 | assert response.status_code == 404 173 | -------------------------------------------------------------------------------- /examples/benchmarks/test_benchmarks.py: -------------------------------------------------------------------------------- 1 | """Tests for the redis orm""" 2 | 3 | import asyncio 4 | from datetime import date 5 | from ipaddress import ip_network 6 | from ipaddress import IPv4Network 7 | from random import randint 8 | from random import sample 9 | from typing import List 10 | from typing import Optional 11 | 12 | import pytest 13 | import pytest_asyncio 14 | from pydantic_aioredis.config import RedisConfig 15 | from pydantic_aioredis.model import Model 16 | from pydantic_aioredis.store import Store 17 | 18 | 19 | class Book(Model): 20 | _primary_key_field: str = "title" 21 | title: str 22 | author: str 23 | published_on: date 24 | in_stock: bool = True 25 | 26 | 27 | books = [ 28 | Book( 29 | title="Oliver Twist", 30 | author="Charles Dickens", 31 | published_on=date(year=1215, month=4, day=4), 32 | in_stock=False, 33 | ), 34 | Book( 35 | title="Great Expectations", 36 | author="Charles Dickens", 37 | published_on=date(year=1220, month=4, day=4), 38 | ), 39 | Book( 40 | title="Jane Eyre", 41 | author="Charles Dickens", 42 | published_on=date(year=1225, month=6, day=4), 43 | in_stock=False, 44 | ), 45 | Book( 46 | title="Wuthering Heights", 47 | author="Jane Austen", 48 | published_on=date(year=1600, month=4, day=4), 49 | ), 50 | ] 51 | 52 | editions = ["first", "second", "third", "hardbound", "paperback", "ebook"] 53 | 54 | 55 | class ExtendedBook(Book): 56 | editions: List[Optional[str]] 57 | 58 | 59 | class ModelWithNone(Model): 60 | _primary_key_field = "name" 61 | name: str 62 | optional_field: Optional[str] 63 | 64 | 65 | class ModelWithIP(Model): 66 | _primary_key_field = "name" 67 | name: str 68 | ip_network: IPv4Network 69 | 70 | 71 | class ModelWithPrefix(Model): 72 | _primary_key_field = "name" 73 | _redis_prefix = "prefix" 74 | name: str 75 | 76 | 77 | class ModelWithSeparator(Model): 78 | _primary_key_field = "name" 79 | _redis_separator = "!!" 80 | name: str 81 | 82 | 83 | class ModelWithTableName(Model): 84 | _primary_key_field = "name" 85 | _table_name = "tablename" 86 | name: str 87 | 88 | 89 | class ModelWithFullCustomKey(Model): 90 | _primary_key_field = "name" 91 | _redis_prefix = "prefix" 92 | _redis_separator = "!!" 93 | _table_name = "custom" 94 | name: str 95 | 96 | 97 | extended_books = [ExtendedBook(**book.dict(), editions=sample(editions, randint(0, len(editions)))) for book in books] 98 | extended_books[0].editions = list() 99 | 100 | test_models = [ 101 | ModelWithNone(name="test", optional_field="test"), 102 | ModelWithNone(name="test2"), 103 | ] 104 | 105 | test_ip_models = [ 106 | ModelWithIP(name="test", ip_network=ip_network("10.10.0.0/24")), 107 | ModelWithIP(name="test2", ip_network=ip_network("192.168.0.0/16")), 108 | ] 109 | 110 | test_models_with_prefix = [ 111 | ModelWithPrefix(name="test"), 112 | ModelWithPrefix(name="test2"), 113 | ] 114 | 115 | test_models_with_separator = [ 116 | ModelWithSeparator(name="test"), 117 | ModelWithSeparator(name="test2"), 118 | ] 119 | 120 | 121 | test_models_with_tablename = [ 122 | ModelWithTableName(name="test"), 123 | ModelWithTableName(name="test2"), 124 | ] 125 | 126 | test_models_with_fullcustom = [ 127 | ModelWithFullCustomKey(name="test"), 128 | ModelWithFullCustomKey(name="test2"), 129 | ] 130 | 131 | 132 | @pytest_asyncio.fixture() 133 | async def redis_store(redis_server): 134 | """Sets up a redis store using the redis_server fixture and adds the book model to it""" 135 | store = Store( 136 | name="sample", 137 | redis_config=RedisConfig(port=redis_server, db=1), # nosec 138 | life_span_in_seconds=3600, 139 | ) 140 | store.register_model(Book) 141 | store.register_model(ExtendedBook) 142 | store.register_model(ModelWithNone) 143 | store.register_model(ModelWithIP) 144 | store.register_model(ModelWithPrefix) 145 | store.register_model(ModelWithSeparator) 146 | store.register_model(ModelWithTableName) 147 | store.register_model(ModelWithFullCustomKey) 148 | yield store 149 | await store.redis_store.flushall() 150 | 151 | 152 | parameters = [ 153 | ( 154 | pytest.lazy_fixture("redis_store"), 155 | pytest.lazy_fixture("aio_benchmark"), 156 | books, 157 | Book, 158 | "book:", 159 | ), 160 | ( 161 | pytest.lazy_fixture("redis_store"), 162 | pytest.lazy_fixture("aio_benchmark"), 163 | extended_books, 164 | ExtendedBook, 165 | "extendedbook:", 166 | ), 167 | ( 168 | pytest.lazy_fixture("redis_store"), 169 | pytest.lazy_fixture("aio_benchmark"), 170 | test_models, 171 | ModelWithNone, 172 | "modelwithnone:", 173 | ), 174 | ( 175 | pytest.lazy_fixture("redis_store"), 176 | pytest.lazy_fixture("aio_benchmark"), 177 | test_ip_models, 178 | ModelWithIP, 179 | "modelwithip:", 180 | ), 181 | ( 182 | pytest.lazy_fixture("redis_store"), 183 | pytest.lazy_fixture("aio_benchmark"), 184 | test_models_with_prefix, 185 | ModelWithPrefix, 186 | "prefix:modelwithprefix:", 187 | ), 188 | ( 189 | pytest.lazy_fixture("redis_store"), 190 | pytest.lazy_fixture("aio_benchmark"), 191 | test_models_with_separator, 192 | ModelWithSeparator, 193 | "modelwithseparator!!", 194 | ), 195 | ( 196 | pytest.lazy_fixture("redis_store"), 197 | pytest.lazy_fixture("aio_benchmark"), 198 | test_models_with_tablename, 199 | ModelWithTableName, 200 | "tablename:", 201 | ), 202 | ( 203 | pytest.lazy_fixture("redis_store"), 204 | pytest.lazy_fixture("aio_benchmark"), 205 | test_models_with_fullcustom, 206 | ModelWithFullCustomKey, 207 | "prefix!!custom!!", 208 | ), 209 | ] 210 | 211 | 212 | @pytest_asyncio.fixture 213 | async def aio_benchmark(benchmark, event_loop): 214 | def _wrapper(func, *args, **kwargs): 215 | if asyncio.iscoroutinefunction(func): 216 | 217 | @benchmark 218 | def _(): 219 | return event_loop.run_until_complete(func(*args, **kwargs)) 220 | 221 | else: 222 | benchmark(func, *args, **kwargs) 223 | 224 | return _wrapper 225 | 226 | 227 | async def import_benchmark(rs, model_class, models): 228 | await model_class.insert(models) 229 | 230 | 231 | @pytest.mark.parametrize("rs, ab, models, model_class, key_prefix", parameters) 232 | def test_bulk_insert(rs, ab, models, model_class, key_prefix): 233 | ab(import_benchmark, rs, model_class, models) 234 | -------------------------------------------------------------------------------- /examples/benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # benchmarks 2 | 3 | [test_benchmarks.py](./test_benchmarks.py) is an example benchmarking suite for pydantic-aioredis. Its a fairly contrived example, using 4 | models copied from our test suite. 5 | 6 | This benchmarking suite serves two purposes. First, you can use it as an example to build your own benchmarking suite for your own models and application. Second, it is useful when developing pydantic-aioredis 7 | 8 | ## Benchmarking during development 9 | 10 | Benchmarking is not run as part of CI, due to its results not really being indicative of much. 11 | 12 | For development purposes, if you are making changes that might cause performance changes, you can run the benchmarking suite to test that. 13 | 14 | 1. Run the benchmarking suite against the master branch 15 | 16 | ```shell 17 | make run-benchmark 18 | ``` 19 | 20 | 2. Make your changes in a branch 21 | 3. Run the benchmark again against your branch 22 | 23 | ```shell 24 | make run-benchmark 25 | test_benchmarks.py ........ [100%] 26 | Saved benchmark data in: /Users/andrew/Documents/code/pydantic-aioredis/examples/benchmarks/.benchmarks/Darwin-CPython-3.10-64bit/0002_a8ba8e5626baf18a710577db946d52a6ddaed6fa_20220918_011903_uncommited-changes.json 27 | 28 | 29 | 30 | ------------------------------------------------------------------------------------------------------------------------------------ benchmark: 16 tests ------------------------------------------------------------------------------------------------------------------------------------ 31 | Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations 32 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 33 | test_bulk_insert[redis_store-aio_benchmark-models4-ModelWithPrefix-prefix:modelwithprefix:] (0001_a8ba8e5) 236.1250 (1.0) 575.1670 (1.18) 285.3251 (1.0) 35.3125 (1.80) 272.5420 (1.0) 25.5731 (1.47) 98;70 3.5048 (1.0) 645 1 34 | test_bulk_insert[redis_store-aio_benchmark-models5-ModelWithSeparator-modelwithseparator!!] (NOW) 239.2500 (1.01) 524.4169 (1.07) 306.2764 (1.07) 63.9848 (3.27) 274.1665 (1.01) 102.2499 (5.86) 97;1 3.2650 (0.93) 442 1 35 | test_bulk_insert[redis_store-aio_benchmark-models3-ModelWithIP-modelwithip:] (NOW) 247.7920 (1.05) 544.1250 (1.12) 299.9481 (1.05) 35.7845 (1.83) 288.3755 (1.06) 27.1461 (1.56) 73;33 3.3339 (0.95) 436 1 36 | test_bulk_insert[redis_store-aio_benchmark-models7-ModelWithFullCustomKey-prefix!!custom!!] (NOW) 252.1670 (1.07) 968.1670 (1.98) 345.5339 (1.21) 88.7134 (4.53) 335.8750 (1.23) 114.0832 (6.54) 25;9 2.8941 (0.83) 273 1 37 | test_bulk_insert[redis_store-aio_benchmark-models6-ModelWithTableName-tablename:] (NOW) 257.3340 (1.09) 1,078.4591 (2.21) 383.8750 (1.35) 132.0333 (6.74) 355.8125 (1.31) 99.1239 (5.68) 23;23 2.6050 (0.74) 238 1 38 | test_bulk_insert[redis_store-aio_benchmark-models6-ModelWithTableName-tablename:] (0001_a8ba8e5) 257.6250 (1.09) 1,053.8750 (2.16) 346.8971 (1.22) 103.2863 (5.27) 328.8955 (1.21) 105.7080 (6.06) 11;8 2.8827 (0.82) 290 1 39 | test_bulk_insert[redis_store-aio_benchmark-models4-ModelWithPrefix-prefix:modelwithprefix:] (NOW) 258.6249 (1.10) 835.5831 (1.71) 345.5334 (1.21) 85.7124 (4.37) 316.0409 (1.16) 117.2609 (6.72) 32;6 2.8941 (0.83) 271 1 40 | test_bulk_insert[redis_store-aio_benchmark-models5-ModelWithSeparator-modelwithseparator!!] (0001_a8ba8e5) 261.9170 (1.11) 979.4589 (2.01) 368.7724 (1.29) 111.1759 (5.67) 346.8749 (1.27) 102.1872 (5.86) 20;13 2.7117 (0.77) 251 1 41 | test_bulk_insert[redis_store-aio_benchmark-models2-ModelWithNone-modelwithnone:] (NOW) 267.7920 (1.13) 1,136.8330 (2.33) 364.0978 (1.28) 99.5982 (5.08) 346.2080 (1.27) 127.4063 (7.31) 13;7 2.7465 (0.78) 257 1 42 | test_bulk_insert[redis_store-aio_benchmark-models7-ModelWithFullCustomKey-prefix!!custom!!] (0001_a8ba8e5) 267.9999 (1.13) 779.0411 (1.60) 376.6928 (1.32) 87.3057 (4.46) 369.0000 (1.35) 85.1772 (4.88) 47;12 2.6547 (0.76) 207 1 43 | test_bulk_insert[redis_store-aio_benchmark-models3-ModelWithIP-modelwithip:] (0001_a8ba8e5) 286.6250 (1.21) 1,870.9170 (3.83) 425.0030 (1.49) 158.7233 (8.10) 398.2920 (1.46) 100.8335 (5.78) 21;21 2.3529 (0.67) 192 1 44 | test_bulk_insert[redis_store-aio_benchmark-models2-ModelWithNone-modelwithnone:] (0001_a8ba8e5) 290.4161 (1.23) 1,206.3751 (2.47) 415.8359 (1.46) 129.6744 (6.62) 394.7085 (1.45) 88.8335 (5.09) 14;14 2.4048 (0.69) 180 1 45 | test_bulk_insert[redis_store-aio_benchmark-models0-Book-book:] (NOW) 373.8340 (1.58) 487.9159 (1.0) 400.8765 (1.40) 20.5551 (1.05) 394.2915 (1.45) 17.4375 (1.0) 55;29 2.4945 (0.71) 276 1 46 | test_bulk_insert[redis_store-aio_benchmark-models0-Book-book:] (0001_a8ba8e5) 374.8330 (1.59) 538.2920 (1.10) 399.7707 (1.40) 19.5931 (1.0) 393.6250 (1.44) 19.3750 (1.11) 58;15 2.5014 (0.71) 307 1 47 | test_bulk_insert[redis_store-aio_benchmark-models1-ExtendedBook-extendedbook:] (0001_a8ba8e5) 405.1670 (1.72) 782.5830 (1.60) 456.8254 (1.60) 71.1623 (3.63) 428.8960 (1.57) 33.9170 (1.95) 49;55 2.1890 (0.62) 384 1 48 | test_bulk_insert[redis_store-aio_benchmark-models1-ExtendedBook-extendedbook:] (NOW) 405.4171 (1.72) 754.2920 (1.55) 452.1815 (1.58) 53.4064 (2.73) 432.6666 (1.59) 24.4590 (1.40) 44;58 2.2115 (0.63) 396 1 49 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 50 | 51 | Legend: 52 | Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile. 53 | OPS: Operations Per Second, computed as 1 / Mean 54 | ============================================================================================================================================ 8 passed in 3.87s ============================================================================================================================================= 55 | ``` 56 | 57 | Benchmarks will automatically compare the benchmark now vs your first run 58 | 59 | ## How this works 60 | 61 | The benchmarks make use of 62 | 63 | ## Benchmark status 64 | 65 | The benchmarks are a work in progress. At this time, they're testing very basic inserts. More work could be done to add additional benchmarks 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.4.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.3.1...v1.4.0) (2024-05-08) 4 | 5 | 6 | ### Features 7 | 8 | * NestedAsyncIO contextmanager ([#872](https://github.com/andrewthetechie/pydantic-aioredis/issues/872)) ([f061046](https://github.com/andrewthetechie/pydantic-aioredis/commit/f06104661b16e516994b3469bb775bcb52ff6508)) 9 | 10 | 11 | ### Dependencies 12 | 13 | * bump anyio from 3.7.1 to 4.3.0 ([#863](https://github.com/andrewthetechie/pydantic-aioredis/issues/863)) ([eec20b7](https://github.com/andrewthetechie/pydantic-aioredis/commit/eec20b76216aa569b1b39a95ec047843387be0ca)) 14 | * bump fakeredis from 2.22.0 to 2.23.0 ([f304bc8](https://github.com/andrewthetechie/pydantic-aioredis/commit/f304bc8840bc45b33ec9fb07a6f3def4ebc91281)) 15 | * bump hypothesis from 6.100.4 to 6.100.5 ([7253067](https://github.com/andrewthetechie/pydantic-aioredis/commit/725306722d2aa8b31e736bcb3dbcf16a9440449b)) 16 | * bump jinja2 from 3.1.3 to 3.1.4 ([450952e](https://github.com/andrewthetechie/pydantic-aioredis/commit/450952ecedee21734aa9fc633d41924e372cf0d4)) 17 | * bump mypy from 1.4.1 to 1.10.0 ([89e1421](https://github.com/andrewthetechie/pydantic-aioredis/commit/89e14214b043b0dfbe36ce3ea792b13fa6602fd6)) 18 | * bump pytest-env from 0.8.2 to 1.1.3 ([#862](https://github.com/andrewthetechie/pydantic-aioredis/issues/862)) ([e0e66a5](https://github.com/andrewthetechie/pydantic-aioredis/commit/e0e66a58fb0578337ff4f62ae909792a65a2a84f)) 19 | * bump redis from 4.6.0 to 5.0.4 ([#860](https://github.com/andrewthetechie/pydantic-aioredis/issues/860)) ([5a30eb2](https://github.com/andrewthetechie/pydantic-aioredis/commit/5a30eb288a955161df8b026e032d9d4e9129727c)) 20 | * bump sphinx from 5.3.0 to 7.1.2 ([#861](https://github.com/andrewthetechie/pydantic-aioredis/issues/861)) ([583c127](https://github.com/andrewthetechie/pydantic-aioredis/commit/583c1275c9ea78f76fc50e0a2dc08b2a83d833c6)) 21 | 22 | 23 | ### Documentation 24 | 25 | * add CharlieJiangXXX as a contributor for code ([#873](https://github.com/andrewthetechie/pydantic-aioredis/issues/873)) ([0bc4c67](https://github.com/andrewthetechie/pydantic-aioredis/commit/0bc4c67c2f9eb9dd3897710a06f49342021d618c)) 26 | * bump furo from 2024.4.27 to 2024.5.6 in /docs ([94bbb44](https://github.com/andrewthetechie/pydantic-aioredis/commit/94bbb444a84a7c69c34632a6c695759003373682)) 27 | 28 | ## [1.3.1](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.3.0...v1.3.1) (2024-05-05) 29 | 30 | 31 | ### Dependencies 32 | 33 | * bump anyio from 3.7.1 to 4.3.0 ([#855](https://github.com/andrewthetechie/pydantic-aioredis/issues/855)) ([9581b6e](https://github.com/andrewthetechie/pydantic-aioredis/commit/9581b6e5787d4a1218dc59447158010ba21d8d8e)) 34 | 35 | ## [1.3.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.2.3...v1.3.0) (2024-05-05) 36 | 37 | 38 | ### Features 39 | 40 | * drop 3.7, add 3.12 ([#850](https://github.com/andrewthetechie/pydantic-aioredis/issues/850)) ([67b8f08](https://github.com/andrewthetechie/pydantic-aioredis/commit/67b8f0866f24c2f757ce1d6b94d431b751def97f)) 41 | 42 | 43 | ### Documentation 44 | 45 | * add kraczak as a contributor for doc ([#547](https://github.com/andrewthetechie/pydantic-aioredis/issues/547)) ([c7310c1](https://github.com/andrewthetechie/pydantic-aioredis/commit/c7310c11c2f7bc9c02d1e5a0dc071eb89fcbf182)) 46 | * update readme example ([992c4a1](https://github.com/andrewthetechie/pydantic-aioredis/commit/992c4a12ce83df793c13c9f266a704adb67ab7d4)) 47 | 48 | ## [1.2.3](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.2.2...v1.2.3) (2023-03-30) 49 | 50 | 51 | ### Documentation 52 | 53 | * remove unneccesary fields from bug report ([d1454f9](https://github.com/andrewthetechie/pydantic-aioredis/commit/d1454f91ec394a6f6c0d043ad2511d156808e5df)) 54 | 55 | 56 | ### Dependencies 57 | 58 | * update redis-py for CVE ([#542](https://github.com/andrewthetechie/pydantic-aioredis/issues/542)) ([ea3c9bf](https://github.com/andrewthetechie/pydantic-aioredis/commit/ea3c9bf177ada446e97f91c55e982c86c4f4b856)) 59 | 60 | ## [1.2.2](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.2.1...v1.2.2) (2023-02-10) 61 | 62 | 63 | ### Documentation 64 | 65 | * fix read the docs ([#457](https://github.com/andrewthetechie/pydantic-aioredis/issues/457)) ([1bb8ff8](https://github.com/andrewthetechie/pydantic-aioredis/commit/1bb8ff8a7f63b0d742a798fc902db8b6fd499561)) 66 | 67 | ## [1.2.1](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.2.0...v1.2.1) (2023-01-21) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * fix json types ([38a2608](https://github.com/andrewthetechie/pydantic-aioredis/commit/38a26084c65c9f01319318fecc3bfcf43d03474d)) 73 | * poetry update ([#431](https://github.com/andrewthetechie/pydantic-aioredis/issues/431)) ([968282e](https://github.com/andrewthetechie/pydantic-aioredis/commit/968282e4c87ac324e2380853508a189d0fcfe39d)) 74 | 75 | ## [1.2.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.1.1...v1.2.0) (2022-12-21) 76 | 77 | 78 | ### Features 79 | 80 | * auto_sync and auto_save ([#401](https://github.com/andrewthetechie/pydantic-aioredis/issues/401)) ([0e11e9d](https://github.com/andrewthetechie/pydantic-aioredis/commit/0e11e9dedef38c8d7b6226a143e7a3ae7ad2a340)) 81 | 82 | ## [1.1.1](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.1.0...v1.1.1) (2022-12-19) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * cleanup serializer ([#399](https://github.com/andrewthetechie/pydantic-aioredis/issues/399)) ([ec1421c](https://github.com/andrewthetechie/pydantic-aioredis/commit/ec1421c55608d87400eeb3e49332c61dddc0c5f6)) 88 | 89 | 90 | ### Documentation 91 | 92 | * add david-wahlstedt as a contributor for doc, and review ([#394](https://github.com/andrewthetechie/pydantic-aioredis/issues/394)) ([c9beb1a](https://github.com/andrewthetechie/pydantic-aioredis/commit/c9beb1aa85a7c394aceb0ab5cde10f6dfb32bf1a)) 93 | * add docs about Union types and casting ([#383](https://github.com/andrewthetechie/pydantic-aioredis/issues/383)) ([2af6167](https://github.com/andrewthetechie/pydantic-aioredis/commit/2af61672e3fb02db60f5faea0a6bf1fd528dcb6a)) 94 | * update contributors shield ([84cc727](https://github.com/andrewthetechie/pydantic-aioredis/commit/84cc727763f7757ead8ecaf870885e4dd71636b0)) 95 | 96 | ## [1.1.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v1.0.0...v1.1.0) (2022-12-17) 97 | 98 | 99 | ### Features 100 | 101 | * py3.11 support ([#391](https://github.com/andrewthetechie/pydantic-aioredis/issues/391)) ([2a82181](https://github.com/andrewthetechie/pydantic-aioredis/commit/2a82181ffb29ed7e26c314a0b9d9f0f2f29a6abb)) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * add toml ([ea583b1](https://github.com/andrewthetechie/pydantic-aioredis/commit/ea583b152c2aecf31495cc95303ba79ec821e799)) 107 | * update all requirements, safety ignore ([ebc1e86](https://github.com/andrewthetechie/pydantic-aioredis/commit/ebc1e863e251c9d8a7976d75335f84b29d1d2b4b)) 108 | 109 | 110 | ### Documentation 111 | 112 | * add all contributors ([#384](https://github.com/andrewthetechie/pydantic-aioredis/issues/384)) ([01dae8f](https://github.com/andrewthetechie/pydantic-aioredis/commit/01dae8fbfe706bba60e6be429ad040c4d641ecef)) 113 | * add andrewthetechie as a contributor for code, and doc ([#386](https://github.com/andrewthetechie/pydantic-aioredis/issues/386)) ([90739f9](https://github.com/andrewthetechie/pydantic-aioredis/commit/90739f9bae9667662175811404b7ecc3d7ca76ec)) 114 | * add david-wahlstedt as a contributor for test ([#388](https://github.com/andrewthetechie/pydantic-aioredis/issues/388)) ([ad155f7](https://github.com/andrewthetechie/pydantic-aioredis/commit/ad155f79e303d72a9717021ae89668e51d8f50d6)) 115 | * add gtmanfred as a contributor for test ([#390](https://github.com/andrewthetechie/pydantic-aioredis/issues/390)) ([2428db4](https://github.com/andrewthetechie/pydantic-aioredis/commit/2428db486b9fcd2ce654c711f5a7fe5b25f456de)) 116 | * add martin ([23f0e56](https://github.com/andrewthetechie/pydantic-aioredis/commit/23f0e560a65bad108b82f6e4f40384ff69156e26)) 117 | * update development docs ([97640e8](https://github.com/andrewthetechie/pydantic-aioredis/commit/97640e843f2f201f88e6c3ed580fd5947464e00a)) 118 | 119 | ## [1.0.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v0.7.0...v1.0.0) (2022-09-23) 120 | 121 | 122 | ### ⚠ BREAKING CHANGES 123 | 124 | * This is a breaking change to how updates for model attributes are saved to Redis. It removes the update classmethod and replaces with with a save method on each model. 125 | * This will result in "data loss" for existing models stored in redis due to the change in default separator. To maintain backwards compatbility with 0.7.0 and below, you will need to modify your existing models to set _redis_separator = "_%&_" as a field on them. 126 | 127 | ### Features 128 | 129 | * json_object_hook and serializer example ([#294](https://github.com/andrewthetechie/pydantic-aioredis/issues/294)) ([80c725e](https://github.com/andrewthetechie/pydantic-aioredis/commit/80c725e087b1a09917df1770ebc676139808b2cb)) 130 | * redis-separator ([#278](https://github.com/andrewthetechie/pydantic-aioredis/issues/278)) ([f367d30](https://github.com/andrewthetechie/pydantic-aioredis/commit/f367d300751b3a7550b54c31f6a7da58e9296351)) 131 | * update on setattr ([#287](https://github.com/andrewthetechie/pydantic-aioredis/issues/287)) ([f1ce5c2](https://github.com/andrewthetechie/pydantic-aioredis/commit/f1ce5c2b1fe292cfe8dd509cac477f617e36c057)) 132 | 133 | 134 | ### Bug Fixes 135 | 136 | * fix pre-commit in example ([ab94167](https://github.com/andrewthetechie/pydantic-aioredis/commit/ab94167a8ff22b5290f05a4b2eb3ea11a2fb4ab0)) 137 | 138 | 139 | ### Documentation 140 | 141 | * change typing to <3.10 compatible ([2ccfa0a](https://github.com/andrewthetechie/pydantic-aioredis/commit/2ccfa0a38911e2fce0c6baaa79d3d39a896e2613)) 142 | * fix incorrect links in CONTRIBUTING.rst ([a8ba8e5](https://github.com/andrewthetechie/pydantic-aioredis/commit/a8ba8e5626baf18a710577db946d52a6ddaed6fa)) 143 | * fix invalid doc in abstract.py ([32d0d13](https://github.com/andrewthetechie/pydantic-aioredis/commit/32d0d137fe87024f45e1875fe349d819a957f3f0)) 144 | 145 | ## [0.7.0](https://github.com/andrewthetechie/pydantic-aioredis/compare/v0.6.0...v0.7.0) (2022-07-21) 146 | 147 | 148 | ### Features 149 | 150 | * move to redis-py ([#217](https://github.com/andrewthetechie/pydantic-aioredis/issues/217)) ([bd2831b](https://github.com/andrewthetechie/pydantic-aioredis/commit/bd2831b66b7a4949cbd5f116b508d6cb54361321)) 151 | 152 | 153 | ### Documentation 154 | 155 | * update shields in readme ([a5bea90](https://github.com/andrewthetechie/pydantic-aioredis/commit/a5bea90df6a68eca2a08d01747d32bc1fdf03648)) 156 | -------------------------------------------------------------------------------- /pydantic_aioredis/model.py: -------------------------------------------------------------------------------- 1 | """Module containing the model classes""" 2 | 3 | import asyncio 4 | from contextlib import asynccontextmanager 5 | from functools import lru_cache 6 | from sys import version_info 7 | from typing import Any 8 | from typing import List 9 | from typing import Optional 10 | from typing import Tuple 11 | from typing import Union 12 | 13 | from pydantic_aioredis.abstract import _AbstractModel 14 | from pydantic_aioredis.utils import bytes_to_string, NestedAsyncIO 15 | 16 | 17 | class Model(_AbstractModel): 18 | """ 19 | The section in the store that saves rows of the same kind 20 | 21 | Model has some custom fields you can set in your models that alter the behavior of how this is stored in redis 22 | 23 | _primary_key_field -- The field of your model that is the primary key 24 | _redis_prefix -- If set, will be added to the beginning of the keys we store in redis 25 | _redis_separator -- Defaults to :, used to separate prefix, table_name, and primary_key 26 | _table_name -- Defaults to the model's name, can set a custom name in redis 27 | 28 | 29 | If your model was named ThisModel, the primary key was "key", and prefix and 30 | separator were left at default (not set), the keys stored in redis would be 31 | thismodel:key 32 | """ 33 | 34 | _auto_sync = False 35 | _auto_save = False 36 | 37 | def __init__(self, **data: Any) -> None: 38 | auto_save = data.pop("auto_save") if "auto_save" in data.keys() else getattr(self, "_auto_save", False) 39 | auto_sync = data.pop("auto_sync") if "auto_sync" in data.keys() else getattr(self, "_auto_sync", False) 40 | super().__init__(**data) 41 | # set _auto_save and _auto_sync to the class defaults in the Config 42 | self.Config.auto_sync = auto_sync 43 | self.Config.auto_save = auto_save 44 | # If this instance is _auto_save, save it 45 | if self.auto_save and getattr(self, "_store", None) is not None: 46 | self.__save_from_sync() 47 | 48 | def __setattr__(self, name: str, value: Any): 49 | """ 50 | This overrides __setattr__ to allow for auto_sync 51 | because __setattr__ has be to sync, this uses a hack to call the async save method 52 | 53 | """ 54 | super().__setattr__(name, value) 55 | if self._auto_sync and getattr(self, "_store", None) is not None: 56 | self.__save_from_sync() 57 | 58 | def __save_from_sync(self): 59 | """Calls the async save coroutine from a sync context, used with _auto_save and _auto_sync""" 60 | if version_info.minor < 10: # pragma: no cover 61 | # less than 3.10.0 62 | io_loop = asyncio.get_event_loop() 63 | else: # pragma: no cover 64 | # equal or greater than 3.10.0 65 | try: 66 | io_loop = asyncio.get_running_loop() 67 | # https://github.com/erdewit/nest_asyncio 68 | # Use nest_asyncio so we can call the async save 69 | except RuntimeError: 70 | io_loop = asyncio.new_event_loop() 71 | with NestedAsyncIO(): 72 | io_loop.run_until_complete(self.save()) 73 | 74 | @asynccontextmanager 75 | async def update(self): 76 | """ 77 | Async Context manager to allow for updating multiple fields without syncing to redis until the end 78 | """ 79 | auto_sync = self.auto_sync 80 | if auto_sync: 81 | self.set_auto_sync(False) 82 | yield self 83 | if getattr(self, "_store", None) is not None: 84 | await self.save() 85 | if auto_sync != self.auto_sync: 86 | self.set_auto_sync(auto_sync) 87 | 88 | @property 89 | def auto_sync(self) -> bool: 90 | return getattr(self.Config, "auto_sync", False) 91 | 92 | def set_auto_sync(self, auto_sync: bool = True) -> None: 93 | """Change this instance of a model's _auto_sync setting""" 94 | self.Config.auto_sync = auto_sync 95 | 96 | @property 97 | def auto_save(self) -> bool: 98 | return getattr(self.Config, "auto_save", False) 99 | 100 | def set_auto_save(self, auto_save: bool = True) -> None: 101 | """Change this instance of a model's _auto_save setting""" 102 | self.Config.auto_save = auto_save 103 | 104 | @classmethod 105 | @lru_cache(1) 106 | def _get_prefix(cls) -> str: 107 | prefix_str = getattr(cls, "_redis_prefix", "").lower() 108 | return f"{prefix_str}{cls._get_separator()}" if prefix_str != "" else "" 109 | 110 | @classmethod 111 | @lru_cache(1) 112 | def _get_separator(cls): 113 | return getattr(cls, "_redis_separator", ":").lower() 114 | 115 | @classmethod 116 | @lru_cache(1) 117 | def _get_tablename(cls): 118 | return cls.__name__.lower() if cls._table_name is None else cls._table_name 119 | 120 | @classmethod 121 | @lru_cache(1) 122 | def __get_primary_key(cls, primary_key_value: Any): 123 | """ 124 | Uses _table_name, _table_refix, and _redis_separator from the model to build our primary key. 125 | 126 | _table_name defaults to the name of the model class if it is not set 127 | _redis_separator defualts to : if it is not set 128 | _prefix defaults to nothing if it is not set 129 | 130 | The key is contructed as {_prefix}{_redis_separator}{_table_name}{_redis_separator}{primary_key_value} 131 | So a model named ThisModel with a primary key of id, by default would result in a key of thismodel:id 132 | """ 133 | 134 | return f"{cls._get_prefix()}{cls._get_tablename()}{cls._get_separator()}{primary_key_value}" 135 | 136 | @classmethod 137 | def get_table_index_key(cls): 138 | """Returns the key in which the primary keys of the given table have been saved""" 139 | return f"{cls._get_prefix()}{cls._get_tablename()}{cls._get_separator()}__index" 140 | 141 | @classmethod 142 | async def _ids_to_primary_keys(cls, ids: Optional[Union[Any, List[Any]]] = None) -> Tuple[List[Optional[str]], str]: 143 | """Turn passed in ids into primary key values""" 144 | table_index_key = cls.get_table_index_key() 145 | if ids is None: 146 | keys_generator = cls._store.redis_store.sscan_iter(name=table_index_key) 147 | keys = [key async for key in keys_generator] 148 | else: 149 | if not isinstance(ids, list): 150 | ids = [ids] 151 | keys = [cls.__get_primary_key(primary_key_value=primary_key_value) for primary_key_value in ids] 152 | keys.sort() 153 | return keys, table_index_key 154 | 155 | @classmethod 156 | async def insert( 157 | cls, 158 | data: Union[List[_AbstractModel], _AbstractModel], 159 | life_span_seconds: Optional[int] = None, 160 | ): 161 | """ 162 | Inserts a given row or sets of rows into the table 163 | """ 164 | life_span = life_span_seconds if life_span_seconds is not None else cls._store.life_span_in_seconds 165 | async with cls._store.redis_store.pipeline(transaction=True) as pipeline: 166 | data_list = [] 167 | 168 | data_list = [data] if not isinstance(data, list) else data 169 | 170 | for record in data_list: 171 | primary_key_value = getattr(record, cls._primary_key_field) 172 | name = cls.__get_primary_key(primary_key_value=primary_key_value) 173 | mapping = cls.serialize_partially(record.dict()) 174 | pipeline.hset(name=name, mapping=mapping) 175 | 176 | if life_span is not None: 177 | pipeline.expire(name=name, time=life_span) 178 | # save the primary key in an index 179 | table_index_key = cls.get_table_index_key() 180 | pipeline.sadd(table_index_key, name) 181 | if life_span is not None: 182 | pipeline.expire(table_index_key, time=life_span) 183 | response = await pipeline.execute() 184 | 185 | return response 186 | 187 | async def save(self): 188 | await self.insert(self) 189 | 190 | @classmethod 191 | async def delete(cls, ids: Optional[Union[Any, List[Any]]] = None) -> Optional[List[int]]: 192 | """ 193 | deletes a given row or sets of rows in the table 194 | """ 195 | keys, table_index_key = await cls._ids_to_primary_keys(ids) 196 | if len(keys) == 0: 197 | return None 198 | async with cls._store.redis_store.pipeline(transaction=True) as pipeline: 199 | pipeline.delete(*keys) 200 | # remove the primary keys from the index 201 | pipeline.srem(table_index_key, *keys) 202 | response = await pipeline.execute() 203 | return response 204 | 205 | @classmethod 206 | async def select( 207 | cls, 208 | columns: Optional[List[str]] = None, 209 | ids: Optional[List[Any]] = None, 210 | skip: Optional[int] = None, 211 | limit: Optional[int] = None, 212 | ) -> Optional[List[Any]]: 213 | """ 214 | Selects given rows or sets of rows in the table 215 | 216 | Pagination is accomplished by using the below variables 217 | skip: Optional[int] 218 | limit: Optional[int] 219 | """ 220 | all_keys, _ = await cls._ids_to_primary_keys(ids) 221 | if limit is not None and skip is not None: 222 | limit = limit + skip 223 | keys = all_keys[skip:limit] 224 | async with cls._store.redis_store.pipeline() as pipeline: 225 | for key in keys: 226 | if columns is None: 227 | pipeline.hgetall(name=key) 228 | else: 229 | pipeline.hmget(name=key, keys=columns) 230 | 231 | response = await pipeline.execute() 232 | 233 | if len(response) == 0: 234 | return None 235 | 236 | if response[0] == {}: 237 | return None 238 | 239 | if isinstance(response, list) and columns is None: 240 | result = [cls(**cls.deserialize_partially(record)) for record in response if record != {}] 241 | else: 242 | result = [ 243 | cls.deserialize_partially( 244 | {field: bytes_to_string(record[index]) for index, field in enumerate(columns)} 245 | ) 246 | for record in response 247 | if record != {} 248 | ] 249 | return result 250 | 251 | 252 | class AutoModel(Model): 253 | """A model that automatically saves to redis on creation and syncs changing fields to redis""" 254 | 255 | _auto_sync: bool = True 256 | _auto_save: bool = True 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydantic-aioredis 2 | 3 | Pydantic-aioredis is designed to provide an efficient way of integrating Redis databases with Python-based applications. Built on top of [Pydantic](https://docs.pydantic.dev/), pydantic-aioredis allows you to define data models and validate input data before it is stored in Redis. Data is validated before storing and after retrieval from Redis. The library also provides an easy-to-use API for querying, updating, and deleting data stored in Redis. 4 | 5 | Inspired by 6 | [pydantic-redis](https://github.com/sopherapps/pydantic-redis) by 7 | [Martin Ahindura](https://github.com/Tinitto) 8 | 9 |

10 | 11 | Latest Commit 12 | 13 | 14 | GitHub release (latest by date) 15 |
16 | 17 | GitHub Workflow Status Test and Lint (branch) 18 | Contributors 19 |
20 | 21 | Package version 22 | 23 | 24 |

25 | 26 | ## Main Dependencies 27 | 28 | - [Python +3.7](https://www.python.org) 29 | - [redis-py <4.2.0](https://github.com/redis/redis-py) 30 | - [pydantic](https://github.com/samuelcolvin/pydantic/) 31 | 32 | ## Getting Started 33 | 34 | ### Examples 35 | 36 | Examples are in the [examples/](./examples) directory of this repo. 37 | 38 | ### Installation 39 | 40 | Install the package 41 | 42 | pip install pydantic-aioredis 43 | 44 | ### Quick Usage 45 | 46 | Import the `Store`, the `RedisConfig` and the `Model` classes and use accordingly 47 | 48 | ```python 49 | import asyncio 50 | from datetime import date 51 | from pydantic_aioredis import RedisConfig, Model, Store 52 | 53 | # Create models as you would create pydantic models i.e. using typings 54 | class Book(Model): 55 | _primary_key_field: str = 'title' 56 | title: str 57 | author: str 58 | published_on: date 59 | in_stock: bool = True 60 | 61 | # Do note that there is no concept of relationships here 62 | class Library(Model): 63 | # the _primary_key_field is mandatory 64 | _primary_key_field: str = 'name' 65 | name: str 66 | address: str 67 | 68 | # Create the store and register your models 69 | store = Store(name='some_name', redis_config=RedisConfig(db=5, host='localhost', port=6379), life_span_in_seconds=3600) 70 | store.register_model(Book) 71 | store.register_model(Library) 72 | 73 | # Sample books. You can create as many as you wish anywhere in the code 74 | books = [ 75 | Book(title="Oliver Twist", author='Charles Dickens', published_on=date(year=1215, month=4, day=4), 76 | in_stock=False), 77 | Book(title="Great Expectations", author='Charles Dickens', published_on=date(year=1220, month=4, day=4)), 78 | Book(title="Jane Eyre", author='Charles Dickens', published_on=date(year=1225, month=6, day=4), in_stock=False), 79 | Book(title="Wuthering Heights", author='Jane Austen', published_on=date(year=1600, month=4, day=4)), 80 | ] 81 | # Some library objects 82 | libraries = [ 83 | Library(name="The Grand Library", address="Kinogozi, Hoima, Uganda"), 84 | Library(name="Christian Library", address="Buhimba, Hoima, Uganda") 85 | ] 86 | 87 | async def work_with_orm(): 88 | # Insert them into redis 89 | await Book.insert(books) 90 | await Library.insert(libraries) 91 | 92 | # Select all books to view them. A list of Model instances will be returned 93 | all_books = await Book.select() 94 | print(all_books) # Will print [Book(title="Oliver Twist", author="Charles Dickens", published_on=date(year=1215, month=4, day=4), in_stock=False), Book(...] 95 | 96 | # Or select some of the books 97 | some_books = await Book.select(ids=["Oliver Twist", "Jane Eyre"]) 98 | print(some_books) # Will return only those two books 99 | 100 | # Or select some of the columns. THIS RETURNS DICTIONARIES not MODEL Instances 101 | # The Dictionaries have values in string form so you might need to do some extra work 102 | books_with_few_fields = await Book.select(columns=["author", "in_stock"]) 103 | print(books_with_few_fields) # Will print [{"author": "'Charles Dickens", "in_stock": "True"},...] 104 | 105 | 106 | this_book = Book(title="Moby Dick", author='Herman Melvill', published_on=date(year=1851, month=10, day=17)) 107 | await Book.insert(this_book) 108 | # oops, there was a typo. Fix it 109 | # Update is an async context manager and will update redis with all changes in one operations 110 | async with this_book.update(): 111 | this_book.author = "Herman Melville" 112 | this_book.published_on=date(year=1851, month=10, day=18) 113 | this_book_from_redis = await Book.select(ids=["Moby Dick"]) 114 | assert this_book_from_redis[0].author == "Herman Melville" 115 | assert this_book_from_redis[0].published_on == date(year=1851, month=10, day=18) 116 | 117 | # Delete any number of items 118 | await Library.delete(ids=["The Grand Library"]) 119 | 120 | # Now run these updates 121 | loop = asyncio.get_event_loop() 122 | loop.run_until_complete(work_with_orm()) 123 | ``` 124 | 125 | ### Custom Fields in Model 126 | 127 | | Field Name | Required | Default | Description | 128 | | ------------------- | -------- | ------------ | -------------------------------------------------------------------- | 129 | | \_primary_key_field | Yes | None | The field of your model that is the primary key | 130 | | \_redis_prefix | No | None | If set, will be added to the beginning of the keys we store in redis | 131 | | \_redis_separator | No | : | Defaults to :, used to separate prefix, table_name, and primary_key | 132 | | \_table_name | No | cls.**name** | Defaults to the model's name, can set a custom name in redis | 133 | | \_auto_save | No | False | Defaults to false. If true, will save to redis on instantiation | 134 | | \_auto_sync | No | False | Defaults to false. If true, will save to redis on attr update | 135 | 136 | ## License 137 | 138 | Licensed under the [MIT License](./LICENSE) 139 | 140 | ## Contributing 141 | 142 | Contributions are very welcome. 143 | To learn more, see the [Contributor Guide](./CONTRIBUTING.rst) 144 | 145 | ### Contributors 146 | 147 | Thanks go to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 |
Andrew
Andrew

💻 📖
Martin Ahindura
Martin Ahindura

💻 🤔
david-wahlstedt
david-wahlstedt

⚠️ 📖 👀
Daniel Wallace
Daniel Wallace

⚠️
Paco Nathan
Paco Nathan

💡
Andreas Brodersen
Andreas Brodersen

📖
kraczak
kraczak

📖
CharlieJiangXXX
CharlieJiangXXX

💻
168 | 169 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /pydantic_aioredis/utils.py: -------------------------------------------------------------------------------- 1 | """Module containing common utilities""" 2 | 3 | import asyncio 4 | import asyncio.events as events 5 | import os 6 | import sys 7 | import threading 8 | from contextlib import contextmanager, suppress 9 | from heapq import heappop 10 | 11 | 12 | def bytes_to_string(data: bytes): 13 | """Converts data to string""" 14 | return str(data, "utf-8") if isinstance(data, bytes) else data 15 | 16 | 17 | class NestedAsyncIO: 18 | __slots__ = [ 19 | "_loop", 20 | "orig_run", 21 | "orig_tasks", 22 | "orig_futures", 23 | "orig_loop_attrs", 24 | "policy_get_loop", 25 | "orig_get_loops", 26 | "orig_tc", 27 | "patched", 28 | ] 29 | _instance = None 30 | _initialized = False 31 | 32 | def __new__(cls, *args, **kwargs): 33 | if not cls._instance: 34 | cls._instance = super().__new__(cls) 35 | return cls._instance 36 | 37 | def __init__(self, loop=None): 38 | if not self._initialized: 39 | self._loop = loop 40 | self.orig_run = None 41 | self.orig_tasks = [] 42 | self.orig_futures = [] 43 | self.orig_loop_attrs = {} 44 | self.policy_get_loop = None 45 | self.orig_get_loops = {} 46 | self.orig_tc = None 47 | self.patched = False 48 | self.__class__._initialized = True 49 | 50 | def __enter__(self): 51 | self.apply(self._loop) 52 | return self 53 | 54 | def __exit__(self, exc_type, exc_val, exc_tb): 55 | self.revert() 56 | 57 | def apply(self, loop=None): 58 | """Patch asyncio to make its event loop reentrant.""" 59 | if not self.patched: 60 | self.patch_asyncio() 61 | self.patch_policy() 62 | self.patch_tornado() 63 | 64 | loop = loop or asyncio.get_event_loop() 65 | self.patch_loop(loop) 66 | self.patched = True 67 | 68 | def revert(self): 69 | if self.patched: 70 | for loop in self.orig_loop_attrs: 71 | self.unpatch_loop(loop) 72 | self.unpatch_tornado() 73 | self.unpatch_policy() 74 | self.unpatch_asyncio() 75 | self.patched = False 76 | 77 | def patch_asyncio(self): 78 | """Patch asyncio module to use pure Python tasks and futures.""" 79 | 80 | def run(main, *, debug=False): 81 | loop = asyncio.get_event_loop() 82 | loop.set_debug(debug) 83 | task = asyncio.ensure_future(main) 84 | try: 85 | return loop.run_until_complete(task) 86 | finally: 87 | if not task.done(): 88 | task.cancel() 89 | with suppress(asyncio.CancelledError): 90 | loop.run_until_complete(task) 91 | 92 | def _get_event_loop(stacklevel=3): 93 | return events._get_running_loop() or events.get_event_loop_policy().get_event_loop() 94 | 95 | # Use module level _current_tasks, all_tasks and patch run method. 96 | if getattr(asyncio, "_nest_patched", False): 97 | return 98 | self.orig_tasks = [asyncio.Task, asyncio.tasks._CTask, asyncio.tasks.Task] 99 | asyncio.Task = asyncio.tasks._CTask = asyncio.tasks.Task = asyncio.tasks._PyTask 100 | self.orig_futures = [asyncio.Future, asyncio.futures._CFuture, asyncio.futures.Future] 101 | asyncio.Future = asyncio.futures._CFuture = asyncio.futures.Future = asyncio.futures._PyFuture 102 | self.orig_get_loops = { 103 | "events_get_event_loop": events.get_event_loop, 104 | "asyncio_get_event_loop": asyncio.get_event_loop, 105 | } 106 | if sys.version_info >= (3, 10, 0) and sys.version_info < (3, 12, 0): 107 | self.orig_get_loops["events__get_event_loop"] = events._get_event_loop 108 | events._get_event_loop = events.get_event_loop = asyncio.get_event_loop = _get_event_loop 109 | self.orig_run = asyncio.run 110 | asyncio.run = run 111 | asyncio._nest_patched = True 112 | 113 | def unpatch_asyncio(self): 114 | if self.orig_run: 115 | asyncio.run = self.orig_run 116 | asyncio._nest_patched = False 117 | (asyncio.Task, asyncio.tasks._CTask, asyncio.tasks.Task) = self.orig_tasks 118 | (asyncio.Future, asyncio.futures._CFuture, asyncio.futures.Future) = self.orig_futures 119 | if sys.version_info >= (3, 9, 0): 120 | for key, value in self.orig_get_loops.items(): 121 | setattr(asyncio if key.startswith("asyncio") else events, key.split("_")[-1], value) 122 | 123 | def patch_policy(self): 124 | """Patch the policy to always return a patched loop.""" 125 | 126 | def get_event_loop(this): 127 | if this._local._loop is None: 128 | loop = this.new_event_loop() 129 | self.patch_loop(loop) 130 | this.set_event_loop(loop) 131 | return this._local._loop 132 | 133 | cls = events.get_event_loop_policy().__class__ 134 | self.policy_get_loop = cls.get_event_loop 135 | cls.get_event_loop = get_event_loop 136 | 137 | def unpatch_policy(self): 138 | cls = events.get_event_loop_policy().__class__ 139 | orig = self.policy_get_loop 140 | if orig: 141 | cls.get_event_loop = orig 142 | 143 | def patch_loop(self, loop): 144 | """Patch loop to make it reentrant.""" 145 | 146 | def run_forever(this): 147 | with manage_run(this), manage_asyncgens(this): 148 | while True: 149 | this._run_once() 150 | if this._stopping: 151 | break 152 | this._stopping = False 153 | 154 | def run_until_complete(this, future): 155 | with manage_run(this): 156 | f = asyncio.ensure_future(future, loop=this) 157 | if f is not future: 158 | f._log_destroy_pending = False 159 | while not f.done(): 160 | this._run_once() 161 | if this._stopping: 162 | break 163 | if not f.done(): 164 | raise RuntimeError("Event loop stopped before Future completed.") 165 | return f.result() 166 | 167 | def _run_once(this): 168 | """ 169 | Simplified re-implementation of asyncio's _run_once that 170 | runs handles as they become ready. 171 | """ 172 | ready = this._ready 173 | scheduled = this._scheduled 174 | while scheduled and scheduled[0]._cancelled: 175 | heappop(scheduled) 176 | 177 | timeout = ( 178 | 0 179 | if ready or this._stopping 180 | else min(max(scheduled[0]._when - this.time(), 0), 86400) 181 | if scheduled 182 | else None 183 | ) 184 | event_list = this._selector.select(timeout) 185 | this._process_events(event_list) 186 | 187 | end_time = this.time() + this._clock_resolution 188 | while scheduled and scheduled[0]._when < end_time: 189 | handle = heappop(scheduled) 190 | ready.append(handle) 191 | 192 | for _ in range(len(ready)): 193 | if not ready: 194 | break 195 | handle = ready.popleft() 196 | if not handle._cancelled: 197 | # preempt the current task so that that checks in 198 | # Task.__step do not raise 199 | curr_task = curr_tasks.pop(this, None) 200 | 201 | try: 202 | handle._run() 203 | finally: 204 | # restore the current task 205 | if curr_task is not None: 206 | curr_tasks[this] = curr_task 207 | 208 | handle = None 209 | 210 | @contextmanager 211 | def manage_run(this): 212 | """Set up the loop for running.""" 213 | this._check_closed() 214 | old_thread_id = this._thread_id 215 | old_running_loop = events._get_running_loop() 216 | try: 217 | this._thread_id = threading.get_ident() 218 | events._set_running_loop(this) 219 | this._num_runs_pending += 1 220 | if this._is_proactorloop: 221 | if this._self_reading_future is None: 222 | this.call_soon(this._loop_self_reading) 223 | yield 224 | finally: 225 | this._thread_id = old_thread_id 226 | events._set_running_loop(old_running_loop) 227 | this._num_runs_pending -= 1 228 | if this._is_proactorloop: 229 | if this._num_runs_pending == 0 and this._self_reading_future is not None: 230 | ov = this._self_reading_future._ov 231 | this._self_reading_future.cancel() 232 | if ov is not None: 233 | this._proactor._unregister(ov) 234 | this._self_reading_future = None 235 | 236 | @contextmanager 237 | def manage_asyncgens(this): 238 | if not hasattr(sys, "get_asyncgen_hooks"): 239 | # Python version is too old. 240 | return 241 | old_agen_hooks = sys.get_asyncgen_hooks() 242 | try: 243 | this._set_coroutine_origin_tracking(this._debug) 244 | if this._asyncgens is not None: 245 | sys.set_asyncgen_hooks( 246 | firstiter=this._asyncgen_firstiter_hook, finalizer=this._asyncgen_finalizer_hook 247 | ) 248 | yield 249 | finally: 250 | this._set_coroutine_origin_tracking(False) 251 | if this._asyncgens is not None: 252 | sys.set_asyncgen_hooks(*old_agen_hooks) 253 | 254 | def _check_running(this): 255 | """Do not throw exception if loop is already running.""" 256 | pass 257 | 258 | if getattr(loop, "_nest_patched", False): 259 | return 260 | if not isinstance(loop, asyncio.BaseEventLoop): 261 | raise ValueError("Can't patch loop of type %s" % type(loop)) 262 | cls = loop.__class__ 263 | self.orig_loop_attrs[cls] = {} 264 | self.orig_loop_attrs[cls]["run_forever"] = cls.run_forever 265 | cls.run_forever = run_forever 266 | self.orig_loop_attrs[cls]["run_until_complete"] = cls.run_until_complete 267 | cls.run_until_complete = run_until_complete 268 | self.orig_loop_attrs[cls]["_run_once"] = cls._run_once 269 | cls._run_once = _run_once 270 | self.orig_loop_attrs[cls]["_check_running"] = cls._check_running 271 | cls._check_running = _check_running 272 | self.orig_loop_attrs[cls]["_check_runnung"] = cls._check_running 273 | cls._check_runnung = _check_running # typo in Python 3.7 source 274 | cls._num_runs_pending = 1 if loop.is_running() else 0 275 | cls._is_proactorloop = os.name == "nt" and issubclass(cls, asyncio.ProactorEventLoop) 276 | if sys.version_info < (3, 7, 0): 277 | cls._set_coroutine_origin_tracking = cls._set_coroutine_wrapper 278 | curr_tasks = asyncio.tasks._current_tasks if sys.version_info >= (3, 7, 0) else asyncio.Task._current_tasks 279 | cls._nest_patched = True 280 | 281 | def unpatch_loop(self, loop): 282 | loop._nest_patched = False 283 | if self.orig_loop_attrs[loop]: 284 | for key, value in self.orig_loop_attrs[loop].items(): 285 | setattr(loop, key, value) 286 | 287 | for attr in ["_num_runs_pending", "_is_proactorloop"]: 288 | if hasattr(loop, attr): 289 | delattr(loop, attr) 290 | 291 | def patch_tornado(self): 292 | """ 293 | If tornado is imported before nest_asyncio, make tornado aware of 294 | the pure-Python asyncio Future. 295 | """ 296 | if "tornado" in sys.modules: 297 | import tornado.concurrent as tc # type: ignore 298 | 299 | self.orig_tc = tc.Future 300 | tc.Future = asyncio.Future 301 | if asyncio.Future not in tc.FUTURES: 302 | tc.FUTURES += (asyncio.Future,) 303 | 304 | def unpatch_tornado(self): 305 | if self.orig_tc: 306 | import tornado.concurrent as tc 307 | 308 | tc.Future = self.orig_tc 309 | -------------------------------------------------------------------------------- /test/test_pydantic_aioredis.py: -------------------------------------------------------------------------------- 1 | """Tests for the redis orm""" 2 | 3 | from datetime import date 4 | from enum import Enum 5 | from ipaddress import ip_network 6 | from ipaddress import IPv4Network 7 | from random import randint 8 | from random import sample 9 | from typing import List 10 | from typing import Optional 11 | from uuid import UUID 12 | from uuid import uuid4 13 | 14 | import pytest 15 | import pytest_asyncio 16 | from fakeredis.aioredis import FakeRedis 17 | from pydantic_aioredis.abstract import _AbstractModel 18 | from pydantic_aioredis.config import RedisConfig 19 | from pydantic_aioredis.model import Model 20 | from pydantic_aioredis.store import Store 21 | 22 | 23 | class Book(Model): 24 | _primary_key_field: str = "title" 25 | title: str 26 | author: str 27 | published_on: date 28 | in_stock: bool = True 29 | 30 | 31 | books = [ 32 | Book( 33 | title="Oliver Twist", 34 | author="Charles Dickens", 35 | published_on=date(year=1215, month=4, day=4), 36 | in_stock=False, 37 | ), 38 | Book( 39 | title="Great Expectations", 40 | author="Charles Dickens", 41 | published_on=date(year=1220, month=4, day=4), 42 | ), 43 | Book( 44 | title="Jane Eyre", 45 | author="Charles Dickens", 46 | published_on=date(year=1225, month=6, day=4), 47 | in_stock=False, 48 | ), 49 | Book( 50 | title="Wuthering Heights", 51 | author="Jane Austen", 52 | published_on=date(year=1600, month=4, day=4), 53 | ), 54 | ] 55 | 56 | editions = ["first", "second", "third", "hardbound", "paperback", "ebook"] 57 | 58 | 59 | class ExtendedBook(Book): 60 | editions: List[Optional[str]] 61 | 62 | 63 | class ModelWithNone(Model): 64 | _primary_key_field = "name" 65 | name: str 66 | optional_field: Optional[str] 67 | 68 | 69 | class ModelWithIP(Model): 70 | _primary_key_field = "name" 71 | name: str 72 | ip_network: IPv4Network 73 | 74 | 75 | class ModelWithPrefix(Model): 76 | _primary_key_field = "name" 77 | _redis_prefix = "prefix" 78 | name: str 79 | 80 | 81 | class ModelWithSeparator(Model): 82 | _primary_key_field = "name" 83 | _redis_separator = "!!" 84 | name: str 85 | 86 | 87 | class ModelWithTableName(Model): 88 | _primary_key_field = "name" 89 | _table_name = "tablename" 90 | name: str 91 | 92 | 93 | class ModelWithFullCustomKey(Model): 94 | _primary_key_field = "name" 95 | _redis_prefix = "prefix" 96 | _redis_separator = "!!" 97 | _table_name = "custom" 98 | name: str 99 | 100 | 101 | extended_books = [ExtendedBook(**book.dict(), editions=sample(editions, randint(0, len(editions)))) for book in books] 102 | extended_books[0].editions = list() 103 | 104 | test_models = [ 105 | ModelWithNone(name="test", optional_field="test"), 106 | ModelWithNone(name="test2"), 107 | ] 108 | 109 | test_ip_models = [ 110 | ModelWithIP(name="test", ip_network=ip_network("10.10.0.0/24")), 111 | ModelWithIP(name="test2", ip_network=ip_network("192.168.0.0/16")), 112 | ] 113 | 114 | test_models_with_prefix = [ 115 | ModelWithPrefix(name="test"), 116 | ModelWithPrefix(name="test2"), 117 | ] 118 | 119 | test_models_with_separator = [ 120 | ModelWithSeparator(name="test"), 121 | ModelWithSeparator(name="test2"), 122 | ] 123 | 124 | 125 | test_models_with_tablename = [ 126 | ModelWithTableName(name="test"), 127 | ModelWithTableName(name="test2"), 128 | ] 129 | 130 | test_models_with_fullcustom = [ 131 | ModelWithFullCustomKey(name="test"), 132 | ModelWithFullCustomKey(name="test2"), 133 | ] 134 | 135 | 136 | @pytest_asyncio.fixture() 137 | async def redis_store(): 138 | """Sets up a redis store using the redis_server fixture and adds the book model to it""" 139 | store = Store( 140 | name="sample", 141 | redis_config=RedisConfig(port=1024, db=1), # nosec 142 | life_span_in_seconds=3600, 143 | ) 144 | store.redis_store = FakeRedis(decode_responses=True) 145 | store.register_model(Book) 146 | store.register_model(ExtendedBook) 147 | store.register_model(ModelWithNone) 148 | store.register_model(ModelWithIP) 149 | store.register_model(ModelWithPrefix) 150 | store.register_model(ModelWithSeparator) 151 | store.register_model(ModelWithTableName) 152 | store.register_model(ModelWithFullCustomKey) 153 | yield store 154 | await store.redis_store.flushall() 155 | 156 | 157 | def test_redis_config_redis_url(): 158 | password = "password" 159 | config_with_no_pass = RedisConfig() 160 | config_with_ssl = RedisConfig(ssl=True) 161 | config_with_pass = RedisConfig(password=password) 162 | config_with_pass_ssl = RedisConfig(ssl=True, password=password) 163 | 164 | assert config_with_no_pass.redis_url == "redis://localhost:6379/0" 165 | assert config_with_ssl.redis_url == "rediss://localhost:6379/0" 166 | assert config_with_pass.redis_url == f"redis://:{password}@localhost:6379/0" 167 | assert config_with_pass_ssl.redis_url == f"rediss://:{password}@localhost:6379/0" 168 | 169 | 170 | def test_register_model_without_primary_key(redis_store): 171 | """Throws error when a model without the _primary_key_field class variable set is registered""" 172 | 173 | class ModelWithoutPrimaryKey(Model): 174 | title: str 175 | 176 | with pytest.raises(AttributeError, match=r"_primary_key_field"): 177 | redis_store.register_model(ModelWithoutPrimaryKey) 178 | 179 | ModelWithoutPrimaryKey._primary_key_field = None 180 | 181 | with pytest.raises(Exception, match=r"should have a _primary_key_field"): 182 | redis_store.register_model(ModelWithoutPrimaryKey) 183 | 184 | 185 | def test_store_model(redis_store): 186 | """Tests the model method in store""" 187 | assert redis_store.model("Book") == Book 188 | 189 | with pytest.raises(KeyError): 190 | redis_store.model("Notabook") 191 | 192 | 193 | def test_json_object_hook(): 194 | class TestObj: 195 | def __init__(self, value: str): 196 | self.value = value 197 | 198 | test_obj = TestObj("test") 199 | assert test_obj.value == _AbstractModel.json_object_hook(test_obj).value 200 | 201 | 202 | parameters = [ 203 | (pytest.lazy_fixture("redis_store"), books, Book, "book:"), 204 | (pytest.lazy_fixture("redis_store"), extended_books, ExtendedBook, "extendedbook:"), 205 | (pytest.lazy_fixture("redis_store"), test_models, ModelWithNone, "modelwithnone:"), 206 | (pytest.lazy_fixture("redis_store"), test_ip_models, ModelWithIP, "modelwithip:"), 207 | ( 208 | pytest.lazy_fixture("redis_store"), 209 | test_models_with_prefix, 210 | ModelWithPrefix, 211 | "prefix:modelwithprefix:", 212 | ), 213 | ( 214 | pytest.lazy_fixture("redis_store"), 215 | test_models_with_separator, 216 | ModelWithSeparator, 217 | "modelwithseparator!!", 218 | ), 219 | ( 220 | pytest.lazy_fixture("redis_store"), 221 | test_models_with_tablename, 222 | ModelWithTableName, 223 | "tablename:", 224 | ), 225 | ( 226 | pytest.lazy_fixture("redis_store"), 227 | test_models_with_fullcustom, 228 | ModelWithFullCustomKey, 229 | "prefix!!custom!!", 230 | ), 231 | ] 232 | 233 | 234 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 235 | async def test_bulk_insert(store, models, model_class, key_prefix: str): 236 | """Providing a list of Model instances to the insert method inserts the records in redis""" 237 | keys = [f"{key_prefix}{getattr(model, type(model)._primary_key_field)}" for model in models] 238 | # keys = [f"book:{book.title}" for book in models] 239 | 240 | await store.redis_store.delete(*keys) 241 | 242 | for key in keys: 243 | book_in_redis = await store.redis_store.hgetall(name=key) 244 | assert book_in_redis == {} 245 | 246 | await model_class.insert(models) 247 | 248 | async with store.redis_store.pipeline() as pipeline: 249 | for key in keys: 250 | pipeline.hgetall(name=key) 251 | models_in_redis = await pipeline.execute() 252 | models_deserialized = [model_class(**model_class.deserialize_partially(model)) for model in models_in_redis] 253 | assert models == models_deserialized 254 | 255 | 256 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 257 | async def test_insert_single(store, models, model_class, key_prefix: str): 258 | """ 259 | Providing a single Model instance 260 | """ 261 | key = f"{key_prefix}{getattr(models[0], type(models[0])._primary_key_field)}" 262 | model = await store.redis_store.hgetall(name=key) 263 | assert model == {} 264 | 265 | await model_class.insert(models[0]) 266 | 267 | model = await store.redis_store.hgetall(name=key) 268 | model_deser = model_class(**model_class.deserialize_partially(model)) 269 | assert models[0] == model_deser 270 | 271 | 272 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 273 | async def test_insert_single_lifespan(store, models, model_class, key_prefix: str): 274 | """ 275 | Providing a single Model instance with a lifespam 276 | """ 277 | key = f"{key_prefix}{getattr(models[0], type(models[0])._primary_key_field)}" 278 | model = await store.redis_store.hgetall(name=key) 279 | assert model == {} 280 | 281 | await model_class.insert(models[0], life_span_seconds=60) 282 | 283 | model = await store.redis_store.hgetall(name=key) 284 | model_deser = model_class(**model_class.deserialize_partially(model)) 285 | assert models[0] == model_deser 286 | 287 | 288 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 289 | async def test_select_default(store, models, model_class, key_prefix): 290 | """Selecting without arguments returns all the book models""" 291 | await model_class.insert(models) 292 | response = await model_class.select() 293 | for model in response: 294 | assert model in models 295 | 296 | 297 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 298 | @pytest.mark.parametrize("execution_count", range(5)) 299 | async def test_select_pagination(store, models, model_class, key_prefix, execution_count): 300 | """Selecting with pagination""" 301 | limit = 2 302 | skip = randint(0, len(models) - limit) 303 | await model_class.insert(models) 304 | response = await model_class.select(skip=skip, limit=limit) 305 | assert len(response) == limit 306 | for model in response: 307 | assert model in models 308 | 309 | 310 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 311 | async def test_select_no_contents(store, models, model_class, key_prefix): 312 | """Test that we get None when there are no models""" 313 | await store.redis_store.flushall() 314 | response = await model_class.select() 315 | 316 | assert response is None 317 | 318 | 319 | async def test_select_single_content(redis_store): 320 | """Check returns for a single instance""" 321 | # await redis_store.redis_store.flushall() 322 | await Book.insert([books[1]]) 323 | response = await Book.select() 324 | assert len(response) == 1 325 | assert response[0] == books[1] 326 | 327 | {book.title: book for book in books} 328 | response = await Book.select(columns=["title", "author", "in_stock"]) 329 | 330 | assert response[0]["title"] == books[1].title 331 | assert response[0]["author"] == books[1].author 332 | assert response[0]["in_stock"] == books[1].in_stock 333 | with pytest.raises(KeyError): 334 | response[0]["published_on"] 335 | 336 | 337 | async def test_select_some_columns(redis_store): 338 | """ 339 | Selecting some columns returns a list of dictionaries of all books models with only those columns 340 | """ 341 | await Book.insert(books) 342 | books_dict = {book.title: book for book in books} 343 | columns = ["title", "author", "in_stock"] 344 | response = await Book.select(columns=["title", "author", "in_stock"]) 345 | response_dict = {book["title"]: book for book in response} 346 | 347 | for title, book in books_dict.items(): 348 | book_in_response = response_dict[title] 349 | assert isinstance(book_in_response, dict) 350 | assert sorted(book_in_response.keys()) == sorted(columns) 351 | for column in columns: 352 | assert f"{book_in_response[column]}" == f"{getattr(book, column)}" 353 | 354 | 355 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 356 | @pytest.mark.parametrize("execution_count", range(5)) 357 | async def test_select_some_ids(store, models, model_class, key_prefix, execution_count): 358 | """ 359 | Selecting some ids returns only those elements with the given ids 360 | """ 361 | await model_class.insert(models) 362 | limit = 2 363 | skip = randint(0, len(models) - limit) 364 | 365 | to_select = models[skip : limit + skip] 366 | select_ids = [getattr(model, model_class._primary_key_field) for model in to_select] 367 | response = await model_class.select(ids=select_ids) 368 | assert len(response) > 0 369 | assert len(response) == len(to_select) 370 | for model in response: 371 | assert model in to_select 372 | 373 | 374 | async def test_select_bad_id(redis_store): 375 | """ 376 | Selecting some ids returns only those elements with the given ids 377 | """ 378 | await Book.insert(books) 379 | response = await Book.select(ids=["Not in there"]) 380 | assert response is None 381 | 382 | 383 | async def test_update(redis_store): 384 | """ 385 | Updating an item of a given primary key updates it in redis 386 | """ 387 | await Book.insert(books) 388 | title = books[0].title 389 | new_author = "John Doe" 390 | key = f"book:{title}" 391 | old_book_data = await redis_store.redis_store.hgetall(name=key) 392 | old_book = Book(**Book.deserialize_partially(old_book_data)) 393 | assert old_book == books[0] 394 | assert old_book.author != new_author 395 | 396 | books[0].author = new_author 397 | await books[0].save() 398 | 399 | book_data = await redis_store.redis_store.hgetall(name=key) 400 | book = Book(**Book.deserialize_partially(book_data)) 401 | assert book.author == new_author 402 | assert book.title == old_book.title 403 | assert book.in_stock == old_book.in_stock 404 | assert book.published_on == old_book.published_on 405 | 406 | 407 | async def test_delete_single(redis_store): 408 | """Test deleting a single record""" 409 | await Book.insert(books) 410 | book_to_delete = books[1] 411 | await Book.delete(ids=book_to_delete.title) 412 | check_for_book = await redis_store.redis_store.hgetall(name=book_to_delete.title) 413 | assert check_for_book == {} 414 | 415 | 416 | async def test_delete_multiple(redis_store): 417 | """ 418 | Providing a list of ids to the delete function will remove the items from redis 419 | """ 420 | await Book.insert(books) 421 | books_to_delete = books[:2] 422 | books_left_in_db = books[2:] 423 | 424 | ids_to_delete = [book.title for book in books_to_delete] 425 | ids_to_leave_intact = [book.title for book in books_left_in_db] 426 | 427 | keys_to_delete = [f"book:{_id}" for _id in ids_to_delete] 428 | keys_to_leave_intact = [f"book:{_id}" for _id in ids_to_leave_intact] 429 | 430 | await Book.delete(ids=ids_to_delete) 431 | 432 | for key in keys_to_delete: 433 | deleted_book_in_redis = await redis_store.redis_store.hgetall(name=key) 434 | assert deleted_book_in_redis == {} 435 | 436 | async with redis_store.redis_store.pipeline() as pipeline: 437 | for key in keys_to_leave_intact: 438 | pipeline.hgetall(name=key) 439 | books_in_redis = await pipeline.execute() 440 | books_in_redis_as_models = [Book(**Book.deserialize_partially(book)) for book in books_in_redis] 441 | assert books_left_in_db == books_in_redis_as_models 442 | 443 | 444 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 445 | async def test_delete_all(store, models, model_class, key_prefix): 446 | """ 447 | Delete all of a model from the redis 448 | """ 449 | await model_class.insert(models) 450 | result = await model_class.select() 451 | assert len(result) == len(models) 452 | await model_class.delete() 453 | post_del_result = await model_class.select() 454 | assert post_del_result is None 455 | 456 | 457 | @pytest.mark.parametrize("store, models, model_class, key_prefix", parameters) 458 | async def test_delete_none(store, models, model_class, key_prefix): 459 | """ 460 | Try to delete when the redis is empty for that model 461 | """ 462 | assert await model_class.delete() is None 463 | 464 | 465 | async def test_unserializable_object(redis_store): 466 | class MyClass: ... 467 | 468 | class TestModel(Model): 469 | _primary_key_field = "name" 470 | name: str 471 | object: MyClass 472 | 473 | redis_store.register_model(TestModel) 474 | this_model = TestModel(name="test", object=MyClass()) 475 | with pytest.raises(TypeError): 476 | await TestModel.insert(this_model) 477 | 478 | 479 | async def test_enum_support(redis_store): 480 | """Test case for enum support""" 481 | 482 | class TestEnum(str, Enum): 483 | test = "test" 484 | foo = "foo" 485 | bar = "bar" 486 | baz = "baz" 487 | 488 | class EnumModel(Model): 489 | _primary_key_field = "id" 490 | id: int 491 | enum: TestEnum 492 | 493 | redis_store.register_model(EnumModel) 494 | this_model = EnumModel(id=0, enum="foo") 495 | await EnumModel.insert(this_model) 496 | from_redis = await EnumModel.select() 497 | assert from_redis[0] == this_model 498 | 499 | 500 | async def test_uuid_support(redis_store): 501 | """Test case for uuid support""" 502 | 503 | class UUIDModel(Model): 504 | _primary_key_field = "id" 505 | id: int 506 | uuid: UUID 507 | 508 | redis_store.register_model(UUIDModel) 509 | this_model = UUIDModel(id=0, uuid=uuid4()) 510 | await UUIDModel.insert(this_model) 511 | from_redis = await UUIDModel.select() 512 | assert from_redis[0] == this_model 513 | assert isinstance(from_redis[0].uuid, UUID) 514 | --------------------------------------------------------------------------------