Add Makefile, black, reformat with black

This commit is contained in:
Andrew Brookins 2021-10-20 13:01:46 -07:00
parent cfc50b82bb
commit d2fa4c586f
16 changed files with 978 additions and 366 deletions

0
.install.stamp Normal file
View file

55
Makefile Normal file
View file

@ -0,0 +1,55 @@
NAME := redis_developer
INSTALL_STAMP := .install.stamp
POETRY := $(shell command -v poetry 2> /dev/null)
.DEFAULT_GOAL := help
.PHONY: help
help:
@echo "Please use 'make <target>' where <target> is one of"
@echo ""
@echo " install install packages and prepare environment"
@echo " clean remove all temporary files"
@echo " lint run the code linters"
@echo " format reformat code"
@echo " test run all the tests"
@echo " shell open a Poetry shell"
@echo ""
@echo "Check the Makefile to know exactly what each target is doing."
install: $(INSTALL_STAMP)
$(INSTALL_STAMP): pyproject.toml poetry.lock
@if [ -z $(POETRY) ]; then echo "Poetry could not be found. See https://python-poetry.org/docs/"; exit 2; fi
$(POETRY) install
touch $(INSTALL_STAMP)
.PHONY: clean
clean:
find . -type d -name "__pycache__" | xargs rm -rf {};
rm -rf $(INSTALL_STAMP) .coverage .mypy_cache
.PHONY: lint
lint: $(INSTALL_STAMP)
$(POETRY) run isort --profile=black --lines-after-imports=2 ./tests/ $(NAME)
$(POETRY) run black ./tests/ $(NAME)
$(POETRY) run flake8 --ignore=W503,E501,F401,E731 ./tests/ $(NAME)
$(POETRY) run mypy ./tests/ $(NAME) --ignore-missing-imports
$(POETRY) run bandit -r $(NAME) -s B608
.PHONY: format
format: $(INSTALL_STAMP)
$(POETRY) run isort --profile=black --lines-after-imports=2 ./tests/ $(NAME)
$(POETRY) run black ./tests/ $(NAME)
.PHONY: test
test: $(INSTALL_STAMP)
#$(POETRY) run pytest ./tests/ --cov-report term-missing --cov-fail-under 100 --cov $(NAME)
$(POETRY) run pytest ./tests/
.PHONY: shell
shell: $(INSTALL_STAMP)
$(POETRY) shell
.PHONY: redis
redis:
docker-compose up -d

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
version: "3.8"
services:
redis:
image: "redislabs/redismod:edge"
entrypoint: ["redis-server", "--appendonly", "yes", "--loadmodule", "/usr/lib/redis/modules/rejson.so"]
restart: always
ports:
- "6380:6379"
volumes:
- ./data:/data

388
poetry.lock generated
View file

@ -23,16 +23,16 @@ python-versions = "*"
[[package]]
name = "astroid"
version = "2.8.0"
version = "2.8.3"
description = "An abstract syntax tree for Python with inference support."
category = "main"
category = "dev"
optional = false
python-versions = "~=3.6"
[package.dependencies]
lazy-object-proxy = ">=1.4.0"
typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""}
wrapt = ">=1.11,<1.13"
wrapt = ">=1.11,<1.14"
[[package]]
name = "async-timeout"
@ -72,9 +72,51 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "bandit"
version = "1.7.0"
description = "Security oriented static analyser for python code."
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
GitPython = ">=1.0.1"
PyYAML = ">=5.3.1"
six = ">=1.10.0"
stevedore = ">=1.20.0"
[[package]]
name = "black"
version = "21.9b0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
click = ">=7.1.2"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0,<1"
platformdirs = ">=2"
regex = ">=2020.1.8"
tomli = ">=0.2.6,<2.0.0"
typing-extensions = [
{version = ">=3.10.0.0", markers = "python_version < \"3.10\""},
{version = "!=3.10.0.1", markers = "python_version >= \"3.10\""},
]
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
python2 = ["typed-ast (>=1.4.2)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "click"
version = "8.0.1"
version = "8.0.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
@ -99,6 +141,42 @@ category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "flake8"
version = "4.0.1"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mccabe = ">=0.6.0,<0.7.0"
pycodestyle = ">=2.8.0,<2.9.0"
pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "gitdb"
version = "4.0.7"
description = "Git Object Database"
category = "dev"
optional = false
python-versions = ">=3.4"
[package.dependencies]
smmap = ">=3.0.1,<5"
[[package]]
name = "gitpython"
version = "3.1.24"
description = "GitPython is a python library used to interact with Git repositories"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
gitdb = ">=4.0.1,<5"
typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.10\""}
[[package]]
name = "iniconfig"
version = "1.1.1"
@ -122,7 +200,7 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""}
[[package]]
name = "ipython"
version = "7.27.0"
version = "7.28.0"
description = "IPython: Productive Interactive Computing"
category = "dev"
optional = false
@ -156,7 +234,7 @@ test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipyk
name = "isort"
version = "5.9.3"
description = "A Python utility / library to sort Python imports."
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
@ -185,7 +263,7 @@ testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"]
name = "lazy-object-proxy"
version = "1.6.0"
description = "A fast and thorough lazy object proxy."
category = "main"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
@ -204,7 +282,7 @@ traitlets = "*"
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
category = "main"
category = "dev"
optional = false
python-versions = "*"
@ -212,7 +290,7 @@ python-versions = "*"
name = "mypy"
version = "0.910"
description = "Optional static typing for Python"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.5"
@ -229,7 +307,7 @@ python2 = ["typed-ast (>=1.4.0,<1.5.0)"]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "main"
category = "dev"
optional = false
python-versions = "*"
@ -256,6 +334,22 @@ python-versions = ">=3.6"
qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
testing = ["docopt", "pytest (<6.0.0)"]
[[package]]
name = "pathspec"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "pbr"
version = "5.6.0"
description = "Python Build Reasonableness"
category = "dev"
optional = false
python-versions = ">=2.6"
[[package]]
name = "pexpect"
version = "4.8.0"
@ -279,7 +373,7 @@ python-versions = "*"
name = "platformdirs"
version = "2.4.0"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6"
@ -334,6 +428,14 @@ category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pycodestyle"
version = "2.8.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pydantic"
version = "1.8.2"
@ -349,6 +451,14 @@ typing-extensions = ">=3.7.4.3"
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyflakes"
version = "2.4.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pygments"
version = "2.10.0"
@ -361,7 +471,7 @@ python-versions = ">=3.5"
name = "pylint"
version = "2.11.1"
description = "python code static checker"
category = "main"
category = "dev"
optional = false
python-versions = "~=3.6"
@ -411,6 +521,14 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "redis"
version = "3.5.3"
@ -422,6 +540,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
hiredis = ["hiredis (>=0.1.3)"]
[[package]]
name = "regex"
version = "2021.10.8"
description = "Alternative regular expression module, to replace re."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "six"
version = "1.16.0"
@ -430,14 +556,41 @@ category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "smmap"
version = "4.0.0"
description = "A pure Python implementation of a sliding window memory map manager"
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "stevedore"
version = "3.5.0"
description = "Manage dynamic plugins for Python applications"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "main"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "tomli"
version = "1.2.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "traitlets"
version = "5.1.0"
@ -451,7 +604,7 @@ test = ["pytest"]
[[package]]
name = "types-redis"
version = "3.5.9"
version = "3.5.15"
description = "Typing stubs for redis"
category = "main"
optional = false
@ -459,7 +612,7 @@ python-versions = "*"
[[package]]
name = "types-six"
version = "1.16.1"
version = "1.16.2"
description = "Typing stubs for six"
category = "main"
optional = false
@ -483,16 +636,16 @@ python-versions = "*"
[[package]]
name = "wrapt"
version = "1.12.1"
version = "1.13.2"
description = "Module for decorators, wrappers and monkey patching."
category = "main"
category = "dev"
optional = false
python-versions = "*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "e643c8bcc3f54c414e388a8c62256c3c0fe9e2fb0374c3f3b4140e2b0684b654"
content-hash = "f1ccd73314f307ce41497d093ddce99cfb96ebf1814e854a94e37d5156647967"
[metadata.files]
aioredis = [
@ -504,8 +657,8 @@ appnope = [
{file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"},
]
astroid = [
{file = "astroid-2.8.0-py3-none-any.whl", hash = "sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471"},
{file = "astroid-2.8.0.tar.gz", hash = "sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708"},
{file = "astroid-2.8.3-py3-none-any.whl", hash = "sha256:f9d66e3a4a0e5b52819b2ff41ac2b179df9d180697db71c92beb33a60c661794"},
{file = "astroid-2.8.3.tar.gz", hash = "sha256:0e361da0744d5011d4f5d57e64473ba9b7ab4da1e2d45d6631ebd67dd28c3cce"},
]
async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
@ -523,9 +676,17 @@ backcall = [
{file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
]
bandit = [
{file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"},
{file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"},
]
black = [
{file = "black-21.9b0-py3-none-any.whl", hash = "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115"},
{file = "black-21.9b0.tar.gz", hash = "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91"},
]
click = [
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
{file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
{file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -535,6 +696,18 @@ decorator = [
{file = "decorator-5.1.0-py3-none-any.whl", hash = "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374"},
{file = "decorator-5.1.0.tar.gz", hash = "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7"},
]
flake8 = [
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
]
gitdb = [
{file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"},
{file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"},
]
gitpython = [
{file = "GitPython-3.1.24-py3-none-any.whl", hash = "sha256:dc0a7f2f697657acc8d7f89033e8b1ea94dd90356b2983bca89dc8d2ab3cc647"},
{file = "GitPython-3.1.24.tar.gz", hash = "sha256:df83fdf5e684fef7c6ee2c02fc68a5ceb7e7e759d08b694088d0cacb4eba59e5"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@ -543,8 +716,8 @@ ipdb = [
{file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"},
]
ipython = [
{file = "ipython-7.27.0-py3-none-any.whl", hash = "sha256:75b5e060a3417cf64f138e0bb78e58512742c57dc29db5a5058a2b1f0c10df02"},
{file = "ipython-7.27.0.tar.gz", hash = "sha256:58b55ebfdfa260dad10d509702dc2857cb25ad82609506b070cf2d7b7df5af13"},
{file = "ipython-7.28.0-py3-none-any.whl", hash = "sha256:f16148f9163e1e526f1008d7c8d966d9c15600ca20d1a754287cf96d00ba6f1d"},
{file = "ipython-7.28.0.tar.gz", hash = "sha256:2097be5c814d1b974aea57673176a924c4c8c9583890e7a5f082f547b9975b11"},
]
isort = [
{file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"},
@ -623,6 +796,14 @@ parso = [
{file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"},
{file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
pbr = [
{file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"},
{file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"},
]
pexpect = [
{file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
{file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
@ -654,6 +835,10 @@ py = [
{file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
{file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
]
pycodestyle = [
{file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
{file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
]
pydantic = [
{file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"},
{file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"},
@ -678,6 +863,10 @@ pydantic = [
{file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"},
{file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"},
]
pyflakes = [
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
{file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
]
pygments = [
{file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"},
{file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"},
@ -698,29 +887,125 @@ python-ulid = [
{file = "python-ulid-1.0.3.tar.gz", hash = "sha256:5dd8b969312a40e2212cec9c1ad63f25d4b6eafd92ee3195883e0287b6e9d19e"},
{file = "python_ulid-1.0.3-py3-none-any.whl", hash = "sha256:8704dc20f547f531fe3a41d4369842d737a0f275403b909d0872e7ea0fe8d6f2"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
redis = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
]
regex = [
{file = "regex-2021.10.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:094a905e87a4171508c2a0e10217795f83c636ccc05ddf86e7272c26e14056ae"},
{file = "regex-2021.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072"},
{file = "regex-2021.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b0f2f874c6a157c91708ac352470cb3bef8e8814f5325e3c5c7a0533064c6a24"},
{file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361"},
{file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e"},
{file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01"},
{file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc"},
{file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f"},
{file = "regex-2021.10.8-cp310-cp310-win32.whl", hash = "sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7"},
{file = "regex-2021.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152"},
{file = "regex-2021.10.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820"},
{file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3"},
{file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9"},
{file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838"},
{file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287"},
{file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92"},
{file = "regex-2021.10.8-cp36-cp36m-win32.whl", hash = "sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f"},
{file = "regex-2021.10.8-cp36-cp36m-win_amd64.whl", hash = "sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee"},
{file = "regex-2021.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4"},
{file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf"},
{file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f"},
{file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61"},
{file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e"},
{file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd"},
{file = "regex-2021.10.8-cp37-cp37m-win32.whl", hash = "sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432"},
{file = "regex-2021.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca"},
{file = "regex-2021.10.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:19b8f6d23b2dc93e8e1e7e288d3010e58fafed323474cf7f27ab9451635136d9"},
{file = "regex-2021.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59"},
{file = "regex-2021.10.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:951be934dc25d8779d92b530e922de44dda3c82a509cdb5d619f3a0b1491fafa"},
{file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741"},
{file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354"},
{file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f"},
{file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d"},
{file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3"},
{file = "regex-2021.10.8-cp38-cp38-win32.whl", hash = "sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593"},
{file = "regex-2021.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1"},
{file = "regex-2021.10.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6dcf53d35850ce938b4f044a43b33015ebde292840cef3af2c8eb4c860730fff"},
{file = "regex-2021.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991"},
{file = "regex-2021.10.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e2ec1c106d3f754444abf63b31e5c4f9b5d272272a491fa4320475aba9e8157c"},
{file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3"},
{file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb"},
{file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493"},
{file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2"},
{file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b"},
{file = "regex-2021.10.8-cp39-cp39-win32.whl", hash = "sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b"},
{file = "regex-2021.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700"},
{file = "regex-2021.10.8.tar.gz", hash = "sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
smmap = [
{file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"},
{file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"},
]
stevedore = [
{file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"},
{file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
tomli = [
{file = "tomli-1.2.1-py3-none-any.whl", hash = "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f"},
{file = "tomli-1.2.1.tar.gz", hash = "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"},
]
traitlets = [
{file = "traitlets-5.1.0-py3-none-any.whl", hash = "sha256:03f172516916220b58c9f19d7f854734136dd9528103d04e9bf139a92c9f54c4"},
{file = "traitlets-5.1.0.tar.gz", hash = "sha256:bd382d7ea181fbbcce157c133db9a829ce06edffe097bcf3ab945b435452b46d"},
]
types-redis = [
{file = "types-redis-3.5.9.tar.gz", hash = "sha256:f142c48f4080757ca2a9441ec40213bda3b1535eebebfc4f3519e5aa46498076"},
{file = "types_redis-3.5.9-py3-none-any.whl", hash = "sha256:5f5648ffc025708858097173cf695164c20f2b5e3f57177de14e352cae8cc335"},
{file = "types-redis-3.5.15.tar.gz", hash = "sha256:e52be0077ca1189d8cce813a20c2a70e9e577f34ab898371c6cbed696a88bdee"},
{file = "types_redis-3.5.15-py3-none-any.whl", hash = "sha256:e617c08bff88449b52f6dbdaa9bb81a806f27c89fd30bbf98fe9683ed5d1046a"},
]
types-six = [
{file = "types-six-1.16.1.tar.gz", hash = "sha256:a9e6769cb0808f920958ac95f75c5191f49e21e041eac127fa62e286e1005616"},
{file = "types_six-1.16.1-py2.py3-none-any.whl", hash = "sha256:b14f5abe26c0997bd41a1a32d6816af25932f7bfbc54246dfdc8f6f6404fd1d4"},
{file = "types-six-1.16.2.tar.gz", hash = "sha256:b96bd911f87d15258c38e10ee3f0921c32887a5d22e41c39d15707b4d0e4d0f1"},
{file = "types_six-1.16.2-py2.py3-none-any.whl", hash = "sha256:606dd8c7edff3100fae8277c270e65285e5cdb6a7819c0b1ea6a8973690e68da"},
]
typing-extensions = [
{file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
@ -732,5 +1017,48 @@ wcwidth = [
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
]
wrapt = [
{file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"},
{file = "wrapt-1.13.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3de7b4d3066cc610054e7aa2c005645e308df2f92be730aae3a47d42e910566a"},
{file = "wrapt-1.13.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:8164069f775c698d15582bf6320a4f308c50d048c1c10cf7d7a341feaccf5df7"},
{file = "wrapt-1.13.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9adee1891253670575028279de8365c3a02d3489a74a66d774c321472939a0b1"},
{file = "wrapt-1.13.2-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a70d876c9aba12d3bd7f8f1b05b419322c6789beb717044eea2c8690d35cb91b"},
{file = "wrapt-1.13.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3f87042623530bcffea038f824b63084180513c21e2e977291a9a7e65a66f13b"},
{file = "wrapt-1.13.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:e634136f700a21e1fcead0c137f433dde928979538c14907640607d43537d468"},
{file = "wrapt-1.13.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:3e33c138d1e3620b1e0cc6fd21e46c266393ed5dae0d595b7ed5a6b73ed57aa0"},
{file = "wrapt-1.13.2-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:283e402e5357e104ac1e3fba5791220648e9af6fb14ad7d9cc059091af2b31d2"},
{file = "wrapt-1.13.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:ccb34ce599cab7f36a4c90318697ead18312c67a9a76327b3f4f902af8f68ea1"},
{file = "wrapt-1.13.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:fbad5ba74c46517e6488149514b2e2348d40df88cd6b52a83855b7a8bf04723f"},
{file = "wrapt-1.13.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:724ed2bc9c91a2b9026e5adce310fa60c6e7c8760b03391445730b9789b9d108"},
{file = "wrapt-1.13.2-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:83f2793ec6f3ef513ad8d5b9586f5ee6081cad132e6eae2ecb7eac1cc3decae0"},
{file = "wrapt-1.13.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:0473d1558b93e314e84313cc611f6c86be779369f9d3734302bf185a4d2625b1"},
{file = "wrapt-1.13.2-cp35-cp35m-win32.whl", hash = "sha256:15eee0e6fd07f48af2f66d0e6f2ff1916ffe9732d464d5e2390695296872cad9"},
{file = "wrapt-1.13.2-cp35-cp35m-win_amd64.whl", hash = "sha256:bc85d17d90201afd88e3d25421da805e4e135012b5d1f149e4de2981394b2a52"},
{file = "wrapt-1.13.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6ee5f8734820c21b9b8bf705e99faba87f21566d20626568eeb0d62cbeaf23c"},
{file = "wrapt-1.13.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:53c6706a1bcfb6436f1625511b95b812798a6d2ccc51359cd791e33722b5ea32"},
{file = "wrapt-1.13.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fbe6aebc9559fed7ea27de51c2bf5c25ba2a4156cf0017556f72883f2496ee9a"},
{file = "wrapt-1.13.2-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:0582180566e7a13030f896c2f1ac6a56134ab5f3c3f4c5538086f758b1caf3f2"},
{file = "wrapt-1.13.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:bff0a59387a0a2951cb869251257b6553663329a1b5525b5226cab8c88dcbe7e"},
{file = "wrapt-1.13.2-cp36-cp36m-win32.whl", hash = "sha256:df3eae297a5f1594d1feb790338120f717dac1fa7d6feed7b411f87e0f2401c7"},
{file = "wrapt-1.13.2-cp36-cp36m-win_amd64.whl", hash = "sha256:1eb657ed84f4d3e6ad648483c8a80a0cf0a78922ef94caa87d327e2e1ad49b48"},
{file = "wrapt-1.13.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0cdedf681db878416c05e1831ec69691b0e6577ac7dca9d4f815632e3549580"},
{file = "wrapt-1.13.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:87ee3c73bdfb4367b26c57259995935501829f00c7b3eed373e2ad19ec21e4e4"},
{file = "wrapt-1.13.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3e0d16eedc242d01a6f8cf0623e9cdc3b869329da3f97a15961d8864111d8cf0"},
{file = "wrapt-1.13.2-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:8318088860968c07e741537030b1abdd8908ee2c71fbe4facdaade624a09e006"},
{file = "wrapt-1.13.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d90520616fce71c05dedeac3a0fe9991605f0acacd276e5f821842e454485a70"},
{file = "wrapt-1.13.2-cp37-cp37m-win32.whl", hash = "sha256:22142afab65daffc95863d78effcbd31c19a8003eca73de59f321ee77f73cadb"},
{file = "wrapt-1.13.2-cp37-cp37m-win_amd64.whl", hash = "sha256:d0d717e10f952df7ea41200c507cc7e24458f4c45b56c36ad418d2e79dacd1d4"},
{file = "wrapt-1.13.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:593cb049ce1c391e0288523b30426c4430b26e74c7e6f6e2844bd99ac7ecc831"},
{file = "wrapt-1.13.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8860c8011a6961a651b1b9f46fdbc589ab63b0a50d645f7d92659618a3655867"},
{file = "wrapt-1.13.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ada5e29e59e2feb710589ca1c79fd989b1dd94d27079dc1d199ec954a6ecc724"},
{file = "wrapt-1.13.2-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:fdede980273aeca591ad354608778365a3a310e0ecdd7a3587b38bc5be9b1808"},
{file = "wrapt-1.13.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:af9480de8e63c5f959a092047aaf3d7077422ded84695b3398f5d49254af3e90"},
{file = "wrapt-1.13.2-cp38-cp38-win32.whl", hash = "sha256:c65e623ea7556e39c4f0818200a046cbba7575a6b570ff36122c276fdd30ab0a"},
{file = "wrapt-1.13.2-cp38-cp38-win_amd64.whl", hash = "sha256:b20703356cae1799080d0ad15085dc3213c1ac3f45e95afb9f12769b98231528"},
{file = "wrapt-1.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c5c4cf188b5643a97e87e2110bbd4f5bc491d54a5b90633837b34d5df6a03fe"},
{file = "wrapt-1.13.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:82223f72eba6f63eafca87a0f614495ae5aa0126fe54947e2b8c023969e9f2d7"},
{file = "wrapt-1.13.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:81a4cf257263b299263472d669692785f9c647e7dca01c18286b8f116dbf6b38"},
{file = "wrapt-1.13.2-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:728e2d9b7a99dd955d3426f237b940fc74017c4a39b125fec913f575619ddfe9"},
{file = "wrapt-1.13.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:7574de567dcd4858a2ffdf403088d6df8738b0e1eabea220553abf7c9048f59e"},
{file = "wrapt-1.13.2-cp39-cp39-win32.whl", hash = "sha256:c7ac2c7a8e34bd06710605b21dd1f3576764443d68e069d2afba9b116014d072"},
{file = "wrapt-1.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e6d1a8eeef415d7fb29fe017de0e48f45e45efd2d1bfda28fc50b7b330859ef"},
{file = "wrapt-1.13.2.tar.gz", hash = "sha256:dca56cc5963a5fd7c2aa8607017753f534ee514e09103a6c55d2db70b50e7447"},
]

View file

@ -13,15 +13,20 @@ pydantic = "^1.8.2"
click = "^8.0.1"
six = "^1.16.0"
pptree = "^3.1"
mypy = "^0.910"
types-redis = "^3.5.9"
types-six = "^1.16.1"
python-ulid = "^1.0.3"
pylint = "^2.11.1"
[tool.poetry.dev-dependencies]
mypy = "^0.910"
pytest = "^6.2.4"
ipdb = "^0.13.9"
pylint = "^2.11.1"
black = "^21.9b0"
isort = "^5.9.3"
flake8 = "^4.0.1"
bandit = "^1.7.0"
[tool.poetry.scripts]
migrate = "redis_developer.orm.cli.migrate:migrate"

View file

@ -1,7 +1 @@
from .model import (
RedisModel,
HashModel,
JsonModel,
EmbeddedJsonModel,
Field
)
from .model import EmbeddedJsonModel, Field, HashModel, JsonModel, RedisModel

View file

@ -1,4 +1,5 @@
import click
from redis_developer.model.migrations.migrator import Migrator
@ -12,5 +13,5 @@ def migrate(module):
for migration in migrator.migrations:
print(migration)
if input(f"Run migrations? (y/n) ") == "y":
if input("Run migrations? (y/n) ") == "y":
migrator.run()

View file

@ -34,6 +34,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from pydantic import BaseModel
from pydantic.json import ENCODERS_BY_TYPE
SetIntStr = Set[Union[int, str]]
DictIntStrAny = Dict[Union[int, str], Any]

View file

@ -9,20 +9,33 @@ from redis import ResponseError
from redis_developer.connections import get_redis_connection
from redis_developer.model.model import model_registry
redis = get_redis_connection()
log = logging.getLogger(__name__)
import importlib
import pkgutil
import importlib # noqa: E402
import pkgutil # noqa: E402
class MigrationError(Exception):
pass
def import_submodules(root_module_name: str):
"""Import all submodules of a module, recursively."""
# TODO: Call this without specifying a module name, to import everything?
root_module = importlib.import_module(root_module_name)
if not hasattr(root_module, "__path__"):
raise MigrationError(
"The root module must be a Python package. "
f"You specified: {root_module_name}"
)
for loader, module_name, is_pkg in pkgutil.walk_packages(
root_module.__path__, root_module.__name__ + '.'):
root_module.__path__, root_module.__name__ + "." # type: ignore
):
importlib.import_module(module_name)
@ -77,14 +90,20 @@ class Migrator:
except NotImplementedError:
log.info("Skipping migrations for %s", name)
continue
current_hash = hashlib.sha1(schema.encode("utf-8")).hexdigest()
current_hash = hashlib.sha1(schema.encode("utf-8")).hexdigest() # nosec
try:
redis.execute_command("ft.info", cls.Meta.index_name)
except ResponseError:
self.migrations.append(
IndexMigration(name, cls.Meta.index_name, schema, current_hash,
MigrationAction.CREATE))
IndexMigration(
name,
cls.Meta.index_name,
schema,
current_hash,
MigrationAction.CREATE,
)
)
continue
stored_hash = redis.get(hash_key)
@ -93,11 +112,25 @@ class Migrator:
if schema_out_of_date:
# TODO: Switch out schema with an alias to avoid downtime -- separate migration?
self.migrations.append(
IndexMigration(name, cls.Meta.index_name, schema, current_hash,
MigrationAction.DROP, stored_hash))
IndexMigration(
name,
cls.Meta.index_name,
schema,
current_hash,
MigrationAction.DROP,
stored_hash,
)
)
self.migrations.append(
IndexMigration(name, cls.Meta.index_name, schema, current_hash,
MigrationAction.CREATE, stored_hash))
IndexMigration(
name,
cls.Meta.index_name,
schema,
current_hash,
MigrationAction.CREATE,
stored_hash,
)
)
def run(self):
# TODO: Migration history

View file

@ -4,7 +4,7 @@ import decimal
import json
import logging
import operator
from copy import deepcopy, copy
from copy import copy, deepcopy
from enum import Enum
from functools import reduce
from typing import (
@ -12,18 +12,19 @@ from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
Sequence,
Set,
Tuple,
Type,
TypeVar,
Union,
Sequence,
no_type_check,
Protocol,
List,
get_args,
get_origin,
get_args, Type
no_type_check,
)
import redis
@ -40,6 +41,7 @@ from .encoders import jsonable_encoder
from .render_tree import render_tree
from .token_escaper import TokenEscaper
model_registry = {}
_T = TypeVar("_T")
log = logging.getLogger(__name__)
@ -92,7 +94,7 @@ class Operators(Enum):
return str(self.name)
ExpressionOrModelField = Union['Expression', 'NegatedExpression', ModelField]
ExpressionOrModelField = Union["Expression", "NegatedExpression", ModelField]
def embedded(cls):
@ -100,20 +102,22 @@ def embedded(cls):
Mark a model as embedded to avoid creating multiple indexes if the model is
only ever used embedded within other models.
"""
setattr(cls.Meta, 'embedded', True)
setattr(cls.Meta, "embedded", True)
def is_supported_container_type(typ: type) -> bool:
def is_supported_container_type(typ: Optional[type]) -> bool:
if typ == list or typ == tuple:
return True
unwrapped = get_origin(typ)
return unwrapped == list or unwrapped == tuple
def validate_model_fields(model: Type['RedisModel'], field_values: Dict[str, Any]):
def validate_model_fields(model: Type["RedisModel"], field_values: Dict[str, Any]):
for field_name in field_values.keys():
if field_name not in model.__fields__:
raise QuerySyntaxError(f"The field {field_name} does not exist on the model {self.model}")
raise QuerySyntaxError(
f"The field {field_name} does not exist on the model {model.__name__}"
)
class ExpressionProtocol(Protocol):
@ -121,7 +125,7 @@ class ExpressionProtocol(Protocol):
left: ExpressionOrModelField
right: ExpressionOrModelField
def __invert__(self) -> 'Expression':
def __invert__(self) -> "Expression":
pass
def __and__(self, other: ExpressionOrModelField):
@ -148,16 +152,21 @@ class NegatedExpression:
responsible for querying) to negate the logic in the wrapped Expression. A
better design is probably possible, maybe at least an ExpressionProtocol?
"""
expression: 'Expression'
expression: "Expression"
def __invert__(self):
return self.expression
def __and__(self, other):
return Expression(left=self, op=Operators.AND, right=other, parents=self.expression.parents)
return Expression(
left=self, op=Operators.AND, right=other, parents=self.expression.parents
)
def __or__(self, other):
return Expression(left=self, op=Operators.OR, right=other, parents=self.expression.parents)
return Expression(
left=self, op=Operators.OR, right=other, parents=self.expression.parents
)
@property
def left(self):
@ -188,13 +197,15 @@ class Expression:
op: Operators
left: Optional[ExpressionOrModelField]
right: Optional[ExpressionOrModelField]
parents: List[Tuple[str, 'RedisModel']]
parents: List[Tuple[str, "RedisModel"]]
def __invert__(self):
return NegatedExpression(self)
def __and__(self, other: ExpressionOrModelField):
return Expression(left=self, op=Operators.AND, right=other, parents=self.parents)
return Expression(
left=self, op=Operators.AND, right=other, parents=self.parents
)
def __or__(self, other: ExpressionOrModelField):
return Expression(left=self, op=Operators.OR, right=other, parents=self.parents)
@ -212,41 +223,59 @@ ExpressionOrNegated = Union[Expression, NegatedExpression]
class ExpressionProxy:
def __init__(self, field: ModelField, parents: List[Tuple[str, 'RedisModel']]):
def __init__(self, field: ModelField, parents: List[Tuple[str, "RedisModel"]]):
self.field = field
self.parents = parents
def __eq__(self, other: Any) -> Expression: # type: ignore[override]
return Expression(left=self.field, op=Operators.EQ, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.EQ, right=other, parents=self.parents
)
def __ne__(self, other: Any) -> Expression: # type: ignore[override]
return Expression(left=self.field, op=Operators.NE, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.NE, right=other, parents=self.parents
)
def __lt__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.LT, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.LT, right=other, parents=self.parents
)
def __le__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.LE, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.LE, right=other, parents=self.parents
)
def __gt__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.GT, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.GT, right=other, parents=self.parents
)
def __ge__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.GE, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.GE, right=other, parents=self.parents
)
def __mod__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.LIKE, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.LIKE, right=other, parents=self.parents
)
def __lshift__(self, other: Any) -> Expression:
return Expression(left=self.field, op=Operators.IN, right=other, parents=self.parents)
return Expression(
left=self.field, op=Operators.IN, right=other, parents=self.parents
)
def __getattr__(self, item):
if is_supported_container_type(self.field.outer_type_):
embedded_cls = get_args(self.field.outer_type_)
if not embedded_cls:
raise QuerySyntaxError("In order to query on a list field, you must define "
"the contents of the list with a type annotation, like: "
"orders: List[Order]. Docs: TODO")
raise QuerySyntaxError(
"In order to query on a list field, you must define "
"the contents of the list with a type annotation, like: "
"orders: List[Order]. Docs: TODO"
)
embedded_cls = embedded_cls[0]
attr = getattr(embedded_cls, item)
else:
@ -266,10 +295,10 @@ class QueryNotSupportedError(Exception):
class RediSearchFieldTypes(Enum):
TEXT = 'TEXT'
TAG = 'TAG'
NUMERIC = 'NUMERIC'
GEO = 'GEO'
TEXT = "TEXT"
TAG = "TAG"
NUMERIC = "NUMERIC"
GEO = "GEO"
# TODO: How to handle Geo fields?
@ -278,13 +307,15 @@ DEFAULT_PAGE_SIZE = 10
class FindQuery:
def __init__(self,
expressions: Sequence[ExpressionOrNegated],
model: Type['RedisModel'],
offset: int = 0,
limit: int = DEFAULT_PAGE_SIZE,
page_size: int = DEFAULT_PAGE_SIZE,
sort_fields: Optional[List[str]] = None):
def __init__(
self,
expressions: Sequence[ExpressionOrNegated],
model: Type["RedisModel"],
offset: int = 0,
limit: int = DEFAULT_PAGE_SIZE,
page_size: int = DEFAULT_PAGE_SIZE,
sort_fields: Optional[List[str]] = None,
):
self.expressions = expressions
self.model = model
self.offset = offset
@ -308,7 +339,7 @@ class FindQuery:
page_size=self.page_size,
limit=self.limit,
expressions=copy(self.expressions),
sort_fields=copy(self.sort_fields)
sort_fields=copy(self.sort_fields),
)
def copy(self, **kwargs):
@ -330,7 +361,9 @@ class FindQuery:
if self.expressions:
self._expression = reduce(operator.and_, self.expressions)
else:
self._expression = Expression(left=None, right=None, op=Operators.ALL, parents=[])
self._expression = Expression(
left=None, right=None, op=Operators.ALL, parents=[]
)
return self._expression
@property
@ -350,24 +383,30 @@ class FindQuery:
for sort_field in sort_fields:
field_name = sort_field.lstrip("-")
if field_name not in self.model.__fields__:
raise QueryNotSupportedError(f"You tried sort by {field_name}, but that field "
f"does not exist on the model {self.model}")
raise QueryNotSupportedError(
f"You tried sort by {field_name}, but that field "
f"does not exist on the model {self.model}"
)
field_proxy = getattr(self.model, field_name)
if not getattr(field_proxy.field.field_info, 'sortable', False):
raise QueryNotSupportedError(f"You tried sort by {field_name}, but {self.model} does "
"not define that field as sortable. See docs: XXX")
if not getattr(field_proxy.field.field_info, "sortable", False):
raise QueryNotSupportedError(
f"You tried sort by {field_name}, but {self.model} does "
"not define that field as sortable. See docs: XXX"
)
return sort_fields
@staticmethod
def resolve_field_type(field: ModelField, op: Operators) -> RediSearchFieldTypes:
if getattr(field.field_info, 'primary_key', None) is True:
if getattr(field.field_info, "primary_key", None) is True:
return RediSearchFieldTypes.TAG
elif op is Operators.LIKE:
fts = getattr(field.field_info, 'full_text_search', None)
fts = getattr(field.field_info, "full_text_search", None)
if fts is not True: # Could be PydanticUndefined
raise QuerySyntaxError(f"You tried to do a full-text search on the field '{field.name}', "
f"but the field is not indexed for full-text search. Use the "
f"full_text_search=True option. Docs: TODO")
raise QuerySyntaxError(
f"You tried to do a full-text search on the field '{field.name}', "
f"but the field is not indexed for full-text search. Use the "
f"full_text_search=True option. Docs: TODO"
)
return RediSearchFieldTypes.TEXT
field_type = field.outer_type_
@ -391,8 +430,10 @@ class FindQuery:
# within the model inside the list marked as `index=True`.
return RediSearchFieldTypes.TAG
elif container_type is not None:
raise QuerySyntaxError("Only lists and tuples are supported for multi-value fields. "
"See docs: TODO")
raise QuerySyntaxError(
"Only lists and tuples are supported for multi-value fields. "
"See docs: TODO"
)
elif any(issubclass(field_type, t) for t in NUMERIC_TYPES):
# Index numeric Python types as NUMERIC fields, so we can support
# range queries.
@ -419,14 +460,23 @@ class FindQuery:
try:
return "|".join([escaper.escape(str(v)) for v in value])
except TypeError:
log.debug("Escaping single non-iterable value used for an IN or "
"NOT_IN query: %s", value)
log.debug(
"Escaping single non-iterable value used for an IN or "
"NOT_IN query: %s",
value,
)
return escaper.escape(str(value))
@classmethod
def resolve_value(cls, field_name: str, field_type: RediSearchFieldTypes,
field_info: PydanticFieldInfo, op: Operators, value: Any,
parents: List[Tuple[str, 'RedisModel']]) -> str:
def resolve_value(
cls,
field_name: str,
field_type: RediSearchFieldTypes,
field_info: PydanticFieldInfo,
op: Operators,
value: Any,
parents: List[Tuple[str, "RedisModel"]],
) -> str:
if parents:
prefix = "_".join([p[0] for p in parents])
field_name = f"{prefix}_{field_name}"
@ -440,9 +490,11 @@ class FindQuery:
elif op is Operators.LIKE:
result += value
else:
raise QueryNotSupportedError("Only equals (=), not-equals (!=), and like() "
"comparisons are supported for TEXT fields. See "
"docs: TODO.")
raise QueryNotSupportedError(
"Only equals (=), not-equals (!=), and like() "
"comparisons are supported for TEXT fields. See "
"docs: TODO."
)
elif field_type is RediSearchFieldTypes.NUMERIC:
if op is Operators.EQ:
result += f"@{field_name}:[{value} {value}]"
@ -460,16 +512,22 @@ class FindQuery:
# field and our hidden use of TAG for exact-match queries?
elif field_type is RediSearchFieldTypes.TAG:
if op is Operators.EQ:
separator_char = getattr(field_info, 'separator',
SINGLE_VALUE_TAG_FIELD_SEPARATOR)
separator_char = getattr(
field_info, "separator", SINGLE_VALUE_TAG_FIELD_SEPARATOR
)
if value == separator_char:
# The value is ONLY the TAG field separator character --
# this is not going to work.
log.warning("Your query against the field %s is for a single character, %s, "
"that is used internally by redis-developer-python. We must ignore "
"this portion of the query. Please review your query to find "
"an alternative query that uses a string containing more than "
"just the character %s.", field_name, separator_char, separator_char)
log.warning(
"Your query against the field %s is for a single character, %s, "
"that is used internally by redis-developer-python. We must ignore "
"this portion of the query. Please review your query to find "
"an alternative query that uses a string containing more than "
"just the character %s.",
field_name,
separator_char,
separator_char,
)
return ""
if separator_char in value:
# The value contains the TAG field separator. We can work
@ -506,8 +564,8 @@ class FindQuery:
return
fields = []
for f in self.sort_fields:
direction = "desc" if f.startswith('-') else 'asc'
fields.extend([f.lstrip('-'), direction])
direction = "desc" if f.startswith("-") else "asc"
fields.extend([f.lstrip("-"), direction])
if self.sort_fields:
return ["SORTBY", *fields]
@ -550,23 +608,30 @@ class FindQuery:
if encompassing_expression_is_negated:
# TODO: Is there a use case for this, perhaps for dynamic
# scoring purposes with full-text search?
raise QueryNotSupportedError("You cannot negate a query for all results.")
raise QueryNotSupportedError(
"You cannot negate a query for all results."
)
return "*"
if isinstance(expression.left, Expression) or \
isinstance(expression.left, NegatedExpression):
if isinstance(expression.left, Expression) or isinstance(
expression.left, NegatedExpression
):
result += f"({cls.resolve_redisearch_query(expression.left)})"
elif isinstance(expression.left, ModelField):
field_type = cls.resolve_field_type(expression.left, expression.op)
field_name = expression.left.name
field_info = expression.left.field_info
if not field_info or not getattr(field_info, "index", None):
raise QueryNotSupportedError(f"You tried to query by a field ({field_name}) "
f"that isn't indexed. See docs: TODO")
raise QueryNotSupportedError(
f"You tried to query by a field ({field_name}) "
f"that isn't indexed. See docs: TODO"
)
else:
raise QueryNotSupportedError(f"A query expression should start with either a field "
f"or an expression enclosed in parenthesis. See docs: "
f"TODO")
raise QueryNotSupportedError(
"A query expression should start with either a field "
"or an expression enclosed in parenthesis. See docs: "
"TODO"
)
right = expression.right
@ -576,8 +641,10 @@ class FindQuery:
elif expression.op == Operators.OR:
result += "| "
else:
raise QueryNotSupportedError("You can only combine two query expressions with"
"AND (&) or OR (|). See docs: TODO")
raise QueryNotSupportedError(
"You can only combine two query expressions with"
"AND (&) or OR (|). See docs: TODO"
)
if isinstance(right, NegatedExpression):
result += "-"
@ -594,10 +661,18 @@ class FindQuery:
elif not field_info:
raise QuerySyntaxError("Could not resolve field info. See docs: TODO")
elif isinstance(right, ModelField):
raise QueryNotSupportedError("Comparing fields is not supported. See docs: TODO")
raise QueryNotSupportedError(
"Comparing fields is not supported. See docs: TODO"
)
else:
result += cls.resolve_value(field_name, field_type, field_info,
expression.op, right, expression.parents)
result += cls.resolve_value(
field_name,
field_type,
field_info,
expression.op,
right,
expression.parents,
)
if encompassing_expression_is_negated:
result = f"-({result})"
@ -658,7 +733,7 @@ class FindQuery:
return self
return self.copy(sort_fields=list(fields))
def update(self, use_transaction=True, **field_values) -> Optional[List[str]]:
def update(self, use_transaction=True, **field_values):
"""
Update models that match this query to the given field-value pairs.
@ -672,11 +747,14 @@ class FindQuery:
for model in self.all():
for field, value in field_values.items():
setattr(model, field, value)
# TODO: In the non-transaction case, can we do more to detect
# failure responses from Redis?
model.save(pipeline=pipeline)
if pipeline:
# TODO: Better response type, error detection
return pipeline.execute()
# TODO: Response type?
# TODO: Better error detection for transactions.
pipeline.execute()
def delete(self):
"""Delete all matching records in this query."""
@ -720,8 +798,9 @@ class PrimaryKeyCreator(Protocol):
class UlidPrimaryKey:
"""A client-side generated primary key that follows the ULID spec.
https://github.com/ulid/javascript#specification
https://github.com/ulid/javascript#specification
"""
@staticmethod
def create_pk(*args, **kwargs) -> str:
return str(ULID())
@ -848,6 +927,7 @@ class DefaultMeta:
TODO: Revisit whether this is really necessary, and whether making
these all optional here is the right choice.
"""
global_key_prefix: Optional[str] = None
model_key_prefix: Optional[str] = None
primary_key_pattern: Optional[str] = None
@ -863,28 +943,32 @@ class ModelMeta(ModelMetaclass):
_meta: MetaProtocol
def __new__(cls, name, bases, attrs, **kwargs): # noqa C901
meta = attrs.pop('Meta', None)
meta = attrs.pop("Meta", None)
new_class = super().__new__(cls, name, bases, attrs, **kwargs)
# The fact that there is a Meta field and _meta field is important: a
# user may have given us a Meta object with their configuration, while
# we might have inherited _meta from a parent class, and should
# therefore use some of the inherited fields.
meta = meta or getattr(new_class, 'Meta', None)
base_meta = getattr(new_class, '_meta', None)
meta = meta or getattr(new_class, "Meta", None)
base_meta = getattr(new_class, "_meta", None)
if meta and meta != DefaultMeta and meta != base_meta:
new_class.Meta = meta
new_class._meta = meta
elif base_meta:
new_class._meta = type(f'{new_class.__name__}Meta', (base_meta,), dict(base_meta.__dict__))
new_class._meta = type(
f"{new_class.__name__}Meta", (base_meta,), dict(base_meta.__dict__)
)
new_class.Meta = new_class._meta
# Unset inherited values we don't want to reuse (typically based on
# the model name).
new_class._meta.model_key_prefix = None
new_class._meta.index_name = None
else:
new_class._meta = type(f'{new_class.__name__}Meta', (DefaultMeta,), dict(DefaultMeta.__dict__))
new_class._meta = type(
f"{new_class.__name__}Meta", (DefaultMeta,), dict(DefaultMeta.__dict__)
)
new_class.Meta = new_class._meta
# Create proxies for each model field so that we can use the field
@ -894,29 +978,40 @@ class ModelMeta(ModelMetaclass):
# Check if this is our FieldInfo version with extended ORM metadata.
if isinstance(field.field_info, FieldInfo):
if field.field_info.primary_key:
new_class._meta.primary_key = PrimaryKey(name=field_name, field=field)
new_class._meta.primary_key = PrimaryKey(
name=field_name, field=field
)
if not getattr(new_class._meta, 'global_key_prefix', None):
new_class._meta.global_key_prefix = getattr(base_meta, "global_key_prefix", "")
if not getattr(new_class._meta, 'model_key_prefix', None):
if not getattr(new_class._meta, "global_key_prefix", None):
new_class._meta.global_key_prefix = getattr(
base_meta, "global_key_prefix", ""
)
if not getattr(new_class._meta, "model_key_prefix", None):
# Don't look at the base class for this.
new_class._meta.model_key_prefix = f"{new_class.__module__}.{new_class.__name__}"
if not getattr(new_class._meta, 'primary_key_pattern', None):
new_class._meta.primary_key_pattern = getattr(base_meta, "primary_key_pattern",
"{pk}")
if not getattr(new_class._meta, 'database', None):
new_class._meta.database = getattr(base_meta, "database",
redis.Redis(decode_responses=True))
if not getattr(new_class._meta, 'primary_key_creator_cls', None):
new_class._meta.primary_key_creator_cls = getattr(base_meta, "primary_key_creator_cls",
UlidPrimaryKey)
if not getattr(new_class._meta, 'index_name', None):
new_class._meta.index_name = f"{new_class._meta.global_key_prefix}:" \
f"{new_class._meta.model_key_prefix}:index"
new_class._meta.model_key_prefix = (
f"{new_class.__module__}.{new_class.__name__}"
)
if not getattr(new_class._meta, "primary_key_pattern", None):
new_class._meta.primary_key_pattern = getattr(
base_meta, "primary_key_pattern", "{pk}"
)
if not getattr(new_class._meta, "database", None):
new_class._meta.database = getattr(
base_meta, "database", redis.Redis(decode_responses=True)
)
if not getattr(new_class._meta, "primary_key_creator_cls", None):
new_class._meta.primary_key_creator_cls = getattr(
base_meta, "primary_key_creator_cls", UlidPrimaryKey
)
if not getattr(new_class._meta, "index_name", None):
new_class._meta.index_name = (
f"{new_class._meta.global_key_prefix}:"
f"{new_class._meta.model_key_prefix}:index"
)
# Not an abstract model class or embedded model, so we should let the
# Migrator create indexes for it.
if abc.ABC not in bases and not getattr(new_class._meta, 'embedded', False):
if abc.ABC not in bases and not getattr(new_class._meta, "embedded", False):
key = f"{new_class.__module__}.{new_class.__qualname__}"
model_registry[key] = new_class
@ -931,7 +1026,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
class Config:
orm_mode = True
arbitrary_types_allowed = True
extra = 'allow'
extra = "allow"
def __init__(__pydantic_self__, **data: Any) -> None:
super().__init__(**data)
@ -953,7 +1048,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
"""Update this model instance with the specified key-value pairs."""
raise NotImplementedError
def save(self, *args, **kwargs) -> 'RedisModel':
def save(self, pipeline: Optional[Pipeline] = None) -> "RedisModel":
raise NotImplementedError
@validator("pk", always=True)
@ -967,7 +1062,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
"""Check for a primary key. We need one (and only one)."""
primary_keys = 0
for name, field in cls.__fields__.items():
if getattr(field.field_info, 'primary_key', None):
if getattr(field.field_info, "primary_key", None):
primary_keys += 1
if primary_keys == 0:
raise RedisModelError("You must define a primary key for the model")
@ -976,8 +1071,8 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
@classmethod
def make_key(cls, part: str):
global_prefix = getattr(cls._meta, 'global_key_prefix', '').strip(":")
model_prefix = getattr(cls._meta, 'model_key_prefix', '').strip(":")
global_prefix = getattr(cls._meta, "global_key_prefix", "").strip(":")
model_prefix = getattr(cls._meta, "model_key_prefix", "").strip(":")
return f"{global_prefix}:{model_prefix}:{part}"
@classmethod
@ -997,13 +1092,14 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
def from_redis(cls, res: Any):
# TODO: Parsing logic copied from redisearch-py. Evaluate.
import six
from six.moves import xrange, zip as izip
from six.moves import xrange
from six.moves import zip as izip
def to_string(s):
if isinstance(s, six.string_types):
return s
elif isinstance(s, six.binary_type):
return s.decode('utf-8', 'ignore')
return s.decode("utf-8", "ignore")
else:
return s # Not a string we care about
@ -1015,23 +1111,27 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
fields_offset = offset
fields = dict(
dict(izip(map(to_string, res[i + fields_offset][::2]),
map(to_string, res[i + fields_offset][1::2])))
dict(
izip(
map(to_string, res[i + fields_offset][::2]),
map(to_string, res[i + fields_offset][1::2]),
)
)
)
try:
del fields['id']
del fields["id"]
except KeyError:
pass
try:
fields['json'] = fields['$']
del fields['$']
fields["json"] = fields["$"]
del fields["$"]
except KeyError:
pass
if 'json' in fields:
json_fields = json.loads(fields['json'])
if "json" in fields:
json_fields = json.loads(fields["json"])
doc = cls(**json_fields)
else:
doc = cls(**fields)
@ -1039,7 +1139,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta):
return docs
@classmethod
def add(cls, models: Sequence['RedisModel']) -> Sequence['RedisModel']:
def add(cls, models: Sequence["RedisModel"]) -> Sequence["RedisModel"]:
# TODO: Add transaction support
return [model.save() for model in models]
@ -1059,15 +1159,18 @@ class HashModel(RedisModel, abc.ABC):
for name, field in cls.__fields__.items():
if issubclass(field.outer_type_, RedisModel):
raise RedisModelError(f"HashModels cannot have embedded model "
f"fields. Field: {name}")
raise RedisModelError(
f"HashModels cannot have embedded model " f"fields. Field: {name}"
)
for typ in (Set, Mapping, List):
if issubclass(field.outer_type_, typ):
raise RedisModelError(f"HashModels cannot have set, list,"
f" or mapping fields. Field: {name}")
raise RedisModelError(
f"HashModels cannot have set, list,"
f" or mapping fields. Field: {name}"
)
def save(self, pipeline: Optional[Pipeline] = None) -> 'HashModel':
def save(self, pipeline: Optional[Pipeline] = None) -> "HashModel":
if pipeline is None:
db = self.db()
else:
@ -1077,7 +1180,7 @@ class HashModel(RedisModel, abc.ABC):
return self
@classmethod
def get(cls, pk: Any) -> 'HashModel':
def get(cls, pk: Any) -> "HashModel":
document = cls.db().hgetall(cls.make_primary_key(pk))
if not document:
raise NotFoundError
@ -1111,13 +1214,17 @@ class HashModel(RedisModel, abc.ABC):
for name, field in cls.__fields__.items():
# TODO: Merge this code with schema_for_type()?
_type = field.outer_type_
if getattr(field.field_info, 'primary_key', None):
if getattr(field.field_info, "primary_key", None):
if issubclass(_type, str):
redisearch_field = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
redisearch_field = (
f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
)
else:
redisearch_field = cls.schema_for_type(name, _type, field.field_info)
redisearch_field = cls.schema_for_type(
name, _type, field.field_info
)
schema_parts.append(redisearch_field)
elif getattr(field.field_info, 'index', None) is True:
elif getattr(field.field_info, "index", None) is True:
schema_parts.append(cls.schema_for_type(name, _type, field.field_info))
elif is_supported_container_type(_type):
embedded_cls = get_args(_type)
@ -1126,8 +1233,9 @@ class HashModel(RedisModel, abc.ABC):
log.warning("Model %s defined an empty list field: %s", cls, name)
continue
embedded_cls = embedded_cls[0]
schema_parts.append(cls.schema_for_type(name, embedded_cls,
field.field_info))
schema_parts.append(
cls.schema_for_type(name, embedded_cls, field.field_info)
)
elif issubclass(_type, RedisModel):
schema_parts.append(cls.schema_for_type(name, _type, field.field_info))
return schema_parts
@ -1141,29 +1249,36 @@ class HashModel(RedisModel, abc.ABC):
# as sortable.
# TODO: Abstract string-building logic for each type (TAG, etc.) into
# classes that take a field name.
sortable = getattr(field_info, 'sortable', False)
sortable = getattr(field_info, "sortable", False)
if is_supported_container_type(typ):
embedded_cls = get_args(typ)
if not embedded_cls:
# TODO: Test if this can really happen.
log.warning("Model %s defined an empty list or tuple field: %s", cls, name)
log.warning(
"Model %s defined an empty list or tuple field: %s", cls, name
)
return ""
embedded_cls = embedded_cls[0]
schema = cls.schema_for_type(name, embedded_cls, field_info)
elif any(issubclass(typ, t) for t in NUMERIC_TYPES):
schema = f"{name} NUMERIC"
elif issubclass(typ, str):
if getattr(field_info, 'full_text_search', False) is True:
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
f"{name}_fts TEXT"
if getattr(field_info, "full_text_search", False) is True:
schema = (
f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} "
f"{name}_fts TEXT"
)
else:
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
elif issubclass(typ, RedisModel):
sub_fields = []
for embedded_name, field in typ.__fields__.items():
sub_fields.append(cls.schema_for_type(f"{name}_{embedded_name}", field.outer_type_,
field.field_info))
sub_fields.append(
cls.schema_for_type(
f"{name}_{embedded_name}", field.outer_type_, field.field_info
)
)
schema = " ".join(sub_fields)
else:
schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
@ -1177,12 +1292,12 @@ class JsonModel(RedisModel, abc.ABC):
# Generate the RediSearch schema once to validate fields.
cls.redisearch_schema()
def save(self, pipeline: Optional[Pipeline] = None) -> 'JsonModel':
def save(self, pipeline: Optional[Pipeline] = None) -> "JsonModel":
if pipeline is None:
db = self.db()
else:
db = pipeline
db.execute_command('JSON.SET', self.key(), ".", self.json())
db.execute_command("JSON.SET", self.key(), ".", self.json())
return self
def update(self, **field_values):
@ -1192,7 +1307,7 @@ class JsonModel(RedisModel, abc.ABC):
self.save()
@classmethod
def get(cls, pk: Any) -> 'JsonModel':
def get(cls, pk: Any) -> "JsonModel":
document = cls.db().execute_command("JSON.GET", cls.make_primary_key(pk))
if not document:
raise NotFoundError
@ -1212,21 +1327,31 @@ class JsonModel(RedisModel, abc.ABC):
for name, field in cls.__fields__.items():
_type = field.outer_type_
schema_parts.append(cls.schema_for_type(
json_path, name, "", _type, field.field_info))
schema_parts.append(
cls.schema_for_type(json_path, name, "", _type, field.field_info)
)
return schema_parts
@classmethod
def schema_for_type(cls, json_path: str, name: str, name_prefix: str, typ: Any,
field_info: PydanticFieldInfo,
parent_type: Optional[Any] = None) -> str:
should_index = getattr(field_info, 'index', False)
def schema_for_type(
cls,
json_path: str,
name: str,
name_prefix: str,
typ: Any,
field_info: PydanticFieldInfo,
parent_type: Optional[Any] = None,
) -> str:
should_index = getattr(field_info, "index", False)
is_container_type = is_supported_container_type(typ)
parent_is_container_type = is_supported_container_type(parent_type)
try:
parent_is_model = issubclass(parent_type, RedisModel)
except TypeError:
parent_is_model = False
parent_is_model = False
if parent_type:
try:
parent_is_model = issubclass(parent_type, RedisModel)
except TypeError:
pass
# TODO: We need a better way to know that we're indexing a value
# discovered in a model within an array.
@ -1253,11 +1378,19 @@ class JsonModel(RedisModel, abc.ABC):
field_type = get_origin(typ)
embedded_cls = get_args(typ)
if not embedded_cls:
log.warning("Model %s defined an empty list or tuple field: %s", cls, name)
log.warning(
"Model %s defined an empty list or tuple field: %s", cls, name
)
return ""
embedded_cls = embedded_cls[0]
return cls.schema_for_type(f"{json_path}.{name}[*]", name, name_prefix,
embedded_cls, field_info, parent_type=field_type)
return cls.schema_for_type(
f"{json_path}.{name}[*]",
name,
name_prefix,
embedded_cls,
field_info,
parent_type=field_type,
)
elif field_is_model:
name_prefix = f"{name_prefix}_{name}" if name_prefix else name
sub_fields = []
@ -1273,12 +1406,16 @@ class JsonModel(RedisModel, abc.ABC):
# current field name and "embedded" field name, e.g.,
# order.address.street_line_1.
path = f"{json_path}.{name}"
sub_fields.append(cls.schema_for_type(path,
embedded_name,
name_prefix,
field.outer_type_,
field.field_info,
parent_type=typ))
sub_fields.append(
cls.schema_for_type(
path,
embedded_name,
name_prefix,
field.outer_type_,
field.field_info,
parent_type=typ,
)
)
return " ".join(filter(None, sub_fields))
# NOTE: This is the termination point for recursion. We've descended
# into models and lists until we found an actual value to index.
@ -1291,20 +1428,26 @@ class JsonModel(RedisModel, abc.ABC):
path = json_path
else:
path = f"{json_path}.{name}"
sortable = getattr(field_info, 'sortable', False)
full_text_search = getattr(field_info, 'full_text_search', False)
sortable_tag_error = RedisModelError("In this Preview release, TAG fields cannot "
f"be marked as sortable. Problem field: {name}. "
"See docs: TODO")
sortable = getattr(field_info, "sortable", False)
full_text_search = getattr(field_info, "full_text_search", False)
sortable_tag_error = RedisModelError(
"In this Preview release, TAG fields cannot "
f"be marked as sortable. Problem field: {name}. "
"See docs: TODO"
)
# TODO: GEO field
if parent_is_container_type or parent_is_model_in_container:
if typ is not str:
raise RedisModelError("In this Preview release, list and tuple fields can only "
f"contain strings. Problem field: {name}. See docs: TODO")
raise RedisModelError(
"In this Preview release, list and tuple fields can only "
f"contain strings. Problem field: {name}. See docs: TODO"
)
if full_text_search is True:
raise RedisModelError("List and tuple fields cannot be indexed for full-text "
f"search. Problem field: {name}. See docs: TODO")
raise RedisModelError(
"List and tuple fields cannot be indexed for full-text "
f"search. Problem field: {name}. See docs: TODO"
)
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}"
if sortable is True:
raise sortable_tag_error
@ -1312,8 +1455,10 @@ class JsonModel(RedisModel, abc.ABC):
schema = f"{path} AS {index_field_name} NUMERIC"
elif issubclass(typ, str):
if full_text_search is True:
schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \
f"{path} AS {index_field_name}_fts TEXT"
schema = (
f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} "
f"{path} AS {index_field_name}_fts TEXT"
)
if sortable is True:
# NOTE: With the current preview release, making a field
# full-text searchable and sortable only makes the TEXT

View file

@ -1,7 +1,7 @@
import abc
from typing import Optional
from redis_developer.model.model import JsonModel, HashModel
from redis_developer.model.model import HashModel, JsonModel
class BaseJsonModel(JsonModel, abc.ABC):
@ -22,6 +22,7 @@ class BaseHashModel(HashModel, abc.ABC):
# postal_code: str
#
class AddressHash(BaseHashModel):
address_line_1: str
address_line_2: Optional[str]

View file

@ -1,5 +1,5 @@
from collections import Sequence
from typing import Any, Dict, Mapping, Union, List
from typing import Any, Dict, List, Mapping, Union
from redis_developer.model.model import Expression
@ -91,6 +91,7 @@ class Not(LogicalOperatorForListOfExpressions):
-(@price:[-inf 10]) -(@category:{Sweets})
```
"""
@property
def query(self):
return "-(expression1) -(expression2)"
@ -102,5 +103,3 @@ class QueryResolver:
def resolve(self) -> str:
"""Resolve expressions to a RediSearch query string."""

View file

@ -5,9 +5,15 @@ and released under the MIT license: https://github.com/clemtoy/pptree
import io
def render_tree(current_node, nameattr='name', left_child='left',
right_child='right', indent='', last='updown',
buffer=None):
def render_tree(
current_node,
nameattr="name",
left_child="left",
right_child="right",
indent="",
last="updown",
buffer=None,
):
"""Print a tree-like structure, `current_node`.
This is a mostly-direct-copy of the print_tree() function from the ppbtree
@ -18,42 +24,52 @@ def render_tree(current_node, nameattr='name', left_child='left',
if buffer is None:
buffer = io.StringIO()
if hasattr(current_node, nameattr):
name = lambda node: getattr(node, nameattr)
name = lambda node: getattr(node, nameattr) # noqa: E731
else:
name = lambda node: str(node)
name = lambda node: str(node) # noqa: E731
up = getattr(current_node, left_child, None)
down = getattr(current_node, right_child, None)
if up is not None:
next_last = 'up'
next_indent = '{0}{1}{2}'.format(indent, ' ' if 'up' in last else '|', ' ' * len(str(name(current_node))))
render_tree(up, nameattr, left_child, right_child, next_indent, next_last, buffer)
next_last = "up"
next_indent = "{0}{1}{2}".format(
indent, " " if "up" in last else "|", " " * len(str(name(current_node)))
)
render_tree(
up, nameattr, left_child, right_child, next_indent, next_last, buffer
)
if last == 'up':
start_shape = ''
elif last == 'down':
start_shape = ''
elif last == 'updown':
start_shape = ' '
if last == "up":
start_shape = ""
elif last == "down":
start_shape = ""
elif last == "updown":
start_shape = " "
else:
start_shape = ''
start_shape = ""
if up is not None and down is not None:
end_shape = ''
end_shape = ""
elif up:
end_shape = ''
end_shape = ""
elif down:
end_shape = ''
end_shape = ""
else:
end_shape = ''
end_shape = ""
print('{0}{1}{2}{3}'.format(indent, start_shape, name(current_node), end_shape),
file=buffer)
print(
"{0}{1}{2}{3}".format(indent, start_shape, name(current_node), end_shape),
file=buffer,
)
if down is not None:
next_last = 'down'
next_indent = '{0}{1}{2}'.format(indent, ' ' if 'down' in last else '|', ' ' * len(str(name(current_node))))
render_tree(down, nameattr, left_child, right_child, next_indent, next_last, buffer)
next_last = "down"
next_indent = "{0}{1}{2}".format(
indent, " " if "down" in last else "|", " " * len(str(name(current_node)))
)
render_tree(
down, nameattr, left_child, right_child, next_indent, next_last, buffer
)
return f"\n{buffer.getvalue()}"

View file

@ -6,6 +6,7 @@ class TokenEscaper:
"""
Escape punctuation within an input string.
"""
# Characters that RediSearch requires us to escape during queries.
# Source: https://oss.redis.com/redisearch/Escaping/#the_rules_of_text_field_tokenization
DEFAULT_ESCAPED_CHARS = r"[,.<>{}\[\]\\\"\':;!@#$%^&*()\-+=~\ ]"

View file

@ -1,6 +1,6 @@
import abc
import decimal
import datetime
import decimal
from typing import Optional
from unittest import mock
@ -8,11 +8,13 @@ import pytest
import redis
from pydantic import ValidationError
from redis_developer.model import (
HashModel,
Field,
from redis_developer.model import Field, HashModel
from redis_developer.model.model import (
NotFoundError,
QueryNotSupportedError,
RedisModelError,
)
from redis_developer.model.model import RedisModelError, QueryNotSupportedError, NotFoundError
r = redis.Redis()
today = datetime.date.today()
@ -48,7 +50,7 @@ def members():
last_name="Brookins",
email="a@example.com",
age=38,
join_date=today
join_date=today,
)
member2 = Member(
@ -56,7 +58,7 @@ def members():
last_name="Brookins",
email="k@example.com",
age=34,
join_date=today
join_date=today,
)
member3 = Member(
@ -64,7 +66,7 @@ def members():
last_name="Smith",
email="as@example.com",
age=100,
join_date=today
join_date=today,
)
member1.save()
member2.save()
@ -76,21 +78,13 @@ def members():
def test_validates_required_fields():
# Raises ValidationError: last_name is required
with pytest.raises(ValidationError):
Member(
first_name="Andrew",
zipcode="97086",
join_date=today
)
Member(first_name="Andrew", zipcode="97086", join_date=today)
def test_validates_field():
# Raises ValidationError: join_date is not a date
with pytest.raises(ValidationError):
Member(
first_name="Andrew",
last_name="Brookins",
join_date="yesterday"
)
Member(first_name="Andrew", last_name="Brookins", join_date="yesterday")
# Passes validation
@ -100,7 +94,7 @@ def test_validation_passes():
last_name="Brookins",
email="a@example.com",
join_date=today,
age=38
age=38,
)
assert member.first_name == "Andrew"
@ -111,7 +105,7 @@ def test_saves_model_and_creates_pk():
last_name="Brookins",
email="a@example.com",
join_date=today,
age=38
age=38,
)
# Save a model instance to Redis
member.save()
@ -129,6 +123,7 @@ def test_raises_error_with_embedded_models():
postal_code: str
with pytest.raises(RedisModelError):
class InvalidMember(BaseHashModel):
address: Address
@ -140,14 +135,14 @@ def test_saves_many():
first_name="Andrew",
last_name="Brookins",
email="a@example.com",
join_date=today
join_date=today,
),
Member(
first_name="Kim",
last_name="Brookins",
email="k@example.com",
join_date=today
)
join_date=today,
),
]
Member.add(members)
@ -174,21 +169,21 @@ def test_paginate_query(members):
def test_access_result_by_index_cached(members):
member1, member2, member3 = members
query = Member.find().sort_by('age')
query = Member.find().sort_by("age")
# Load the cache, throw away the result.
assert query._model_cache == []
query.execute()
assert query._model_cache == [member2, member1, member3]
# Access an item that should be in the cache.
with mock.patch.object(query.model, 'db') as mock_db:
with mock.patch.object(query.model, "db") as mock_db:
assert query[0] == member2
assert not mock_db.called
def test_access_result_by_index_not_cached(members):
member1, member2, member3 = members
query = Member.find().sort_by('age')
query = Member.find().sort_by("age")
# Assert that we don't have any models in the cache yet -- we
# haven't made any requests of Redis.
@ -205,7 +200,8 @@ def test_exact_match_queries(members):
assert actual == [member1, member2]
actual = Member.find(
(Member.last_name == "Brookins") & ~(Member.first_name == "Andrew")).all()
(Member.last_name == "Brookins") & ~(Member.first_name == "Andrew")
).all()
assert actual == [member2]
actual = Member.find(~(Member.last_name == "Brookins")).all()
@ -220,16 +216,19 @@ def test_exact_match_queries(members):
).all()
assert actual == [member1, member2]
actual = Member.find(Member.first_name == "Kim", Member.last_name == "Brookins").all()
actual = Member.find(
Member.first_name == "Kim", Member.last_name == "Brookins"
).all()
assert actual == [member2]
def test_recursive_query_resolution(members):
member1, member2, member3 = members
actual = Member.find((Member.last_name == "Brookins") | (
Member.age == 100
) & (Member.last_name == "Smith")).all()
actual = Member.find(
(Member.last_name == "Brookins")
| (Member.age == 100) & (Member.last_name == "Smith")
).all()
assert actual == [member1, member2, member3]
@ -237,8 +236,9 @@ def test_tag_queries_boolean_logic(members):
member1, member2, member3 = members
actual = Member.find(
(Member.first_name == "Andrew") &
(Member.last_name == "Brookins") | (Member.last_name == "Smith")).all()
(Member.first_name == "Andrew") & (Member.last_name == "Brookins")
| (Member.last_name == "Smith")
).all()
assert actual == [member1, member3]
@ -281,9 +281,7 @@ def test_tag_queries_negation(members):
Andrew
"""
query = Member.find(
~(Member.first_name == "Andrew")
)
query = Member.find(~(Member.first_name == "Andrew"))
assert query.all() == [member2]
"""
@ -315,8 +313,9 @@ def test_tag_queries_negation(members):
Smith
"""
query = Member.find(
~(Member.first_name == "Andrew") &
((Member.last_name == "Brookins") | (Member.last_name == "Smith")))
~(Member.first_name == "Andrew")
& ((Member.last_name == "Brookins") | (Member.last_name == "Smith"))
)
assert query.all() == [member2]
"""
@ -333,12 +332,14 @@ def test_tag_queries_negation(members):
Smith
"""
query = Member.find(
~(Member.first_name == "Andrew") &
(Member.last_name == "Brookins") | (Member.last_name == "Smith"))
~(Member.first_name == "Andrew") & (Member.last_name == "Brookins")
| (Member.last_name == "Smith")
)
assert query.all() == [member2, member3]
actual = Member.find(
(Member.first_name == "Andrew") & ~(Member.last_name == "Brookins")).all()
(Member.first_name == "Andrew") & ~(Member.last_name == "Brookins")
).all()
assert actual == [member3]
@ -373,19 +374,19 @@ def test_numeric_queries(members):
def test_sorting(members):
member1, member2, member3 = members
actual = Member.find(Member.age > 34).sort_by('age').all()
actual = Member.find(Member.age > 34).sort_by("age").all()
assert actual == [member1, member3]
actual = Member.find(Member.age > 34).sort_by('-age').all()
actual = Member.find(Member.age > 34).sort_by("-age").all()
assert actual == [member3, member1]
with pytest.raises(QueryNotSupportedError):
# This field does not exist.
Member.find().sort_by('not-a-real-field').all()
Member.find().sort_by("not-a-real-field").all()
with pytest.raises(QueryNotSupportedError):
# This field is not sortable.
Member.find().sort_by('join_date').all()
Member.find().sort_by("join_date").all()
def test_not_found():
@ -403,4 +404,7 @@ def test_schema():
another_integer: int
another_float: float
assert Address.redisearch_schema() == "ON HASH PREFIX 1 redis-developer:tests.test_hash_model.Address: SCHEMA pk TAG SEPARATOR | a_string TAG SEPARATOR | a_full_text_string TAG SEPARATOR | a_full_text_string_fts TEXT an_integer NUMERIC SORTABLE a_float NUMERIC"
assert (
Address.redisearch_schema()
== "ON HASH PREFIX 1 redis-developer:tests.test_hash_model.Address: SCHEMA pk TAG SEPARATOR | a_string TAG SEPARATOR | a_full_text_string TAG SEPARATOR | a_full_text_string_fts TEXT an_integer NUMERIC SORTABLE a_float NUMERIC"
)

View file

@ -1,20 +1,21 @@
import abc
import decimal
import datetime
from typing import Optional, List
import decimal
from typing import List, Optional
from unittest import mock
import pytest
import redis
from pydantic import ValidationError
from redis_developer.model import (
EmbeddedJsonModel,
JsonModel,
Field,
)
from redis_developer.model import EmbeddedJsonModel, Field, JsonModel
from redis_developer.model.migrations.migrator import Migrator
from redis_developer.model.model import QueryNotSupportedError, NotFoundError, RedisModelError
from redis_developer.model.model import (
NotFoundError,
QueryNotSupportedError,
RedisModelError,
)
r = redis.Redis()
today = datetime.date.today()
@ -75,7 +76,7 @@ def address():
city="Portland",
state="OR",
country="USA",
postal_code=11111
postal_code=11111,
)
@ -87,7 +88,7 @@ def members(address):
email="a@example.com",
age=38,
join_date=today,
address=address
address=address,
)
member2 = Member(
@ -96,7 +97,7 @@ def members(address):
email="k@example.com",
age=34,
join_date=today,
address=address
address=address,
)
member3 = Member(
@ -105,7 +106,7 @@ def members(address):
email="as@example.com",
age=100,
join_date=today,
address=address
address=address,
)
member1.save()
@ -133,7 +134,7 @@ def test_validates_field(address):
first_name="Andrew",
last_name="Brookins",
join_date="yesterday",
address=address
address=address,
)
@ -145,7 +146,7 @@ def test_validation_passes(address):
email="a@example.com",
join_date=today,
age=38,
address=address
address=address,
)
assert member.first_name == "Andrew"
@ -157,7 +158,7 @@ def test_saves_model_and_creates_pk(address):
email="a@example.com",
join_date=today,
age=38,
address=address
address=address,
)
# Save a model instance to Redis
member.save()
@ -176,7 +177,7 @@ def test_saves_many(address):
email="a@example.com",
join_date=today,
address=address,
age=38
age=38,
),
Member(
first_name="Kim",
@ -184,8 +185,8 @@ def test_saves_many(address):
email="k@example.com",
join_date=today,
address=address,
age=34
)
age=34,
),
]
Member.add(members)
@ -216,21 +217,21 @@ def test_paginate_query(members):
def test_access_result_by_index_cached(members):
member1, member2, member3 = members
query = Member.find().sort_by('age')
query = Member.find().sort_by("age")
# Load the cache, throw away the result.
assert query._model_cache == []
query.execute()
assert query._model_cache == [member2, member1, member3]
# Access an item that should be in the cache.
with mock.patch.object(query.model, 'db') as mock_db:
with mock.patch.object(query.model, "db") as mock_db:
assert query[0] == member2
assert not mock_db.called
def test_access_result_by_index_not_cached(members):
member1, member2, member3 = members
query = Member.find().sort_by('age')
query = Member.find().sort_by("age")
# Assert that we don't have any models in the cache yet -- we
# haven't made any requests of Redis.
@ -252,8 +253,11 @@ def test_update_query(members):
Member.find(Member.pk << [member1.pk, member2.pk, member3.pk]).update(
first_name="Bobby"
)
actual = Member.find(
Member.pk << [member1.pk, member2.pk, member3.pk]).sort_by('age').all()
actual = (
Member.find(Member.pk << [member1.pk, member2.pk, member3.pk])
.sort_by("age")
.all()
)
assert actual == [member1, member2, member3]
assert all([m.name == "Bobby" for m in actual])
@ -265,7 +269,8 @@ def test_exact_match_queries(members):
assert actual == [member1, member2]
actual = Member.find(
(Member.last_name == "Brookins") & ~(Member.first_name == "Andrew")).all()
(Member.last_name == "Brookins") & ~(Member.first_name == "Andrew")
).all()
assert actual == [member2]
actual = Member.find(~(Member.last_name == "Brookins")).all()
@ -280,7 +285,9 @@ def test_exact_match_queries(members):
).all()
assert actual == [member1, member2]
actual = Member.find(Member.first_name == "Kim", Member.last_name == "Brookins").all()
actual = Member.find(
Member.first_name == "Kim", Member.last_name == "Brookins"
).all()
assert actual == [member2]
actual = Member.find(Member.address.city == "Portland").all()
@ -290,24 +297,28 @@ def test_exact_match_queries(members):
def test_recursive_query_expression_resolution(members):
member1, member2, member3 = members
actual = Member.find((Member.last_name == "Brookins") | (
Member.age == 100
) & (Member.last_name == "Smith")).all()
actual = Member.find(
(Member.last_name == "Brookins")
| (Member.age == 100) & (Member.last_name == "Smith")
).all()
assert actual == [member1, member2, member3]
def test_recursive_query_field_resolution(members):
member1, _, _ = members
member1.address.note = Note(description="Weird house",
created_on=datetime.datetime.now())
member1.address.note = Note(
description="Weird house", created_on=datetime.datetime.now()
)
member1.save()
actual = Member.find(Member.address.note.description == "Weird house").all()
assert actual == [member1]
member1.orders = [
Order(items=[Item(price=10.99, name="Ball")],
total=10.99,
created_on=datetime.datetime.now())
Order(
items=[Item(price=10.99, name="Ball")],
total=10.99,
created_on=datetime.datetime.now(),
)
]
member1.save()
actual = Member.find(Member.orders.items.name == "Ball").all()
@ -331,8 +342,9 @@ def test_tag_queries_boolean_logic(members):
member1, member2, member3 = members
actual = Member.find(
(Member.first_name == "Andrew") &
(Member.last_name == "Brookins") | (Member.last_name == "Smith")).all()
(Member.first_name == "Andrew") & (Member.last_name == "Brookins")
| (Member.last_name == "Smith")
).all()
assert actual == [member1, member3]
@ -343,7 +355,7 @@ def test_tag_queries_punctuation(address):
email="a|b@example.com", # NOTE: This string uses the TAG field separator.
age=38,
join_date=today,
address=address
address=address,
)
member1.save()
@ -353,7 +365,7 @@ def test_tag_queries_punctuation(address):
email="a|villain@example.com", # NOTE: This string uses the TAG field separator.
age=38,
join_date=today,
address=address
address=address,
)
member2.save()
@ -377,9 +389,7 @@ def test_tag_queries_negation(members):
Andrew
"""
query = Member.find(
~(Member.first_name == "Andrew")
)
query = Member.find(~(Member.first_name == "Andrew"))
assert query.all() == [member2]
"""
@ -411,8 +421,9 @@ def test_tag_queries_negation(members):
Smith
"""
query = Member.find(
~(Member.first_name == "Andrew") &
((Member.last_name == "Brookins") | (Member.last_name == "Smith")))
~(Member.first_name == "Andrew")
& ((Member.last_name == "Brookins") | (Member.last_name == "Smith"))
)
assert query.all() == [member2]
"""
@ -429,12 +440,14 @@ def test_tag_queries_negation(members):
Smith
"""
query = Member.find(
~(Member.first_name == "Andrew") &
(Member.last_name == "Brookins") | (Member.last_name == "Smith"))
~(Member.first_name == "Andrew") & (Member.last_name == "Brookins")
| (Member.last_name == "Smith")
)
assert query.all() == [member2, member3]
actual = Member.find(
(Member.first_name == "Andrew") & ~(Member.last_name == "Brookins")).all()
(Member.first_name == "Andrew") & ~(Member.last_name == "Brookins")
).all()
assert actual == [member3]
@ -469,19 +482,19 @@ def test_numeric_queries(members):
def test_sorting(members):
member1, member2, member3 = members
actual = Member.find(Member.age > 34).sort_by('age').all()
actual = Member.find(Member.age > 34).sort_by("age").all()
assert actual == [member1, member3]
actual = Member.find(Member.age > 34).sort_by('-age').all()
actual = Member.find(Member.age > 34).sort_by("-age").all()
assert actual == [member3, member1]
with pytest.raises(QueryNotSupportedError):
# This field does not exist.
Member.find().sort_by('not-a-real-field').all()
Member.find().sort_by("not-a-real-field").all()
with pytest.raises(QueryNotSupportedError):
# This field is not sortable.
Member.find().sort_by('join_date').all()
Member.find().sort_by("join_date").all()
def test_not_found():
@ -492,24 +505,28 @@ def test_not_found():
def test_list_field_limitations():
with pytest.raises(RedisModelError):
class SortableTarotWitch(BaseJsonModel):
# We support indexing lists of strings for quality and membership
# queries. Sorting is not supported, but is planned.
tarot_cards: List[str] = Field(index=True, sortable=True)
with pytest.raises(RedisModelError):
class SortableFullTextSearchAlchemicalWitch(BaseJsonModel):
# We don't support indexing a list of strings for full-text search
# queries. Support for this feature is not planned.
potions: List[str] = Field(index=True, full_text_search=True)
with pytest.raises(RedisModelError):
class NumerologyWitch(BaseJsonModel):
# We don't support indexing a list of numbers. Support for this
# feature is To Be Determined.
lucky_numbers: List[int] = Field(index=True)
with pytest.raises(RedisModelError):
class ReadingWithPrice(EmbeddedJsonModel):
gold_coins_charged: int = Field(index=True)
@ -532,13 +549,14 @@ def test_list_field_limitations():
# suite's migrator has already looked for migrations to run.
Migrator().run()
witch = TarotWitch(
tarot_cards=['death']
)
witch = TarotWitch(tarot_cards=["death"])
witch.save()
actual = TarotWitch.find(TarotWitch.tarot_cards << 'death').all()
actual = TarotWitch.find(TarotWitch.tarot_cards << "death").all()
assert actual == [witch]
def test_schema():
assert Member.redisearch_schema() == "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | $.first_name AS first_name TAG SEPARATOR | $.last_name AS last_name TAG SEPARATOR | $.email AS email TAG SEPARATOR | $.age AS age NUMERIC $.bio AS bio TAG SEPARATOR | $.bio AS bio_fts TEXT $.address.pk AS address_pk TAG SEPARATOR | $.address.city AS address_city TAG SEPARATOR | $.address.postal_code AS address_postal_code TAG SEPARATOR | $.address.note.pk AS address_note_pk TAG SEPARATOR | $.address.note.description AS address_note_description TAG SEPARATOR | $.orders[*].pk AS orders_pk TAG SEPARATOR | $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR |"
assert (
Member.redisearch_schema()
== "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | $.first_name AS first_name TAG SEPARATOR | $.last_name AS last_name TAG SEPARATOR | $.email AS email TAG SEPARATOR | $.age AS age NUMERIC $.bio AS bio TAG SEPARATOR | $.bio AS bio_fts TEXT $.address.pk AS address_pk TAG SEPARATOR | $.address.city AS address_city TAG SEPARATOR | $.address.postal_code AS address_postal_code TAG SEPARATOR | $.address.note.pk AS address_note_pk TAG SEPARATOR | $.address.note.description AS address_note_description TAG SEPARATOR | $.orders[*].pk AS orders_pk TAG SEPARATOR | $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR |"
)