From c238ff4eaafa4e226497e33864c7705f250971d0 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Fri, 1 Apr 2022 17:22:44 -0700 Subject: [PATCH 01/31] init --- poetry.lock | 102 ++++++- pynocular/backends/base.py | 102 +++++++ pynocular/backends/postgres.py | 251 ++++++++++++++++ pynocular/backends/util.py | 23 ++ pynocular/model.py | 519 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/unit/test_model.py | 27 ++ 7 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 pynocular/backends/base.py create mode 100644 pynocular/backends/postgres.py create mode 100644 pynocular/backends/util.py create mode 100644 pynocular/model.py create mode 100644 tests/unit/test_model.py diff --git a/poetry.lock b/poetry.lock index ca40fde..bea78d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -19,14 +19,14 @@ contextvars = {version = "2.4", markers = "python_version < \"3.7\""} [[package]] name = "aiopg" -version = "1.3.2" +version = "1.3.3" description = "Postgres integration with asyncio." category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -async-timeout = ">=3.0,<4.0" +async-timeout = ">=3.0,<5.0" psycopg2-binary = ">=2.8.4" sqlalchemy = {version = ">=1.3,<1.5", extras = ["postgresql_psycopg2binary"], optional = true, markers = "extra == \"sa\""} @@ -53,6 +53,22 @@ category = "main" optional = false python-versions = ">=3.5.3" +[[package]] +name = "asyncpg" +version = "0.25.0" +description = "An asyncio PostgreSQL driver" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] + [[package]] name = "atomicwrites" version = "1.4.0" @@ -228,6 +244,26 @@ typer = ">=0.3.2,<0.4.0" pyproject = ["toml (>=0.10,<0.11)"] examples = ["examples (>=1.0.2,<2.0.0)"] +[[package]] +name = "databases" +version = "0.5.5" +description = "Async database support for Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiocontextvars = {version = "*", markers = "python_version < \"3.7\""} +asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""} +sqlalchemy = ">=1.4,<1.5" + +[package.extras] +mysql = ["aiomysql"] +mysql_asyncmy = ["asyncmy"] +postgresql = ["asyncpg"] +postgresql_aiopg = ["aiopg"] +sqlite = ["aiosqlite"] + [[package]] name = "dataclasses" version = "0.8" @@ -776,7 +812,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "fb83413d7c2ff4d79caba96393787e7c1904432b482e87d4d075c9d0a15c2760" +content-hash = "f83077c2c4b3df0f753156bf511701677cb6f3f6a90126b0fb8a8fb13a997cd8" [metadata.files] aenum = [ @@ -789,8 +825,8 @@ aiocontextvars = [ {file = "aiocontextvars-0.2.2.tar.gz", hash = "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa"}, ] aiopg = [ - {file = "aiopg-1.3.2-py3-none-any.whl", hash = "sha256:42f9e49bc7fe7b1f46cd9208c0ffc0bba4384a3eb11dda8eabe22c530048eb6d"}, - {file = "aiopg-1.3.2.tar.gz", hash = "sha256:864df77edb9897cea6318a0d2e61f9b08692f2f0a150b7051fc2bde0997b23fd"}, + {file = "aiopg-1.3.3-py3-none-any.whl", hash = "sha256:2842dd8741460eeef940032dcb577bfba4d4115205dd82a73ce13b3271f5bf0a"}, + {file = "aiopg-1.3.3.tar.gz", hash = "sha256:547c6ba4ea0d73c2a11a2f44387d7133cc01d3c6f3b8ed976c0ac1eff4f595d7"}, ] arrow = [ {file = "arrow-1.1.1-py3-none-any.whl", hash = "sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510"}, @@ -800,6 +836,34 @@ async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] +asyncpg = [ + {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, + {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, + {file = "asyncpg-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4"}, + {file = "asyncpg-0.25.0-cp310-cp310-win32.whl", hash = "sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095"}, + {file = "asyncpg-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09"}, + {file = "asyncpg-0.25.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634"}, + {file = "asyncpg-0.25.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5"}, + {file = "asyncpg-0.25.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd"}, + {file = "asyncpg-0.25.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68"}, + {file = "asyncpg-0.25.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b"}, + {file = "asyncpg-0.25.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8"}, + {file = "asyncpg-0.25.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b"}, + {file = "asyncpg-0.25.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e"}, + {file = "asyncpg-0.25.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962"}, + {file = "asyncpg-0.25.0-cp38-cp38-win32.whl", hash = "sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471"}, + {file = "asyncpg-0.25.0-cp38-cp38-win_amd64.whl", hash = "sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6"}, + {file = "asyncpg-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e"}, + {file = "asyncpg-0.25.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855"}, + {file = "asyncpg-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e"}, + {file = "asyncpg-0.25.0-cp39-cp39-win32.whl", hash = "sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2"}, + {file = "asyncpg-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac"}, + {file = "asyncpg-0.25.0.tar.gz", hash = "sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -855,6 +919,10 @@ cruft = [ {file = "cruft-2.9.0-py3-none-any.whl", hash = "sha256:6e77ff2f59a2e2aee7c54ebf302b5a4dd231af44e5971d5ad60135c9e7f4c29d"}, {file = "cruft-2.9.0.tar.gz", hash = "sha256:550703c35b8125612ad89d93d176f29b3e362c19c74ed28895625afa9010fcb1"}, ] +databases = [ + {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"}, + {file = "databases-0.5.5.tar.gz", hash = "sha256:02c6b016c1c951c21cca281dc8e2e002c60dc44026c0084aabbd8c37514aeb37"}, +] dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, @@ -887,6 +955,7 @@ greenlet = [ {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"}, {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, @@ -899,6 +968,7 @@ greenlet = [ {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"}, {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, @@ -907,6 +977,7 @@ greenlet = [ {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"}, {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, @@ -915,6 +986,7 @@ greenlet = [ {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"}, {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, @@ -923,6 +995,7 @@ greenlet = [ {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"}, {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, @@ -990,6 +1063,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, @@ -1001,6 +1077,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -1012,6 +1091,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, @@ -1024,6 +1106,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -1036,6 +1121,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -1079,6 +1167,8 @@ psycopg2-binary = [ {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e"}, {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a"}, {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-win32.whl", hash = "sha256:ebccf1123e7ef66efc615a68295bf6fdba875a75d5bba10a05073202598085fc"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:1f6ca4a9068f5c5c57e744b4baa79f40e83e3746875cac3c45467b16326bab45"}, {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"}, {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"}, @@ -1250,6 +1340,8 @@ sqlalchemy = [ {file = "SQLAlchemy-1.4.26-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c757ba1279b85b3460e72e8b92239dae6f8b060a75fb24b3d9be984dd78cfa55"}, {file = "SQLAlchemy-1.4.26-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:c24c01dcd03426a5fe5ee7af735906bec6084977b9027a3605d11d949a565c01"}, {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c46f013ff31b80cbe36410281675e1fb4eaf3e25c284fd8a69981c73f6fa4cb4"}, + {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fb2aa74a6e3c2cebea38dd21633671841fbe70ea486053cba33d68e3e22ccc0a"}, + {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7e403fc1e3cb76e802872694e30d6ca6129b9bc6ad4e7caa48ca35f8a144f8"}, {file = "SQLAlchemy-1.4.26-cp310-cp310-win32.whl", hash = "sha256:7ef421c3887b39c6f352e5022a53ac18de8387de331130481cb956b2d029cad6"}, {file = "SQLAlchemy-1.4.26-cp310-cp310-win_amd64.whl", hash = "sha256:908fad32c53b17aad12d722379150c3c5317c422437e44032256a77df1746292"}, {file = "SQLAlchemy-1.4.26-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:1ef37c9ec2015ce2f0dc1084514e197f2f199d3dc3514190db7620b78e6004c8"}, diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py new file mode 100644 index 0000000..8c11cbb --- /dev/null +++ b/pynocular/backends/base.py @@ -0,0 +1,102 @@ +from abc import ABC, abstractmethod, abstractmethod +from typing import Any, Dict, List, Optional + +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression + + +class DatabaseModelBackend(ABC): + @abstractmethod + async def select( + self, + where_expressions: Optional[List[BinaryExpression]] = None, + order_by: Optional[List[UnaryExpression]] = None, + limit: Optional[int] = None, + ) -> List["DatabaseModelBackend"]: + """Execute a SELECT on the DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the SELECT + order_by: A list of criteria for the order_by clause + limit: The number of instances to return + + Returns: + A list of DatabaseModel instances + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + pass + + @abstractmethod + async def create_list( + self, models: List["DatabaseModelBackend"] + ) -> List["DatabaseModelBackend"]: + """Create new batch of records in one query + + This will mutate the provided models to include db managed column values. + + Args: + models: List of database models to persist + + Returns: + list of new database models that have been saved + + """ + pass + + @abstractmethod + async def delete_records(self, **kwargs: Any) -> None: + """Execute a DELETE on a DatabaseModel with the provided kwargs + + Args: + kwargs: The filterable key/value pairs for the where clause. These will be + `and`ed together + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + DatabaseModelMissingField: One of the fields provided in the query does not + exist on the database table + + """ + pass + + @abstractmethod + async def update( + self, + where_expressions: Optional[List[BinaryExpression]], + values: Dict[str, Any], + ) -> List["DatabaseModelBackend"]: + """Execute an UPDATE on a DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the UPDATE + values: The field and values to update all records to that match the + where_expressions + + Returns: + The updated DatabaseModels + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + pass + + @abstractmethod + async def save(self, include_nested_models=False) -> None: + """Update the database record this object represents with its current state + + Args: + include_nested_models: If True, any nested models should get saved before + this object gets saved + + """ + pass + + @abstractmethod + async def delete(self) -> None: + """Delete this record from the database""" + pass diff --git a/pynocular/backends/postgres.py b/pynocular/backends/postgres.py new file mode 100644 index 0000000..22c89a9 --- /dev/null +++ b/pynocular/backends/postgres.py @@ -0,0 +1,251 @@ +from operator import mod +import pdb +from sqlite3 import DatabaseError +from typing import Any, Dict, List, Optional, Type +from databases import Database + +from sqlalchemy import ( + and_, +) +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression + +from pynocular.engines import DBEngine +from pynocular.exceptions import ( + DatabaseModelMissingField, + InvalidFieldValue, + InvalidTextRepresentation, + NestedDatabaseModelNotResolved, +) +from pynocular.backends.base import DatabaseModelBackend + + +class PostgresDatabaseModelBackend(DatabaseModelBackend): + """Postgres backend""" + + def __init__(self, model_cls: Any, db: Database): + self.model_cls = model_cls + self.db = db + + async def select( + self, + where_expressions: Optional[List[BinaryExpression]] = None, + order_by: Optional[List[UnaryExpression]] = None, + limit: Optional[int] = None, + ) -> List["PostgresDatabaseModelBackend"]: + """Execute a SELECT on the DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the SELECT + order_by: A list of criteria for the order_by clause + limit: The number of instances to return + + Returns: + A list of DatabaseModel instances + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + async with self.db.transaction(): + query = self.model_cls._table.select() + if where_expressions is not None and len(where_expressions) > 0: + query = query.where(and_(*where_expressions)) + if order_by is not None and len(order_by) > 0: + query = query.order_by(*order_by) + if limit is not None and limit > 0: + query = query.limit(limit) + + try: + records = await self.db.fetch_all(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + return [self.model_cls.from_dict(dict(record)) for record in records] + + @classmethod + async def create_list( + cls, models: List["PostgresDatabaseModelBackend"] + ) -> List["PostgresDatabaseModelBackend"]: + """Create new batch of records in one query + + This will mutate the provided models to include db managed column values. + + Args: + models: List of database models to persist + + Returns: + list of new database models that have been saved + + """ + if not models: + return [] + + values = [] + for model in models: + dict_obj = model.to_dict() + for field in cls._db_managed_fields: + # Remove any fields that the database calculates + del dict_obj[field] + values.append(dict_obj) + + async with ( + await DBEngine.transaction(cls._database_info, is_conditional=False) + ) as conn: + result = await conn.execute( + insert(cls._table).values(values).returning(cls._table) + ) + # Set db managed column information on the object + rows = await result.fetchall() + for row, model in zip(rows, models): + record_dict = dict(row) + for column in cls._db_managed_fields: + col_val = record_dict.get(column) + if col_val is not None: + setattr(model, column, col_val) + + return models + + @classmethod + async def delete_records(cls, **kwargs: Any) -> None: + """Execute a DELETE on a DatabaseModel with the provided kwargs + + Args: + kwargs: The filterable key/value pairs for the where clause. These will be + `and`ed together + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + DatabaseModelMissingField: One of the fields provided in the query does not + exist on the database table + + """ + where_clause_list = [] + for field_name, db_field_value in kwargs.items(): + db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + + try: + db_field = getattr(cls._table.c, db_field_name) + except AttributeError: + raise DatabaseModelMissingField(cls.__name__, db_field_name) + + if isinstance(db_field_value, list): + exp = db_field.in_(db_field_value) + else: + exp = db_field == db_field_value + + where_clause_list.append(exp) + + async with ( + await DBEngine.transaction(cls._database_info, is_conditional=False) + ) as conn: + query = cls._table.delete().where(and_(*where_clause_list)) + try: + await conn.execute(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + async def delete(self) -> None: + """Delete this record from the database""" + + async with ( + await DBEngine.transaction(self._database_info, is_conditional=False) + ) as conn: + where_expressions = [ + getattr(self._table.c, pkey.name) == getattr(self, pkey.name) + for pkey in self._primary_keys + ] + query = self._table.delete().where(and_(*where_expressions)) + try: + await conn.execute(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + @classmethod + async def update( + cls, where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any] + ) -> List["PostgresDatabaseModelBackend"]: + """Execute an UPDATE on a DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the UPDATE + values: The field and values to update all records to that match the + where_expressions + + Returns: + The updated DatabaseModels + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + async with ( + await DBEngine.transaction(cls._database_info, is_conditional=False) + ) as conn: + query = ( + cls._table.update() + .where(and_(*where_expressions)) + .values(**values) + .returning(cls._table) + ) + try: + results = await conn.execute(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + return [cls.from_dict(dict(record)) for record in await results.fetchall()] + + async def save(self, include_nested_models=False) -> None: + """Update the database record this object represents with its current state + + Args: + include_nested_models: If True, any nested models should get saved before + this object gets saved + + """ + + dict_self = self.to_dict() + + primary_key_names = [primary_key.name for primary_key in self._primary_keys] + + for field in self._db_managed_fields: + if field in primary_key_names and dict_self[field] is not None: + continue + + # Remove any fields that the database calculates + del dict_self[field] + + async with ( + await DBEngine.transaction(self._database_info, is_conditional=False) + ) as conn: + # If flag is set, first try to persist any nested models. This needs to + # happen inside of the transaction so if something fails everything gets + # rolled back + if include_nested_models: + for attr_name in self._nested_model_attributes: + try: + obj = getattr(self, attr_name) + if obj is not None: + await obj.save() + except NestedDatabaseModelNotResolved: + # If the object was never resolved than it already exists in the + # DB and the DB has the latest state + continue + + record = await conn.execute( + insert(self._table) + .values(dict_self) + .on_conflict_do_update(index_elements=primary_key_names, set_=dict_self) + .returning(self._table) + ) + + row = await record.fetchone() + + for field in self._db_managed_fields: + setattr(self, field, row[field]) diff --git a/pynocular/backends/util.py b/pynocular/backends/util.py new file mode 100644 index 0000000..868db30 --- /dev/null +++ b/pynocular/backends/util.py @@ -0,0 +1,23 @@ +from contextlib import contextmanager +from functools import partial +from typing import Any, Type + +import aiocontextvars as contextvars + +from .base import DatabaseModelBackend + + +_backend = contextvars.ContextVar("transaction_connections", default=None) + + +@contextmanager +def backend(backend_cls: Type[DatabaseModelBackend], **kwargs: Any) -> None: + token = _backend.set(partial(backend_cls, **kwargs)) + try: + yield + finally: + _backend.reset(token) + + +def get_backend(model_cls: Any) -> DatabaseModelBackend: + return _backend.get()(model_cls) diff --git a/pynocular/model.py b/pynocular/model.py new file mode 100644 index 0000000..0c1e020 --- /dev/null +++ b/pynocular/model.py @@ -0,0 +1,519 @@ +import asyncio +from contextlib import contextmanager +from datetime import datetime +from enum import Enum, EnumMeta +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Type +from uuid import UUID as stdlib_uuid + +from pydantic import UUID4, BaseModel, PositiveFloat, PositiveInt +from sqlalchemy import ( + Boolean, + Column, + Enum as SQLEnum, + Float, + Integer, + MetaData, + Table, + TIMESTAMP, + VARCHAR, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID as sqlalchemy_uuid +from sqlalchemy.schema import FetchedValue +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression +from sqlalchemy.sql.base import ImmutableColumnCollection +from pynocular.backends.base import DatabaseModelBackend +from pynocular.backends.util import get_backend +from pynocular.database_model import UUID_STR + +from pynocular.exceptions import ( + DatabaseModelMisconfigured, + DatabaseModelMissingField, + DatabaseRecordNotFound, + InvalidMethodParameterization, +) + + +class DatabaseModel(BaseModel): + + if TYPE_CHECKING: + # Populated by _initialize_table. Defined here to help IDEs only + _primary_keys: List[Column] + _db_managed_fields: List[str] + _nested_model_attributes: Set[str] + _nested_attr_table_field_map: Dict[str, str] + _nested_table_field_attr_map: Dict[str, str] + _table: Table + columns: ImmutableColumnCollection + + # Set by backend method + _backend: DatabaseModelBackend + + @staticmethod + def _initialize_table(cls) -> "DatabaseModel": + """Returns a SQLAlchemy table definition to expose SQLAlchemy functions + + This method should cache the Table on the __table__ class property. + We don't want to have to recalculate the table for every SQL call, + so it's desirable to cache this at the class level. + + Returns: + A Table object based on the Field properties defined from the Pydantic model + + Raises: + DatabaseModelMisconfigured: When the class does not defined certain properties; + or cannot be converted to a Table + + """ + # We may have times where we need a compound primary key. + # We store each one into this list and have our query functions + # handle using it + _primary_keys: List[Column] = [] + + # Some fields are exclusively produced by the database server + # For all save operations, we need to get those values from the database + # These are the server_default and server_onupdate functions in SQLAlchemy + _db_managed_fields: List[str] = [] + + # The following tables track which attributes on the model are nested model + # references + # Some nested model attributes may have different names than their actual db table; + # For example; on an App we may have an `org` attribute but the db field is + # `organzation_id` + + # In order to manage this we also need maps from attribute name to table_field_name + # and back + _nested_model_attributes: Set[str] = set() + _nested_attr_table_field_map: Dict[str, str] = {} + _nested_table_field_attr_map: Dict[str, str] = {} + + columns: List[Column] = [] + for field in cls.__fields__.values(): + name = field.name + is_nullable = not field.required + is_primary_key = field.field_info.extra.get("primary_key", False) + fetch_on_create = field.field_info.extra.get("fetch_on_create", False) + fetch_on_update = field.field_info.extra.get("fetch_on_update", False) + + if field.type_ is str: + type = VARCHAR + elif field.type_.__name__ == "ConstrainedStrValue": + # This is because pydantic is doing some kind of dynamic type construction. + # See: https://github.com/samuelcolvin/pydantic/blob/e985857e5a9ede8d346b010a5a039aa84a089826/pydantic/types.py#L245-L263 + length = field.field_info.max_length + type = VARCHAR(length) + elif ( + field.type_ in (int, PositiveInt) + or field.type_.__name__ == "ConstrainedIntValue" + ): + type = Integer + elif ( + field.type_ in (float, PositiveFloat) + or field.type_.__name__ == "ConstrainedFloatValue" + ): + type = Float + elif field.type_.__class__ == EnumMeta: + type = SQLEnum(field.type_) + elif field.type_ is bool: + type = Boolean + elif field.type_ in (dict, Dict): + type = JSONB(none_as_null=True) + elif field.type_ in (UUID4, stdlib_uuid, UUID_STR): + type = sqlalchemy_uuid() + elif field.type_ is datetime: + type = TIMESTAMP(timezone=True) + elif field.type_.__name__ == "NestedModel": + _nested_model_attributes.add(name) + # If the field name on the NestedModel type is not None, use that for the + # column name + if field.type_.reference_field_name is not None: + _nested_attr_table_field_map[ + name + ] = field.type_.reference_field_name + _nested_table_field_attr_map[ + field.type_.reference_field_name + ] = name + name = field.type_.reference_field_name + + # Assume all IDs are UUIDs for now + type = sqlalchemy_uuid() + # TODO - how are people using this today? Is there a class we need to make or can we reuse one + # elif field.type_ is bit: + # type = Bit + else: + raise DatabaseModelMisconfigured(f"Unsupported type {field.type_}") + + column = Column( + name, type, primary_key=is_primary_key, nullable=is_nullable + ) + + if fetch_on_create: + column.server_default = FetchedValue() + _db_managed_fields.append(name) + + if fetch_on_update: + column.server_onupdate = FetchedValue() + _db_managed_fields.append(name) + + if is_primary_key: + _primary_keys.append(column) + + columns.append(column) + + # Define metadata for the database connection on the class level so we don't + # have to recalculate the table for each database call + _table = Table(cls.Config.table_name, MetaData(), *columns) + + # _database_info: DBInfo = None + + cls._db_managed_fields = _db_managed_fields + cls._nested_table_field_attr_map = _nested_table_field_attr_map + cls._primary_keys = _primary_keys + cls._table = _table + cls.columns = _table.c + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + DatabaseModel._initialize_table(cls) + return cls + + @classmethod + def _backend(cls) -> None: + cls._backend = backend_cls(cls, **kwargs) + try: + yield + finally: + cls._backend = None + + @classmethod + def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": + """Instantiate a DatabaseModel object from a dict record + + Note: + This is the base implementation and is set up so classes that subclass this + one don't have to make this boilerplate if they don't need to + + Args: + _dict: The dictionary form of the DatabaseModel + + Returns: + The DatabaseModel object + + """ + modified_dict = {} + for key, value in _dict.items(): + modified_key = cls._nested_table_field_attr_map.get(key, key) + modified_dict[modified_key] = value + return cls(**modified_dict) + + def to_dict( + self, serialize: bool = False, include_keys: Optional[Sequence] = None + ) -> Dict[str, Any]: + """Create a dict from the DatabaseModel object + + Note: + This implementation is only valid if __base_props__ is set for the instance + + Args: + serialize: A flag determining whether or not to serialize enum types into + strings + include_keys: Set of keys that should be included in the results. If not + provided or empty, all keys will be included. + + Returns: + A dict of the DatabaseObject object + + Raises: + NotImplementedError: This function implementation is being used without + __base_props__ being set + + """ + _dict = {} + for prop_name, prop_value in self.dict().items(): + if serialize: + if isinstance(prop_value, Enum): + prop_value = prop_value.name + + if prop_name in self._nested_model_attributes: + # self.dict() will serialize any BaseModels into a dict so fetch the + # actual object from self + temp_prop_value = getattr(self, prop_name) + prop_name = self._nested_attr_table_field_map.get(prop_name, prop_name) + # temp_prop_value can be `None` if the nested key is optional + if temp_prop_value is not None: + prop_value = temp_prop_value.get_primary_id() + + if not include_keys or prop_name in include_keys: + _dict[prop_name] = prop_value + + return _dict + + @classmethod + async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": + """Gets the DatabaseModel associated with any nested key references resolved + + Args: + args: The column id for the object's primary key + kwargs: The columns and ids that make up the object's composite primary key + + Returns: + A DatabaseModel object representing the record in the db if one exists + + """ + obj = await cls.get(*args, **kwargs) + gatherables = [ + (getattr(obj, prop_name)).fetch() + for prop_name in cls._nested_model_attributes + ] + await asyncio.gather(*gatherables) + + return obj + + @classmethod + async def get(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": + """Gets the DatabaseModel for the given primary key value(s) + + Args: + args: The column id for the object's primary key + kwargs: The columns and ids that make up the object's composite primary key + + Returns: + A DatabaseModel object representing the record in the db if one exists + + Raises: + InvalidMethodParameterization: An invalid parameter configuration was passed in. + This method should only receive one arg or >= one kwargs. Any other + combination of parameters is invalid. + + """ + if ( + (len(args) > 1) + or (len(args) == 1 and len(kwargs) > 0) + or (len(args) == 1 and len(cls._primary_keys) > 1) + or (len(args) == 0 and len(kwargs) == 0) + ): + raise InvalidMethodParameterization("get", args=args, kwargs=kwargs) + + if len(args) == 1: + primary_key_dict = {cls._primary_keys[0].name: args[0]} + else: + primary_key_dict = kwargs + + original_primary_key_dict = primary_key_dict.copy() + where_expressions = [] + for primary_key in cls._primary_keys: + primary_key_value = primary_key_dict.pop(primary_key.name) + where_expressions.append(primary_key == primary_key_value) + + records = await cls.select(where_expressions=where_expressions, limit=1) + if len(records) == 0: + raise DatabaseRecordNotFound(cls._table.name, **original_primary_key_dict) + + return records[0] + + @classmethod + async def create(cls, **data) -> "DatabaseModel": + """Create a new instance of the this DatabaseModel and save it + + Args: + kwargs: The parameters for the instance + + Returns: + The new DatabaseModel instance + + """ + new = cls(**data) + await new.save() + + return new + + def get_primary_id(self) -> Any: + """Standard interface for returning the id of a field + + This assumes that there is a single primary id, otherwise this returns `None` + + Returns: + The ID value for this DatabaseModel instance + + """ + if len(self._primary_keys) > 1: + return None + + return getattr(self, self._primary_keys[0].name) + + async def fetch(self, resolve_references: bool = False) -> None: + """Gets the latest of the object from the database and updates itself + + Args: + resolve_references: If True, resolve any nested key references + + """ + # Get the latest version of self + get_params = { + primary_key.name: getattr(self, primary_key.name) + for primary_key in self._primary_keys + } + if resolve_references: + new_self = await self.get_with_refs(**get_params) + else: + new_self = await self.get(**get_params) + + for attr_name, new_attr_val in new_self.dict().items(): + setattr(self, attr_name, new_attr_val) + + @classmethod + async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: + """Fetches the DatabaseModel for based on the provided kwargs + + Args: + kwargs: The filterable key/value pairs for the where clause. These will be + `and`ed together + + Returns: + List of DatabaseModel objects + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + DatabaseModelMissingField: One of the fields provided in the query does not + exist on the database table + + """ + where_clause_list = [] + for field_name, db_field_value in kwargs.items(): + db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + + try: + db_field = getattr(cls._table.c, db_field_name) + except AttributeError: + raise DatabaseModelMissingField(cls.__name__, db_field_name) + + if isinstance(db_field_value, list): + exp = db_field.in_(db_field_value) + else: + exp = db_field == db_field_value + + where_clause_list.append(exp) + + return await cls.select(where_expressions=where_clause_list) + + @classmethod + async def select( + cls, + where_expressions: Optional[List[BinaryExpression]] = None, + order_by: Optional[List[UnaryExpression]] = None, + limit: Optional[int] = None, + ) -> List["DatabaseModel"]: + """Execute a SELECT on the DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the SELECT + order_by: A list of criteria for the order_by clause + limit: The number of instances to return + + Returns: + A list of DatabaseModel instances + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + return await get_backend(cls).select( + where_expressions=where_expressions, order_by=order_by, limit=limit + ) + + @classmethod + async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel"]: + """Create new batch of records in one query + + This will mutate the provided models to include db managed column values. + + Args: + models: List of database models to persist + + Returns: + list of new database models that have been saved + + """ + pass + + @classmethod + async def delete_records(cls, **kwargs: Any) -> None: + """Execute a DELETE on a DatabaseModel with the provided kwargs + + Args: + kwargs: The filterable key/value pairs for the where clause. These will be + `and`ed together + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + DatabaseModelMissingField: One of the fields provided in the query does not + exist on the database table + + """ + pass + + @classmethod + async def update_record(cls, **kwargs: Any) -> "DatabaseModel": + """Update a record associated with this DatabaseModel + + Notes: + the primary key must be in the kwargs + + Args: + kwargs: The values to update. + + Returns: + The updated DatabaseModel + + """ + where_expressions = [] + primary_key_dict = {} + for primary_key in cls._primary_keys: + primary_key_value = kwargs.pop(primary_key.name) + where_expressions.append(primary_key == primary_key_value) + primary_key_dict[primary_key.name] = primary_key_value + + modified_kwargs = {} + for field_name, value in kwargs.items(): + db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + modified_kwargs[db_field_name] = value + + updated_records = await cls.update(where_expressions, modified_kwargs) + if len(updated_records) == 0: + raise DatabaseRecordNotFound(cls._table.name, **primary_key_dict) + return updated_records[0] + + @classmethod + async def update( + cls, where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any] + ) -> List["DatabaseModel"]: + """Execute an UPDATE on a DatabaseModel table with the given parameters + + Args: + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the UPDATE + values: The field and values to update all records to that match the + where_expressions + + Returns: + The updated DatabaseModels + + Raises: + DatabaseModelMisconfigured: The class is missing a database table + + """ + pass + + async def save(self, include_nested_models=False) -> None: + """Update the database record this object represents with its current state + + Args: + include_nested_models: If True, any nested models should get saved before + this object gets saved + + """ + pass + + async def delete(self) -> None: + """Delete this record from the database""" + pass diff --git a/pyproject.toml b/pyproject.toml index adcad6d..124b3fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ aenum = "^3.1.0" aiocontextvars = "^0.2.2" aiopg = {extras = ["sa"], version = "^1.3.1"} pydantic = "^1.6" +databases = {extras = ["postgresql"], version = "^0.5.5"} [tool.poetry.dev-dependencies] pre-commit = "^2.10.1" diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py new file mode 100644 index 0000000..2459300 --- /dev/null +++ b/tests/unit/test_model.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import List, Optional +from databases import Database +from pydantic import Field +import pytest +from pynocular.backends.postgres import PostgresDatabaseModelBackend +from pynocular.backends.util import backend +from pynocular.database_model import UUID_STR +from pynocular.model import DatabaseModel + + +class Org(DatabaseModel): + """A test database model""" + + class Config: + table_name = "organizations" + + id: int = Field(primary_key=True) + name: str = Field() + + +@pytest.mark.asyncio +async def test_org(): + async with Database("postgresql://localhost:5432/jon.drake") as db: + with backend(PostgresDatabaseModelBackend, db=db): + orgs: List[Org] = await Org.select() + assert orgs[0].name == "first" From 251b09e6d2f5d536cdae78701afd75db2f76acdf Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 4 Apr 2022 16:03:14 -0700 Subject: [PATCH 02/31] working --- pynocular/backends/base.py | 124 ++++++++++------ pynocular/backends/context.py | 35 +++++ pynocular/backends/postgres.py | 251 --------------------------------- pynocular/backends/sql.py | 181 ++++++++++++++++++++++++ pynocular/backends/util.py | 23 --- pynocular/model.py | 229 +++++++++++++++++++----------- tests/unit/test_model.py | 38 +++-- 7 files changed, 463 insertions(+), 418 deletions(-) create mode 100644 pynocular/backends/context.py delete mode 100644 pynocular/backends/postgres.py create mode 100644 pynocular/backends/sql.py delete mode 100644 pynocular/backends/util.py diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index 8c11cbb..d544a73 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -1,102 +1,136 @@ -from abc import ABC, abstractmethod, abstractmethod -from typing import Any, Dict, List, Optional +"""Contains base classes for defining database backends""" +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Set + +from sqlalchemy import Column, Table from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression +@dataclass +class DatabaseModelConfig: + """Data class with parsed configuration for a database model. + + This class will be instantiated by a database model class at import time. + """ + + primary_keys: List[Column] + db_managed_fields: List[str] + nested_model_attributes: Set[str] + nested_attr_table_field_map: Dict[str, str] + nested_table_field_attr_map: Dict[str, str] + table: Table + + @property + def primary_key_names(self) -> Set[str]: + return {primary_key.name for primary_key in self.primary_keys} + + class DatabaseModelBackend(ABC): + """Defines abstract base class that database backends must implement + + The backend is agnostic to the DatabaseModel. This means that the concept of a + DatabaseModel should not show up in any of the backend method implementations. + + * Methods should accept and return raw dictionaries. + * Each method should accept a DatabaseModelConfig instance, which contains references + to a table and columns that can be used to build queries suited to the backend. + + """ + @abstractmethod async def select( self, + config: DatabaseModelConfig, where_expressions: Optional[List[BinaryExpression]] = None, order_by: Optional[List[UnaryExpression]] = None, limit: Optional[int] = None, - ) -> List["DatabaseModelBackend"]: - """Execute a SELECT on the DatabaseModel table with the given parameters + ) -> List[Dict[str, Any]]: + """Select a group of records Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the SELECT + `and`ed together for the where clause of the backend query order_by: A list of criteria for the order_by clause - limit: The number of instances to return + limit: The number of records to return Returns: - A list of DatabaseModel instances - - Raises: - DatabaseModelMisconfigured: The class is missing a database table + A list of record dicts """ pass @abstractmethod - async def create_list( - self, models: List["DatabaseModelBackend"] - ) -> List["DatabaseModelBackend"]: - """Create new batch of records in one query - - This will mutate the provided models to include db managed column values. + async def create_records( + self, config: DatabaseModelConfig, records: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Create new group of records Args: - models: List of database models to persist + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + records: List of records to persist Returns: - list of new database models that have been saved + list of newly created records """ pass @abstractmethod - async def delete_records(self, **kwargs: Any) -> None: - """Execute a DELETE on a DatabaseModel with the provided kwargs + async def delete_records( + self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] + ) -> None: + """Delete a group of records Args: - kwargs: The filterable key/value pairs for the where clause. These will be - `and`ed together - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - DatabaseModelMissingField: One of the fields provided in the query does not - exist on the database table + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query """ pass @abstractmethod - async def update( + async def update_records( self, + config: DatabaseModelConfig, where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any], - ) -> List["DatabaseModelBackend"]: - """Execute an UPDATE on a DatabaseModel table with the given parameters + ) -> List[Dict[str, Any]]: + """Update a group of records Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the UPDATE - values: The field and values to update all records to that match the + `and`ed together for the where clause of the backend query + values: The map of key-values to update all records to that match the where_expressions Returns: - The updated DatabaseModels - - Raises: - DatabaseModelMisconfigured: The class is missing a database table + the updated database records """ pass @abstractmethod - async def save(self, include_nested_models=False) -> None: - """Update the database record this object represents with its current state + async def upsert( + self, config: DatabaseModelConfig, record: Dict[str, Any] + ) -> Dict[str, Any]: + """Upsert a single database record Args: - include_nested_models: If True, any nested models should get saved before - this object gets saved + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + record: The record to update - """ - pass + Returns: + the updated record - @abstractmethod - async def delete(self) -> None: - """Delete this record from the database""" + """ pass diff --git a/pynocular/backends/context.py b/pynocular/backends/context.py new file mode 100644 index 0000000..90c9278 --- /dev/null +++ b/pynocular/backends/context.py @@ -0,0 +1,35 @@ +"""Contains contextvar definition for the active database backend""" + +from contextlib import contextmanager + +import aiocontextvars as contextvars + +from .base import DatabaseModelBackend + + +_backend = contextvars.ContextVar("database_model_backend", default=None) + + +@contextmanager +def backend(backend: DatabaseModelBackend) -> None: + """Set the database backend in the aio context + + Args: + backend: Database backend instance + + """ + token = _backend.set(backend) + try: + yield + finally: + _backend.reset(token) + + +def get_backend() -> DatabaseModelBackend: + """Get the currently active database backend + + Returns: + database backend instance + + """ + return _backend.get() diff --git a/pynocular/backends/postgres.py b/pynocular/backends/postgres.py deleted file mode 100644 index 22c89a9..0000000 --- a/pynocular/backends/postgres.py +++ /dev/null @@ -1,251 +0,0 @@ -from operator import mod -import pdb -from sqlite3 import DatabaseError -from typing import Any, Dict, List, Optional, Type -from databases import Database - -from sqlalchemy import ( - and_, -) -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression - -from pynocular.engines import DBEngine -from pynocular.exceptions import ( - DatabaseModelMissingField, - InvalidFieldValue, - InvalidTextRepresentation, - NestedDatabaseModelNotResolved, -) -from pynocular.backends.base import DatabaseModelBackend - - -class PostgresDatabaseModelBackend(DatabaseModelBackend): - """Postgres backend""" - - def __init__(self, model_cls: Any, db: Database): - self.model_cls = model_cls - self.db = db - - async def select( - self, - where_expressions: Optional[List[BinaryExpression]] = None, - order_by: Optional[List[UnaryExpression]] = None, - limit: Optional[int] = None, - ) -> List["PostgresDatabaseModelBackend"]: - """Execute a SELECT on the DatabaseModel table with the given parameters - - Args: - where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the SELECT - order_by: A list of criteria for the order_by clause - limit: The number of instances to return - - Returns: - A list of DatabaseModel instances - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - - """ - async with self.db.transaction(): - query = self.model_cls._table.select() - if where_expressions is not None and len(where_expressions) > 0: - query = query.where(and_(*where_expressions)) - if order_by is not None and len(order_by) > 0: - query = query.order_by(*order_by) - if limit is not None and limit > 0: - query = query.limit(limit) - - try: - records = await self.db.fetch_all(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - return [self.model_cls.from_dict(dict(record)) for record in records] - - @classmethod - async def create_list( - cls, models: List["PostgresDatabaseModelBackend"] - ) -> List["PostgresDatabaseModelBackend"]: - """Create new batch of records in one query - - This will mutate the provided models to include db managed column values. - - Args: - models: List of database models to persist - - Returns: - list of new database models that have been saved - - """ - if not models: - return [] - - values = [] - for model in models: - dict_obj = model.to_dict() - for field in cls._db_managed_fields: - # Remove any fields that the database calculates - del dict_obj[field] - values.append(dict_obj) - - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - result = await conn.execute( - insert(cls._table).values(values).returning(cls._table) - ) - # Set db managed column information on the object - rows = await result.fetchall() - for row, model in zip(rows, models): - record_dict = dict(row) - for column in cls._db_managed_fields: - col_val = record_dict.get(column) - if col_val is not None: - setattr(model, column, col_val) - - return models - - @classmethod - async def delete_records(cls, **kwargs: Any) -> None: - """Execute a DELETE on a DatabaseModel with the provided kwargs - - Args: - kwargs: The filterable key/value pairs for the where clause. These will be - `and`ed together - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - DatabaseModelMissingField: One of the fields provided in the query does not - exist on the database table - - """ - where_clause_list = [] - for field_name, db_field_value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) - - try: - db_field = getattr(cls._table.c, db_field_name) - except AttributeError: - raise DatabaseModelMissingField(cls.__name__, db_field_name) - - if isinstance(db_field_value, list): - exp = db_field.in_(db_field_value) - else: - exp = db_field == db_field_value - - where_clause_list.append(exp) - - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - query = cls._table.delete().where(and_(*where_clause_list)) - try: - await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - async def delete(self) -> None: - """Delete this record from the database""" - - async with ( - await DBEngine.transaction(self._database_info, is_conditional=False) - ) as conn: - where_expressions = [ - getattr(self._table.c, pkey.name) == getattr(self, pkey.name) - for pkey in self._primary_keys - ] - query = self._table.delete().where(and_(*where_expressions)) - try: - await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - @classmethod - async def update( - cls, where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any] - ) -> List["PostgresDatabaseModelBackend"]: - """Execute an UPDATE on a DatabaseModel table with the given parameters - - Args: - where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the UPDATE - values: The field and values to update all records to that match the - where_expressions - - Returns: - The updated DatabaseModels - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - - """ - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - query = ( - cls._table.update() - .where(and_(*where_expressions)) - .values(**values) - .returning(cls._table) - ) - try: - results = await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - return [cls.from_dict(dict(record)) for record in await results.fetchall()] - - async def save(self, include_nested_models=False) -> None: - """Update the database record this object represents with its current state - - Args: - include_nested_models: If True, any nested models should get saved before - this object gets saved - - """ - - dict_self = self.to_dict() - - primary_key_names = [primary_key.name for primary_key in self._primary_keys] - - for field in self._db_managed_fields: - if field in primary_key_names and dict_self[field] is not None: - continue - - # Remove any fields that the database calculates - del dict_self[field] - - async with ( - await DBEngine.transaction(self._database_info, is_conditional=False) - ) as conn: - # If flag is set, first try to persist any nested models. This needs to - # happen inside of the transaction so if something fails everything gets - # rolled back - if include_nested_models: - for attr_name in self._nested_model_attributes: - try: - obj = getattr(self, attr_name) - if obj is not None: - await obj.save() - except NestedDatabaseModelNotResolved: - # If the object was never resolved than it already exists in the - # DB and the DB has the latest state - continue - - record = await conn.execute( - insert(self._table) - .values(dict_self) - .on_conflict_do_update(index_elements=primary_key_names, set_=dict_self) - .returning(self._table) - ) - - row = await record.fetchone() - - for field in self._db_managed_fields: - setattr(self, field, row[field]) diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py new file mode 100644 index 0000000..c5cd8da --- /dev/null +++ b/pynocular/backends/sql.py @@ -0,0 +1,181 @@ +"""Contains the SQLDatabaseModelBackend class""" + +from typing import Any, Dict, List, Optional +from databases import Database + +from sqlalchemy import and_ +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression + +from pynocular.exceptions import ( + InvalidFieldValue, + InvalidTextRepresentation, +) +from pynocular.backends.base import ( + DatabaseModelBackend, + DatabaseModelConfig, +) + + +class SQLDatabaseModelBackend(DatabaseModelBackend): + """SQL database model backend + + This backend works with any SQL dialect supported by https://www.encode.io/databases/ + """ + + def __init__(self, db: Database): + """Initialize a SQLDatabaseModelBackend + + Args: + db: Database object that has already established a connection + + """ + self.db = db + + async def select( + self, + config: DatabaseModelConfig, + where_expressions: Optional[List[BinaryExpression]] = None, + order_by: Optional[List[UnaryExpression]] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Execute a SELECT on the DatabaseModel table with the given parameters + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + order_by: A list of criteria for the order_by clause + limit: The number of records to return + + Returns: + list of records + + Raises: + InvalidFieldValue: The class is missing a database table + + """ + async with self.db.transaction(): + query = config.table.select() + if where_expressions is not None and len(where_expressions) > 0: + query = query.where(and_(*where_expressions)) + if order_by is not None and len(order_by) > 0: + query = query.order_by(*order_by) + if limit is not None and limit > 0: + query = query.limit(limit) + + try: + records = await self.db.fetch_all(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + return [dict(record) for record in records] + + async def create_records( + self, config: DatabaseModelConfig, records: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Create new group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + records: List of records to persist + + Returns: + list of newly created records + + """ + if not records: + return [] + + async with self.db.transaction(): + result = await self.db.fetch_all( + insert(config.table).values(records).returning(config.table) + ) + + return [dict(row) for row in result] + + async def delete_records( + self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] + ) -> None: + """Delete a group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + + """ + async with self.db.transaction(): + query = config.table.delete().where(and_(*where_expressions)) + try: + await self.db.execute(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + async def update_records( + self, + config: DatabaseModelConfig, + where_expressions: Optional[List[BinaryExpression]], + values: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """Update a group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + values: The map of key-values to update all records to that match the + where_expressions + + Returns: + the updated database records + + """ + async with self.db.transaction(): + query = ( + config.table.update() + .where(and_(*where_expressions)) + .values(values) + .returning(config.table) + ) + try: + results = await self.db.execute(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + return [dict(record) for record in await results] + + async def upsert( + self, + config: DatabaseModelConfig, + record: Dict[str, Any], + ) -> Dict[str, Any]: + """Upsert a single database record + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + record: The record to update + + Returns: + the updated record + + """ + async with self.db.transaction(): + query = ( + insert(config.table) + .values(record) + .on_conflict_do_update( + index_elements=config.primary_key_names, set_=record + ) + .returning(config.table) + ) + updated_record = await self.db.fetch_one(query) + return dict(updated_record) diff --git a/pynocular/backends/util.py b/pynocular/backends/util.py deleted file mode 100644 index 868db30..0000000 --- a/pynocular/backends/util.py +++ /dev/null @@ -1,23 +0,0 @@ -from contextlib import contextmanager -from functools import partial -from typing import Any, Type - -import aiocontextvars as contextvars - -from .base import DatabaseModelBackend - - -_backend = contextvars.ContextVar("transaction_connections", default=None) - - -@contextmanager -def backend(backend_cls: Type[DatabaseModelBackend], **kwargs: Any) -> None: - token = _backend.set(partial(backend_cls, **kwargs)) - try: - yield - finally: - _backend.reset(token) - - -def get_backend(model_cls: Any) -> DatabaseModelBackend: - return _backend.get()(model_cls) diff --git a/pynocular/model.py b/pynocular/model.py index 0c1e020..dc36a23 100644 --- a/pynocular/model.py +++ b/pynocular/model.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from datetime import datetime from enum import Enum, EnumMeta +from pyexpat import model from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Type from uuid import UUID as stdlib_uuid @@ -19,10 +20,10 @@ ) from sqlalchemy.dialects.postgresql import JSONB, UUID as sqlalchemy_uuid from sqlalchemy.schema import FetchedValue -from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression from sqlalchemy.sql.base import ImmutableColumnCollection -from pynocular.backends.base import DatabaseModelBackend -from pynocular.backends.util import get_backend +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression +from pynocular.backends.base import DatabaseModelConfig +from pynocular.backends.context import get_backend from pynocular.database_model import UUID_STR from pynocular.exceptions import ( @@ -34,45 +35,42 @@ class DatabaseModel(BaseModel): + """DatabaseModel defines a Pydantic model that abstracts away backend storage + + This allows us to use the same object for both database queries and HTTP requests. + Methods on the DatabaseModel call through to the active backend implementation. The + backend handle queries and storage. + """ if TYPE_CHECKING: - # Populated by _initialize_table. Defined here to help IDEs only - _primary_keys: List[Column] - _db_managed_fields: List[str] - _nested_model_attributes: Set[str] - _nested_attr_table_field_map: Dict[str, str] - _nested_table_field_attr_map: Dict[str, str] - _table: Table - columns: ImmutableColumnCollection - - # Set by backend method - _backend: DatabaseModelBackend + # Set by _process_config + _config: DatabaseModelConfig @staticmethod - def _initialize_table(cls) -> "DatabaseModel": - """Returns a SQLAlchemy table definition to expose SQLAlchemy functions + def _process_config(cls, table_name: str) -> DatabaseModelConfig: + """Process configuration passed into the DatabaseModel subclass signature - This method should cache the Table on the __table__ class property. - We don't want to have to recalculate the table for every SQL call, - so it's desirable to cache this at the class level. + The primary job of this method is to generate a DatabaseModelConfig instance, + specifically a SQLAlchemy table definition for backend implementations to + leverage. Returns: - A Table object based on the Field properties defined from the Pydantic model + DatabaseModelConfig instance Raises: - DatabaseModelMisconfigured: When the class does not defined certain properties; - or cannot be converted to a Table + DatabaseModelMisconfigured: When the class does not defined certain + properties; or cannot be converted to a Table """ # We may have times where we need a compound primary key. # We store each one into this list and have our query functions # handle using it - _primary_keys: List[Column] = [] + primary_keys: List[Column] = [] # Some fields are exclusively produced by the database server # For all save operations, we need to get those values from the database # These are the server_default and server_onupdate functions in SQLAlchemy - _db_managed_fields: List[str] = [] + db_managed_fields: List[str] = [] # The following tables track which attributes on the model are nested model # references @@ -82,9 +80,9 @@ def _initialize_table(cls) -> "DatabaseModel": # In order to manage this we also need maps from attribute name to table_field_name # and back - _nested_model_attributes: Set[str] = set() - _nested_attr_table_field_map: Dict[str, str] = {} - _nested_table_field_attr_map: Dict[str, str] = {} + nested_model_attributes: Set[str] = set() + nested_attr_table_field_map: Dict[str, str] = {} + nested_table_field_attr_map: Dict[str, str] = {} columns: List[Column] = [] for field in cls.__fields__.values(): @@ -122,16 +120,12 @@ def _initialize_table(cls) -> "DatabaseModel": elif field.type_ is datetime: type = TIMESTAMP(timezone=True) elif field.type_.__name__ == "NestedModel": - _nested_model_attributes.add(name) + nested_model_attributes.add(name) # If the field name on the NestedModel type is not None, use that for the # column name if field.type_.reference_field_name is not None: - _nested_attr_table_field_map[ - name - ] = field.type_.reference_field_name - _nested_table_field_attr_map[ - field.type_.reference_field_name - ] = name + nested_attr_table_field_map[name] = field.type_.reference_field_name + nested_table_field_attr_map[field.type_.reference_field_name] = name name = field.type_.reference_field_name # Assume all IDs are UUIDs for now @@ -148,41 +142,44 @@ def _initialize_table(cls) -> "DatabaseModel": if fetch_on_create: column.server_default = FetchedValue() - _db_managed_fields.append(name) + db_managed_fields.append(name) if fetch_on_update: column.server_onupdate = FetchedValue() - _db_managed_fields.append(name) + db_managed_fields.append(name) if is_primary_key: - _primary_keys.append(column) + primary_keys.append(column) columns.append(column) # Define metadata for the database connection on the class level so we don't # have to recalculate the table for each database call - _table = Table(cls.Config.table_name, MetaData(), *columns) + table = Table(table_name, MetaData(), *columns) + + return DatabaseModelConfig( + db_managed_fields=db_managed_fields, + nested_attr_table_field_map=nested_attr_table_field_map, + nested_model_attributes=nested_model_attributes, + nested_table_field_attr_map=nested_table_field_attr_map, + primary_keys=primary_keys, + table=table, + ) - # _database_info: DBInfo = None + def __init_subclass__(cls, table_name: str, **kwargs) -> None: + """Hook for processing class configuration when DatabaseModel is subclassed - cls._db_managed_fields = _db_managed_fields - cls._nested_table_field_attr_map = _nested_table_field_attr_map - cls._primary_keys = _primary_keys - cls._table = _table - cls.columns = _table.c + Args: + table_name: Name of the database table - def __init_subclass__(cls, **kwargs): + """ super().__init_subclass__(**kwargs) - DatabaseModel._initialize_table(cls) - return cls + cls._config = DatabaseModel._process_config(cls, table_name) @classmethod - def _backend(cls) -> None: - cls._backend = backend_cls(cls, **kwargs) - try: - yield - finally: - cls._backend = None + @property + def columns(self) -> ImmutableColumnCollection: + return self._config.table.c @classmethod def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": @@ -201,7 +198,7 @@ def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": """ modified_dict = {} for key, value in _dict.items(): - modified_key = cls._nested_table_field_attr_map.get(key, key) + modified_key = cls._config.nested_table_field_attr_map.get(key, key) modified_dict[modified_key] = value return cls(**modified_dict) @@ -233,11 +230,13 @@ def to_dict( if isinstance(prop_value, Enum): prop_value = prop_value.name - if prop_name in self._nested_model_attributes: + if prop_name in self._config.nested_model_attributes: # self.dict() will serialize any BaseModels into a dict so fetch the # actual object from self temp_prop_value = getattr(self, prop_name) - prop_name = self._nested_attr_table_field_map.get(prop_name, prop_name) + prop_name = self._config.nested_attr_table_field_map.get( + prop_name, prop_name + ) # temp_prop_value can be `None` if the nested key is optional if temp_prop_value is not None: prop_value = temp_prop_value.get_primary_id() @@ -262,7 +261,7 @@ async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": obj = await cls.get(*args, **kwargs) gatherables = [ (getattr(obj, prop_name)).fetch() - for prop_name in cls._nested_model_attributes + for prop_name in cls._config.nested_model_attributes ] await asyncio.gather(*gatherables) @@ -288,25 +287,27 @@ async def get(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": if ( (len(args) > 1) or (len(args) == 1 and len(kwargs) > 0) - or (len(args) == 1 and len(cls._primary_keys) > 1) + or (len(args) == 1 and len(cls._config.primary_keys) > 1) or (len(args) == 0 and len(kwargs) == 0) ): raise InvalidMethodParameterization("get", args=args, kwargs=kwargs) if len(args) == 1: - primary_key_dict = {cls._primary_keys[0].name: args[0]} + primary_key_dict = {cls._config.primary_keys[0].name: args[0]} else: primary_key_dict = kwargs original_primary_key_dict = primary_key_dict.copy() where_expressions = [] - for primary_key in cls._primary_keys: + for primary_key in cls._config.primary_keys: primary_key_value = primary_key_dict.pop(primary_key.name) where_expressions.append(primary_key == primary_key_value) records = await cls.select(where_expressions=where_expressions, limit=1) if len(records) == 0: - raise DatabaseRecordNotFound(cls._table.name, **original_primary_key_dict) + raise DatabaseRecordNotFound( + cls._config.table.name, **original_primary_key_dict + ) return records[0] @@ -335,10 +336,10 @@ def get_primary_id(self) -> Any: The ID value for this DatabaseModel instance """ - if len(self._primary_keys) > 1: + if len(self._config.primary_keys) > 1: return None - return getattr(self, self._primary_keys[0].name) + return getattr(self, self._config.primary_keys[0].name) async def fetch(self, resolve_references: bool = False) -> None: """Gets the latest of the object from the database and updates itself @@ -350,7 +351,7 @@ async def fetch(self, resolve_references: bool = False) -> None: # Get the latest version of self get_params = { primary_key.name: getattr(self, primary_key.name) - for primary_key in self._primary_keys + for primary_key in self._config.primary_keys } if resolve_references: new_self = await self.get_with_refs(**get_params) @@ -377,12 +378,14 @@ async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: exist on the database table """ - where_clause_list = [] + where_expressions = [] for field_name, db_field_value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) try: - db_field = getattr(cls._table.c, db_field_name) + db_field = getattr(cls._config.table.c, db_field_name) except AttributeError: raise DatabaseModelMissingField(cls.__name__, db_field_name) @@ -391,9 +394,9 @@ async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: else: exp = db_field == db_field_value - where_clause_list.append(exp) + where_expressions.append(exp) - return await cls.select(where_expressions=where_clause_list) + return await cls.select(where_expressions=where_expressions) @classmethod async def select( @@ -417,9 +420,13 @@ async def select( DatabaseModelMisconfigured: The class is missing a database table """ - return await get_backend(cls).select( - where_expressions=where_expressions, order_by=order_by, limit=limit + records = await get_backend().select( + cls._config, + where_expressions=where_expressions, + order_by=order_by, + limit=limit, ) + return [cls.from_dict(record) for record in records] @classmethod async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel"]: @@ -434,7 +441,23 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel list of new database models that have been saved """ - pass + values = [] + for model in models: + dict_obj = model.to_dict() + for field in cls._config.db_managed_fields: + # Remove any fields that the database calculates + del dict_obj[field] + values.append(dict_obj) + + records = await get_backend().create_records(cls._config, values) + # Set db managed column information on the object + for record, model in zip(records, models): + for column in cls._config.db_managed_fields: + col_val = record.get(column) + if col_val is not None: + setattr(model, column, col_val) + + return models @classmethod async def delete_records(cls, **kwargs: Any) -> None: @@ -450,7 +473,25 @@ async def delete_records(cls, **kwargs: Any) -> None: exist on the database table """ - pass + where_expressions = [] + for field_name, db_field_value in kwargs.items(): + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) + + try: + db_field = getattr(cls._config.table.c, db_field_name) + except AttributeError: + raise DatabaseModelMissingField(cls.__name__, db_field_name) + + if isinstance(db_field_value, list): + exp = db_field.in_(db_field_value) + else: + exp = db_field == db_field_value + + where_expressions.append(exp) + + return await get_backend().delete_records(cls._config, where_expressions) @classmethod async def update_record(cls, **kwargs: Any) -> "DatabaseModel": @@ -468,19 +509,21 @@ async def update_record(cls, **kwargs: Any) -> "DatabaseModel": """ where_expressions = [] primary_key_dict = {} - for primary_key in cls._primary_keys: + for primary_key in cls._config.primary_keys: primary_key_value = kwargs.pop(primary_key.name) where_expressions.append(primary_key == primary_key_value) primary_key_dict[primary_key.name] = primary_key_value modified_kwargs = {} for field_name, value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) modified_kwargs[db_field_name] = value updated_records = await cls.update(where_expressions, modified_kwargs) if len(updated_records) == 0: - raise DatabaseRecordNotFound(cls._table.name, **primary_key_dict) + raise DatabaseRecordNotFound(cls._config.table.name, **primary_key_dict) return updated_records[0] @classmethod @@ -502,18 +545,34 @@ async def update( DatabaseModelMisconfigured: The class is missing a database table """ - pass + return [ + cls.from_dict(record) + for record in await get_backend().update_records( + where_expressions=where_expressions, values=values + ) + ] - async def save(self, include_nested_models=False) -> None: - """Update the database record this object represents with its current state + async def save(self) -> None: + """Update the database record this object represents with its current state""" + dict_self = self.to_dict() + for field in self._config.db_managed_fields: + if field in self._config.primary_key_names and dict_self[field] is not None: + continue - Args: - include_nested_models: If True, any nested models should get saved before - this object gets saved + # Remove any fields that the database calculates + del dict_self[field] - """ - pass + record = await get_backend().upsert( + self._config, + dict_self, + ) + for field in self._config.db_managed_fields: + setattr(self, field, record[field]) async def delete(self) -> None: """Delete this record from the database""" - pass + where_expressions = [ + getattr(self._config.table.c, pkey.name) == getattr(self, pkey.name) + for pkey in self._config.primary_keys + ] + return await get_backend().delete_records(self._config, where_expressions) diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 2459300..e92ad96 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -1,27 +1,37 @@ -from datetime import datetime from typing import List, Optional from databases import Database from pydantic import Field import pytest -from pynocular.backends.postgres import PostgresDatabaseModelBackend -from pynocular.backends.util import backend -from pynocular.database_model import UUID_STR +from sqlalchemy import asc + +from pynocular.backends.sql import SQLDatabaseModelBackend +from pynocular.backends.context import backend from pynocular.model import DatabaseModel -class Org(DatabaseModel): - """A test database model""" +@pytest.fixture() +async def sql_backend(): + async with Database("postgresql://localhost:5432/jon.drake") as db: + yield SQLDatabaseModelBackend(db=db) + - class Config: - table_name = "organizations" +class Org(DatabaseModel, table_name="organizations"): + """A test database model""" - id: int = Field(primary_key=True) + id: Optional[int] = Field(primary_key=True, fetch_on_create=True) name: str = Field() @pytest.mark.asyncio -async def test_org(): - async with Database("postgresql://localhost:5432/jon.drake") as db: - with backend(PostgresDatabaseModelBackend, db=db): - orgs: List[Org] = await Org.select() - assert orgs[0].name == "first" +async def test_org(sql_backend): + with backend(sql_backend): + orgs: List[Org] = await Org.select(order_by=[asc(Org.columns.id)]) + org = orgs[0] + assert org.name == "second" + + org.name = "second" + await org.save() + + await Org.create_list([Org(name="hello"), Org(name="world")]) + orgs: List[Org] = await Org.select(order_by=[asc(Org.columns.id)]) + assert {o.name for o in orgs} == {"second", "hello", "world"} From 06495cc7d96deeb66206049d99f88e11437ab8e4 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 4 Apr 2022 19:49:02 -0700 Subject: [PATCH 03/31] test --- poetry.lock | 18 +++++++++++++++++- pyproject.toml | 1 + tests/unit/test_model.py | 32 ++++++++++++++++++-------------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index bea78d4..f0d2403 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,6 +33,17 @@ sqlalchemy = {version = ">=1.3,<1.5", extras = ["postgresql_psycopg2binary"], op [package.extras] sa = ["sqlalchemy[postgresql_psycopg2binary] (>=1.3,<1.5)"] +[[package]] +name = "aiosqlite" +version = "0.17.0" +description = "asyncio bridge to the standard sqlite3 module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing_extensions = ">=3.7.2" + [[package]] name = "arrow" version = "1.1.1" @@ -254,6 +265,7 @@ python-versions = ">=3.6" [package.dependencies] aiocontextvars = {version = "*", markers = "python_version < \"3.7\""} +aiosqlite = {version = "*", optional = true, markers = "extra == \"sqlite\""} asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""} sqlalchemy = ">=1.4,<1.5" @@ -812,7 +824,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "f83077c2c4b3df0f753156bf511701677cb6f3f6a90126b0fb8a8fb13a997cd8" +content-hash = "4ce167aae2512e7d7a20cb24bd95051135aa01bae526295b5ca3f5b642dfdf45" [metadata.files] aenum = [ @@ -828,6 +840,10 @@ aiopg = [ {file = "aiopg-1.3.3-py3-none-any.whl", hash = "sha256:2842dd8741460eeef940032dcb577bfba4d4115205dd82a73ce13b3271f5bf0a"}, {file = "aiopg-1.3.3.tar.gz", hash = "sha256:547c6ba4ea0d73c2a11a2f44387d7133cc01d3c6f3b8ed976c0ac1eff4f595d7"}, ] +aiosqlite = [ + {file = "aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231"}, + {file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"}, +] arrow = [ {file = "arrow-1.1.1-py3-none-any.whl", hash = "sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510"}, {file = "arrow-1.1.1.tar.gz", hash = "sha256:dee7602f6c60e3ec510095b5e301441bc56288cb8f51def14dcb3079f623823a"}, diff --git a/pyproject.toml b/pyproject.toml index 124b3fc..14e0c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ pytest = "^6.2.2" pytest-asyncio = "^0.15" black = {version = "^21.7b0", allow-prereleases = true} cruft = "^2.9.0" +databases = {extras = ["sqlite"], version = "^0.5.5"} [tool.cruft] skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.yml"] diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index e92ad96..4cd8a83 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -11,11 +11,17 @@ @pytest.fixture() async def sql_backend(): - async with Database("postgresql://localhost:5432/jon.drake") as db: - yield SQLDatabaseModelBackend(db=db) + async with Database("sqlite:///example.db") as db: + await db.execute( + "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY, name TEXT)" + ) + try: + yield SQLDatabaseModelBackend(db) + finally: + await db.execute("DROP TABLE things") -class Org(DatabaseModel, table_name="organizations"): +class Thing(DatabaseModel, table_name="things"): """A test database model""" id: Optional[int] = Field(primary_key=True, fetch_on_create=True) @@ -23,15 +29,13 @@ class Org(DatabaseModel, table_name="organizations"): @pytest.mark.asyncio -async def test_org(sql_backend): +async def test_thing(sql_backend): with backend(sql_backend): - orgs: List[Org] = await Org.select(order_by=[asc(Org.columns.id)]) - org = orgs[0] - assert org.name == "second" - - org.name = "second" - await org.save() - - await Org.create_list([Org(name="hello"), Org(name="world")]) - orgs: List[Org] = await Org.select(order_by=[asc(Org.columns.id)]) - assert {o.name for o in orgs} == {"second", "hello", "world"} + things: List[Thing] = await Thing.select() + assert things == [] + + things = await Thing.create_list([Thing(name="hello"), Thing(name="world")]) + assert things[0].name == "hello" + assert things[0].id == 1 + assert things[1].name == "world" + assert things[1].id == 2 From 519798a86734b550d9355c7c43ccdb8105705051 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Tue, 5 Apr 2022 11:50:31 -0700 Subject: [PATCH 04/31] docs --- poetry.lock | 139 +++++++---------------- pynocular/backends/base.py | 3 +- pynocular/backends/context.py | 3 +- pynocular/backends/memory.py | 200 +++++++++++++++++++++++++++++++++ pynocular/backends/sql.py | 18 ++- pynocular/model.py | 27 ++++- pyproject.toml | 3 +- tests/functional/test_model.py | 110 ++++++++++++++++++ tests/unit/test_model.py | 41 ------- 9 files changed, 381 insertions(+), 163 deletions(-) create mode 100644 pynocular/backends/memory.py create mode 100644 tests/functional/test_model.py delete mode 100644 tests/unit/test_model.py diff --git a/poetry.lock b/poetry.lock index f0d2403..7338d29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,17 +33,6 @@ sqlalchemy = {version = ">=1.3,<1.5", extras = ["postgresql_psycopg2binary"], op [package.extras] sa = ["sqlalchemy[postgresql_psycopg2binary] (>=1.3,<1.5)"] -[[package]] -name = "aiosqlite" -version = "0.17.0" -description = "asyncio bridge to the standard sqlite3 module" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -typing_extensions = ">=3.7.2" - [[package]] name = "arrow" version = "1.1.1" @@ -64,22 +53,6 @@ category = "main" optional = false python-versions = ">=3.5.3" -[[package]] -name = "asyncpg" -version = "0.25.0" -description = "An asyncio PostgreSQL driver" -category = "main" -optional = false -python-versions = ">=3.6.0" - -[package.dependencies] -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} - -[package.extras] -dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] -docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] -test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] - [[package]] name = "atomicwrites" version = "1.4.0" @@ -265,8 +238,6 @@ python-versions = ">=3.6" [package.dependencies] aiocontextvars = {version = "*", markers = "python_version < \"3.7\""} -aiosqlite = {version = "*", optional = true, markers = "extra == \"sqlite\""} -asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""} sqlalchemy = ">=1.4,<1.5" [package.extras] @@ -686,7 +657,7 @@ python-versions = ">=3.5" [[package]] name = "sqlalchemy" -version = "1.4.26" +version = "1.4.34" description = "Database Abstraction Library" category = "main" optional = false @@ -701,7 +672,7 @@ psycopg2-binary = {version = "*", optional = true, markers = "extra == \"postgre aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] mariadb_connector = ["mariadb (>=1.0.1)"] mssql = ["pyodbc"] mssql_pymssql = ["pymssql"] @@ -824,7 +795,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "4ce167aae2512e7d7a20cb24bd95051135aa01bae526295b5ca3f5b642dfdf45" +content-hash = "67de4d72e56ba9ffa8847f18369a0c39ca503ed18c10f63df72fcdc8125b0903" [metadata.files] aenum = [ @@ -840,10 +811,6 @@ aiopg = [ {file = "aiopg-1.3.3-py3-none-any.whl", hash = "sha256:2842dd8741460eeef940032dcb577bfba4d4115205dd82a73ce13b3271f5bf0a"}, {file = "aiopg-1.3.3.tar.gz", hash = "sha256:547c6ba4ea0d73c2a11a2f44387d7133cc01d3c6f3b8ed976c0ac1eff4f595d7"}, ] -aiosqlite = [ - {file = "aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231"}, - {file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"}, -] arrow = [ {file = "arrow-1.1.1-py3-none-any.whl", hash = "sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510"}, {file = "arrow-1.1.1.tar.gz", hash = "sha256:dee7602f6c60e3ec510095b5e301441bc56288cb8f51def14dcb3079f623823a"}, @@ -852,34 +819,6 @@ async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] -asyncpg = [ - {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, - {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, - {file = "asyncpg-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4"}, - {file = "asyncpg-0.25.0-cp310-cp310-win32.whl", hash = "sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095"}, - {file = "asyncpg-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09"}, - {file = "asyncpg-0.25.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634"}, - {file = "asyncpg-0.25.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5"}, - {file = "asyncpg-0.25.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92"}, - {file = "asyncpg-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4"}, - {file = "asyncpg-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd"}, - {file = "asyncpg-0.25.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68"}, - {file = "asyncpg-0.25.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b"}, - {file = "asyncpg-0.25.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256"}, - {file = "asyncpg-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9"}, - {file = "asyncpg-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8"}, - {file = "asyncpg-0.25.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b"}, - {file = "asyncpg-0.25.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e"}, - {file = "asyncpg-0.25.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962"}, - {file = "asyncpg-0.25.0-cp38-cp38-win32.whl", hash = "sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471"}, - {file = "asyncpg-0.25.0-cp38-cp38-win_amd64.whl", hash = "sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6"}, - {file = "asyncpg-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e"}, - {file = "asyncpg-0.25.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855"}, - {file = "asyncpg-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e"}, - {file = "asyncpg-0.25.0-cp39-cp39-win32.whl", hash = "sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2"}, - {file = "asyncpg-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac"}, - {file = "asyncpg-0.25.0.tar.gz", hash = "sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540"}, -] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -1349,42 +1288,42 @@ smmap = [ {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"}, ] sqlalchemy = [ - {file = "SQLAlchemy-1.4.26-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c2f2114b0968a280f94deeeaa31cfbac9175e6ac7bd3058b3ce6e054ecd762b3"}, - {file = "SQLAlchemy-1.4.26-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91efbda4e6d311812f23996242bad7665c1392209554f8a31ec6db757456db5c"}, - {file = "SQLAlchemy-1.4.26-cp27-cp27m-win32.whl", hash = "sha256:de996756d894a2d52c132742e3b6d64ecd37e0919ddadf4dc3981818777c7e67"}, - {file = "SQLAlchemy-1.4.26-cp27-cp27m-win_amd64.whl", hash = "sha256:463ef692259ff8189be42223e433542347ae17e33f91c1013e9c5c64e2798088"}, - {file = "SQLAlchemy-1.4.26-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c757ba1279b85b3460e72e8b92239dae6f8b060a75fb24b3d9be984dd78cfa55"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:c24c01dcd03426a5fe5ee7af735906bec6084977b9027a3605d11d949a565c01"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c46f013ff31b80cbe36410281675e1fb4eaf3e25c284fd8a69981c73f6fa4cb4"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fb2aa74a6e3c2cebea38dd21633671841fbe70ea486053cba33d68e3e22ccc0a"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7e403fc1e3cb76e802872694e30d6ca6129b9bc6ad4e7caa48ca35f8a144f8"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-win32.whl", hash = "sha256:7ef421c3887b39c6f352e5022a53ac18de8387de331130481cb956b2d029cad6"}, - {file = "SQLAlchemy-1.4.26-cp310-cp310-win_amd64.whl", hash = "sha256:908fad32c53b17aad12d722379150c3c5317c422437e44032256a77df1746292"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:1ef37c9ec2015ce2f0dc1084514e197f2f199d3dc3514190db7620b78e6004c8"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:090536fd23bf49077ee94ff97142bc5ee8bad24294c3d7c8d5284267c885dde7"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e700d48056475d077f867e6a36e58546de71bdb6fdc3d34b879e3240827fefab"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295b90efef1278f27fe27d94a45460ae3c17f5c5c2b32c163e29c359740a1599"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-win32.whl", hash = "sha256:cc6b21f19bc9d4cd77cbcba5f3b260436ce033f1053cea225b6efea2603d201e"}, - {file = "SQLAlchemy-1.4.26-cp36-cp36m-win_amd64.whl", hash = "sha256:ba84026e84379326bbf2f0c50792f2ae56ab9c01937df5597b6893810b8ca369"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f1e97c5f36b94542f72917b62f3a2f92be914b2cf33b80fa69cede7529241d2a"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c185c928e2638af9bae13acc3f70e0096eac76471a1101a10f96b80666b8270"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca660b76672e15d70a7dba5e703e1ce451a0257b6bd2028e62b0487885e8ae9"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff8f91a7b1c4a1c7772caa9efe640f2768828897044748f2458b708f1026e2d4"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-win32.whl", hash = "sha256:a95bf9c725012dcd7ea3cac16bf647054e0d62b31d67467d228338e6a163e4ff"}, - {file = "SQLAlchemy-1.4.26-cp37-cp37m-win_amd64.whl", hash = "sha256:07ac4461a1116b317519ddf6f34bcb00b011b5c1370ebeaaf56595504ffc7e84"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5039faa365e7522a8eb4736a54afd24a7e75dcc33b81ab2f0e6c456140f1ad64"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8ef103eaa72a857746fd57dda5b8b5961e8e82a528a3f8b7e2884d8506f0b7"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:31f4426cfad19b5a50d07153146b2bcb372a279975d5fa39f98883c0ef0f3313"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2feb028dc75e13ba93456a42ac042b255bf94dbd692bf80b47b22653bb25ccf8"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-win32.whl", hash = "sha256:2ce42ad1f59eb85c55c44fb505f8854081ee23748f76b62a7f569cfa9b6d0604"}, - {file = "SQLAlchemy-1.4.26-cp38-cp38-win_amd64.whl", hash = "sha256:dbf588ab09e522ac2cbd010919a592c6aae2f15ccc3cd9a96d01c42fbc13f63e"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a6506c17b0b6016656783232d0bdd03fd333f1f654d51a14d93223f953903646"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a882dedb9dfa6f33524953c3e3d72bcf518a5defd6d5863150a821928b19ad3"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1dee515578d04bc80c4f9a8c8cfe93f455db725059e885f1b1da174d91c4d077"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c0c5f54560a92691d54b0768d67b4d3159e514b426cfcb1258af8c195577e8f"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-win32.whl", hash = "sha256:b86f762cee3709722ab4691981958cbec475ea43406a6916a7ec375db9cbd9e9"}, - {file = "SQLAlchemy-1.4.26-cp39-cp39-win_amd64.whl", hash = "sha256:5c6774b34782116ad9bdec61c2dbce9faaca4b166a0bc8e7b03c2b870b121d94"}, - {file = "SQLAlchemy-1.4.26.tar.gz", hash = "sha256:6bc7f9d7d90ef55e8c6db1308a8619cd8f40e24a34f759119b95e7284dca351a"}, + {file = "SQLAlchemy-1.4.34-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c025d45318b73c0601cca451532556cbab532b2742839ebb8cb58f9ebf06811e"}, + {file = "SQLAlchemy-1.4.34-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cd5cffd1dd753828f1069f33062f3896e51c990acd957c264f40e051b3e19887"}, + {file = "SQLAlchemy-1.4.34-cp27-cp27m-win32.whl", hash = "sha256:a47bf6b7ca6c28e4f4e262fabcf5be6b907af81be36de77839c9eeda2cdf3bb3"}, + {file = "SQLAlchemy-1.4.34-cp27-cp27m-win_amd64.whl", hash = "sha256:c9218e3519398129e364121e0d89823e6ba2a2b77c28bfc661face0829c41433"}, + {file = "SQLAlchemy-1.4.34-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7ee14a7f9f76d1ef9d5e5b760c9252617c839b87eee04d1ce8325ac66ae155c4"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4414ace6e3a5e39523e55a5d9f3b215699b2ead4ff91fca98f1b659b7ab2d92a"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a6cfd468f54d65324fd3847cfd0148b0610efa6a43e5f5fcc89f455696ae9e7"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:27a42894a2751e438eaed12fc0dcfe741ff2f66c14760d081222c5adc5460064"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:671f61c3db4595b0e86cc4b30f675a7c0206d9ce99f041b4f6761c7ddd1e0074"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-win32.whl", hash = "sha256:3ebb97ed96f4506e2f212e1fcf0ec07a103bb194938627660a5acb4d9feae49c"}, + {file = "SQLAlchemy-1.4.34-cp310-cp310-win_amd64.whl", hash = "sha256:d8efcaa709ea8e7c08c3d3e7639c39b36083f5a995f397f9e6eedf5f5e4e4946"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a4fb5c6ee84a6bba4ff6f9f5379f0b3a0ffe9de7ba5a0945659b3da8d519709b"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07f4dab2deb6d34618a2ccfff3971a85923ad7c3a9a45401818870fc51d3f0cc"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67c1c27c48875afc950bee5ee24582794f20b545e64e4f9ca94071a9b514d6ed"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:954ea8c527c4322afb6885944904714893af81fe9167e421273770991bf08a4a"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-win32.whl", hash = "sha256:2a3e4dc7c452ba3c0f3175ad5a8e0ba49c2b0570a8d07272cf50844c8d78e74f"}, + {file = "SQLAlchemy-1.4.34-cp36-cp36m-win_amd64.whl", hash = "sha256:f47996b1810894f766c9ee689607077c6c0e0fd6761e04c12ba13efb56d50c1d"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b34bbc683789559f1bc9bb685fc162e0956dbbdfbe2fbd6755a9f5982c113610"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804cf491437f3e4ce31247ab4b309b181f06ecc97d309b746d10f09439b4eb85"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f197c66663ed0f9e1178d51141d864688fb244a83f6b17f667d521e482537b2e"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08aaad905aba8940f27aeb9f1f851bf63f18ef97b0062ca41f64afc4b64e0e8c"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-win32.whl", hash = "sha256:345306707bb0e51e7cd6e7573adafbce018894ee5e3b9c31134545f704936db0"}, + {file = "SQLAlchemy-1.4.34-cp37-cp37m-win_amd64.whl", hash = "sha256:50174e173d03209c34e07e7b57cca48d0082ac2390edf927aafc706c111da11e"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:878c7beaafa365602762c19f638282e1885454fed1aed86f8fae038933c7c671"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70048a83f0a1ece1fcd7189891c888e20af2c57fbd33eb760d8cece9843b896c"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:621d3f6c0ba2407bb97e82b649be5ca7d5b6c201dcfb964ce13f517bf1cb6305"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:045d6a26c262929af0b9cb25441aae675ac04db4ea8bd2446b355617cd6b6b7d"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-win32.whl", hash = "sha256:e297a5cc625e3f1367a82deedf2d48ee4d2b2bd263b8b8d2efbaaf5608b5229e"}, + {file = "SQLAlchemy-1.4.34-cp38-cp38-win_amd64.whl", hash = "sha256:36f08d94670315ca04c8139bd80b3e02b9dd9cc66fc11bcb96fd10ad51a051ab"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:40b995d7aeeb6f88a1927ce6692c0f626b59d8effd3e1d597f125e141707b37c"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb6558ba07409dafa18c793c34292b3265be455904966f0724c10198829477e3"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e67278ceb63270cdac0a7b89fc3c29a56f7dac9616a7ee48e7ad6b52e3b631e5"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50107d8183da3fbe5715957aa3954cd9d82aed555c5b4d3fd37fac861af422fa"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-win32.whl", hash = "sha256:c3ad7f5b61ba014f5045912aea15b03c473bb02b1c07fd92c9d2c794fa183276"}, + {file = "SQLAlchemy-1.4.34-cp39-cp39-win_amd64.whl", hash = "sha256:5e88912bf192e7b5739c446d2276e1cba74cfa6c1c93eea2b2534404f6be1dbd"}, + {file = "SQLAlchemy-1.4.34.tar.gz", hash = "sha256:623bac2d6bdca3f3e61cf1e1c466c5fb9f5cf08735736ee1111187b7a4108891"}, ] text-unidecode = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index d544a73..c8dbde1 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -10,7 +10,7 @@ @dataclass class DatabaseModelConfig: - """Data class with parsed configuration for a database model. + """Data class that holds parsed configuration for a database model. This class will be instantiated by a database model class at import time. """ @@ -24,6 +24,7 @@ class DatabaseModelConfig: @property def primary_key_names(self) -> Set[str]: + """Set of primary key names""" return {primary_key.name for primary_key in self.primary_keys} diff --git a/pynocular/backends/context.py b/pynocular/backends/context.py index 90c9278..456158c 100644 --- a/pynocular/backends/context.py +++ b/pynocular/backends/context.py @@ -1,4 +1,4 @@ -"""Contains contextvar definition for the active database backend""" +"""Contains contextvar and helper functions to manage the active database backend""" from contextlib import contextmanager @@ -6,7 +6,6 @@ from .base import DatabaseModelBackend - _backend = contextvars.ContextVar("database_model_backend", default=None) diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py new file mode 100644 index 0000000..da9a1f2 --- /dev/null +++ b/pynocular/backends/memory.py @@ -0,0 +1,200 @@ +"""Contains the MemoryDatabaseModelBackend class""" + +from collections import defaultdict +import itertools +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from sqlalchemy import Integer +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression +from sqlalchemy.sql.operators import desc_op + +from pynocular.backends.base import DatabaseModelBackend, DatabaseModelConfig +from pynocular.patch_models import _evaluate_column_element + + +class MemoryDatabaseModelBackend(DatabaseModelBackend): + """In-memory database model backend + + This backend stores records in memory. It translates SQLAlchemy expressions into + Python operations. It should only be used in tests. + """ + + def __init__(self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None): + """Initialize a SQLDatabaseModelBackend + + Args: + records: Optional map of table name to list of records to bootstrap the + in-memory database + + """ + self.records = records or defaultdict(list) + # Serial primary key generator + self._pk_generator = itertools.count(start=1) + + async def select( + self, + config: DatabaseModelConfig, + where_expressions: Optional[List[BinaryExpression]] = None, + order_by: Optional[List[UnaryExpression]] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Select a group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + order_by: A list of criteria for the order_by clause + limit: The number of records to return + + Returns: + list of records + + Raises: + InvalidFieldValue: The class is missing a database table + + """ + records = self.records[config.table.name] + + if where_expressions: + records = [ + record + for record in records + if all( + _evaluate_column_element(expr, record) for expr in where_expressions + ) + ] + + if order_by: + for expr in order_by: + if isinstance(expr, UnaryExpression): + column = expr.element + reverse = expr.modifier == desc_op + else: + # Assume a column was provided with no explicit sorting modifier + column = expr + reverse = False + + records = sorted( + records, key=lambda r: r.get(column.name), reverse=reverse + ) + + if limit is None: + records[:limit] + + return records + + async def create_records( + self, config: DatabaseModelConfig, records: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Create new group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + records: List of records to persist + + Returns: + list of newly created records + + """ + for record in records: + for primary_key in config.primary_keys: + value = ( + next(self._pk_generator) + if isinstance(primary_key.type, Integer) + else str(uuid4()) + ) + record.setdefault(primary_key.name, value) + + self.records[config.table.name].extend(records) + + return self.records[config.table.name] + + async def delete_records( + self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] + ) -> None: + """Delete a group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + + """ + self.records[config.table.name][:] = [ + record + for record in self.records[config.table.name] + if not all( + _evaluate_column_element(expr, record) for expr in where_expressions + ) + ] + + async def update_records( + self, + config: DatabaseModelConfig, + where_expressions: Optional[List[BinaryExpression]], + values: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """Update a group of records + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + where_expressions: A list of BinaryExpressions for the table that will be + `and`ed together for the where clause of the backend query + values: The map of key-values to update all records to that match the + where_expressions + + Returns: + the updated database records + + """ + records = await self.select(config, where_expressions=where_expressions) + for record in records: + record.update(values) + + return records + + async def upsert( + self, + config: DatabaseModelConfig, + record: Dict[str, Any], + ) -> Dict[str, Any]: + """Upsert a single database record + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + record: The record to update + + Returns: + the updated record + + """ + if all( + record.get(primary_key.name) is not None + for primary_key in config.primary_keys + ): + # All primary keys are already set so update + where_expressions = [ + primary_key == record.get(primary_key.name) + for primary_key in config.primary_keys + ] + records = await self.update_records(config, where_expressions, record) + return records[0] + else: + # Primary keys have not been set so this is a new record + for primary_key in config.primary_keys: + value = ( + next(self._pk_generator) + if isinstance(primary_key.type, Integer) + else str(uuid4()) + ) + record.setdefault(primary_key.name, value) + + self.records[config.table.name].append(record) + return record diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py index c5cd8da..df24e6e 100644 --- a/pynocular/backends/sql.py +++ b/pynocular/backends/sql.py @@ -1,26 +1,22 @@ """Contains the SQLDatabaseModelBackend class""" from typing import Any, Dict, List, Optional -from databases import Database +from databases import Database from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression -from pynocular.exceptions import ( - InvalidFieldValue, - InvalidTextRepresentation, -) -from pynocular.backends.base import ( - DatabaseModelBackend, - DatabaseModelConfig, -) +from pynocular.backends.base import DatabaseModelBackend, DatabaseModelConfig +from pynocular.exceptions import InvalidFieldValue, InvalidTextRepresentation class SQLDatabaseModelBackend(DatabaseModelBackend): """SQL database model backend - This backend works with any SQL dialect supported by https://www.encode.io/databases/ + This backend works with SQL dialects supported by https://www.encode.io/databases/. except sqlite* + + * sqlalchemy does not support the `RETURNING` clause. See https://github.com/sqlalchemy/sqlalchemy/issues/6195 """ def __init__(self, db: Database): @@ -39,7 +35,7 @@ async def select( order_by: Optional[List[UnaryExpression]] = None, limit: Optional[int] = None, ) -> List[Dict[str, Any]]: - """Execute a SELECT on the DatabaseModel table with the given parameters + """Select a group of records Args: config: DatabaseModelConfig instance that contains references to a table and diff --git a/pynocular/model.py b/pynocular/model.py index dc36a23..f255bda 100644 --- a/pynocular/model.py +++ b/pynocular/model.py @@ -1,12 +1,12 @@ +"""Contains DatabaseModel class""" + import asyncio -from contextlib import contextmanager from datetime import datetime from enum import Enum, EnumMeta -from pyexpat import model -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Type +from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING from uuid import UUID as stdlib_uuid -from pydantic import UUID4, BaseModel, PositiveFloat, PositiveInt +from pydantic import BaseModel, PositiveFloat, PositiveInt, UUID4 from sqlalchemy import ( Boolean, Column, @@ -22,10 +22,10 @@ from sqlalchemy.schema import FetchedValue from sqlalchemy.sql.base import ImmutableColumnCollection from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression + from pynocular.backends.base import DatabaseModelConfig from pynocular.backends.context import get_backend from pynocular.database_model import UUID_STR - from pynocular.exceptions import ( DatabaseModelMisconfigured, DatabaseModelMissingField, @@ -179,6 +179,7 @@ def __init_subclass__(cls, table_name: str, **kwargs) -> None: @classmethod @property def columns(self) -> ImmutableColumnCollection: + """Reference to the model's table's column collection""" return self._config.table.c @classmethod @@ -444,12 +445,21 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel values = [] for model in models: dict_obj = model.to_dict() + + # Remove any fields that the database calculates for field in cls._config.db_managed_fields: - # Remove any fields that the database calculates del dict_obj[field] + + # Remove keys for primary keys that don't have a value. This indicates that + # the backend will generate new values. + for field in cls._config.primary_keys: + if dict_obj.get(field.name) is None: + del dict_obj[field.name] + values.append(dict_obj) records = await get_backend().create_records(cls._config, values) + # Set db managed column information on the object for record, model in zip(records, models): for column in cls._config.db_managed_fields: @@ -457,6 +467,11 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel if col_val is not None: setattr(model, column, col_val) + for field in cls._config.primary_keys: + value = record.get(field.name) + if value is not None: + setattr(model, field.name, value) + return models @classmethod diff --git a/pyproject.toml b/pyproject.toml index 14e0c37..bdc6ecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ aenum = "^3.1.0" aiocontextvars = "^0.2.2" aiopg = {extras = ["sa"], version = "^1.3.1"} pydantic = "^1.6" -databases = {extras = ["postgresql"], version = "^0.5.5"} +databases = "^0.5.5" [tool.poetry.dev-dependencies] pre-commit = "^2.10.1" @@ -26,7 +26,6 @@ pytest = "^6.2.2" pytest-asyncio = "^0.15" black = {version = "^21.7b0", allow-prereleases = true} cruft = "^2.9.0" -databases = {extras = ["sqlite"], version = "^0.5.5"} [tool.cruft] skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.yml"] diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py new file mode 100644 index 0000000..180a0aa --- /dev/null +++ b/tests/functional/test_model.py @@ -0,0 +1,110 @@ +"""Contains tests for the DatabaseModel backends""" + +from typing import List, Optional + +from databases import Database +from pydantic import Field +import pytest +from sqlalchemy import desc + +from pynocular.backends.context import backend +from pynocular.backends.memory import MemoryDatabaseModelBackend +from pynocular.backends.sql import SQLDatabaseModelBackend +from pynocular.model import DatabaseModel + + +@pytest.fixture() +async def postgres_backend(): + """Fixture that yields a Postgres backend + + Yields: + postgres backend + + """ + async with Database("postgres://localhost:5432/postgres") as db: + await db.execute( + "CREATE TABLE IF NOT EXISTS things (id SERIAL PRIMARY KEY, name TEXT)" + ) + try: + yield SQLDatabaseModelBackend(db) + finally: + await db.execute("DROP TABLE things") + + +@pytest.fixture() +async def memory_backend(): + """Fixture that yields an in-memory backend + + Returns: + in-memory backend + + """ + return MemoryDatabaseModelBackend() + + +class Thing(DatabaseModel, table_name="things"): + """A test database model""" + + id: Optional[int] = Field(primary_key=True) + name: str = Field() + + +async def _run_tests(): + """Run tests agnostic to the backend""" + things: List[Thing] = await Thing.select() + assert things == [] + + things = await Thing.create_list([Thing(name="hello"), Thing(name="world")]) + assert [t.to_dict() for t in things] == [ + { + "name": "hello", + "id": 1, + }, + { + "name": "world", + "id": 2, + }, + ] + + things[1].name = "you" + await things[1].save() + + things: List[Thing] = await Thing.select(order_by=[desc(Thing.columns.name)]) + assert [t.to_dict() for t in things] == [ + { + "name": "you", + "id": 2, + }, + { + "name": "hello", + "id": 1, + }, + ] + + things: List[Thing] = await Thing.get_list(name="hello") + assert [t.to_dict() for t in things] == [ + { + "name": "hello", + "id": 1, + }, + ] + + await things[0].delete() + assert len(await Thing.get_list()) == 1 + + await Thing.delete_records(name="you") + assert len(await Thing.get_list()) == 0 + + +@pytest.mark.asyncio +async def test_postgres(postgres_backend): + """Should run a set of operations on a Postgres backend""" + with backend(postgres_backend): + await _run_tests() + + +@pytest.mark.asyncio +async def test_memory(memory_backend): + """Should run a set of operations on an in-memory backend""" + with backend(memory_backend): + await _run_tests() diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py deleted file mode 100644 index 4cd8a83..0000000 --- a/tests/unit/test_model.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import List, Optional -from databases import Database -from pydantic import Field -import pytest -from sqlalchemy import asc - -from pynocular.backends.sql import SQLDatabaseModelBackend -from pynocular.backends.context import backend -from pynocular.model import DatabaseModel - - -@pytest.fixture() -async def sql_backend(): - async with Database("sqlite:///example.db") as db: - await db.execute( - "CREATE TABLE IF NOT EXISTS things (id INTEGER PRIMARY KEY, name TEXT)" - ) - try: - yield SQLDatabaseModelBackend(db) - finally: - await db.execute("DROP TABLE things") - - -class Thing(DatabaseModel, table_name="things"): - """A test database model""" - - id: Optional[int] = Field(primary_key=True, fetch_on_create=True) - name: str = Field() - - -@pytest.mark.asyncio -async def test_thing(sql_backend): - with backend(sql_backend): - things: List[Thing] = await Thing.select() - assert things == [] - - things = await Thing.create_list([Thing(name="hello"), Thing(name="world")]) - assert things[0].name == "hello" - assert things[0].id == 1 - assert things[1].name == "world" - assert things[1].id == 2 From fa4d547a9834959c0aa24a26ce873ec32850ac58 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Tue, 5 Apr 2022 11:52:39 -0700 Subject: [PATCH 05/31] package --- poetry.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7338d29..97fd65a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -795,7 +795,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "67de4d72e56ba9ffa8847f18369a0c39ca503ed18c10f63df72fcdc8125b0903" +content-hash = "8277a3946b4117c0f3fa39d3f4422e51eb6be90051e3e514db69463aaf04cd66" [metadata.files] aenum = [ diff --git a/pyproject.toml b/pyproject.toml index bdc6ecc..8c6ddea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ aenum = "^3.1.0" aiocontextvars = "^0.2.2" aiopg = {extras = ["sa"], version = "^1.3.1"} pydantic = "^1.6" -databases = "^0.5.5" +databases = {extras = ["postgres"], version = "^0.5.5"} [tool.poetry.dev-dependencies] pre-commit = "^2.10.1" From 84f5198948793cb767b5c10536de17e12ab1bc19 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Tue, 5 Apr 2022 11:55:46 -0700 Subject: [PATCH 06/31] reinstall --- poetry.lock | 47 ++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 97fd65a..46d527c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -53,6 +53,22 @@ category = "main" optional = false python-versions = ">=3.5.3" +[[package]] +name = "asyncpg" +version = "0.25.0" +description = "An asyncio PostgreSQL driver" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] + [[package]] name = "atomicwrites" version = "1.4.0" @@ -238,6 +254,7 @@ python-versions = ">=3.6" [package.dependencies] aiocontextvars = {version = "*", markers = "python_version < \"3.7\""} +asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""} sqlalchemy = ">=1.4,<1.5" [package.extras] @@ -795,7 +812,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "8277a3946b4117c0f3fa39d3f4422e51eb6be90051e3e514db69463aaf04cd66" +content-hash = "f83077c2c4b3df0f753156bf511701677cb6f3f6a90126b0fb8a8fb13a997cd8" [metadata.files] aenum = [ @@ -819,6 +836,34 @@ async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] +asyncpg = [ + {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, + {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, + {file = "asyncpg-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4"}, + {file = "asyncpg-0.25.0-cp310-cp310-win32.whl", hash = "sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095"}, + {file = "asyncpg-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09"}, + {file = "asyncpg-0.25.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634"}, + {file = "asyncpg-0.25.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5"}, + {file = "asyncpg-0.25.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd"}, + {file = "asyncpg-0.25.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68"}, + {file = "asyncpg-0.25.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b"}, + {file = "asyncpg-0.25.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8"}, + {file = "asyncpg-0.25.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b"}, + {file = "asyncpg-0.25.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e"}, + {file = "asyncpg-0.25.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962"}, + {file = "asyncpg-0.25.0-cp38-cp38-win32.whl", hash = "sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471"}, + {file = "asyncpg-0.25.0-cp38-cp38-win_amd64.whl", hash = "sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6"}, + {file = "asyncpg-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e"}, + {file = "asyncpg-0.25.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855"}, + {file = "asyncpg-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e"}, + {file = "asyncpg-0.25.0-cp39-cp39-win32.whl", hash = "sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2"}, + {file = "asyncpg-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac"}, + {file = "asyncpg-0.25.0.tar.gz", hash = "sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, diff --git a/pyproject.toml b/pyproject.toml index 8c6ddea..124b3fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ aenum = "^3.1.0" aiocontextvars = "^0.2.2" aiopg = {extras = ["sa"], version = "^1.3.1"} pydantic = "^1.6" -databases = {extras = ["postgres"], version = "^0.5.5"} +databases = {extras = ["postgresql"], version = "^0.5.5"} [tool.poetry.dev-dependencies] pre-commit = "^2.10.1" From b17e3d1380fec7c1655da57994738a7d6b6e21d2 Mon Sep 17 00:00:00 2001 From: ns-circle-ci Date: Tue, 5 Apr 2022 19:14:17 +0000 Subject: [PATCH 07/31] Version bumped to 0.19.0 --- pynocular/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index b9a83dc..2429f99 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "0.18.0" +__version__ = "0.19.0" from pynocular.database_model import DatabaseModel, UUID_STR from pynocular.engines import DatabaseType, DBInfo diff --git a/pyproject.toml b/pyproject.toml index 124b3fc..00ac0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "0.18.0" +version = "0.19.0" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", From 12272794e38d4da46bb708c2fd295ec5af931d6d Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Tue, 5 Apr 2022 12:14:51 -0700 Subject: [PATCH 08/31] version --- pynocular/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index b9a83dc..20f61c6 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "0.18.0" +__version__ = "2.0.0rc1" from pynocular.database_model import DatabaseModel, UUID_STR from pynocular.engines import DatabaseType, DBInfo diff --git a/pyproject.toml b/pyproject.toml index 124b3fc..51b05c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "0.18.0" +version = "2.0.0rc1" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", From 158888f13061d1fbafa13a6d6460214347c9e6c9 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 11:02:01 -0700 Subject: [PATCH 09/31] black --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 164f5df..50a63f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,7 +56,7 @@ repos: entry: black language: python types: [file, python] - additional_dependencies: [black==21.9b0] + additional_dependencies: [black==22.3.0] - id: pydocstyle name: Lint Python docstrings (pydocstyle) diff --git a/pyproject.toml b/pyproject.toml index 51b05c8..a8c27fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ databases = {extras = ["postgresql"], version = "^0.5.5"} pre-commit = "^2.10.1" pytest = "^6.2.2" pytest-asyncio = "^0.15" -black = {version = "^21.7b0", allow-prereleases = true} +black = "^22.3.0" cruft = "^2.9.0" [tool.cruft] From ed7cfafa489949df29756efb305711774dae64bb Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 11:09:51 -0700 Subject: [PATCH 10/31] black --- poetry.lock | 787 ++++++++++++++++++++++++++-------------------------- 1 file changed, 397 insertions(+), 390 deletions(-) diff --git a/poetry.lock b/poetry.lock index 46d527c..352bd3c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "aenum" -version = "3.1.0" +version = "3.1.8" description = "Advanced Enumerations (compatible with Python's stdlib Enum), NamedTuples, and NamedConstants" category = "main" optional = false @@ -35,7 +35,7 @@ sa = ["sqlalchemy[postgresql_psycopg2binary] (>=1.3,<1.5)"] [[package]] name = "arrow" -version = "1.1.1" +version = "1.2.2" description = "Better dates & times for Python" category = "dev" optional = false @@ -47,11 +47,14 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "async-timeout" -version = "3.0.1" +version = "4.0.2" description = "Timeout context manager for asyncio programs" category = "main" optional = false -python-versions = ">=3.5.3" +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} [[package]] name = "asyncpg" @@ -79,32 +82,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] - -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.0" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "binaryornot" @@ -119,36 +107,31 @@ chardet = ">=3.0.2" [[package]] name = "black" -version = "21.9b0" +version = "22.3.0" description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.6.2" [package.dependencies] -click = ">=7.1.2" +click = ">=8.0.0" dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0,<1" +pathspec = ">=0.9.0" platformdirs = ">=2" -regex = ">=2020.1.8" -tomli = ">=0.2.6,<2.0.0" -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""} -typing-extensions = [ - {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, - {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, -] +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"] +d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -python2 = ["typed-ast (>=1.4.2)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -172,7 +155,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "charset-normalizer" -version = "2.0.6" +version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "dev" optional = false @@ -183,11 +166,15 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "7.1.2" +version = "8.0.4" description = "Composable command line interface toolkit" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -228,17 +215,18 @@ six = ">=1.10" [[package]] name = "cruft" -version = "2.9.0" +version = "2.10.1" description = "Allows you to maintain all the necessary cruft for packaging and building projects separate from the code you intentionally write. Built on-top of CookieCutter." category = "dev" optional = false python-versions = ">=3.6,<4.0" [package.dependencies] -click = ">=7.1.2,<8.0.0" +click = ">=7.1.2,<9.0.0" cookiecutter = ">=1.6,<2.0" gitpython = ">=3.0,<4.0" -typer = ">=0.3.2,<0.4.0" +importlib_metadata = ">=3.4.0,<4.0.0" +typer = ">=0.4.0,<0.5.0" [package.extras] pyproject = ["toml (>=0.10,<0.11)"] @@ -274,7 +262,7 @@ python-versions = ">=3.6, <3.7" [[package]] name = "distlib" -version = "0.3.3" +version = "0.3.4" description = "Distribution utilities" category = "dev" optional = false @@ -282,26 +270,26 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.2.0" +version = "3.4.1" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "gitdb" -version = "4.0.7" +version = "4.0.9" description = "Git Object Database" category = "dev" optional = false -python-versions = ">=3.4" +python-versions = ">=3.6" [package.dependencies] -smmap = ">=3.0.1,<5" +smmap = ">=3.0.1,<6" [[package]] name = "gitpython" @@ -328,18 +316,18 @@ docs = ["sphinx"] [[package]] name = "identify" -version = "2.2.15" +version = "2.4.4" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.6.1" [package.extras] -license = ["editdistance-s"] +license = ["ukkonen"] [[package]] name = "idna" -version = "3.2" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "dev" optional = false @@ -347,7 +335,7 @@ python-versions = ">=3.5" [[package]] name = "immutables" -version = "0.16" +version = "0.17" description = "Immutable Collections" category = "main" optional = false @@ -361,7 +349,7 @@ test = ["flake8 (>=3.8.4,<3.9.0)", "pycodestyle (>=2.6.0,<2.7.0)", "mypy (>=0.91 [[package]] name = "importlib-metadata" -version = "4.8.1" +version = "3.10.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -373,12 +361,11 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -perf = ["ipython"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "importlib-resources" -version = "5.2.2" +version = "5.2.3" description = "Read resources from Python packages" category = "dev" optional = false @@ -389,7 +376,7 @@ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] [[package]] name = "iniconfig" @@ -401,7 +388,7 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.3" description = "A very fast and expressive template engine." category = "dev" optional = false @@ -451,14 +438,14 @@ python-versions = "*" [[package]] name = "packaging" -version = "21.0" +version = "21.3" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pathspec" @@ -505,7 +492,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pre-commit" -version = "2.15.0" +version = "2.17.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -515,7 +502,7 @@ python-versions = ">=3.6.1" cfgv = ">=2.0.0" identify = ">=1.0.0" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -importlib-resources = {version = "*", markers = "python_version < \"3.7\""} +importlib-resources = {version = "<5.3", markers = "python_version < \"3.7\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" @@ -523,7 +510,7 @@ virtualenv = ">=20.0.8" [[package]] name = "psycopg2-binary" -version = "2.9.1" +version = "2.9.3" description = "psycopg2 - Python-PostgreSQL Database Adapter" category = "main" optional = false @@ -531,15 +518,15 @@ python-versions = ">=3.6" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pydantic" -version = "1.8.2" +version = "1.9.0" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = false @@ -555,11 +542,14 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.7" description = "Python parsing module" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -610,11 +600,11 @@ six = ">=1.5" [[package]] name = "python-slugify" -version = "5.0.2" -description = "A Python Slugify application that handles Unicode" +version = "6.1.1" +description = "A Python slugify application that also handles Unicode" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] text-unidecode = ">=1.3" @@ -624,23 +614,15 @@ unidecode = ["Unidecode (>=1.1.1)"] [[package]] name = "pyyaml" -version = "5.4.1" +version = "6.0" description = "YAML parser and emitter for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" - -[[package]] -name = "regex" -version = "2021.9.24" -description = "Alternative regular expression module, to replace re." -category = "dev" -optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "requests" -version = "2.26.0" +version = "2.27.1" description = "Python HTTP for Humans." category = "dev" optional = false @@ -666,15 +648,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "smmap" -version = "4.0.0" +version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [[package]] name = "sqlalchemy" -version = "1.4.34" +version = "1.4.35" description = "Database Abstraction Library" category = "main" optional = false @@ -724,7 +706,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "1.2.1" +version = "1.2.3" description = "A lil' TOML parser" category = "dev" optional = false @@ -732,69 +714,68 @@ python-versions = ">=3.6" [[package]] name = "typed-ast" -version = "1.4.3" +version = "1.5.2" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "typer" -version = "0.3.2" +version = "0.4.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -click = ">=7.1.1,<7.2.0" +click = ">=7.1.1,<9.0.0" [package.extras] -test = ["pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.782)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)", "shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)"] all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] -doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)"] +test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=22.3.0,<23.0.0)", "isort (>=5.0.6,<6.0.0)"] [[package]] name = "typing-extensions" -version = "3.10.0.2" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.7" +version = "1.26.9" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.8.1" +version = "20.14.0" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] @@ -812,13 +793,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "f83077c2c4b3df0f753156bf511701677cb6f3f6a90126b0fb8a8fb13a997cd8" +content-hash = "5815b4c48ee7d27546cea46725fe5b26d626499eaadccd20e38cc553297d2cfa" [metadata.files] aenum = [ - {file = "aenum-3.1.0-py2-none-any.whl", hash = "sha256:1f92fb906e3d745064e85f9a1937006ee341e00a35ecd8b7f899041b8e1d67d7"}, - {file = "aenum-3.1.0-py3-none-any.whl", hash = "sha256:f8401f1a258436719ed013444ab37ff22a72517e0e3097058dd1511cf284447c"}, - {file = "aenum-3.1.0.tar.gz", hash = "sha256:87f0e9ef4f828578ab06af30e4d7944043bf4ecd3f4b7bd1cbe37e2173cde94a"}, + {file = "aenum-3.1.8-py2-none-any.whl", hash = "sha256:07ea89f43d78b3d5997b32b8d5b0ec3e5be17b3e05b7bac0154b8c484a4aeff5"}, + {file = "aenum-3.1.8-py3-none-any.whl", hash = "sha256:859fe994719e6b5e39f15f73acd84e08b4e57dc642373b177a5fa6646798706a"}, + {file = "aenum-3.1.8.tar.gz", hash = "sha256:8dbe15f446eb8264b788dfeca163fb0a043d408d212152397dc11377b851e4ae"}, ] aiocontextvars = [ {file = "aiocontextvars-0.2.2-py2.py3-none-any.whl", hash = "sha256:885daf8261818767d8f7cbd79f9d4482d118f024b6586ef6e67980236a27bfa3"}, @@ -829,12 +810,12 @@ aiopg = [ {file = "aiopg-1.3.3.tar.gz", hash = "sha256:547c6ba4ea0d73c2a11a2f44387d7133cc01d3c6f3b8ed976c0ac1eff4f595d7"}, ] arrow = [ - {file = "arrow-1.1.1-py3-none-any.whl", hash = "sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510"}, - {file = "arrow-1.1.1.tar.gz", hash = "sha256:dee7602f6c60e3ec510095b5e301441bc56288cb8f51def14dcb3079f623823a"}, + {file = "arrow-1.2.2-py3-none-any.whl", hash = "sha256:d622c46ca681b5b3e3574fcb60a04e5cc81b9625112d5fb2b44220c36c892177"}, + {file = "arrow-1.2.2.tar.gz", hash = "sha256:05caf1fd3d9a11a1135b2b6f09887421153b94558e5ef4d090b567b47173ac2b"}, ] async-timeout = [ - {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, - {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] asyncpg = [ {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, @@ -869,24 +850,41 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, -] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, - {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] binaryornot = [ {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, ] black = [ - {file = "black-21.9b0-py3-none-any.whl", hash = "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115"}, - {file = "black-21.9b0.tar.gz", hash = "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, + {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, + {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, + {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, + {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, + {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, + {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, + {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, + {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, + {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, + {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, + {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, + {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, + {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, + {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, + {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, + {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, + {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, ] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, @@ -897,12 +895,12 @@ chardet = [ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"}, - {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, + {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, + {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -916,8 +914,8 @@ cookiecutter = [ {file = "cookiecutter-1.7.3.tar.gz", hash = "sha256:6b9a4d72882e243be077a7397d0f1f76fe66cf3df91f3115dbb5330e214fa457"}, ] cruft = [ - {file = "cruft-2.9.0-py3-none-any.whl", hash = "sha256:6e77ff2f59a2e2aee7c54ebf302b5a4dd231af44e5971d5ad60135c9e7f4c29d"}, - {file = "cruft-2.9.0.tar.gz", hash = "sha256:550703c35b8125612ad89d93d176f29b3e362c19c74ed28895625afa9010fcb1"}, + {file = "cruft-2.10.1-py3-none-any.whl", hash = "sha256:082e8044c1a80c8b27645a94ae2bdb0544fec752d2b2798ec901b30e6981426d"}, + {file = "cruft-2.10.1.tar.gz", hash = "sha256:c5350ca3ef7b671409f9b24e8e0bd73d870433aa6b5abbf6e7b9b73f7b4adef0"}, ] databases = [ {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"}, @@ -928,16 +926,16 @@ dataclasses = [ {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, ] distlib = [ - {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, - {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] filelock = [ - {file = "filelock-3.2.0-py2.py3-none-any.whl", hash = "sha256:61a99e9b12b47b685d1389f4cf969c1eba0efd2348a8471f86e01e8c622267af"}, - {file = "filelock-3.2.0.tar.gz", hash = "sha256:85ecb30757aa19d06bfcdad29cc332b9a3e4851bf59976aea1e8dadcbd9ef883"}, + {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"}, + {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"}, ] gitdb = [ - {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, - {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, + {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, + {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, ] gitpython = [ {file = "GitPython-3.1.20-py3-none-any.whl", hash = "sha256:b1e1c269deab1b08ce65403cf14e10d2ef1f6c89e33ea7c5e5bb0222ea593b8a"}, @@ -1001,57 +999,79 @@ greenlet = [ {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, ] identify = [ - {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"}, - {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"}, + {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, + {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] immutables = [ - {file = "immutables-0.16-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:acbfa79d44228d96296279068441f980dc63dbed52522d9227ff9f4d96c6627e"}, - {file = "immutables-0.16-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c9ed003eacb92e630ef200e31f47236c2139b39476894f7963b32bd39bafa3"}, - {file = "immutables-0.16-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a396314b9024fa55bf83a27813fd76cf9f27dce51f53b0f19b51de035146251"}, - {file = "immutables-0.16-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a2a71678348fb95b13ca108d447f559a754c41b47bd1e7e4fb23974e735682d"}, - {file = "immutables-0.16-cp36-cp36m-win32.whl", hash = "sha256:064001638ab5d36f6aa05b6101446f4a5793fb71e522bc81b8fc65a1894266ff"}, - {file = "immutables-0.16-cp36-cp36m-win_amd64.whl", hash = "sha256:1de393f1b188740ca7b38f946f2bbc7edf3910d2048f03bbb8d01f17a038d67c"}, - {file = "immutables-0.16-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fcf678a3074613119385a02a07c469ec5130559f5ea843c85a0840c80b5b71c6"}, - {file = "immutables-0.16-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a307eb0984eb43e815dcacea3ac50c11d00a936ecf694c46991cd5a23bcb0ec0"}, - {file = "immutables-0.16-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7a58825ff2254e2612c5a932174398a4ea8fbddd8a64a02c880cc32ee28b8820"}, - {file = "immutables-0.16-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:798b095381eb42cf40db6876339e7bed84093e5868018a9e73d8e1f7ab4bb21e"}, - {file = "immutables-0.16-cp37-cp37m-win32.whl", hash = "sha256:19bdede174847c2ef1292df0f23868ab3918b560febb09fcac6eec621bd4812b"}, - {file = "immutables-0.16-cp37-cp37m-win_amd64.whl", hash = "sha256:9ccf4c0e3e2e3237012b516c74c49de8872ccdf9129739f7a0b9d7444a8c4862"}, - {file = "immutables-0.16-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d59beef203a3765db72b1d0943547425c8318ecf7d64c451fd1e130b653c2fbb"}, - {file = "immutables-0.16-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0020aaa4010b136056c20a46ce53204e1407a9e4464246cb2cf95b90808d9161"}, - {file = "immutables-0.16-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edd9f67671555af1eb99ad3c7550238487dd7ac0ac5205b40204ed61c9a922ac"}, - {file = "immutables-0.16-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:298a301f85f307b4c056a0825eb30f060e64d73605e783289f3df37dd762bab8"}, - {file = "immutables-0.16-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b779617f5b94486bfd0f22162cd72eb5f2beb0214a14b75fdafb7b2c908ed0cb"}, - {file = "immutables-0.16-cp38-cp38-win32.whl", hash = "sha256:511c93d8b1bbbf103ff3f1f120c5a68a9866ce03dea6ac406537f93ca9b19139"}, - {file = "immutables-0.16-cp38-cp38-win_amd64.whl", hash = "sha256:b651b61c1af6cda2ee201450f2ffe048a5959bc88e43e6c312f4c93e69c9e929"}, - {file = "immutables-0.16-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:aa7bf572ae1e006104c584be70dc634849cf0dc62f42f4ee194774f97e7fd17d"}, - {file = "immutables-0.16-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:50793a44ba0d228ed8cad4d0925e00dfd62ea32f44ddee8854f8066447272d05"}, - {file = "immutables-0.16-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:799621dcdcdcbb2516546a40123b87bf88de75fe7459f7bd8144f079ace6ec3e"}, - {file = "immutables-0.16-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7bcf52aeb983bd803b7c6106eae1b2d9a0c7ab1241bc6b45e2174ba2b7283031"}, - {file = "immutables-0.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:734c269e82e5f307fb6e17945953b67659d1731e65309787b8f7ba267d1468f2"}, - {file = "immutables-0.16-cp39-cp39-win32.whl", hash = "sha256:a454d5d3fee4b7cc627345791eb2ca4b27fa3bbb062ccf362ecaaa51679a07ed"}, - {file = "immutables-0.16-cp39-cp39-win_amd64.whl", hash = "sha256:2505d93395d3f8ae4223e21465994c3bc6952015a38dc4f03cb3e07a2b8d8325"}, - {file = "immutables-0.16.tar.gz", hash = "sha256:d67e86859598eed0d926562da33325dac7767b7b1eff84e232c22abea19f4360"}, + {file = "immutables-0.17-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cab10d65a29b2019fffd7a3924f6965a8f785e7bd409641ce36ab2d3335f88c4"}, + {file = "immutables-0.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f73088c9b8595ddfd45a5658f8cce0cb3ae6e5890458381fccba3ed3035081d4"}, + {file = "immutables-0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef632832fa1acae6861d83572b866126f9e35706ab6e581ce6b175b3e0b7a3c4"}, + {file = "immutables-0.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0efdcec7b63859b41f794ffa0cd0d6dc87e77d1be4ff0ec23471a3a1e719235f"}, + {file = "immutables-0.17-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eca96f12bc1535657d24eae2c69816d0b22c4a4bc7f4753115e028a137e8dad"}, + {file = "immutables-0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:01a25b1056754aa486afea5471ca348410d77f458477ccb6fa3baf2d3e3ff3d5"}, + {file = "immutables-0.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c41a6648f7355f1241da677c418edae56fdc45af19ad3540ca8a1e7a81606a7a"}, + {file = "immutables-0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0b578bba11bd8ae55dee9536edf8d82be18463d15d4b4c9827e27eeeb73826bf"}, + {file = "immutables-0.17-cp310-cp310-win32.whl", hash = "sha256:a28682e115191e909673aedb9ccea3377da3a6a929f8bd86982a2a76bdfa89db"}, + {file = "immutables-0.17-cp310-cp310-win_amd64.whl", hash = "sha256:293ddb681502945f29b3065e688a962e191e752320040892316b9dd1e3b9c8c9"}, + {file = "immutables-0.17-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ec04fc7d9f76f26d82a5d9d1715df0409d0096309828fc46cd1a2067c7fbab95"}, + {file = "immutables-0.17-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f024f25e9fda42251a2b2167668ca70678c19fb3ab6ed509cef0b4b431d0ff73"}, + {file = "immutables-0.17-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b02083b2052cc201ac5cbd38f34a5da21fcd51016cb4ddd1fb43d7dc113eac17"}, + {file = "immutables-0.17-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea32db31afb82d8369e98f85c5b815ff81610a12fbc837830a34388f1b56f080"}, + {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:898a9472d1dd3d17f291114395a1be65be035355fc65af0b2c88238f8fbeaa62"}, + {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:736dd3d88d44da0ee48804792bd095c01a344c5d1b0f10beeb9ccb3a00b9c19d"}, + {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:15ff4139720f79b902f435a25e3c00f9c8adcc41d79bed64b7e51ae36cfe9620"}, + {file = "immutables-0.17-cp36-cp36m-win32.whl", hash = "sha256:4f018a6c4c3689b82f763ad4f84dec6aa91c83981db7f6bafef963f036e5e815"}, + {file = "immutables-0.17-cp36-cp36m-win_amd64.whl", hash = "sha256:d7400a6753b292ac80102ed026efa8da2c3fedd50c443924cbe9b6448d3b19e4"}, + {file = "immutables-0.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f7a6e0380bddb99c46bb3f12ae5eee9a23d6a66d99bbf0fb10fa552f935c2e8d"}, + {file = "immutables-0.17-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7696c42d1f9a16ecda0ee46229848df8706973690b45e8a090d995d647a5ec57"}, + {file = "immutables-0.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:892b6a1619cd8c398fa70302c4cfa9768a694377639330e7a58cc7be111ab23e"}, + {file = "immutables-0.17-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89093d5a85357250b1d5ae218fdcfdbac4097cbb2d8b55004aa7a2ca2a00a09f"}, + {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99a8bc6d0623300eb46beea74f7a5061968fb3efc4e072f23f6c0b21c588238d"}, + {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:00380474f8e3b4a2eeb06ce694e0e3cb85a144919140a2b3116defb6c1587471"}, + {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:078e3ed63be0ac36523b80bbabbfb1bb57e55009f4efb5650b0e3b3ed569c3f1"}, + {file = "immutables-0.17-cp37-cp37m-win32.whl", hash = "sha256:14905aecc62b318d86045dcf8d35ef2063803d9d331aeccd88958f03caadc7b0"}, + {file = "immutables-0.17-cp37-cp37m-win_amd64.whl", hash = "sha256:3774d403d1570105a1da2e00c38ce3f04065fd1deff04cf998f8d8e946d0ae13"}, + {file = "immutables-0.17-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5a9caee1b99eccf1447056ae6bda77edd15c357421293e81fa1a4f28e83448a"}, + {file = "immutables-0.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fed1e1baf1de1bc94a0310da29814892064928d7d40ff5a3b86bcd11d5e7cfff"}, + {file = "immutables-0.17-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d7daa340d76747ba5a8f64816b48def74bd4be45a9508073b34fa954d099fba"}, + {file = "immutables-0.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4644c29fe07fb92ba84b26659708e1799fecaaf781214adf13edd8a4d7495a9"}, + {file = "immutables-0.17-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e9ea0e2a31db44fb01617ff875d4c26f962696e1c5ff11ed7767c2d8dedac4"}, + {file = "immutables-0.17-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:64100dfdb29fae2bc84748fff5d66dd6b3997806c717eeb75f7099aeee9b1878"}, + {file = "immutables-0.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5f933e5bf6f2c1afb24bc2fc8bea8b132096a4a6ba54f36be59787981f3e50ff"}, + {file = "immutables-0.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9508a087a47f9f9506adf2fa8383ab14c46a222b57eea8612bc4c2aa9a9550fe"}, + {file = "immutables-0.17-cp38-cp38-win32.whl", hash = "sha256:dfd2c63f15d1e5ea1ed2a05b7c602b5f61a64337415d299df20e103a57ae4906"}, + {file = "immutables-0.17-cp38-cp38-win_amd64.whl", hash = "sha256:301c539660c988c5b24051ccad1e36c040a916f1e58fa3e245e3122fc50dd28d"}, + {file = "immutables-0.17-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:563bc2ddbe75c10faa3b4b0206870653b44a231b97ed23cff8ab8aff503d922d"}, + {file = "immutables-0.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f621ea6130393cd14d0fbd35b306d4dc70bcd0fda550a8cd313db8015e34ca60"}, + {file = "immutables-0.17-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c2d1b16b716bca70345db334dd6a861bf45c46cb11bb1801277f8a9012e864"}, + {file = "immutables-0.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a08e1a80bd8c5df72c2bf0af24a37ceec17e8ffdb850ed5a62d0bba1d4d86018"}, + {file = "immutables-0.17-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b99155ad112149d43208c611c6c42f19e16716526dacc0fcc16736d2f5d2e20"}, + {file = "immutables-0.17-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ed71e736f8fb82545d00c8969dbc167547c15e85729058edbed3c03b94fca86c"}, + {file = "immutables-0.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:19e4b8e5810dd7cab63fa700373f787a369d992166eabc23f4b962e5704d33c5"}, + {file = "immutables-0.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:305062012497d4c4a70fe35e20cef2c6f65744e721b04671092a63354799988d"}, + {file = "immutables-0.17-cp39-cp39-win32.whl", hash = "sha256:f5c6bd012384a8d6af7bb25675719214d76640fe6c336e2b5fba9eef1407ae6a"}, + {file = "immutables-0.17-cp39-cp39-win_amd64.whl", hash = "sha256:615ab26873a794559ccaf4e0e9afdb5aefad0867c15262ba64a55a12a5a41573"}, + {file = "immutables-0.17.tar.gz", hash = "sha256:ad894446355b6f5289a9c84fb46f7c47c6ef2b1bfbdd2be6cb177dbb7f1587ad"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, - {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, + {file = "importlib_metadata-3.10.1-py3-none-any.whl", hash = "sha256:2ec0faae539743ae6aaa84b49a169670a465f7f5d64e6add98388cc29fd1f2f6"}, + {file = "importlib_metadata-3.10.1.tar.gz", hash = "sha256:c9356b657de65c53744046fa8f7358afe0714a1af7d570c00c3835c2d724a7c1"}, ] importlib-resources = [ - {file = "importlib_resources-5.2.2-py3-none-any.whl", hash = "sha256:2480d8e07d1890056cb53c96e3de44fead9c62f2ba949b0f2e4c4345f4afa977"}, - {file = "importlib_resources-5.2.2.tar.gz", hash = "sha256:a65882a4d0fe5fbf702273456ba2ce74fe44892c25e42e057aca526b702a6d4b"}, + {file = "importlib_resources-5.2.3-py3-none-any.whl", hash = "sha256:ae35ed1cfe8c0d6c1a53ecd168167f01fa93b893d51a62cdf23aea044c67211b"}, + {file = "importlib_resources-5.2.3.tar.gz", hash = "sha256:203d70dda34cfbfbb42324a8d4211196e7d3e858de21a5eb68c6d1cdd99e4e98"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] jinja2-time = [ {file = "jinja2-time-0.2.0.tar.gz", hash = "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40"}, @@ -1137,8 +1157,8 @@ nodeenv = [ {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, - {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pathspec = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, @@ -1157,78 +1177,111 @@ poyo = [ {file = "poyo-0.5.0.tar.gz", hash = "sha256:e26956aa780c45f011ca9886f044590e2d8fd8b61db7b1c1cf4e0869f48ed4dd"}, ] pre-commit = [ - {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, - {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, + {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, + {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, ] psycopg2-binary = [ - {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-win32.whl", hash = "sha256:ebccf1123e7ef66efc615a68295bf6fdba875a75d5bba10a05073202598085fc"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:1f6ca4a9068f5c5c57e744b4baa79f40e83e3746875cac3c45467b16326bab45"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"}, + {file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-win32.whl", hash = "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-win32.whl", hash = "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-win32.whl", hash = "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] 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"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, - {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, - {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, - {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, - {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, - {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, - {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, - {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, - {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, - {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, @@ -1243,132 +1296,93 @@ python-dateutil = [ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-slugify = [ - {file = "python-slugify-5.0.2.tar.gz", hash = "sha256:f13383a0b9fcbe649a1892b9c8eb4f8eab1d6d84b84bb7a624317afa98159cab"}, - {file = "python_slugify-5.0.2-py2.py3-none-any.whl", hash = "sha256:6d8c5df75cd4a7c3a2d21e257633de53f52ab0265cd2d1dc62a730e8194a7380"}, + {file = "python-slugify-6.1.1.tar.gz", hash = "sha256:00003397f4e31414e922ce567b3a4da28cf1436a53d332c9aeeb51c7d8c469fd"}, + {file = "python_slugify-6.1.1-py2.py3-none-any.whl", hash = "sha256:8c0016b2d74503eb64761821612d58fcfc729493634b1eb0575d8f5b4aa1fbcf"}, ] pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, -] -regex = [ - {file = "regex-2021.9.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0628ed7d6334e8f896f882a5c1240de8c4d9b0dd7c7fb8e9f4692f5684b7d656"}, - {file = "regex-2021.9.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3baf3eaa41044d4ced2463fd5d23bf7bd4b03d68739c6c99a59ce1f95599a673"}, - {file = "regex-2021.9.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c000635fd78400a558bd7a3c2981bb2a430005ebaa909d31e6e300719739a949"}, - {file = "regex-2021.9.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:295bc8a13554a25ad31e44c4bedabd3c3e28bba027e4feeb9bb157647a2344a7"}, - {file = "regex-2021.9.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0e3f59d3c772f2c3baaef2db425e6fc4149d35a052d874bb95ccfca10a1b9f4"}, - {file = "regex-2021.9.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:aea4006b73b555fc5bdb650a8b92cf486d678afa168cf9b38402bb60bf0f9c18"}, - {file = "regex-2021.9.24-cp310-cp310-win32.whl", hash = "sha256:09eb62654030f39f3ba46bc6726bea464069c29d00a9709e28c9ee9623a8da4a"}, - {file = "regex-2021.9.24-cp310-cp310-win_amd64.whl", hash = "sha256:8d80087320632457aefc73f686f66139801959bf5b066b4419b92be85be3543c"}, - {file = "regex-2021.9.24-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7e3536f305f42ad6d31fc86636c54c7dafce8d634e56fef790fbacb59d499dd5"}, - {file = "regex-2021.9.24-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31f35a984caffb75f00a86852951a337540b44e4a22171354fb760cefa09346"}, - {file = "regex-2021.9.24-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7cb25adba814d5f419733fe565f3289d6fa629ab9e0b78f6dff5fa94ab0456"}, - {file = "regex-2021.9.24-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:85c61bee5957e2d7be390392feac7e1d7abd3a49cbaed0c8cee1541b784c8561"}, - {file = "regex-2021.9.24-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c94722bf403b8da744b7d0bb87e1f2529383003ceec92e754f768ef9323f69ad"}, - {file = "regex-2021.9.24-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6adc1bd68f81968c9d249aab8c09cdc2cbe384bf2d2cb7f190f56875000cdc72"}, - {file = "regex-2021.9.24-cp36-cp36m-win32.whl", hash = "sha256:2054dea683f1bda3a804fcfdb0c1c74821acb968093d0be16233873190d459e3"}, - {file = "regex-2021.9.24-cp36-cp36m-win_amd64.whl", hash = "sha256:7783d89bd5413d183a38761fbc68279b984b9afcfbb39fa89d91f63763fbfb90"}, - {file = "regex-2021.9.24-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b15dc34273aefe522df25096d5d087abc626e388a28a28ac75a4404bb7668736"}, - {file = "regex-2021.9.24-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10a7a9cbe30bd90b7d9a1b4749ef20e13a3528e4215a2852be35784b6bd070f0"}, - {file = "regex-2021.9.24-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb9f5844db480e2ef9fce3a72e71122dd010ab7b2920f777966ba25f7eb63819"}, - {file = "regex-2021.9.24-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:17310b181902e0bb42b29c700e2c2346b8d81f26e900b1328f642e225c88bce1"}, - {file = "regex-2021.9.24-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bba1f6df4eafe79db2ecf38835c2626dbd47911e0516f6962c806f83e7a99ae"}, - {file = "regex-2021.9.24-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:821e10b73e0898544807a0692a276e539e5bafe0a055506a6882814b6a02c3ec"}, - {file = "regex-2021.9.24-cp37-cp37m-win32.whl", hash = "sha256:9c371dd326289d85906c27ec2bc1dcdedd9d0be12b543d16e37bad35754bde48"}, - {file = "regex-2021.9.24-cp37-cp37m-win_amd64.whl", hash = "sha256:1e8d1898d4fb817120a5f684363b30108d7b0b46c7261264b100d14ec90a70e7"}, - {file = "regex-2021.9.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8a5c2250c0a74428fd5507ae8853706fdde0f23bfb62ee1ec9418eeacf216078"}, - {file = "regex-2021.9.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8aec4b4da165c4a64ea80443c16e49e3b15df0f56c124ac5f2f8708a65a0eddc"}, - {file = "regex-2021.9.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:650c4f1fc4273f4e783e1d8e8b51a3e2311c2488ba0fcae6425b1e2c248a189d"}, - {file = "regex-2021.9.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2cdb3789736f91d0b3333ac54d12a7e4f9efbc98f53cb905d3496259a893a8b3"}, - {file = "regex-2021.9.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e61100200fa6ab7c99b61476f9f9653962ae71b931391d0264acfb4d9527d9c"}, - {file = "regex-2021.9.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c268e78d175798cd71d29114b0a1f1391c7d011995267d3b62319ec1a4ecaa1"}, - {file = "regex-2021.9.24-cp38-cp38-win32.whl", hash = "sha256:658e3477676009083422042c4bac2bdad77b696e932a3de001c42cc046f8eda2"}, - {file = "regex-2021.9.24-cp38-cp38-win_amd64.whl", hash = "sha256:a731552729ee8ae9c546fb1c651c97bf5f759018fdd40d0e9b4d129e1e3a44c8"}, - {file = "regex-2021.9.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:86f9931eb92e521809d4b64ec8514f18faa8e11e97d6c2d1afa1bcf6c20a8eab"}, - {file = "regex-2021.9.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcbbc9cfa147d55a577d285fd479b43103188855074552708df7acc31a476dd9"}, - {file = "regex-2021.9.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29385c4dbb3f8b3a55ce13de6a97a3d21bd00de66acd7cdfc0b49cb2f08c906c"}, - {file = "regex-2021.9.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c50a6379763c733562b1fee877372234d271e5c78cd13ade5f25978aa06744db"}, - {file = "regex-2021.9.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f74b6d8f59f3cfb8237e25c532b11f794b96f5c89a6f4a25857d85f84fbef11"}, - {file = "regex-2021.9.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6c4d83d21d23dd854ffbc8154cf293f4e43ba630aa9bd2539c899343d7f59da3"}, - {file = "regex-2021.9.24-cp39-cp39-win32.whl", hash = "sha256:95e89a8558c8c48626dcffdf9c8abac26b7c251d352688e7ab9baf351e1c7da6"}, - {file = "regex-2021.9.24-cp39-cp39-win_amd64.whl", hash = "sha256:835962f432bce92dc9bf22903d46c50003c8d11b1dc64084c8fae63bca98564a"}, - {file = "regex-2021.9.24.tar.gz", hash = "sha256:6266fde576e12357b25096351aac2b4b880b0066263e7bc7a9a1b4307991bb0e"}, + {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"}, ] requests = [ - {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, - {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, ] 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"}, + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] sqlalchemy = [ - {file = "SQLAlchemy-1.4.34-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c025d45318b73c0601cca451532556cbab532b2742839ebb8cb58f9ebf06811e"}, - {file = "SQLAlchemy-1.4.34-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cd5cffd1dd753828f1069f33062f3896e51c990acd957c264f40e051b3e19887"}, - {file = "SQLAlchemy-1.4.34-cp27-cp27m-win32.whl", hash = "sha256:a47bf6b7ca6c28e4f4e262fabcf5be6b907af81be36de77839c9eeda2cdf3bb3"}, - {file = "SQLAlchemy-1.4.34-cp27-cp27m-win_amd64.whl", hash = "sha256:c9218e3519398129e364121e0d89823e6ba2a2b77c28bfc661face0829c41433"}, - {file = "SQLAlchemy-1.4.34-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7ee14a7f9f76d1ef9d5e5b760c9252617c839b87eee04d1ce8325ac66ae155c4"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4414ace6e3a5e39523e55a5d9f3b215699b2ead4ff91fca98f1b659b7ab2d92a"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a6cfd468f54d65324fd3847cfd0148b0610efa6a43e5f5fcc89f455696ae9e7"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:27a42894a2751e438eaed12fc0dcfe741ff2f66c14760d081222c5adc5460064"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:671f61c3db4595b0e86cc4b30f675a7c0206d9ce99f041b4f6761c7ddd1e0074"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-win32.whl", hash = "sha256:3ebb97ed96f4506e2f212e1fcf0ec07a103bb194938627660a5acb4d9feae49c"}, - {file = "SQLAlchemy-1.4.34-cp310-cp310-win_amd64.whl", hash = "sha256:d8efcaa709ea8e7c08c3d3e7639c39b36083f5a995f397f9e6eedf5f5e4e4946"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a4fb5c6ee84a6bba4ff6f9f5379f0b3a0ffe9de7ba5a0945659b3da8d519709b"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07f4dab2deb6d34618a2ccfff3971a85923ad7c3a9a45401818870fc51d3f0cc"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67c1c27c48875afc950bee5ee24582794f20b545e64e4f9ca94071a9b514d6ed"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:954ea8c527c4322afb6885944904714893af81fe9167e421273770991bf08a4a"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-win32.whl", hash = "sha256:2a3e4dc7c452ba3c0f3175ad5a8e0ba49c2b0570a8d07272cf50844c8d78e74f"}, - {file = "SQLAlchemy-1.4.34-cp36-cp36m-win_amd64.whl", hash = "sha256:f47996b1810894f766c9ee689607077c6c0e0fd6761e04c12ba13efb56d50c1d"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b34bbc683789559f1bc9bb685fc162e0956dbbdfbe2fbd6755a9f5982c113610"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804cf491437f3e4ce31247ab4b309b181f06ecc97d309b746d10f09439b4eb85"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f197c66663ed0f9e1178d51141d864688fb244a83f6b17f667d521e482537b2e"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08aaad905aba8940f27aeb9f1f851bf63f18ef97b0062ca41f64afc4b64e0e8c"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-win32.whl", hash = "sha256:345306707bb0e51e7cd6e7573adafbce018894ee5e3b9c31134545f704936db0"}, - {file = "SQLAlchemy-1.4.34-cp37-cp37m-win_amd64.whl", hash = "sha256:50174e173d03209c34e07e7b57cca48d0082ac2390edf927aafc706c111da11e"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:878c7beaafa365602762c19f638282e1885454fed1aed86f8fae038933c7c671"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70048a83f0a1ece1fcd7189891c888e20af2c57fbd33eb760d8cece9843b896c"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:621d3f6c0ba2407bb97e82b649be5ca7d5b6c201dcfb964ce13f517bf1cb6305"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:045d6a26c262929af0b9cb25441aae675ac04db4ea8bd2446b355617cd6b6b7d"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-win32.whl", hash = "sha256:e297a5cc625e3f1367a82deedf2d48ee4d2b2bd263b8b8d2efbaaf5608b5229e"}, - {file = "SQLAlchemy-1.4.34-cp38-cp38-win_amd64.whl", hash = "sha256:36f08d94670315ca04c8139bd80b3e02b9dd9cc66fc11bcb96fd10ad51a051ab"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:40b995d7aeeb6f88a1927ce6692c0f626b59d8effd3e1d597f125e141707b37c"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb6558ba07409dafa18c793c34292b3265be455904966f0724c10198829477e3"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e67278ceb63270cdac0a7b89fc3c29a56f7dac9616a7ee48e7ad6b52e3b631e5"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50107d8183da3fbe5715957aa3954cd9d82aed555c5b4d3fd37fac861af422fa"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-win32.whl", hash = "sha256:c3ad7f5b61ba014f5045912aea15b03c473bb02b1c07fd92c9d2c794fa183276"}, - {file = "SQLAlchemy-1.4.34-cp39-cp39-win_amd64.whl", hash = "sha256:5e88912bf192e7b5739c446d2276e1cba74cfa6c1c93eea2b2534404f6be1dbd"}, - {file = "SQLAlchemy-1.4.34.tar.gz", hash = "sha256:623bac2d6bdca3f3e61cf1e1c466c5fb9f5cf08735736ee1111187b7a4108891"}, + {file = "SQLAlchemy-1.4.35-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6"}, + {file = "SQLAlchemy-1.4.35-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041"}, + {file = "SQLAlchemy-1.4.35-cp27-cp27m-win32.whl", hash = "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70"}, + {file = "SQLAlchemy-1.4.35-cp27-cp27m-win_amd64.whl", hash = "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157"}, + {file = "SQLAlchemy-1.4.35-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-win32.whl", hash = "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e"}, + {file = "SQLAlchemy-1.4.35-cp310-cp310-win_amd64.whl", hash = "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-win32.whl", hash = "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d"}, + {file = "SQLAlchemy-1.4.35-cp36-cp36m-win_amd64.whl", hash = "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-win32.whl", hash = "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b"}, + {file = "SQLAlchemy-1.4.35-cp37-cp37m-win_amd64.whl", hash = "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-win32.whl", hash = "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b"}, + {file = "SQLAlchemy-1.4.35-cp38-cp38-win_amd64.whl", hash = "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-win32.whl", hash = "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228"}, + {file = "SQLAlchemy-1.4.35-cp39-cp39-win_amd64.whl", hash = "sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a"}, + {file = "SQLAlchemy-1.4.35.tar.gz", hash = "sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5"}, ] text-unidecode = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, @@ -1379,57 +1393,50 @@ toml = [ {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"}, + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, ] typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, + {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, + {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, + {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, + {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, + {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, + {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, + {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, + {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, ] typer = [ - {file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"}, - {file = "typer-0.3.2.tar.gz", hash = "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303"}, + {file = "typer-0.4.1-py3-none-any.whl", hash = "sha256:e8467f0ebac0c81366c2168d6ad9f888efdfb6d4e1d3d5b4a004f46fa444b5c3"}, + {file = "typer-0.4.1.tar.gz", hash = "sha256:5646aef0d936b2c761a10393f0384ee6b5c7fe0bb3e5cd710b17134ca1d99cff"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, - {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, - {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] urllib3 = [ - {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, - {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, - {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, + {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, + {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, ] zipp = [ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, From b1d19be0dff0a051db327d49ff609371462a2b9b Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 11:57:06 -0700 Subject: [PATCH 11/31] env --- tests/functional/test_model.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py index 180a0aa..7c3846f 100644 --- a/tests/functional/test_model.py +++ b/tests/functional/test_model.py @@ -1,5 +1,6 @@ """Contains tests for the DatabaseModel backends""" +import os from typing import List, Optional from databases import Database @@ -21,7 +22,21 @@ async def postgres_backend(): postgres backend """ - async with Database("postgres://localhost:5432/postgres") as db: + db_host = os.environ.get( + "DB_HOST", "postgres" if os.environ.get("CI") else "localhost" + ) + db_user_name = os.environ.get("DB_USER_NAME", os.environ["USER"]) + db_user_password = os.environ.get("DB_USER_PASSWORD", "") + test_db_name = os.environ.get("TEST_DB_NAME", "test_db") + + maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" + db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" + + async with Database(maintenance_connection_string) as db: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + await db.execute(f"CREATE DATABASE {test_db_name}") + + async with Database(db_connection_string) as db: await db.execute( "CREATE TABLE IF NOT EXISTS things (id SERIAL PRIMARY KEY, name TEXT)" ) @@ -30,6 +45,9 @@ async def postgres_backend(): finally: await db.execute("DROP TABLE things") + async with Database(maintenance_connection_string) as db: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + @pytest.fixture() async def memory_backend(): From ee7777f466d558c5b5e9d033d596983847c1f23b Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 12:00:23 -0700 Subject: [PATCH 12/31] user --- tests/functional/test_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py index 7c3846f..4560233 100644 --- a/tests/functional/test_model.py +++ b/tests/functional/test_model.py @@ -25,7 +25,7 @@ async def postgres_backend(): db_host = os.environ.get( "DB_HOST", "postgres" if os.environ.get("CI") else "localhost" ) - db_user_name = os.environ.get("DB_USER_NAME", os.environ["USER"]) + db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) db_user_password = os.environ.get("DB_USER_PASSWORD", "") test_db_name = os.environ.get("TEST_DB_NAME", "test_db") From 64f23afb585ae9cbab8e0f78a5f13b91bd0ffd12 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 12:05:19 -0700 Subject: [PATCH 13/31] localhost --- tests/functional/test_model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py index 4560233..3248ca4 100644 --- a/tests/functional/test_model.py +++ b/tests/functional/test_model.py @@ -22,9 +22,7 @@ async def postgres_backend(): postgres backend """ - db_host = os.environ.get( - "DB_HOST", "postgres" if os.environ.get("CI") else "localhost" - ) + db_host = os.environ.get("DB_HOST", "localhost") db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) db_user_password = os.environ.get("DB_USER_PASSWORD", "") test_db_name = os.environ.get("TEST_DB_NAME", "test_db") From 51582d5d391f6a001ac5afedada7f51cd5a37567 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 12:33:07 -0700 Subject: [PATCH 14/31] test --- tests/functional/test_model.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py index 3248ca4..11da68e 100644 --- a/tests/functional/test_model.py +++ b/tests/functional/test_model.py @@ -1,5 +1,6 @@ """Contains tests for the DatabaseModel backends""" +import logging import os from typing import List, Optional @@ -31,8 +32,11 @@ async def postgres_backend(): db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" async with Database(maintenance_connection_string) as db: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") - await db.execute(f"CREATE DATABASE {test_db_name}") + try: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + await db.execute(f"CREATE DATABASE {test_db_name}") + except Exception as e: + logging.info(str(e)) async with Database(db_connection_string) as db: await db.execute( From 3fc018b1e99a857fef79c97221370c1f13dfe5ef Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 7 Apr 2022 12:42:10 -0700 Subject: [PATCH 15/31] test --- tests/functional/test_model.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py index 11da68e..f7bc0a7 100644 --- a/tests/functional/test_model.py +++ b/tests/functional/test_model.py @@ -48,7 +48,10 @@ async def postgres_backend(): await db.execute("DROP TABLE things") async with Database(maintenance_connection_string) as db: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + try: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + except Exception as e: + logging.info(str(e)) @pytest.fixture() From 1d55f6714179a2ce11c209e52b01b42f797c8d93 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Wed, 20 Apr 2022 19:53:48 -0700 Subject: [PATCH 16/31] lock --- poetry.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 1f2ac5f..dece1d1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -56,6 +56,22 @@ python-versions = ">=3.6" [package.dependencies] typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} +[[package]] +name = "asyncpg" +version = "0.25.0" +description = "An asyncio PostgreSQL driver" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] + [[package]] name = "atomicwrites" version = "1.4.0" @@ -778,7 +794,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "bb884979f923922a0a7465041174e6a0e1150d01bdefd1c28b3305d314d147f6" +content-hash = "5815b4c48ee7d27546cea46725fe5b26d626499eaadccd20e38cc553297d2cfa" [metadata.files] aenum = [ @@ -802,6 +818,34 @@ async-timeout = [ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] +asyncpg = [ + {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, + {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, + {file = "asyncpg-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4"}, + {file = "asyncpg-0.25.0-cp310-cp310-win32.whl", hash = "sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095"}, + {file = "asyncpg-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09"}, + {file = "asyncpg-0.25.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634"}, + {file = "asyncpg-0.25.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5"}, + {file = "asyncpg-0.25.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd"}, + {file = "asyncpg-0.25.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68"}, + {file = "asyncpg-0.25.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b"}, + {file = "asyncpg-0.25.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8"}, + {file = "asyncpg-0.25.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b"}, + {file = "asyncpg-0.25.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e"}, + {file = "asyncpg-0.25.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962"}, + {file = "asyncpg-0.25.0-cp38-cp38-win32.whl", hash = "sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471"}, + {file = "asyncpg-0.25.0-cp38-cp38-win_amd64.whl", hash = "sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6"}, + {file = "asyncpg-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e"}, + {file = "asyncpg-0.25.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855"}, + {file = "asyncpg-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e"}, + {file = "asyncpg-0.25.0-cp39-cp39-win32.whl", hash = "sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2"}, + {file = "asyncpg-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac"}, + {file = "asyncpg-0.25.0.tar.gz", hash = "sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -874,6 +918,10 @@ cruft = [ {file = "cruft-2.10.2-py3-none-any.whl", hash = "sha256:8ccf0b74ea07f4de3bc7c6a798c0fbfb922a02c7c44f472905edd624f383085c"}, {file = "cruft-2.10.2.tar.gz", hash = "sha256:fe7aaace048df17efc0e597c8035cb0deaa7a8734a86eb8c6cca5388971a2a42"}, ] +databases = [ + {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"}, + {file = "databases-0.5.5.tar.gz", hash = "sha256:02c6b016c1c951c21cca281dc8e2e002c60dc44026c0084aabbd8c37514aeb37"}, +] dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, From 7aadb2d3dff99880170f7017515326c2eb37ad04 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Wed, 20 Apr 2022 21:18:44 -0700 Subject: [PATCH 17/31] tests --- poetry.lock | 17 +- pynocular/__init__.py | 7 +- pynocular/backends/base.py | 2 + pynocular/backends/memory.py | 53 ++- pynocular/backends/sql.py | 10 +- pynocular/database_model.py | 40 +- pynocular/db_util.py | 75 ++-- pynocular/engines.py | 253 ------------ pynocular/evaluate_column_element.py | 239 ++++++++++++ pynocular/model.py | 15 +- pynocular/nested_database_model.py | 78 ---- pynocular/patch_models.py | 248 +----------- pynocular/uuid_str.py | 43 +++ pyproject.toml | 4 + tests/functional/conftest.py | 11 + tests/functional/test_database_model.py | 359 ++++++++++++------ tests/functional/test_db_util.py | 77 ++-- tests/functional/test_model.py | 133 ------- .../functional/test_nested_database_model.py | 312 --------------- 19 files changed, 717 insertions(+), 1259 deletions(-) delete mode 100644 pynocular/engines.py create mode 100644 pynocular/evaluate_column_element.py delete mode 100644 pynocular/nested_database_model.py create mode 100644 pynocular/uuid_str.py create mode 100644 tests/functional/conftest.py delete mode 100644 tests/functional/test_model.py delete mode 100644 tests/functional/test_nested_database_model.py diff --git a/poetry.lock b/poetry.lock index dece1d1..181aabc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -588,6 +588,17 @@ pytest = ">=5.4.0" [package.extras] testing = ["coverage", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-lazy-fixture" +version = "0.6.3" +description = "It helps to use fixtures in pytest.mark.parametrize" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=3.2.5" + [[package]] name = "python-dateutil" version = "2.8.2" @@ -794,7 +805,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.5" -content-hash = "5815b4c48ee7d27546cea46725fe5b26d626499eaadccd20e38cc553297d2cfa" +content-hash = "c0f390f16cd2c5a8c34a6320347151251234d2cf842e72c26a1b3c5ea176a5f2" [metadata.files] aenum = [ @@ -1292,6 +1303,10 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] +pytest-lazy-fixture = [ + {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, + {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, +] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 20f61c6..7d1c964 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -2,5 +2,8 @@ __version__ = "2.0.0rc1" -from pynocular.database_model import DatabaseModel, UUID_STR -from pynocular.engines import DatabaseType, DBInfo +from pynocular.backends.context import backend +from pynocular.backends.memory import MemoryDatabaseModelBackend +from pynocular.backends.sql import SQLDatabaseModelBackend +from pynocular.model import DatabaseModel +from pynocular.uuid_str import is_valid_uuid, UUID_STR diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index c8dbde1..56d11b0 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Set +from pydantic import Field from sqlalchemy import Column, Table from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression @@ -15,6 +16,7 @@ class DatabaseModelConfig: This class will be instantiated by a database model class at import time. """ + fields: Dict[str, Field] primary_keys: List[Column] db_managed_fields: List[str] nested_model_attributes: Set[str] diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index da9a1f2..7d0e6fc 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -1,6 +1,7 @@ """Contains the MemoryDatabaseModelBackend class""" from collections import defaultdict +from datetime import datetime import itertools from typing import Any, Dict, List, Optional from uuid import uuid4 @@ -10,7 +11,7 @@ from sqlalchemy.sql.operators import desc_op from pynocular.backends.base import DatabaseModelBackend, DatabaseModelConfig -from pynocular.patch_models import _evaluate_column_element +from pynocular.evaluate_column_element import evaluate_column_element class MemoryDatabaseModelBackend(DatabaseModelBackend): @@ -63,7 +64,7 @@ async def select( record for record in records if all( - _evaluate_column_element(expr, record) for expr in where_expressions + evaluate_column_element(expr, record) for expr in where_expressions ) ] @@ -129,7 +130,7 @@ async def delete_records( record for record in self.records[config.table.name] if not all( - _evaluate_column_element(expr, record) for expr in where_expressions + evaluate_column_element(expr, record) for expr in where_expressions ) ] @@ -175,19 +176,49 @@ async def upsert( the updated record """ - if all( - record.get(primary_key.name) is not None + where_expressions = [ + primary_key == record.get(primary_key.name) for primary_key in config.primary_keys - ): - # All primary keys are already set so update - where_expressions = [ - primary_key == record.get(primary_key.name) + ] + existing_records = await self.select( + config, where_expressions=where_expressions, limit=1 + ) + if ( + all( + record.get(primary_key.name) is not None for primary_key in config.primary_keys - ] + ) + and existing_records + ): + # All primary keys are already set and a record was found so update + + # Set default values for db managed fields + for name in config.db_managed_fields: + field = config.fields[name] + if field.type_ == datetime: + if field.field_info.extra.get("fetch_on_update"): + record[name] = datetime.utcnow() + else: + raise NotImplementedError(field.type_) + records = await self.update_records(config, where_expressions, record) return records[0] + else: - # Primary keys have not been set so this is a new record + # Primary keys have not been set or there were no records found, so this is + # a new record + + # Set default values for db managed fields + for name in config.db_managed_fields: + field = config.fields[name] + if field.type_ == datetime: + if field.field_info.extra.get( + "fetch_on_create" + ) or field.field_info.extra.get("fetch_on_update"): + record[name] = datetime.utcnow() + else: + raise NotImplementedError(field.type_) + for primary_key in config.primary_keys: value = ( next(self._pk_generator) diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py index df24e6e..0b55a83 100644 --- a/pynocular/backends/sql.py +++ b/pynocular/backends/sql.py @@ -62,12 +62,12 @@ async def select( query = query.limit(limit) try: - records = await self.db.fetch_all(query) + result = await self.db.fetch_all(query) # The value was the wrong type. This usually happens with UUIDs. except InvalidTextRepresentation as e: raise InvalidFieldValue(message=e.diag.message_primary) - return [dict(record) for record in records] + return [dict(record) for record in result] async def create_records( self, config: DatabaseModelConfig, records: List[Dict[str, Any]] @@ -91,7 +91,7 @@ async def create_records( insert(config.table).values(records).returning(config.table) ) - return [dict(row) for row in result] + return [dict(record) for record in result] async def delete_records( self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] @@ -141,12 +141,12 @@ async def update_records( .returning(config.table) ) try: - results = await self.db.execute(query) + result = await self.db.fetch_all(query) # The value was the wrong type. This usually happens with UUIDs. except InvalidTextRepresentation as e: raise InvalidFieldValue(message=e.diag.message_primary) - return [dict(record) for record in await results] + return [dict(record) for record in result] async def upsert( self, diff --git a/pynocular/database_model.py b/pynocular/database_model.py index 2703751..20fbdeb 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -37,45 +37,7 @@ NestedDatabaseModelNotResolved, ) from pynocular.nested_database_model import NestedDatabaseModel - - -def is_valid_uuid(string: str) -> bool: - """Check if a string is a valid UUID - - Args: - string: the string to check - - Returns: - Whether or not the string is a well-formed UUIDv4 - - """ - try: - stdlib_uuid(string, version=4) - return True - except (TypeError, AttributeError, ValueError): - return False - - -class UUID_STR(str): - """A string that represents a UUID4 value""" - - @classmethod - def __get_validators__(cls) -> Generator: - """Get the validators for the given class""" - yield cls.validate - - @classmethod - def validate(cls, v: Any) -> str: - """Function to validate the value - - Args: - v: The value to validate - - """ - if isinstance(v, stdlib_uuid) or (isinstance(v, str) and is_valid_uuid(v)): - return str(v) - else: - raise ValueError("invalid UUID string") +from pynocular.uuid_str import is_valid_uuid, UUID_STR def nested_model( diff --git a/pynocular/db_util.py b/pynocular/db_util.py index 7ad1ebb..57123d9 100644 --- a/pynocular/db_util.py +++ b/pynocular/db_util.py @@ -3,36 +3,31 @@ import logging import re -from aiopg.sa.connection import SAConnection +from databases import Database import sqlalchemy as sa -from sqlalchemy.sql.ddl import CreateTable +from sqlalchemy.sql.ddl import CreateTable, DropTable -from pynocular.engines import DBEngine, DBInfo from pynocular.exceptions import InvalidSqlIdentifierErr logger = logging.getLogger() -async def is_database_available(db_info: DBInfo) -> bool: +async def is_database_available(connection_string: str) -> bool: """Check if the database is available Args: - db_info: A database's connection information + connection_string: A connection string for the database Returns: true if the DB exists """ - engine = None try: - engine = await DBEngine.get_engine(db_info) - await engine.acquire() - return True + async with Database(connection_string) as db: + await db.execute("SELECT 1") + return True except Exception: return False - finally: - if engine: - engine.close() async def create_new_database(connection_string: str, db_name: str) -> None: @@ -43,54 +38,46 @@ async def create_new_database(connection_string: str, db_name: str) -> None: db_name: the name of the database to create """ - existing_db = DBInfo(connection_string) - conn = await (await DBEngine.get_engine(existing_db)).acquire() - # End existing commit - await conn.execute("commit") - # Create db - await conn.execute(f"drop database if exists {db_name}") - await conn.execute(f"create database {db_name}") - await conn.close() + async with Database(connection_string) as db: + # End existing commit + await db.execute("COMMIT") + # Create db + await db.execute(f"DROP DATABASE IF EXISTS {db_name}") + await db.execute(f"CREATE DATABASE {db_name}") -async def create_table(db_info: DBInfo, table: sa.Table) -> None: +async def create_table(db: Database, table: sa.Table) -> None: """Create table in database Args: - db_info: Information for the database to connect to + db: an async database connection table: The table to create """ - engine = await DBEngine.get_engine(db_info) - conn = await engine.acquire() - await conn.execute(CreateTable(table)) - await conn.close() + await db.execute(CreateTable(table)) -async def drop_table(db_info: DBInfo, table: sa.Table) -> None: +async def drop_table(db: Database, table: sa.Table) -> None: """Drop table in database Args: - db_info: Information for the database to connect to + db: an async database connection table: The table to create """ - engine = await DBEngine.get_engine(db_info) - conn = await engine.acquire() - await conn.execute(f"drop table if exists {table.name}") - await conn.close() + await db.execute(DropTable(table, if_exists=True)) -async def setup_datetime_trigger(conn: SAConnection) -> None: +async def setup_datetime_trigger(db: Database) -> None: """Set up created_at/updated_at datetime trigger Args: - conn: an async sqlalchemy connection + db: an async database connection """ - await conn.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";') - await conn.execute('CREATE EXTENSION IF NOT EXISTS "plpgsql";') - await conn.execute( + await db.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";') + await db.execute('CREATE EXTENSION IF NOT EXISTS "plpgsql";') + await db.execute( """ CREATE OR REPLACE FUNCTION update_timestamp_columns() RETURNS TRIGGER AS $$ @@ -107,17 +94,17 @@ async def setup_datetime_trigger(conn: SAConnection) -> None: ) -async def add_datetime_trigger(conn: SAConnection, table: str) -> None: +async def add_datetime_trigger(db: Database, table: str) -> None: """Helper method for adding created_at and updated_at datetime triggers on a table Args: - conn: an async sqlalchemy connection + db: an async database connection table: The name of the table to add an edit trigger for """ - await setup_datetime_trigger(conn) - await conn.execute( + await setup_datetime_trigger(db) + await db.execute( """ CREATE TRIGGER update_{table}_timestamps BEFORE INSERT OR UPDATE ON {table} @@ -128,15 +115,15 @@ async def add_datetime_trigger(conn: SAConnection, table: str) -> None: ) -async def remove_datetime_trigger(conn: SAConnection, table: str) -> None: +async def remove_datetime_trigger(db: Database, table: str) -> None: """Helper method for removing datetime triggers on a table Args: - conn: an async sqlalchemy connection + db: an async database connection table: The name of the table to remove a trigger for """ - await conn.execute( + await db.execute( "DROP TRIGGER IF EXISTS update_{table}_timestamps on {table}".format( table=table ) diff --git a/pynocular/engines.py b/pynocular/engines.py deleted file mode 100644 index f79a453..0000000 --- a/pynocular/engines.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Functions for getting database engines and connections""" -import asyncio -from enum import Enum -from functools import wraps -import logging -from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Union - -from aiopg import Connection as AIOPGConnection -from aiopg.sa import create_engine, Engine - -from pynocular.aiopg_transaction import ( - ConditionalTransaction, - transaction as Transaction, -) -from pynocular.config import POOL_RECYCLE - -logger = logging.getLogger(__name__) - -_engines: Dict[Tuple[str, str], Engine] = {} - - -async def get_aiopg_engine( - conn_str: str, - enable_hstore: bool = True, - force: bool = False, - application_name: str = None, - if_exists: bool = False, -) -> Optional[Engine]: - """Returns the aiopg SQLAlchemy connection engine for a given connection string. - - This function lazily creates the connection engine if it doesn't already - exist. Callers of this function shouldn't close the engine. It will be - closed automatically when the process exits. - - This function exists to keep a single engine (and thus a single connection - pool) per database, which prevents us from maxing out the number of - connections the database server will give us. - - We include the hash of the event loop in the cache key because otherwise, if the - event loop closes, the cached engine will raise an exception when it's used. - - Args: - conn_str: The connection string for the engine - enable_hstore: determines if the hstore should be enabled on the database. - Redshift requires this to be disabled. - force: Force the creation of the engine regardless of the cache - application_name: Arbitrary string that shows up in queries to the - ``pg_stat_activity`` view for tracking the source of database connections. - if_exists: Only return the engine if it already exists - - Returns: - aiopg engine for the connection string - - """ - global _engines - logger.debug("Attempting to get DB engine") - loop_hash = str(hash(asyncio.get_event_loop())) - cache_key = (loop_hash, conn_str) - engine = _engines.get(cache_key) - - if if_exists and engine is None: - return None - - if engine is None or force or engine.closed: - engine = await create_engine( - conn_str, - enable_hstore=enable_hstore, - application_name=application_name, - pool_recycle=POOL_RECYCLE, - ) - _engines[cache_key] = engine - logger.debug(f"DB engine created successfully: {engine}") - - logger.debug("DB engine retrieved") - return engine - - -class DatabaseType(Enum): - """Database type to differentiate engines and pools""" - - aiopg_engine = "aiopg_engine" - - -class DBInfo(NamedTuple): - """Data class for a database's connection information""" - - connection_string: str - enable_hstore: bool = True - engine_type: DatabaseType = DatabaseType.aiopg_engine - - -class DBEngine: - """Wrapper over database engine types""" - - @classmethod - async def _get_engine( - cls, - db_info: DBInfo, - force: bool = False, - application_name: str = None, - if_exists: bool = False, - ) -> Optional[Engine]: - """Get an async db engine depending on the database configuration. - - Args: - db_info: Information for making the database connection - force: Force the creation of the pool regardless of the cache - application_name: Arbitrary string that shows up in queries to the - ``pg_stat_activity`` view for tracking the source of database - connections. - if_exists: Only return the engine or pool if it already exists - - Returns: - database engine or pool - - Raises: - :py:exec:`ValueError` if the database type isn't supported - - """ - if db_info.engine_type == DatabaseType.aiopg_engine: - return await get_aiopg_engine( - db_info.connection_string, - enable_hstore=db_info.enable_hstore, - force=force, - application_name=application_name, - if_exists=if_exists, - ) - - raise ValueError(f"Unsupported database type: {db_info.engine_type}") - - @classmethod - async def get_engine( - cls, db_info: DBInfo, force: bool = False, application_name: str = None - ) -> Union[Engine]: - """Get a SQLAlchemy connection engine for a given database alias. - - See :py:func:`.get_engine` for more details. - - Args: - db_info: database connection information - force: Force the creation of the pool regardless of the cache - application_name: Arbitrary string that shows up in queries to the - ``pg_stat_activity`` view for tracking the source of database - connections. - - Returns: - database engine - - """ - return await cls._get_engine( - db_info, force=force, application_name=application_name - ) - - @classmethod - async def acquire(cls, db_info: DBInfo) -> Union[AIOPGConnection]: - """Acquire a SQLAlchemy connection for a given database alias. - - This is a convenience function that first gets/creates the engine or pool then - calls acquire. This returns a context manager. - - Args: - db_info: database connection information - - Returns: - context manager that yields the connection - - """ - engine = await cls._get_engine(db_info) - return engine.acquire() - - @classmethod - async def transaction( - cls, db_info: DBInfo, is_conditional: bool = False - ) -> Union[ConditionalTransaction, Transaction]: - """Acquire a SQLAlchemy transaction for a given database alias. - - This is a convenience function that first gets/creates the engine then calls - ConditionalTransaction. This returns a context manager. - - Args: - db_info: database connection information - is_conditional: If true, returns a conditional transaction. - - Returns: - Transaction or ConditionalTransaction for use as a context manager - - """ - if db_info.engine_type != DatabaseType.aiopg_engine: - raise ValueError( - f"Transaction does not support database type {db_info.engine_type}" - ) - engine = await cls._get_engine(db_info) - return ConditionalTransaction(engine) if is_conditional else Transaction(engine) - - @classmethod - def open_transaction(cls, db_info: DBInfo) -> Callable: - """Decorator that wraps the function call in a database transaction - - Args: - database_alias: The database alias to use for the transaction - - Returns: - The wrapped function call - - """ - - def parameterized_decorator(fn: Callable) -> Callable: - """Function that will create the wrapper function - - Args: - fn: The function to wrap - - Returns: - The wrapped function - - """ - - @wraps(fn) - async def wrapped_funct(*args: Any, **kwargs: Any) -> Any: - """The actual wrapper function - - Args: - args: The argument calls to the wrapped function - kwargs: The keyword args to the wrapped function - - Returns: - The result of the function - - """ - async with await DBEngine.transaction(db_info, is_conditional=False): - ret = await fn(*args, **kwargs) - return ret - - return wrapped_funct - - return parameterized_decorator - - @classmethod - async def close(cls, db_info: DBInfo) -> None: - """Close existing database engines and pools - - Args: - db_info: database connection information - - """ - logger.info("Closing database engine") - pool_engine = await cls._get_engine(db_info, if_exists=True) - if pool_engine is None: - # The engine/pool doesn't exist so nothing to close - pass - else: - pool_engine.close() - await pool_engine.wait_closed() diff --git a/pynocular/evaluate_column_element.py b/pynocular/evaluate_column_element.py new file mode 100644 index 0000000..cbca97b --- /dev/null +++ b/pynocular/evaluate_column_element.py @@ -0,0 +1,239 @@ +"""Contains evaluate_column_element function for evaluating filter expressions""" + +import functools +from typing import Any, Dict, List + +from sqlalchemy import Column +from sqlalchemy.sql.elements import ( + AsBoolean, + BinaryExpression, + BindParameter, + BooleanClauseList, + ClauseList, + ColumnElement, + False_, + Grouping, + Null, + True_, +) +from sqlalchemy.sql.operators import in_op, is_, is_false, is_not + + +@functools.singledispatch +def evaluate_column_element( + column_element: ColumnElement, model: Dict[str, Any] +) -> Any: + """Evaluate a ColumnElement on a dictionary representing a database model + + This function can be overridden based on the type of ColumnElement to return + an element from the model, a static value, or the result of some operation (e.g. + addition). + + Args: + column_element: The element to evaluate. + model: The model to evaluate the column element on. Represented as a dictionary + where the keys are column names. + + """ + raise Exception(f"Cannot evaluate a {column_element} object.") + + +@evaluate_column_element.register(BooleanClauseList) +def _evaluate_boolean_clause_list( + column_element: ClauseList, model: Dict[str, Any] +) -> Any: + """Evaluates a boolean clause list and breaks it down into its sub column elements + + Args: + column_element: The BooleanClauseList in question. + model: The model of data this clause should be evaluated for. + + Returns: + The result of the evaluation. + + """ + operator = column_element.operator + + return functools.reduce( + operator, + [ + evaluate_column_element(sub_element, model) + for sub_element in column_element.get_children() + ], + ) + + +@evaluate_column_element.register(ClauseList) +def _evaluate_clause_list(column_element: ClauseList, model: Dict[str, Any]) -> Any: + """Evaluates a clause list and breaks it down into its sub column elements + + Args: + column_element: The ClauseList in question. + model: The model of data this clause should be evaluated for. + + Returns: + The result of the evaluation. + + """ + operator = column_element.operator + + return operator( + *[ + evaluate_column_element(sub_element, model) + for sub_element in column_element.get_children() + ] + ) + + +@evaluate_column_element.register(BinaryExpression) +def _evaluate_binary_expression( + column_element: BinaryExpression, model: Dict[str, Any] +) -> Any: + """Evaluates the binary expression + + Args: + column_element: The binary expression to evaluate. + model: The model to evaluate the expression on. + + Returns: + The evaluation response dictated by the operator of the expression. + + """ + operator = column_element.operator + + # The sqlalchemy `in` operator does not work on evaluated columns, so we replace + # it with the standard `in` operator. + if operator == in_op: + operator = lambda x, y: x in y + + # The sqlalchemy `is` operator does not work on evaluated columns, so we replace it + # with the standard `is` operator. + if operator == is_: + operator = lambda x, y: x is y + + # The sqlalchemy `is_not` operator does not work on evaluated columns, so we replace + # it with the standard `!=` operator. + if operator == is_not: + operator = lambda x, y: x != y + + return operator( + evaluate_column_element(column_element.left, model), + evaluate_column_element(column_element.right, model), + ) + + +@evaluate_column_element.register(AsBoolean) +def _evaluate_as_boolean(column_element: AsBoolean, model: Dict[str, Any]) -> Any: + """Evaluates a boolean + + Args: + column_element: The boolean to evaluate. + model: The model to evaluate the expression on. + + Returns: + The evaluation response dictated by the operator of the expression. + + """ + result = bool(evaluate_column_element(column_element.element, model)) + if column_element.operator == is_false: + return not result + return result + + +@evaluate_column_element.register(Column) +def _evaluate_column(column_element: Column, model: Dict[str, Any]) -> Any: + """Evaluate the column based on the column element name + + Args: + column_element: The column to evaluate. + model: The model dictionary. + + Returns: + The value from the model of attribute referenced by column_element. + + """ + return model.get(column_element.name) + + +@evaluate_column_element.register(BindParameter) +def _evaluate_bind_parameter( + column_element: BindParameter, model: Dict[str, Any] +) -> Any: + """Evaluate the column_elements value + + Args: + column_element: The column's bind parameter. + model: The model dictionary. + + Returns: + The value of the column_element + + """ + return column_element.value + + +@evaluate_column_element.register(True_) +def _evaluate_true(column_element: True_, model: Dict[str, Any]) -> bool: + """Wrapper around evaluating True + + Args: + column_element: The column to evaluate. This is just True + model: The model dictionary. + + Returns: + True + + """ + # The boolean value True is its own SQLAlchemy element + return True + + +@evaluate_column_element.register(False_) +def _evaluate_false(column_element: False_, model: Dict[str, Any]) -> bool: + """Wrapper around evaluating False + + Args: + column_element: The column to evaluate. This just returns False + model: The model dictionary. + + Returns: + False + + """ + # The boolean value False is its own SQLAlchemy element + return False + + +@evaluate_column_element.register(Grouping) +def _evaluate_grouping(column_element: Grouping, model: Dict[str, Any]) -> List[Any]: + """Wrapper around evaluating a grouping + + Args: + column_element: The grouping to evaluate. + model: The model dictionary. + + Returns: + A list of of values that are the resulting of evaluating each element in the + group. + + """ + return [ + evaluate_column_element(clause, model) + for clause in column_element.element.clauses + ] + + +@evaluate_column_element.register(Null) +def _evaluate_null(column_element: Null, model: Dict[str, Any]) -> Any: + """Wrapper around evaluating null + + Args: + column_element: The column element to evaluate. This is null + model: The model dictionary. + + Returns: + None + + """ + # The Null value is its own SQLAlchemy element + return None diff --git a/pynocular/model.py b/pynocular/model.py index f255bda..3f8834b 100644 --- a/pynocular/model.py +++ b/pynocular/model.py @@ -25,13 +25,13 @@ from pynocular.backends.base import DatabaseModelConfig from pynocular.backends.context import get_backend -from pynocular.database_model import UUID_STR from pynocular.exceptions import ( DatabaseModelMisconfigured, DatabaseModelMissingField, DatabaseRecordNotFound, InvalidMethodParameterization, ) +from pynocular.uuid_str import UUID_STR class DatabaseModel(BaseModel): @@ -158,6 +158,7 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: table = Table(table_name, MetaData(), *columns) return DatabaseModelConfig( + fields={**cls.__fields__}, db_managed_fields=db_managed_fields, nested_attr_table_field_map=nested_attr_table_field_map, nested_model_attributes=nested_model_attributes, @@ -178,9 +179,15 @@ def __init_subclass__(cls, table_name: str, **kwargs) -> None: @classmethod @property - def columns(self) -> ImmutableColumnCollection: + def table(cls) -> Table: + """Returns SQLAlchemy table object for the model""" + return cls._config.table + + @classmethod + @property + def columns(cls) -> ImmutableColumnCollection: """Reference to the model's table's column collection""" - return self._config.table.c + return cls.table.c @classmethod def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": @@ -563,7 +570,7 @@ async def update( return [ cls.from_dict(record) for record in await get_backend().update_records( - where_expressions=where_expressions, values=values + cls._config, where_expressions=where_expressions, values=values ) ] diff --git a/pynocular/nested_database_model.py b/pynocular/nested_database_model.py deleted file mode 100644 index a8f0c63..0000000 --- a/pynocular/nested_database_model.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Class that wraps nested DatabaseModels""" -from typing import Any, Callable - -from pynocular.exceptions import NestedDatabaseModelNotResolved - - -class NestedDatabaseModel: - """Class that wraps nested DatabaseModels""" - - def __init__( - self, - model_cls: Callable, - _id: Any, - model: "DatabaseModel" = None, # noqa - ) -> None: - """Init for NestedDatabaseModel - - Args: - model_cls: The class that the id relates to - _id: The id of the references - model: The model object if it is already loaded - - """ - self._model_cls = model_cls - self._model = model - # We can only support nested database models that are based off of a single - # unique identifier - self._primary_key_name = model_cls._primary_keys[0].name - setattr(self, self._primary_key_name, _id) - - def get_primary_id(self) -> Any: - """Standard interface for returning the id of a field - - Returns: - The ID value for the proxied DatabaseModel - - """ - return getattr(self, self._primary_key_name) - - async def fetch(self) -> None: - """Resolves the reference via the id set""" - if self._model is None: - self._model = await self._model_cls.get( - getattr(self, self._primary_key_name) - ) - - def __getattr__(self, attr_name: str) -> Any: - """Wrapper around getattr - - This will only get hit if the class doesn't have a reference to attr_name - - Args: - attr_name: The name of the attribute - - Returns: - The value of the attribute on the object - - """ - if self._model is None: - raise NestedDatabaseModelNotResolved(self._model_cls, self.get_primary_id()) - else: - return getattr(self._model, attr_name) - - def __eq__(self, other: Any) -> bool: - """Equality function - - Args: - other: The object to compare to - - Returns: - If the object is equal to this one - - """ - - if self._model is None: - return False - else: - return self._model == other diff --git a/pynocular/patch_models.py b/pynocular/patch_models.py index e9607ed..c3a7b28 100644 --- a/pynocular/patch_models.py +++ b/pynocular/patch_models.py @@ -1,27 +1,13 @@ """Context manager for mocking db calls for DatabaseModels during tests""" from contextlib import contextmanager -import functools from typing import Any, Dict, List, Optional from unittest.mock import patch from uuid import uuid4 -from sqlalchemy import Column -from sqlalchemy.sql.elements import ( - AsBoolean, - BinaryExpression, - BindParameter, - BooleanClauseList, - ClauseList, - ColumnElement, - False_, - Grouping, - Null, - True_, - UnaryExpression, -) -from sqlalchemy.sql.operators import in_op, is_, is_false, is_not +from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression from pynocular.database_model import DatabaseModel +from pynocular.evaluate_column_element import evaluate_column_element @contextmanager @@ -82,7 +68,7 @@ async def select( model for model in models if all( - _evaluate_column_element(expr, model.to_dict()) + evaluate_column_element(expr, model.to_dict()) for expr in where_expressions ) ] @@ -143,7 +129,7 @@ async def save(model, include_nested_models=False) -> None: model for model in models if all( - _evaluate_column_element(expr, model.to_dict()) + evaluate_column_element(expr, model.to_dict()) for expr in where_expressions ) ] @@ -180,7 +166,7 @@ async def update_record(**kwargs: Any) -> DatabaseModel: model for model in models if all( - _evaluate_column_element(expr, model.to_dict()) + evaluate_column_element(expr, model.to_dict()) for expr in where_expressions ) ] @@ -230,7 +216,7 @@ async def delete(model) -> None: model for model in models if not all( - _evaluate_column_element(expr, model.to_dict()) + evaluate_column_element(expr, model.to_dict()) for expr in where_expressions ) ] @@ -255,7 +241,7 @@ async def delete_records(**kwargs: Any) -> None: model for model in models if not all( - _evaluate_column_element(expr, model.to_dict()) for expr in where_exp + evaluate_column_element(expr, model.to_dict()) for expr in where_exp ) ] @@ -275,223 +261,3 @@ async def delete_records(**kwargs: Any) -> None: model_cls, "delete_records", delete_records ): yield - - -@functools.singledispatch -def _evaluate_column_element( - column_element: ColumnElement, model: Dict[str, Any] -) -> Any: - """Evaluate a ColumnElement on a dictionary representing a database model - - This function can be overridden based on the type of ColumnElement to return - an element from the model, a static value, or the result of some operation (e.g. - addition). - - Args: - column_element: The element to evaluate. - model: The model to evaluate the column element on. Represented as a dictionary - where the keys are column names. - - """ - raise Exception(f"Cannot evaluate a {column_element} object.") - - -@_evaluate_column_element.register(BooleanClauseList) -def _evaluate_boolean_clause_list( - column_element: ClauseList, model: Dict[str, Any] -) -> Any: - """Evaluates a boolean clause list and breaks it down into its sub column elements - - Args: - column_element: The BooleanClauseList in question. - model: The model of data this clause should be evaluated for. - - Returns: - The result of the evaluation. - - """ - operator = column_element.operator - - return functools.reduce( - operator, - [ - _evaluate_column_element(sub_element, model) - for sub_element in column_element.get_children() - ], - ) - - -@_evaluate_column_element.register(ClauseList) -def _evaluate_clause_list(column_element: ClauseList, model: Dict[str, Any]) -> Any: - """Evaluates a clause list and breaks it down into its sub column elements - - Args: - column_element: The ClauseList in question. - model: The model of data this clause should be evaluated for. - - Returns: - The result of the evaluation. - - """ - operator = column_element.operator - - return operator( - *[ - _evaluate_column_element(sub_element, model) - for sub_element in column_element.get_children() - ] - ) - - -@_evaluate_column_element.register(BinaryExpression) -def _evaluate_binary_expression( - column_element: BinaryExpression, model: Dict[str, Any] -) -> Any: - """Evaluates the binary expression - - Args: - column_element: The binary expression to evaluate. - model: The model to evaluate the expression on. - - Returns: - The evaluation response dictated by the operator of the expression. - - """ - operator = column_element.operator - - # The sqlalchemy `in` operator does not work on evaluated columns, so we replace - # it with the standard `in` operator. - if operator == in_op: - operator = lambda x, y: x in y - - # The sqlalchemy `is` operator does not work on evaluated columns, so we replace it - # with the standard `is` operator. - if operator == is_: - operator = lambda x, y: x is y - - # The sqlalchemy `is_not` operator does not work on evaluated columns, so we replace - # it with the standard `!=` operator. - if operator == is_not: - operator = lambda x, y: x != y - - return operator( - _evaluate_column_element(column_element.left, model), - _evaluate_column_element(column_element.right, model), - ) - - -@_evaluate_column_element.register(AsBoolean) -def _evaluate_as_boolean(column_element: AsBoolean, model: Dict[str, Any]) -> Any: - """Evaluates a boolean - - Args: - column_element: The boolean to evaluate. - model: The model to evaluate the expression on. - - Returns: - The evaluation response dictated by the operator of the expression. - - """ - result = bool(_evaluate_column_element(column_element.element, model)) - if column_element.operator == is_false: - return not result - return result - - -@_evaluate_column_element.register(Column) -def _evaluate_column(column_element: Column, model: Dict[str, Any]) -> Any: - """Evaluate the column based on the column element name - - Args: - column_element: The column to evaluate. - model: The model dictionary. - - Returns: - The value from the model of attribute referenced by column_element. - - """ - return model.get(column_element.name) - - -@_evaluate_column_element.register(BindParameter) -def _evaluate_bind_parameter( - column_element: BindParameter, model: Dict[str, Any] -) -> Any: - """Evaluate the column_elements value - - Args: - column_element: The column's bind parameter. - model: The model dictionary. - - Returns: - The value of the column_element - - """ - return column_element.value - - -@_evaluate_column_element.register(True_) -def _evaluate_true(column_element: True_, model: Dict[str, Any]) -> bool: - """Wrapper around evaluating True - - Args: - column_element: The column to evaluate. This is just True - model: The model dictionary. - - Returns: - True - - """ - # The boolean value True is its own SQLAlchemy element - return True - - -@_evaluate_column_element.register(False_) -def _evaluate_false(column_element: False_, model: Dict[str, Any]) -> bool: - """Wrapper around evaluating False - - Args: - column_element: The column to evaluate. This just returns False - model: The model dictionary. - - Returns: - False - - """ - # The boolean value False is its own SQLAlchemy element - return False - - -@_evaluate_column_element.register(Grouping) -def _evaluate_grouping(column_element: Grouping, model: Dict[str, Any]) -> List[Any]: - """Wrapper around evaluating a grouping - - Args: - column_element: The grouping to evaluate. - model: The model dictionary. - - Returns: - A list of of values that are the resulting of evaluating each element in the - group. - - """ - return [ - _evaluate_column_element(clause, model) - for clause in column_element.element.clauses - ] - - -@_evaluate_column_element.register(Null) -def _evaluate_null(column_element: Null, model: Dict[str, Any]) -> Any: - """Wrapper around evaluating null - - Args: - column_element: The column element to evaluate. This is null - model: The model dictionary. - - Returns: - None - - """ - # The Null value is its own SQLAlchemy element - return None diff --git a/pynocular/uuid_str.py b/pynocular/uuid_str.py new file mode 100644 index 0000000..d400959 --- /dev/null +++ b/pynocular/uuid_str.py @@ -0,0 +1,43 @@ +"""Contains util functions""" + +from typing import Any, Generator +from uuid import UUID as stdlib_uuid + + +def is_valid_uuid(string: str) -> bool: + """Check if a string is a valid UUID + + Args: + string: the string to check + + Returns: + Whether or not the string is a well-formed UUIDv4 + + """ + try: + stdlib_uuid(string, version=4) + return True + except (TypeError, AttributeError, ValueError): + return False + + +class UUID_STR(str): + """A string that represents a UUID4 value""" + + @classmethod + def __get_validators__(cls) -> Generator: + """Get the validators for the given class""" + yield cls.validate + + @classmethod + def validate(cls, v: Any) -> str: + """Function to validate the value + + Args: + v: The value to validate + + """ + if isinstance(v, stdlib_uuid) or (isinstance(v, str) and is_valid_uuid(v)): + return str(v) + else: + raise ValueError("invalid UUID string") diff --git a/pyproject.toml b/pyproject.toml index a8c27fc..abb95da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ pytest = "^6.2.2" pytest-asyncio = "^0.15" black = "^22.3.0" cruft = "^2.9.0" +pytest-lazy-fixture = "^0.6.3" [tool.cruft] skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.yml"] @@ -33,3 +34,6 @@ skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.y [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py new file mode 100644 index 0000000..ded70b4 --- /dev/null +++ b/tests/functional/conftest.py @@ -0,0 +1,11 @@ +"""Contains shared functional test fixtures""" + +import asyncio + +import pytest + + +@pytest.fixture(scope="session") +def event_loop(): + """Returns the event loop so we can define async, session-scoped fixtures""" + return asyncio.get_event_loop() diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index 84d0bc3..bb83bcb 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -1,46 +1,83 @@ """Tests for DatabaseModel abstract class""" -import asyncio from asyncio import gather, sleep from datetime import datetime +import logging import os from typing import Optional from uuid import uuid4 -from pydantic import BaseModel, Field +from databases import Database +from pydantic import Field from pydantic.error_wrappers import ValidationError import pytest -from pynocular.database_model import database_model, UUID_STR +from pynocular import ( + backend, + DatabaseModel, + MemoryDatabaseModelBackend, + SQLDatabaseModelBackend, + UUID_STR, +) from pynocular.db_util import ( add_datetime_trigger, create_new_database, create_table, drop_table, ) -from pynocular.engines import DBEngine, DBInfo from pynocular.exceptions import DatabaseModelMissingField, DatabaseRecordNotFound -db_user_password = str(os.environ.get("DB_USER_PASSWORD")) -# DB to initially connect to so we can create a new db -existing_connection_string = str( - os.environ.get( - "EXISTING_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/postgres?sslmode=disable", - ) -) -test_db_name = str(os.environ.get("TEST_DB_NAME", "test_db")) -test_connection_string = str( - os.environ.get( - "TEST_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/{test_db_name}?sslmode=disable", - ) -) -testdb = DBInfo(test_connection_string) +@pytest.fixture(scope="module") +async def postgres_backend(): + """Fixture that yields a Postgres backend + + Yields: + postgres backend + + """ + db_host = os.environ.get("DB_HOST", "localhost") + db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) + db_user_password = os.environ.get("DB_USER_PASSWORD", "") + test_db_name = os.environ.get("TEST_DB_NAME", "test_db") + + maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" + db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" + + try: + await create_new_database(maintenance_connection_string, test_db_name) + except Exception as e: + # If this fails, assume its already created + logging.info(str(e)) + + async with Database(db_connection_string) as db: + await create_table(db, Org.table) + await create_table(db, Topic.table) + await add_datetime_trigger(db, "organizations") + try: + yield SQLDatabaseModelBackend(db) + finally: + await drop_table(db, Topic.table) + await drop_table(db, Org.table) + + async with Database(maintenance_connection_string) as db: + try: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + except Exception as e: + logging.info(str(e)) + +@pytest.fixture() +async def memory_backend(): + """Fixture that yields an in-memory backend -@database_model("organizations", testdb) -class Org(BaseModel): + Returns: + in-memory backend + + """ + return MemoryDatabaseModelBackend() + + +class Org(DatabaseModel, table_name="organizations"): """A test database model""" id: UUID_STR = Field(primary_key=True) @@ -52,8 +89,7 @@ class Org(BaseModel): updated_at: Optional[datetime] = Field(fetch_on_update=True) -@database_model("topics", testdb) -class Topic(BaseModel): +class Topic(DatabaseModel, table_name="topics"): """A test class with a nullable JSONB field""" id: UUID_STR = Field(primary_key=True) @@ -63,45 +99,17 @@ class Topic(BaseModel): name: str = Field(max_length=45) -class TestDatabaseModel: - """Test suite for DatabaseModel object management""" - - @classmethod - async def _setup_class(cls): - """Create the database and tables""" - try: - await create_new_database(existing_connection_string, test_db_name) - except Exception: - # If this fails, assume its already created - pass - - await create_table(testdb, Org.get_table()) - await create_table(testdb, Topic.get_table()) - conn = await (await DBEngine.get_engine(testdb)).acquire() - await add_datetime_trigger(conn, "organizations") - await conn.close() - - @classmethod - def setup_class(cls): - """Setup class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._setup_class()) - - @classmethod - async def _teardown_class(cls): - """Drop database tables""" - await drop_table(testdb, Org._table) - await drop_table(testdb, Topic._table) - - @classmethod - def teardown_class(cls): - """Teardown class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._teardown_class()) - - @pytest.mark.asyncio - async def test_select(self) -> None: - """Test that we can select the full set of DatabaseModels""" +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_select(_backend) -> None: + """Test that we can select the full set of DatabaseModels""" + with backend(_backend): try: org = await Org.create( id=str(uuid4()), @@ -114,9 +122,18 @@ async def test_select(self) -> None: finally: await org.delete() - @pytest.mark.asyncio - async def test_get_list(self) -> None: - """Test that we can get_list and get a subset of DatabaseModels""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_get_list(_backend) -> None: + """Test that we can get_list and get a subset of DatabaseModels""" + with backend(_backend): try: org1 = await Org.create( id=str(uuid4()), name="orgus borgus", slug="orgus_borgus", serial_id=1 @@ -138,9 +155,18 @@ async def test_get_list(self) -> None: await org2.delete() await org3.delete() - @pytest.mark.asyncio - async def test_get_list__none_filter_value(self) -> None: - """Test that we can get_list based on a None filter value""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_get_list__none_filter_value(_backend) -> None: + """Test that we can get_list based on a None filter value""" + with backend(_backend): try: test_org = await Org.create( id=uuid4(), name="orgus borgus", slug="orgus_borgus", serial_id=None @@ -150,9 +176,18 @@ async def test_get_list__none_filter_value(self) -> None: finally: await test_org.delete() - @pytest.mark.asyncio - async def test_get_list__none_json_value(self) -> None: - """Test that we can get_list for a None value on a JSON field""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_get_list__none_json_value(_backend) -> None: + """Test that we can get_list for a None value on a JSON field""" + with backend(_backend): # The None value will be persisted as a SQL NULL value rather than a JSON-encoded # null value when the Topic is created, so the filter value None will work here try: @@ -168,9 +203,18 @@ async def test_get_list__none_json_value(self) -> None: finally: await base_topic.delete() - @pytest.mark.asyncio - async def test_create_new_record(self) -> None: - """Test that we can create a database record""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_create_new_record(_backend) -> None: + """Test that we can create a database record""" + with backend(_backend): org_id = str(uuid4()) serial_id = 100 try: @@ -183,9 +227,18 @@ async def test_create_new_record(self) -> None: # Make sure we delete org so we don't leak out of test await org.delete() - @pytest.mark.asyncio - async def test_create_list(self) -> None: - """Test that we can create a list of database records""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_create_list(_backend) -> None: + """Test that we can create a list of database records""" + with backend(_backend): try: initial_orgs = [ Org(id=str(uuid4()), name="fake org 1", slug="fake-slug-1"), @@ -199,15 +252,33 @@ async def test_create_list(self) -> None: finally: await gather(*[org.delete() for org in created_orgs]) - @pytest.mark.asyncio - async def test_create_list__empty(self) -> None: - """Should return empty list for input of empty list""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_create_list__empty(_backend) -> None: + """Should return empty list for input of empty list""" + with backend(_backend): created_orgs = await Org.create_list([]) assert created_orgs == [] - @pytest.mark.asyncio - async def test_update_new_record__save(self) -> None: - """Test that we can update a database record using `save`""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_update_new_record__save(_backend) -> None: + """Test that we can update a database record using `save`""" + with backend(_backend): org_id = str(uuid4()) serial_id = 101 @@ -227,9 +298,18 @@ async def test_update_new_record__save(self) -> None: # Make sure we delete org so we don't leak out of test await org.delete() - @pytest.mark.asyncio - async def test_update_new_record__update_record(self) -> None: - """Test that we can update a database record using `update_record`""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_update_new_record__update_record(_backend) -> None: + """Test that we can update a database record using `update_record`""" + with backend(_backend): org_id = str(uuid4()) serial_id = 100000 @@ -247,9 +327,18 @@ async def test_update_new_record__update_record(self) -> None: # Make sure we delete org so we don't leak out of test await org.delete() - @pytest.mark.asyncio - async def test_delete_new_record__delete(self) -> None: - """Test that we can delete a database record using `delete`""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_delete_new_record__delete(_backend) -> None: + """Test that we can delete a database record using `delete`""" + with backend(_backend): org_id = str(uuid4()) serial_id = 102 @@ -267,9 +356,18 @@ async def test_delete_new_record__delete(self) -> None: with pytest.raises(DatabaseRecordNotFound): await Org.get(org_id) - @pytest.mark.asyncio - async def test_delete_new_record__delete_records(self) -> None: - """Test that we can delete a database record using `delete_records`""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_delete_new_record__delete_records(_backend) -> None: + """Test that we can delete a database record using `delete_records`""" + with backend(_backend): org_id = str(uuid4()) serial_id = 103 @@ -285,9 +383,18 @@ async def test_delete_new_record__delete_records(self) -> None: with pytest.raises(DatabaseRecordNotFound): await Org.get(org_id) - @pytest.mark.asyncio - async def test_delete_new_record__delete_records_multi_kwargs(self) -> None: - """Test that we can delete a database record using `delete_records` with multiple kwargs""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_delete_new_record__delete_records_multi_kwargs(_backend) -> None: + """Test that we can delete a database record using `delete_records` with multiple kwargs""" + with backend(_backend): org_id = str(uuid4()) serial_id = 104 @@ -303,23 +410,50 @@ async def test_delete_new_record__delete_records_multi_kwargs(self) -> None: with pytest.raises(DatabaseRecordNotFound): await Org.get(org_id) - @pytest.mark.asyncio - async def test_bad_org_object_creation(self) -> None: - """Test that we raise an Exception if the object is missing fields""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_bad_org_object_creation(_backend) -> None: + """Test that we raise an Exception if the object is missing fields""" + with backend(_backend): org_id = str(uuid4()) with pytest.raises(ValidationError): Org(**{"id": org_id}) - @pytest.mark.asyncio - async def test_raise_error_get_list_wrong_field(self) -> None: - """Test that we raise an exception if we query for a wrong field on the object""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_raise_error_get_list_wrong_field(_backend) -> None: + """Test that we raise an exception if we query for a wrong field on the object""" + with backend(_backend): with pytest.raises(DatabaseModelMissingField): await Org.get_list(table_id="Table1") - @pytest.mark.asyncio - async def test_setting_db_managed_columns(self) -> None: - """Test that db managed columns get automatically set on save""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_setting_db_managed_columns(_backend) -> None: + """Test that db managed columns get automatically set on save""" + with backend(_backend): org = await Org.create( id=str(uuid4()), serial_id=105, name="fake_org105", slug="fake_org105" ) @@ -336,9 +470,18 @@ async def test_setting_db_managed_columns(self) -> None: finally: await org.delete() - @pytest.mark.asyncio - async def test_fetch(self) -> None: - """Test that we can fetch the latest state of a database record""" + +@pytest.mark.parametrize( + "_backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_fetch(_backend) -> None: + """Test that we can fetch the latest state of a database record""" + with backend(_backend): org_id = str(uuid4()) serial_id = 100 try: diff --git a/tests/functional/test_db_util.py b/tests/functional/test_db_util.py index c34200a..72ec37f 100644 --- a/tests/functional/test_db_util.py +++ b/tests/functional/test_db_util.py @@ -1,35 +1,56 @@ """Tests for the db_util module""" +import logging import os +from databases import Database import pytest -from pynocular.db_util import is_database_available -from pynocular.engines import DBInfo +from pynocular.db_util import create_new_database, is_database_available db_user_password = str(os.environ.get("DB_USER_PASSWORD")) -test_db_name = str(os.environ.get("TEST_DB_NAME", "test_db")) -test_connection_string = str( - os.environ.get( - "TEST_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/{test_db_name}?sslmode=disable", - ) -) -test_db = DBInfo(test_connection_string) - - -class TestDBUtil: - """Test cases for DB util functions""" - - @pytest.mark.asyncio - async def test_is_database_available(self) -> None: - """Test successful database connection""" - available = await is_database_available(test_db) - assert available is True - - @pytest.mark.asyncio - async def test_is_database_not_available(self) -> None: - """Test db connection unavailable""" - invalid_connection_string = f"postgresql://postgres:{db_user_password}@localhost:5432/INVALID?sslmode=disable" - non_existing_db = DBInfo(invalid_connection_string) - available = await is_database_available(non_existing_db) - assert available is False + + +@pytest.fixture(scope="module") +async def test_connection_string(): + """Fixture that yields a test connection string + + Yields: + postgres connection string + + """ + db_host = os.environ.get("DB_HOST", "localhost") + db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) + db_user_password = os.environ.get("DB_USER_PASSWORD", "") + test_db_name = os.environ.get("TEST_DB_NAME", "test_db") + + maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" + db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" + + try: + await create_new_database(maintenance_connection_string, test_db_name) + except Exception as e: + # If this fails, assume its already created + logging.info(str(e)) + + yield db_connection_string + + async with Database(maintenance_connection_string) as db: + try: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + except Exception as e: + logging.info(str(e)) + + +@pytest.mark.asyncio +async def test_is_database_available(test_connection_string) -> None: + """Test successful database connection""" + available = await is_database_available(test_connection_string) + assert available is True + + +@pytest.mark.asyncio +async def test_is_database_not_available() -> None: + """Test db connection unavailable""" + invalid_connection_string = f"postgresql://postgres:{db_user_password}@localhost:5432/INVALID?sslmode=disable" + available = await is_database_available(invalid_connection_string) + assert available is False diff --git a/tests/functional/test_model.py b/tests/functional/test_model.py deleted file mode 100644 index f7bc0a7..0000000 --- a/tests/functional/test_model.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Contains tests for the DatabaseModel backends""" - -import logging -import os -from typing import List, Optional - -from databases import Database -from pydantic import Field -import pytest -from sqlalchemy import desc - -from pynocular.backends.context import backend -from pynocular.backends.memory import MemoryDatabaseModelBackend -from pynocular.backends.sql import SQLDatabaseModelBackend -from pynocular.model import DatabaseModel - - -@pytest.fixture() -async def postgres_backend(): - """Fixture that yields a Postgres backend - - Yields: - postgres backend - - """ - db_host = os.environ.get("DB_HOST", "localhost") - db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) - db_user_password = os.environ.get("DB_USER_PASSWORD", "") - test_db_name = os.environ.get("TEST_DB_NAME", "test_db") - - maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" - db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" - - async with Database(maintenance_connection_string) as db: - try: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") - await db.execute(f"CREATE DATABASE {test_db_name}") - except Exception as e: - logging.info(str(e)) - - async with Database(db_connection_string) as db: - await db.execute( - "CREATE TABLE IF NOT EXISTS things (id SERIAL PRIMARY KEY, name TEXT)" - ) - try: - yield SQLDatabaseModelBackend(db) - finally: - await db.execute("DROP TABLE things") - - async with Database(maintenance_connection_string) as db: - try: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") - except Exception as e: - logging.info(str(e)) - - -@pytest.fixture() -async def memory_backend(): - """Fixture that yields an in-memory backend - - Returns: - in-memory backend - - """ - return MemoryDatabaseModelBackend() - - -class Thing(DatabaseModel, table_name="things"): - """A test database model""" - - id: Optional[int] = Field(primary_key=True) - name: str = Field() - - -async def _run_tests(): - """Run tests agnostic to the backend""" - things: List[Thing] = await Thing.select() - assert things == [] - - things = await Thing.create_list([Thing(name="hello"), Thing(name="world")]) - assert [t.to_dict() for t in things] == [ - { - "name": "hello", - "id": 1, - }, - { - "name": "world", - "id": 2, - }, - ] - - things[1].name = "you" - await things[1].save() - - things: List[Thing] = await Thing.select(order_by=[desc(Thing.columns.name)]) - assert [t.to_dict() for t in things] == [ - { - "name": "you", - "id": 2, - }, - { - "name": "hello", - "id": 1, - }, - ] - - things: List[Thing] = await Thing.get_list(name="hello") - assert [t.to_dict() for t in things] == [ - { - "name": "hello", - "id": 1, - }, - ] - - await things[0].delete() - assert len(await Thing.get_list()) == 1 - - await Thing.delete_records(name="you") - assert len(await Thing.get_list()) == 0 - - -@pytest.mark.asyncio -async def test_postgres(postgres_backend): - """Should run a set of operations on a Postgres backend""" - with backend(postgres_backend): - await _run_tests() - - -@pytest.mark.asyncio -async def test_memory(memory_backend): - """Should run a set of operations on an in-memory backend""" - with backend(memory_backend): - await _run_tests() diff --git a/tests/functional/test_nested_database_model.py b/tests/functional/test_nested_database_model.py deleted file mode 100644 index 48ad8b8..0000000 --- a/tests/functional/test_nested_database_model.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Tests for DatabaseModel abstract class""" -import asyncio -from datetime import datetime -import os -from typing import Optional -from uuid import uuid4 - -from pydantic import BaseModel, Field -import pytest - -from pynocular.database_model import database_model, nested_model, UUID_STR -from pynocular.db_util import ( - add_datetime_trigger, - create_new_database, - create_table, - drop_table, -) -from pynocular.engines import DBEngine, DBInfo -from pynocular.exceptions import NestedDatabaseModelNotResolved - -db_user_password = str(os.environ.get("DB_USER_PASSWORD")) -# DB to initially connect to so we can create a new db -existing_connection_string = str( - os.environ.get( - "EXISTING_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/postgres?sslmode=disable", - ) -) - -test_db_name = str(os.environ.get("TEST_DB_NAME", "test_db")) -test_connection_string = str( - os.environ.get( - "TEST_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/{test_db_name}?sslmode=disable", - ) -) -testdb = DBInfo(test_connection_string) - - -@database_model("users", testdb) -class User(BaseModel): - """Model that represents the `users` table""" - - id: UUID_STR = Field(primary_key=True) - username: str = Field(max_length=100) - - -@database_model("organizations", testdb) -class Org(BaseModel): - """Model that represents the `organizations` table""" - - id: UUID_STR = Field(primary_key=True) - name: str = Field(max_length=45) - slug: str = Field(max_length=45) - tech_owner: Optional[ - nested_model(User, reference_field="tech_owner_id") # noqa F821 - ] - business_owner: Optional[ - nested_model(User, reference_field="business_owner_id") # noqa F821 - ] - - created_at: Optional[datetime] = Field(fetch_on_create=True) - updated_at: Optional[datetime] = Field(fetch_on_update=True) - - -@database_model("apps", testdb) -class App(BaseModel): - """Model that represents the `apps` table""" - - id: Optional[UUID_STR] = Field(primary_key=True, fetch_on_create=True) - name: str = Field(max_length=45) - org: nested_model(Org, reference_field="organization_id") # noqa F821 - slug: str = Field(max_length=45) - - -@database_model("topics", testdb) -class Topic(BaseModel): - """Model that represents the `topics` table""" - - id: UUID_STR = Field(primary_key=True) - app: nested_model(App, reference_field="app_id") # noqa F821 - name: str = Field(max_length=45) - - -class TestNestedDatabaseModel: - """Test suite for NestedDatabaseModel interactions""" - - @classmethod - async def _setup_class(cls): - """Create the database and tables""" - try: - await create_new_database(existing_connection_string, test_db_name) - except Exception: - # If this fails, assume its already created - pass - - await create_table(testdb, User._table) - await create_table(testdb, Org._table) - await create_table(testdb, Topic._table) - await create_table(testdb, App._table) - conn = await (await DBEngine.get_engine(testdb)).acquire() - await add_datetime_trigger(conn, "organizations") - await conn.close() - - @classmethod - def setup_class(cls): - """Setup class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._setup_class()) - - @classmethod - async def _teardown_class(cls): - """Drop database tables""" - await drop_table(testdb, User._table) - await drop_table(testdb, Org._table) - await drop_table(testdb, Topic._table) - await drop_table(testdb, App._table) - - @classmethod - def teardown_class(cls): - """Teardown class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._teardown_class()) - - @pytest.mark.asyncio - async def test_fetch(self) -> None: - """Test that we can resolve the reference for a foreign key""" - - try: - tech_owner = await User.create(id=str(uuid4()), username="owner1") - business_owner = await User.create(id=str(uuid4()), username="owner2") - org = await Org.create( - id=str(uuid4()), - name="fake org104", - slug="fake slug104", - tech_owner=tech_owner, - business_owner=business_owner, - ) - - org_get = await Org.get(org.id) - assert org_get.tech_owner.id == tech_owner.id - assert org_get.business_owner.id == business_owner.id - - # Error should be raised if we try to access a property before it is fetched - with pytest.raises(NestedDatabaseModelNotResolved): - org_get.tech_owner.username - - await org_get.tech_owner.fetch() - await org_get.business_owner.fetch() - assert org_get.tech_owner == tech_owner - assert org_get.business_owner == business_owner - finally: - await org.delete() - await tech_owner.delete() - await business_owner.delete() - - @pytest.mark.asyncio - async def test_swap_foreign_reference(self) -> None: - """Test that we can swap foreign key references""" - org_id = str(uuid4()) - - try: - org1 = await Org.create(id=org_id, name="fake org104", slug="fake slug104") - org2 = await Org.create( - id=str(uuid4()), - name="fake org105", - slug="fake slug105", - ) - - # Start with app pointing to the first org - app = await App.create( - id=str(uuid4()), - name="app name", - org=org1, - slug="app-slug", - ) - - # Confirm app is associated with org 1 - app_get = await App.get(app.id) - assert app_get.org.id == org1.id - - # Move app to org 2 - app_get.org = org2 - await app_get.save() - app_get = await App.get(app.id) - assert app_get.org.id == org2.id - await app_get.org.fetch() - assert app_get.org == org2 - finally: - await org1.delete() - await org2.delete() - await app.delete() - - @pytest.mark.asyncio - async def test_get_with_refs(self) -> None: - """Test that we can resolve foreign keys when we retrieve the record object""" - org_id = str(uuid4()) - - try: - org = await Org.create(id=org_id, name="fake org104", slug="fake slug104") - app = await App.create( - id=str(uuid4()), - name="app name", - org=org, - slug="app-slug", - ) - - app_get = await App.get_with_refs(app.id) - assert app_get.org == org - finally: - await org.delete() - await app.delete() - - @pytest.mark.asyncio - async def test_nested_foreign_references(self) -> None: - """Test that we can nest foreign key references and resolve them""" - org_id = str(uuid4()) - - try: - org = await Org.create(id=org_id, name="fake org104", slug="fake slug104") - app = await App.create( - id=str(uuid4()), - name="app name", - org=org, - slug="app-slug", - ) - - topic = await Topic.create(id=str(uuid4()), name="topic name", app=app) - - assert topic.app.id == app.id - assert topic.app == app - assert topic.app.org.id == org.id - assert topic.app.org == org - finally: - await org.delete() - await app.delete() - await topic.delete() - - @pytest.mark.asyncio - async def test_nested_save(self) -> None: - """Test that all the objects will persist if the proper flag is provided""" - - try: - tech_owner = User(id=str(uuid4()), username="owner1") - business_owner = User(id=str(uuid4()), username="owner2") - org = Org( - id=str(uuid4()), - name="fake org104", - slug="fake slug104", - business_owner=business_owner, - ) - - await org.save(include_nested_models=True) - - # Get the org and user that should have persisted - org_get = await Org.get(org.id) - user_get = await User.get(business_owner.id) - - assert org_get.business_owner.id == user_get.id - - # Now add the tech owner and save again. This time, org_get.business_owner is - # not resolved but it should still successfully persist everything - org_get.tech_owner = tech_owner - await org_get.save(include_nested_models=True) - - org_get = await Org.get(org_get.id) - user_get = await User.get(tech_owner.id) - - assert org_get.tech_owner.id == user_get.id - assert org_get.business_owner.id == business_owner.id - finally: - await org.delete() - await tech_owner.delete() - await business_owner.delete() - - @pytest.mark.asyncio - async def test_serialization(self) -> None: - """Test that we can handle nested models in serialization correctly""" - - try: - tech_owner = await User.create(id=str(uuid4()), username="owner1") - business_owner = await User.create(id=str(uuid4()), username="owner2") - org = await Org.create( - id=str(uuid4()), - name="fake org104", - slug="fake slug104", - tech_owner=tech_owner, - business_owner=business_owner, - ) - - expected_org_dict = { - "id": org.id, - "name": org.name, - "slug": org.slug, - "tech_owner_id": tech_owner.id, - "business_owner_id": business_owner.id, - "created_at": org.created_at, - "updated_at": org.updated_at, - } - - org_dict = org.to_dict() - assert org_dict == expected_org_dict - - # Confirm the serialization is the same regardless of if nested models are - # resolved - org = await Org.get(org.id) - org_dict = org.to_dict() - assert org_dict == expected_org_dict - finally: - await org.delete() - await tech_owner.delete() - await business_owner.delete() From 8b9a3639365c69ce7cabb6a63a7f35826f1b6c79 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 17:24:09 -0700 Subject: [PATCH 18/31] tx --- poetry.lock | 486 +++------------ pynocular/__init__.py | 2 +- pynocular/aiopg_transaction.py | 277 --------- pynocular/backends/base.py | 8 + pynocular/backends/context.py | 7 +- pynocular/backends/memory.py | 8 + pynocular/backends/sql.py | 27 +- pynocular/config.py | 6 - pynocular/database_model.py | 676 +++++++++------------ pynocular/db_util.py | 59 +- pynocular/exceptions.py | 19 - pynocular/model.py | 600 ------------------ pyproject.toml | 11 +- tests/functional/conftest.py | 48 ++ tests/functional/test_database_model.py | 88 +-- tests/functional/test_db_util.py | 45 +- tests/functional/test_transactions.py | 311 ++++------ tests/unit/test_evaluate_column_element.py | 37 ++ tests/unit/test_task_context_connection.py | 49 -- 19 files changed, 729 insertions(+), 2035 deletions(-) delete mode 100644 pynocular/aiopg_transaction.py delete mode 100644 pynocular/config.py delete mode 100644 pynocular/model.py create mode 100644 tests/unit/test_evaluate_column_element.py delete mode 100644 tests/unit/test_task_context_connection.py diff --git a/poetry.lock b/poetry.lock index 181aabc..732a792 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,38 +1,3 @@ -[[package]] -name = "aenum" -version = "3.1.11" -description = "Advanced Enumerations (compatible with Python's stdlib Enum), NamedTuples, and NamedConstants" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "aiocontextvars" -version = "0.2.2" -description = "Asyncio support for PEP-567 contextvars backport." -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -contextvars = {version = "2.4", markers = "python_version < \"3.7\""} - -[[package]] -name = "aiopg" -version = "1.3.3" -description = "Postgres integration with asyncio." -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -async-timeout = ">=3.0,<5.0" -psycopg2-binary = ">=2.8.4" -sqlalchemy = {version = ">=1.3,<1.5", extras = ["postgresql_psycopg2binary"], optional = true, markers = "extra == \"sa\""} - -[package.extras] -sa = ["sqlalchemy[postgresql_psycopg2binary] (>=1.3,<1.5)"] - [[package]] name = "arrow" version = "1.2.2" @@ -43,18 +8,6 @@ python-versions = ">=3.6" [package.dependencies] python-dateutil = ">=2.7.0" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - -[[package]] -name = "async-timeout" -version = "4.0.2" -description = "Timeout context manager for asyncio programs" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} [[package]] name = "asyncpg" @@ -64,9 +17,6 @@ category = "main" optional = false python-versions = ">=3.6.0" -[package.dependencies] -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} - [package.extras] dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] @@ -115,12 +65,10 @@ python-versions = ">=3.6.2" [package.dependencies] click = ">=8.0.0" -dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] @@ -166,15 +114,14 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "8.0.4" +version = "8.1.2" description = "Composable command line interface toolkit" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -184,17 +131,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "contextvars" -version = "2.4" -description = "PEP 567 Backport" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -immutables = ">=0.9" - [[package]] name = "cookiecutter" version = "1.7.3" @@ -225,7 +161,6 @@ python-versions = ">=3.6.2,<4.0.0" click = ">=7.1.2,<9.0.0" cookiecutter = ">=1.6,<2.0" gitpython = ">=3.0,<4.0" -importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""} typer = ">=0.4.0,<0.5.0" [package.extras] @@ -241,7 +176,6 @@ optional = false python-versions = ">=3.6" [package.dependencies] -aiocontextvars = {version = "*", markers = "python_version < \"3.7\""} asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""} sqlalchemy = ">=1.4,<1.5" @@ -252,14 +186,6 @@ postgresql = ["asyncpg"] postgresql_aiopg = ["aiopg"] sqlite = ["aiosqlite"] -[[package]] -name = "dataclasses" -version = "0.8" -description = "A backport of the dataclasses module for Python 3.6" -category = "main" -optional = false -python-versions = ">=3.6, <3.7" - [[package]] name = "distlib" version = "0.3.4" @@ -270,11 +196,11 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.4.1" +version = "3.6.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] @@ -293,15 +219,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.20" -description = "Python Git Library" +version = "3.1.27" +description = "GitPython is a python library used to interact with Git repositories" category = "dev" optional = false -python-versions = ">=3.6" +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 = "greenlet" @@ -316,11 +241,11 @@ docs = ["sphinx"] [[package]] name = "identify" -version = "2.4.4" +version = "2.4.12" description = "File identification library for Python" category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.extras] license = ["ukkonen"] @@ -333,52 +258,6 @@ category = "dev" optional = false python-versions = ">=3.5" -[[package]] -name = "immutables" -version = "0.17" -description = "Immutable Collections" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} - -[package.extras] -test = ["flake8 (>=3.8.4,<3.9.0)", "pycodestyle (>=2.6.0,<2.7.0)", "mypy (>=0.910)", "pytest (>=6.2.4,<6.3.0)"] - -[[package]] -name = "importlib-metadata" -version = "4.8.3" -description = "Read metadata from Python packages" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] - -[[package]] -name = "importlib-resources" -version = "5.2.3" -description = "Read resources from Python packages" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] - [[package]] name = "iniconfig" version = "1.1.1" @@ -389,11 +268,11 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.1" description = "A very fast and expressive template engine." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] MarkupSafe = ">=2.0" @@ -415,11 +294,11 @@ jinja2 = "*" [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mypy-extensions" @@ -458,15 +337,15 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.5.2" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] [[package]] name = "pluggy" @@ -476,9 +355,6 @@ category = "dev" optional = false python-versions = ">=3.6" -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -493,17 +369,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pre-commit" -version = "2.17.0" +version = "2.18.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -importlib-resources = {version = "<5.3", markers = "python_version < \"3.7\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" @@ -534,7 +408,6 @@ optional = false python-versions = ">=3.6.1" [package.dependencies] -dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} typing-extensions = ">=3.7.4.3" [package.extras] @@ -543,36 +416,35 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" -version = "6.2.5" +version = "7.1.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -676,8 +548,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" [package.dependencies] greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -psycopg2-binary = {version = "*", optional = true, markers = "extra == \"postgresql_psycopg2binary\""} [package.extras] aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] @@ -718,19 +588,11 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tomli" -version = "1.2.3" +version = "2.0.1" description = "A lil' TOML parser" category = "dev" optional = false -python-versions = ">=3.6" - -[[package]] -name = "typed-ast" -version = "1.5.3" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "typer" @@ -751,11 +613,11 @@ test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov ( [[package]] name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "urllib3" @@ -781,8 +643,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} platformdirs = ">=2,<3" six = ">=1.9.0,<2" @@ -790,45 +650,16 @@ six = ">=1.9.0,<2" docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] -[[package]] -name = "zipp" -version = "3.6.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] - [metadata] lock-version = "1.1" -python-versions = "^3.6.5" -content-hash = "c0f390f16cd2c5a8c34a6320347151251234d2cf842e72c26a1b3c5ea176a5f2" +python-versions = "^3.9" +content-hash = "5351d91486bad98d75d28fd806b9a5fc239575352a442d43a150a97b7551e0aa" [metadata.files] -aenum = [ - {file = "aenum-3.1.11-py2-none-any.whl", hash = "sha256:525b4870a27d0b471c265bda692bc657f1e0dd7597ad4186d072c59f9db666f6"}, - {file = "aenum-3.1.11-py3-none-any.whl", hash = "sha256:12ae89967f2e25c0ce28c293955d643f891603488bc3d9946158ba2b35203638"}, - {file = "aenum-3.1.11.tar.gz", hash = "sha256:aed2c273547ae72a0d5ee869719c02a643da16bf507c80958faadc7e038e3f73"}, -] -aiocontextvars = [ - {file = "aiocontextvars-0.2.2-py2.py3-none-any.whl", hash = "sha256:885daf8261818767d8f7cbd79f9d4482d118f024b6586ef6e67980236a27bfa3"}, - {file = "aiocontextvars-0.2.2.tar.gz", hash = "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa"}, -] -aiopg = [ - {file = "aiopg-1.3.3-py3-none-any.whl", hash = "sha256:2842dd8741460eeef940032dcb577bfba4d4115205dd82a73ce13b3271f5bf0a"}, - {file = "aiopg-1.3.3.tar.gz", hash = "sha256:547c6ba4ea0d73c2a11a2f44387d7133cc01d3c6f3b8ed976c0ac1eff4f595d7"}, -] arrow = [ {file = "arrow-1.2.2-py3-none-any.whl", hash = "sha256:d622c46ca681b5b3e3574fcb60a04e5cc81b9625112d5fb2b44220c36c892177"}, {file = "arrow-1.2.2.tar.gz", hash = "sha256:05caf1fd3d9a11a1135b2b6f09887421153b94558e5ef4d090b567b47173ac2b"}, ] -async-timeout = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, -] asyncpg = [ {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, @@ -911,16 +742,13 @@ charset-normalizer = [ {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] click = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, + {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, + {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] -contextvars = [ - {file = "contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e"}, -] cookiecutter = [ {file = "cookiecutter-1.7.3-py2.py3-none-any.whl", hash = "sha256:f8671531fa96ab14339d0c59b4f662a4f12a2ecacd94a0f70a3500843da588e2"}, {file = "cookiecutter-1.7.3.tar.gz", hash = "sha256:6b9a4d72882e243be077a7397d0f1f76fe66cf3df91f3115dbb5330e214fa457"}, @@ -933,25 +761,21 @@ databases = [ {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"}, {file = "databases-0.5.5.tar.gz", hash = "sha256:02c6b016c1c951c21cca281dc8e2e002c60dc44026c0084aabbd8c37514aeb37"}, ] -dataclasses = [ - {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, - {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, -] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] filelock = [ - {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"}, - {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"}, + {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, + {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, ] gitdb = [ {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, ] gitpython = [ - {file = "GitPython-3.1.20-py3-none-any.whl", hash = "sha256:b1e1c269deab1b08ce65403cf14e10d2ef1f6c89e33ea7c5e5bb0222ea593b8a"}, - {file = "GitPython-3.1.20.tar.gz", hash = "sha256:df0e072a200703a65387b0cfdf0466e3bab729c0458cf6b7349d0e9877636519"}, + {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"}, + {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"}, ] greenlet = [ {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, @@ -1011,154 +835,66 @@ greenlet = [ {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, ] identify = [ - {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, - {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, + {file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"}, + {file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] -immutables = [ - {file = "immutables-0.17-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cab10d65a29b2019fffd7a3924f6965a8f785e7bd409641ce36ab2d3335f88c4"}, - {file = "immutables-0.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f73088c9b8595ddfd45a5658f8cce0cb3ae6e5890458381fccba3ed3035081d4"}, - {file = "immutables-0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef632832fa1acae6861d83572b866126f9e35706ab6e581ce6b175b3e0b7a3c4"}, - {file = "immutables-0.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0efdcec7b63859b41f794ffa0cd0d6dc87e77d1be4ff0ec23471a3a1e719235f"}, - {file = "immutables-0.17-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eca96f12bc1535657d24eae2c69816d0b22c4a4bc7f4753115e028a137e8dad"}, - {file = "immutables-0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:01a25b1056754aa486afea5471ca348410d77f458477ccb6fa3baf2d3e3ff3d5"}, - {file = "immutables-0.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c41a6648f7355f1241da677c418edae56fdc45af19ad3540ca8a1e7a81606a7a"}, - {file = "immutables-0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0b578bba11bd8ae55dee9536edf8d82be18463d15d4b4c9827e27eeeb73826bf"}, - {file = "immutables-0.17-cp310-cp310-win32.whl", hash = "sha256:a28682e115191e909673aedb9ccea3377da3a6a929f8bd86982a2a76bdfa89db"}, - {file = "immutables-0.17-cp310-cp310-win_amd64.whl", hash = "sha256:293ddb681502945f29b3065e688a962e191e752320040892316b9dd1e3b9c8c9"}, - {file = "immutables-0.17-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ec04fc7d9f76f26d82a5d9d1715df0409d0096309828fc46cd1a2067c7fbab95"}, - {file = "immutables-0.17-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f024f25e9fda42251a2b2167668ca70678c19fb3ab6ed509cef0b4b431d0ff73"}, - {file = "immutables-0.17-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b02083b2052cc201ac5cbd38f34a5da21fcd51016cb4ddd1fb43d7dc113eac17"}, - {file = "immutables-0.17-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea32db31afb82d8369e98f85c5b815ff81610a12fbc837830a34388f1b56f080"}, - {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:898a9472d1dd3d17f291114395a1be65be035355fc65af0b2c88238f8fbeaa62"}, - {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:736dd3d88d44da0ee48804792bd095c01a344c5d1b0f10beeb9ccb3a00b9c19d"}, - {file = "immutables-0.17-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:15ff4139720f79b902f435a25e3c00f9c8adcc41d79bed64b7e51ae36cfe9620"}, - {file = "immutables-0.17-cp36-cp36m-win32.whl", hash = "sha256:4f018a6c4c3689b82f763ad4f84dec6aa91c83981db7f6bafef963f036e5e815"}, - {file = "immutables-0.17-cp36-cp36m-win_amd64.whl", hash = "sha256:d7400a6753b292ac80102ed026efa8da2c3fedd50c443924cbe9b6448d3b19e4"}, - {file = "immutables-0.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f7a6e0380bddb99c46bb3f12ae5eee9a23d6a66d99bbf0fb10fa552f935c2e8d"}, - {file = "immutables-0.17-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7696c42d1f9a16ecda0ee46229848df8706973690b45e8a090d995d647a5ec57"}, - {file = "immutables-0.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:892b6a1619cd8c398fa70302c4cfa9768a694377639330e7a58cc7be111ab23e"}, - {file = "immutables-0.17-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89093d5a85357250b1d5ae218fdcfdbac4097cbb2d8b55004aa7a2ca2a00a09f"}, - {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99a8bc6d0623300eb46beea74f7a5061968fb3efc4e072f23f6c0b21c588238d"}, - {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:00380474f8e3b4a2eeb06ce694e0e3cb85a144919140a2b3116defb6c1587471"}, - {file = "immutables-0.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:078e3ed63be0ac36523b80bbabbfb1bb57e55009f4efb5650b0e3b3ed569c3f1"}, - {file = "immutables-0.17-cp37-cp37m-win32.whl", hash = "sha256:14905aecc62b318d86045dcf8d35ef2063803d9d331aeccd88958f03caadc7b0"}, - {file = "immutables-0.17-cp37-cp37m-win_amd64.whl", hash = "sha256:3774d403d1570105a1da2e00c38ce3f04065fd1deff04cf998f8d8e946d0ae13"}, - {file = "immutables-0.17-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5a9caee1b99eccf1447056ae6bda77edd15c357421293e81fa1a4f28e83448a"}, - {file = "immutables-0.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fed1e1baf1de1bc94a0310da29814892064928d7d40ff5a3b86bcd11d5e7cfff"}, - {file = "immutables-0.17-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d7daa340d76747ba5a8f64816b48def74bd4be45a9508073b34fa954d099fba"}, - {file = "immutables-0.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4644c29fe07fb92ba84b26659708e1799fecaaf781214adf13edd8a4d7495a9"}, - {file = "immutables-0.17-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e9ea0e2a31db44fb01617ff875d4c26f962696e1c5ff11ed7767c2d8dedac4"}, - {file = "immutables-0.17-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:64100dfdb29fae2bc84748fff5d66dd6b3997806c717eeb75f7099aeee9b1878"}, - {file = "immutables-0.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5f933e5bf6f2c1afb24bc2fc8bea8b132096a4a6ba54f36be59787981f3e50ff"}, - {file = "immutables-0.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9508a087a47f9f9506adf2fa8383ab14c46a222b57eea8612bc4c2aa9a9550fe"}, - {file = "immutables-0.17-cp38-cp38-win32.whl", hash = "sha256:dfd2c63f15d1e5ea1ed2a05b7c602b5f61a64337415d299df20e103a57ae4906"}, - {file = "immutables-0.17-cp38-cp38-win_amd64.whl", hash = "sha256:301c539660c988c5b24051ccad1e36c040a916f1e58fa3e245e3122fc50dd28d"}, - {file = "immutables-0.17-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:563bc2ddbe75c10faa3b4b0206870653b44a231b97ed23cff8ab8aff503d922d"}, - {file = "immutables-0.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f621ea6130393cd14d0fbd35b306d4dc70bcd0fda550a8cd313db8015e34ca60"}, - {file = "immutables-0.17-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c2d1b16b716bca70345db334dd6a861bf45c46cb11bb1801277f8a9012e864"}, - {file = "immutables-0.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a08e1a80bd8c5df72c2bf0af24a37ceec17e8ffdb850ed5a62d0bba1d4d86018"}, - {file = "immutables-0.17-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b99155ad112149d43208c611c6c42f19e16716526dacc0fcc16736d2f5d2e20"}, - {file = "immutables-0.17-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ed71e736f8fb82545d00c8969dbc167547c15e85729058edbed3c03b94fca86c"}, - {file = "immutables-0.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:19e4b8e5810dd7cab63fa700373f787a369d992166eabc23f4b962e5704d33c5"}, - {file = "immutables-0.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:305062012497d4c4a70fe35e20cef2c6f65744e721b04671092a63354799988d"}, - {file = "immutables-0.17-cp39-cp39-win32.whl", hash = "sha256:f5c6bd012384a8d6af7bb25675719214d76640fe6c336e2b5fba9eef1407ae6a"}, - {file = "immutables-0.17-cp39-cp39-win_amd64.whl", hash = "sha256:615ab26873a794559ccaf4e0e9afdb5aefad0867c15262ba64a55a12a5a41573"}, - {file = "immutables-0.17.tar.gz", hash = "sha256:ad894446355b6f5289a9c84fb46f7c47c6ef2b1bfbdd2be6cb177dbb7f1587ad"}, -] -importlib-metadata = [ - {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, - {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, -] -importlib-resources = [ - {file = "importlib_resources-5.2.3-py3-none-any.whl", hash = "sha256:ae35ed1cfe8c0d6c1a53ecd168167f01fa93b893d51a62cdf23aea044c67211b"}, - {file = "importlib_resources-5.2.3.tar.gz", hash = "sha256:203d70dda34cfbfbb42324a8d4211196e7d3e858de21a5eb68c6d1cdd99e4e98"}, -] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, - {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, + {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, + {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, ] jinja2-time = [ {file = "jinja2-time-0.2.0.tar.gz", hash = "sha256:d14eaa4d315e7688daa4969f616f226614350c48730bfa1692d2caebd8c90d40"}, {file = "jinja2_time-0.2.0-py2.py3-none-any.whl", hash = "sha256:d3eab6605e3ec8b7a0863df09cc1d23714908fa61aa6986a845c20ba488b4efa"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, @@ -1177,8 +913,8 @@ pathspec = [ {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1189,8 +925,8 @@ poyo = [ {file = "poyo-0.5.0.tar.gz", hash = "sha256:e26956aa780c45f011ca9886f044590e2d8fd8b61db7b1c1cf4e0869f48ed4dd"}, ] pre-commit = [ - {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, - {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, + {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, + {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, ] psycopg2-binary = [ {file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"}, @@ -1292,12 +1028,12 @@ pydantic = [ {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, ] pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, ] pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, + {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, + {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, ] pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, @@ -1409,42 +1145,16 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tomli = [ - {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, - {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, -] -typed-ast = [ - {file = "typed_ast-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad3b48cf2b487be140072fb86feff36801487d4abb7382bb1929aaac80638ea"}, - {file = "typed_ast-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:542cd732351ba8235f20faa0fc7398946fe1a57f2cdb289e5497e1e7f48cfedb"}, - {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc2c11ae59003d4a26dda637222d9ae924387f96acae9492df663843aefad55"}, - {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd5df1313915dbd70eaaa88c19030b441742e8b05e6103c631c83b75e0435ccc"}, - {file = "typed_ast-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:e34f9b9e61333ecb0f7d79c21c28aa5cd63bec15cb7e1310d7d3da6ce886bc9b"}, - {file = "typed_ast-1.5.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f818c5b81966d4728fec14caa338e30a70dfc3da577984d38f97816c4b3071ec"}, - {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3042bfc9ca118712c9809201f55355479cfcdc17449f9f8db5e744e9625c6805"}, - {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fff9fdcce59dc61ec1b317bdb319f8f4e6b69ebbe61193ae0a60c5f9333dc49"}, - {file = "typed_ast-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8e0b8528838ffd426fea8d18bde4c73bcb4167218998cc8b9ee0a0f2bfe678a6"}, - {file = "typed_ast-1.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ef1d96ad05a291f5c36895d86d1375c0ee70595b90f6bb5f5fdbee749b146db"}, - {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed44e81517364cb5ba367e4f68fca01fba42a7a4690d40c07886586ac267d9b9"}, - {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f60d9de0d087454c91b3999a296d0c4558c1666771e3460621875021bf899af9"}, - {file = "typed_ast-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9e237e74fd321a55c90eee9bc5d44be976979ad38a29bbd734148295c1ce7617"}, - {file = "typed_ast-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee852185964744987609b40aee1d2eb81502ae63ee8eef614558f96a56c1902d"}, - {file = "typed_ast-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:27e46cdd01d6c3a0dd8f728b6a938a6751f7bd324817501c15fb056307f918c6"}, - {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d64dabc6336ddc10373922a146fa2256043b3b43e61f28961caec2a5207c56d5"}, - {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8cdf91b0c466a6c43f36c1964772918a2c04cfa83df8001ff32a89e357f8eb06"}, - {file = "typed_ast-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:9cc9e1457e1feb06b075c8ef8aeb046a28ec351b1958b42c7c31c989c841403a"}, - {file = "typed_ast-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e20d196815eeffb3d76b75223e8ffed124e65ee62097e4e73afb5fec6b993e7a"}, - {file = "typed_ast-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:37e5349d1d5de2f4763d534ccb26809d1c24b180a477659a12c4bde9dd677d74"}, - {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f1a27592fac87daa4e3f16538713d705599b0a27dfe25518b80b6b017f0a6d"}, - {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8831479695eadc8b5ffed06fdfb3e424adc37962a75925668deeb503f446c0a3"}, - {file = "typed_ast-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:20d5118e494478ef2d3a2702d964dae830aedd7b4d3b626d003eea526be18718"}, - {file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] typer = [ {file = "typer-0.4.1-py3-none-any.whl", hash = "sha256:e8467f0ebac0c81366c2168d6ad9f888efdfb6d4e1d3d5b4a004f46fa444b5c3"}, {file = "typer-0.4.1.tar.gz", hash = "sha256:5646aef0d936b2c761a10393f0384ee6b5c7fe0bb3e5cd710b17134ca1d99cff"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] urllib3 = [ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, @@ -1454,7 +1164,3 @@ virtualenv = [ {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, ] -zipp = [ - {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, - {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, -] diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 7d1c964..2a45c8a 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -5,5 +5,5 @@ from pynocular.backends.context import backend from pynocular.backends.memory import MemoryDatabaseModelBackend from pynocular.backends.sql import SQLDatabaseModelBackend -from pynocular.model import DatabaseModel +from pynocular.database_model import DatabaseModel from pynocular.uuid_str import is_valid_uuid, UUID_STR diff --git a/pynocular/aiopg_transaction.py b/pynocular/aiopg_transaction.py deleted file mode 100644 index ac04d15..0000000 --- a/pynocular/aiopg_transaction.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Module for aiopg transaction utils""" -import asyncio -import sys -from typing import Dict, Optional, Union - -import aiocontextvars as contextvars -from aiopg.sa.connection import SAConnection -import aiopg.sa.engine - -transaction_connections_var = contextvars.ContextVar("transaction_connections") - - -def get_current_task() -> asyncio.Task: - """Get the current task when this method is called - - Returns: - The current task the method is called in - - """ - if sys.version_info.major == 3 and sys.version_info.minor > 7: - # If this is version 3.7 or higher then use the new function to get the current task - return asyncio.current_task() - else: - return asyncio.Task.current_task() - - -class LockedConnection(SAConnection): - """A wrapper connection class that won't make multiple queries at once""" - - def __init__(self, connection: SAConnection) -> None: - """Create a new LockedConnection - - Args: - connection: The connection to wrap - - """ - self._conn = connection - self.lock = asyncio.Lock() - - async def execute(self, *args, **kwargs): - """Wrapper around the `execute` method of the wrapped SAConnection""" - async with self.lock: - return await self._conn.execute(*args, **kwargs) - - def __getattr__(self, attr): - """Except for execute, all other attributes should pass through""" - return getattr(self._conn, attr) - - -class TaskContextConnection: - """Interface for managing a connection entry on the asyncio Task context - - The current asyncio.Task has a context attribute that keeps track of various keys. - We'll use this to store the open connection so we can perform our nested/conditional - transaction logic in :py:class:`transaction`. The actual value stored on the context - is a dict of connections keyed by the engine. - """ - - def __init__(self, connection_key: str) -> None: - """Initializer - - Args: - connection_key: Key for getting/setting/clearing from the connection map - - """ - self.connection_key = connection_key - self._token: Optional[contextvars.Token] = None - - # Set the asyncio task context if it's not set already. We'll look in the - # context for an open connection. - task = get_current_task() - if not hasattr(task, "context"): - task.context = contextvars.copy_context() - - @classmethod - def _get_connections(cls) -> Dict[str, LockedConnection]: - """Get the map of connections from the task context""" - global transaction_connections_var - return transaction_connections_var.get({}) - - def get(self) -> Optional[LockedConnection]: - """If there is already a connection stored, get it""" - return self._get_connections().get(self.connection_key) - - def set(self, conn: LockedConnection) -> contextvars.Token: - """Set the connection on the context - - Args: - conn: Connection to store - - Returns: - contextvars token used to reset the var in :py:meth:`.clear` - - """ - global transaction_connections_var - connections = self._get_connections() - connections[self.connection_key] = conn - token = transaction_connections_var.set(connections) - self._token = token - return token - - def clear(self) -> None: - """Clear the connection from the context""" - if not self._token: - raise ValueError("Token must be defined") - - global transaction_connections_var - transaction_connections_var.reset(self._token) - - -class transaction: - """A context manager to collect nested calls in a transaction - - To use, anywhere you want to have queries put into a transaction, do - - async with transaction(aiopg_engine) as trx: - ... - - The resulting trx object can be used just like a connection you would - get from `aiopg_engine.acquire()`, but any nested usages of this decorator - will ensure that we do not deadlock from nested acquire calls, and do not - run into errors where we attempt to use the same connection to make - multiple calls at once. - NB: It does this by ensuring that we get the same connection object and - execute serially, so you only want to use this in cases where you are - worried about these issues. - - For example, using just aiopg, this is an error: - - async with engine.acquire() as conn: - await asyncio.gather( - conn.execute(TABLE.insert().values(id=uuid(), name="foo")), - conn.execute(TABLE.insert().values(id=uuid(), name="bar"))) - - But using this class, we will not error: - - async with transaction(engine) as conn: - await asyncio.gather( - conn.execute(TABLE.insert().values(id=uuid(), name="foo")), - conn.execute(TABLE.insert().values(id=uuid(), name="bar"))) - - Note: - There are limits to the transaction rollback protection that this - context manager affords. Specifically, a known failure case can be - encountered if a DB connection is created by calling `Engine.acquire` - rather than `transaction(Engine)`, even if the call to `acquire` is - made within a transaction context. For more information, see: - :py:module:`python_core.tests.functional - .test_aiopg_transaction_integrity`. - - """ - - def __init__(self, engine: aiopg.sa.engine.Engine) -> None: - """Create a new transaction context - - Args: - engine: Database engine for making connections - - """ - self._engine = engine - - # Is this the outer-most transaction context? - # If so, this will be set to true in `__aenter__` - self._top = False - - # If we have started a transaction, store it here - self._trx = None - - # Initiatize an interface for managing the connection on the asyncio task context - self.task_connection = TaskContextConnection(str(engine)) - - async def __aenter__(self) -> LockedConnection: - """Establish the transaction context - - Figure out if this is the top level context. If so, get a connection - and start a transaction. If not, then just grab the stored connection. - """ - conn = self.task_connection.get() - if not conn: - # There is no stored connection in this context, so this must be - # the top level call. - self._top = True - # Create the connection - conn = LockedConnection(await self._engine.acquire()) - self.task_connection.set(conn) - # Start a transaction - try: - self._trx = await conn.begin() - except Exception: - self.task_connection.clear() - await conn.close() - raise - return conn - - async def __aexit__(self, exc_type, exc_value, tb) -> None: - """Exit the transaction context - - If this is the top level context, then commit the transaction (unless - there was an error, in which case we should rollback instead). - If this is not the top level context, we don't need to do anything, - since everything will be committed or rolled back by that top level - context. - """ - if self._top: - # We may have gotten here from an error, in which case it is - # possible that we are also awaiting for a query to finish - # executing. So before rolling back the connection, make sure we - # can acquire the connection lock to ensure nothing else is - # executing - conn = self.task_connection.get() - async with conn.lock: - try: - if exc_type: # There was an exception - await self._trx.rollback() - else: - await self._trx.commit() - finally: - self.task_connection.clear() - await conn.close() - - -class ConditionalTransaction(transaction): - """Context manager to conditionally collect nested calls in a transaction - - This context manager allows you to conditionally execute code in a - transaction if nested within another transaction. If it is the top level - "transaction", this will behave like a standard `engine.acquire()`. Usage - is otherwise the same as for the parent transaction class. - - Examples: - This Will behave the same as `engine.acquire`, assuming this is not - nested under a transaction elsewhere - - async with ConditionalTransaction(engine) as trx: - ... - - This will behave as a nested transaction: - - async with transaction(engine) as outer_trx: - async with ConditionalTransaction(engine) as inner_trx: - ... - - """ - - def __init__(self, engine: aiopg.sa.engine.Engine) -> None: - """Initialize the context manager - - Args: - engine: An aiopg engine - - """ - super().__init__(engine) - # The connection object, if functioning as standard connection - self._conn = None - - async def __aenter__(self) -> Union[LockedConnection, SAConnection]: - """Conditionally establish the transaction context - - Returns: - Either a locked connection or a standard connection, depending on - whether this context manager is nested under a transaction. - - """ - conn = self.task_connection.get() - # If there is already a connection stored, act as a transaction - if conn: - return await super().__aenter__() - # Otherwise behave as a standard connection - self._conn = await self._engine.acquire() - return self._conn - - async def __aexit__(self, exc_type, exc_value, tb) -> None: - """Exit the transaction context""" - if self._conn is not None: - await self._conn.close() - else: - await super().__aexit__(exc_type, exc_value, tb) diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index 56d11b0..1a7d6dd 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -42,6 +42,14 @@ class DatabaseModelBackend(ABC): """ + @abstractmethod + def transaction(self) -> Any: + """Create a new transaction + + Not all backends will be able to implement this method. + """ + pass + @abstractmethod async def select( self, diff --git a/pynocular/backends/context.py b/pynocular/backends/context.py index 456158c..8c4ddf4 100644 --- a/pynocular/backends/context.py +++ b/pynocular/backends/context.py @@ -1,11 +1,12 @@ """Contains contextvar and helper functions to manage the active database backend""" from contextlib import contextmanager - -import aiocontextvars as contextvars +import contextvars +import logging from .base import DatabaseModelBackend +logger = logging.getLogger("pynocular") _backend = contextvars.ContextVar("database_model_backend", default=None) @@ -17,11 +18,13 @@ def backend(backend: DatabaseModelBackend) -> None: backend: Database backend instance """ + logger.debug("Setting backend") token = _backend.set(backend) try: yield finally: _backend.reset(token) + logger.debug("Reset backend") def get_backend() -> DatabaseModelBackend: diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index 7d0e6fc..22f48f2 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -29,10 +29,18 @@ def __init__(self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None): in-memory database """ + super().__init__() self.records = records or defaultdict(list) # Serial primary key generator self._pk_generator = itertools.count(start=1) + def transaction(self) -> Any: + """Create a new transaction + + This fails as a warning that the in-memory backend does not support transactions. + """ + raise NotImplementedError() + async def select( self, config: DatabaseModelConfig, diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py index 0b55a83..ade5c5f 100644 --- a/pynocular/backends/sql.py +++ b/pynocular/backends/sql.py @@ -1,8 +1,10 @@ """Contains the SQLDatabaseModelBackend class""" +import logging from typing import Any, Dict, List, Optional from databases import Database +from databases.core import Transaction from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression @@ -10,6 +12,8 @@ from pynocular.backends.base import DatabaseModelBackend, DatabaseModelConfig from pynocular.exceptions import InvalidFieldValue, InvalidTextRepresentation +logger = logging.getLogger("pynocular") + class SQLDatabaseModelBackend(DatabaseModelBackend): """SQL database model backend @@ -28,6 +32,15 @@ def __init__(self, db: Database): """ self.db = db + def transaction(self) -> Transaction: + """Create a new transaction + + Returns: + new transaction to be used as a context manager + + """ + return self.db.transaction() + async def select( self, config: DatabaseModelConfig, @@ -52,7 +65,7 @@ async def select( InvalidFieldValue: The class is missing a database table """ - async with self.db.transaction(): + async with self.transaction(): query = config.table.select() if where_expressions is not None and len(where_expressions) > 0: query = query.where(and_(*where_expressions)) @@ -86,12 +99,12 @@ async def create_records( if not records: return [] - async with self.db.transaction(): + async with self.transaction(): result = await self.db.fetch_all( insert(config.table).values(records).returning(config.table) ) - return [dict(record) for record in result] + return [dict(record) for record in result] async def delete_records( self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] @@ -105,7 +118,7 @@ async def delete_records( `and`ed together for the where clause of the backend query """ - async with self.db.transaction(): + async with self.transaction(): query = config.table.delete().where(and_(*where_expressions)) try: await self.db.execute(query) @@ -133,7 +146,7 @@ async def update_records( the updated database records """ - async with self.db.transaction(): + async with self.transaction(): query = ( config.table.update() .where(and_(*where_expressions)) @@ -164,7 +177,8 @@ async def upsert( the updated record """ - async with self.db.transaction(): + async with self.transaction(): + logger.debug("Upsert starting") query = ( insert(config.table) .values(record) @@ -174,4 +188,5 @@ async def upsert( .returning(config.table) ) updated_record = await self.db.fetch_one(query) + logger.debug("Upsert complete") return dict(updated_record) diff --git a/pynocular/config.py b/pynocular/config.py deleted file mode 100644 index ab125c0..0000000 --- a/pynocular/config.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Configuration for engines and models""" -import os - -POOL_RECYCLE = int(os.environ.get("POOL_RECYCLE", 300)) -DB_POOL_MIN_SIZE = int(os.environ.get("DB_POOL_MIN_SIZE", 2)) -DB_POOL_MAX_SIZE = int(os.environ.get("DB_POOL_MAX_SIZE", 10)) diff --git a/pynocular/database_model.py b/pynocular/database_model.py index 20fbdeb..fe98db2 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -1,16 +1,13 @@ -"""Base Model class that implements CRUD methods for database entities based on Pydantic dataclasses""" +"""Contains DatabaseModel class""" + import asyncio from datetime import datetime from enum import Enum, EnumMeta -import inspect -from typing import Any, Callable, Dict, Generator, List, Optional, Sequence, Set, Union +from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING from uuid import UUID as stdlib_uuid -from aenum import Enum as AEnum, EnumMeta as AEnumMeta -from pydantic import BaseModel, PositiveFloat, PositiveInt -from pydantic.types import UUID4 +from pydantic import BaseModel, PositiveFloat, PositiveInt, UUID4 from sqlalchemy import ( - and_, Boolean, Column, Enum as SQLEnum, @@ -21,152 +18,73 @@ TIMESTAMP, VARCHAR, ) -from sqlalchemy.dialects.postgresql import insert, JSONB, UUID as sqlalchemy_uuid +from sqlalchemy.dialects.postgresql import JSONB, UUID as sqlalchemy_uuid from sqlalchemy.schema import FetchedValue from sqlalchemy.sql.base import ImmutableColumnCollection from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression -from pynocular.engines import DBEngine, DBInfo +from pynocular.backends.base import DatabaseModelConfig +from pynocular.backends.context import get_backend from pynocular.exceptions import ( DatabaseModelMisconfigured, DatabaseModelMissingField, DatabaseRecordNotFound, - InvalidFieldValue, InvalidMethodParameterization, - InvalidTextRepresentation, - NestedDatabaseModelNotResolved, ) -from pynocular.nested_database_model import NestedDatabaseModel -from pynocular.uuid_str import is_valid_uuid, UUID_STR - - -def nested_model( - db_model_class: "DatabaseModel", reference_field: str = None -) -> Callable: - """Generate a NestedModel class with dynamic model references - - Args: - db_model_class: The specific model class that will be nested. This will be a - subclass of `DatabaseModel` - reference_field: The name of the field on the database table that this nested - model references. - - """ - - class NestedModel: - """NestedModel type for NestedDatabaseModels""" - - reference_field_name = reference_field - - @classmethod - def __get_validators__(cls) -> Generator: - """Get the validators for the given class""" - yield cls.validate - - @classmethod - def validate(cls, v: Union[UUID_STR, "DatabaseModel"]) -> NestedDatabaseModel: - """Validate value and generate a nested database model""" - # If value is a uuid then create a NestedDatabaseModel, otherwise just - # Set the DatabaseModel as the value - if is_valid_uuid(v): - return NestedDatabaseModel(db_model_class, v) - else: - return NestedDatabaseModel(db_model_class, v.get_primary_id(), v) - - return NestedModel - - -def database_model(table_name: str, database_info: DBInfo) -> "DatabaseModel": - """Decorator that adds SQL functionality to Pydantic BaseModel objects - - Args: - table_name: Name of the table this model represents in the database - database_info: Database connection info for the database to connect to - - Raises: - DatabaseModelMisconfigured: Raised when class with this decorator is not a pydantic.BaseModel - subclass. We depend on the class implementing a some specific things and currently don't - support any other type of dataclass. - - """ - - def wrapped(cls): - if BaseModel not in inspect.getmro(cls): - raise DatabaseModelMisconfigured( - "Model is not subclass of pydantic.BaseModel" - ) - - cls.__bases__ += (DatabaseModel,) - cls.initialize_table(table_name, database_info) - - return cls - - return wrapped +from pynocular.uuid_str import UUID_STR -class DatabaseModel: - """Adds database functionality to a Pydantic BaseModel - - A DatabaseModel is a Pydantic based model along with a SQLAlchemy - table object. This allows us to use the same object for both - database queries and HTTP requests +class DatabaseModel(BaseModel): + """DatabaseModel defines a Pydantic model that abstracts away backend storage + This allows us to use the same object for both database queries and HTTP requests. + Methods on the DatabaseModel call through to the active backend implementation. The + backend handle queries and storage. """ - # Define metadata for the database connection on the class level so we don't - # have to recaluclate the table for each database call - _table: Table = None - _database_info: DBInfo = None - - # We may have times where we need a compound primary key. - # We store each one into this list and have our query functions - # handle using it - _primary_keys: List[Column] = None - - # Some fields are exclusively produced by the database server - # For all save operations, we need to get those values from the database - # These are the server_default and server_onupdate functions in SQLAlchemy - _db_managed_fields: List[str] = None - - # The following tables track which attributes on the model are nested model - # references - # Some nested model attributes may have different names than their actual db table; - # For example; on an App we may have an `org` attribute but the db field is - # `organzation_id` + if TYPE_CHECKING: + # Set by _process_config + _config: DatabaseModelConfig - # In order to manage this we also need maps from attribute name to table_field_name - # and back - _nested_model_attributes: Set[str] = None - _nested_attr_table_field_map: Dict[str, str] = None - _nested_table_field_attr_map: Dict[str, str] = None + @staticmethod + def _process_config(cls, table_name: str) -> DatabaseModelConfig: + """Process configuration passed into the DatabaseModel subclass signature - # This can be used to access the table when defining where expressions - columns: ImmutableColumnCollection = None - - @classmethod - def initialize_table(cls, table_name: str, database_info: DBInfo) -> None: - """Returns a SQLAlchemy table definition to expose SQLAlchemy functions - - This method should cache the Table on the __table__ class property. - We don't want to have to recaluclate the table for every SQL call, - so it's desirable to cache this at the class level. + The primary job of this method is to generate a DatabaseModelConfig instance, + specifically a SQLAlchemy table definition for backend implementations to + leverage. Returns: - A Table object based on the Field properties defined from the Pydantic model + DatabaseModelConfig instance Raises: - DatabaseModelMisconfigured: When the class does not defined certain properties; - or cannot be converted to a Table + DatabaseModelMisconfigured: When the class does not define certain + properties or cannot be converted to a SQLAlchemy Table """ - cls._primary_keys = [] - cls._database_info = database_info - cls._db_managed_fields = [] - cls._nested_attr_table_field_map = {} - cls._nested_table_field_attr_map = {} - cls._nested_model_attributes = set() - - columns = [] + # We may have times where we need a compound primary key. + # We store each one into this list and have our query functions + # handle using it + primary_keys: List[Column] = [] + + # Some fields are exclusively produced by the database server + # For all save operations, we need to get those values from the database + # These are the server_default and server_onupdate functions in SQLAlchemy + db_managed_fields: List[str] = [] + + # The following tables track which attributes on the model are nested model + # references + # Some nested model attributes may have different names than their actual db table; + # For example; on an App we may have an `org` attribute but the db field is + # `organzation_id` + + # In order to manage this we also need maps from attribute name to table_field_name + # and back + nested_model_attributes: Set[str] = set() + nested_attr_table_field_map: Dict[str, str] = {} + nested_table_field_attr_map: Dict[str, str] = {} + + columns: List[Column] = [] for field in cls.__fields__.values(): name = field.name is_nullable = not field.required @@ -191,10 +109,8 @@ def initialize_table(cls, table_name: str, database_info: DBInfo) -> None: or field.type_.__name__ == "ConstrainedFloatValue" ): type = Float - elif field.type_.__class__ in (AEnumMeta, EnumMeta): - type = SQLEnum( - field.type_, values_callable=lambda obj: [e.value for e in obj] - ) + elif field.type_.__class__ == EnumMeta: + type = SQLEnum(field.type_) elif field.type_ is bool: type = Boolean elif field.type_ in (dict, Dict): @@ -204,16 +120,12 @@ def initialize_table(cls, table_name: str, database_info: DBInfo) -> None: elif field.type_ is datetime: type = TIMESTAMP(timezone=True) elif field.type_.__name__ == "NestedModel": - cls._nested_model_attributes.add(name) + nested_model_attributes.add(name) # If the field name on the NestedModel type is not None, use that for the # column name if field.type_.reference_field_name is not None: - cls._nested_attr_table_field_map[ - name - ] = field.type_.reference_field_name - cls._nested_table_field_attr_map[ - field.type_.reference_field_name - ] = name + nested_attr_table_field_map[name] = field.type_.reference_field_name + nested_table_field_attr_map[field.type_.reference_field_name] = name name = field.type_.reference_field_name # Assume all IDs are UUIDs for now @@ -230,29 +142,117 @@ def initialize_table(cls, table_name: str, database_info: DBInfo) -> None: if fetch_on_create: column.server_default = FetchedValue() - cls._db_managed_fields.append(name) + db_managed_fields.append(name) if fetch_on_update: column.server_onupdate = FetchedValue() - cls._db_managed_fields.append(name) + db_managed_fields.append(name) if is_primary_key: - cls._primary_keys.append(column) + primary_keys.append(column) columns.append(column) - cls._table = Table(table_name, MetaData(), *columns) - cls.columns = cls._table.c + # Define metadata for the database connection on the class level so we don't + # have to recalculate the table for each database call + table = Table(table_name, MetaData(), *columns) + + return DatabaseModelConfig( + fields={**cls.__fields__}, + db_managed_fields=db_managed_fields, + nested_attr_table_field_map=nested_attr_table_field_map, + nested_model_attributes=nested_model_attributes, + nested_table_field_attr_map=nested_table_field_attr_map, + primary_keys=primary_keys, + table=table, + ) + + def __init_subclass__(cls, table_name: str, **kwargs) -> None: + """Hook for processing class configuration when DatabaseModel is subclassed + + Args: + table_name: Name of the database table + + """ + super().__init_subclass__(**kwargs) + cls._config = DatabaseModel._process_config(cls, table_name) + + @classmethod + @property + def table(cls) -> Table: + """Returns SQLAlchemy table object for the model""" + return cls._config.table + + @classmethod + @property + def columns(cls) -> ImmutableColumnCollection: + """Reference to the model's table's column collection""" + return cls.table.c @classmethod - def get_table(cls) -> Table: - """Get the table object associated with this model + def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": + """Instantiate a DatabaseModel object from a dict record + + Note: + This is the base implementation and is set up so classes that subclass this + one don't have to make this boilerplate if they don't need to + + Args: + _dict: The dictionary form of the DatabaseModel + + Returns: + The DatabaseModel object + + """ + modified_dict = {} + for key, value in _dict.items(): + modified_key = cls._config.nested_table_field_attr_map.get(key, key) + modified_dict[modified_key] = value + return cls(**modified_dict) + + def to_dict( + self, serialize: bool = False, include_keys: Optional[Sequence] = None + ) -> Dict[str, Any]: + """Create a dict from the DatabaseModel object + + Note: + This implementation is only valid if __base_props__ is set for the instance + + Args: + serialize: A flag determining whether or not to serialize enum types into + strings + include_keys: Set of keys that should be included in the results. If not + provided or empty, all keys will be included. Returns: - The SQLALChemy table object for the model + A dict of the DatabaseObject object + + Raises: + NotImplementedError: This function implementation is being used without + __base_props__ being set """ - return cls._table + _dict = {} + for prop_name, prop_value in self.dict().items(): + if serialize: + if isinstance(prop_value, Enum): + prop_value = prop_value.name + + if prop_name in self._config.nested_model_attributes: + # self.dict() will serialize any BaseModels into a dict so fetch the + # actual object from self + temp_prop_value = getattr(self, prop_name) + prop_name = self._config.nested_attr_table_field_map.get( + prop_name, prop_name + ) + # temp_prop_value can be `None` if the nested key is optional + if temp_prop_value is not None: + prop_value = temp_prop_value.get_primary_id() + + if not include_keys or prop_name in include_keys: + _dict[prop_name] = prop_value + + return _dict @classmethod async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": @@ -269,7 +269,7 @@ async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": obj = await cls.get(*args, **kwargs) gatherables = [ (getattr(obj, prop_name)).fetch() - for prop_name in cls._nested_model_attributes + for prop_name in cls._config.nested_model_attributes ] await asyncio.gather(*gatherables) @@ -295,28 +295,80 @@ async def get(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": if ( (len(args) > 1) or (len(args) == 1 and len(kwargs) > 0) - or (len(args) == 1 and len(cls._primary_keys) > 1) + or (len(args) == 1 and len(cls._config.primary_keys) > 1) or (len(args) == 0 and len(kwargs) == 0) ): raise InvalidMethodParameterization("get", args=args, kwargs=kwargs) if len(args) == 1: - primary_key_dict = {cls._primary_keys[0].name: args[0]} + primary_key_dict = {cls._config.primary_keys[0].name: args[0]} else: primary_key_dict = kwargs original_primary_key_dict = primary_key_dict.copy() where_expressions = [] - for primary_key in cls._primary_keys: + for primary_key in cls._config.primary_keys: primary_key_value = primary_key_dict.pop(primary_key.name) where_expressions.append(primary_key == primary_key_value) records = await cls.select(where_expressions=where_expressions, limit=1) if len(records) == 0: - raise DatabaseRecordNotFound(cls._table.name, **original_primary_key_dict) + raise DatabaseRecordNotFound( + cls._config.table.name, **original_primary_key_dict + ) return records[0] + @classmethod + async def create(cls, **data) -> "DatabaseModel": + """Create a new instance of the this DatabaseModel and save it + + Args: + kwargs: The parameters for the instance + + Returns: + The new DatabaseModel instance + + """ + new = cls(**data) + await new.save() + + return new + + def get_primary_id(self) -> Any: + """Standard interface for returning the id of a field + + This assumes that there is a single primary id, otherwise this returns `None` + + Returns: + The ID value for this DatabaseModel instance + + """ + if len(self._config.primary_keys) > 1: + return None + + return getattr(self, self._config.primary_keys[0].name) + + async def fetch(self, resolve_references: bool = False) -> None: + """Gets the latest of the object from the database and updates itself + + Args: + resolve_references: If True, resolve any nested key references + + """ + # Get the latest version of self + get_params = { + primary_key.name: getattr(self, primary_key.name) + for primary_key in self._config.primary_keys + } + if resolve_references: + new_self = await self.get_with_refs(**get_params) + else: + new_self = await self.get(**get_params) + + for attr_name, new_attr_val in new_self.dict().items(): + setattr(self, attr_name, new_attr_val) + @classmethod async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: """Fetches the DatabaseModel for based on the provided kwargs @@ -334,12 +386,14 @@ async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: exist on the database table """ - where_clause_list = [] + where_expressions = [] for field_name, db_field_value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) try: - db_field = getattr(cls._table.c, db_field_name) + db_field = getattr(cls._config.table.c, db_field_name) except AttributeError: raise DatabaseModelMissingField(cls.__name__, db_field_name) @@ -348,9 +402,9 @@ async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: else: exp = db_field == db_field_value - where_clause_list.append(exp) + where_expressions.append(exp) - return await cls.select(where_expressions=where_clause_list) + return await cls.select(where_expressions=where_expressions) @classmethod async def select( @@ -374,41 +428,13 @@ async def select( DatabaseModelMisconfigured: The class is missing a database table """ - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=True) - ) as conn: - query = cls._table.select() - if where_expressions is not None and len(where_expressions) > 0: - query = query.where(and_(*where_expressions)) - if order_by is not None and len(order_by) > 0: - query = query.order_by(*order_by) - if limit is not None and limit > 0: - query = query.limit(limit) - - try: - result = await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - records = await result.fetchall() - - return [cls.from_dict(dict(record)) for record in records] - - @classmethod - async def create(cls, **data) -> "DatabaseModel": - """Create a new instance of the this DatabaseModel and save it - - Args: - kwargs: The parameters for the instance - - Returns: - The new DatabaseModel instance - - """ - new = cls(**data) - await new.save() - - return new + records = await get_backend().select( + cls._config, + where_expressions=where_expressions, + order_by=order_by, + limit=limit, + ) + return [cls.from_dict(record) for record in records] @classmethod async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel"]: @@ -423,31 +449,35 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel list of new database models that have been saved """ - if not models: - return [] - values = [] for model in models: dict_obj = model.to_dict() - for field in cls._db_managed_fields: - # Remove any fields that the database calculates + + # Remove any fields that the database calculates + for field in cls._config.db_managed_fields: del dict_obj[field] + + # Remove keys for primary keys that don't have a value. This indicates that + # the backend will generate new values. + for field in cls._config.primary_keys: + if dict_obj.get(field.name) is None: + del dict_obj[field.name] + values.append(dict_obj) - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - result = await conn.execute( - insert(cls._table).values(values).returning(cls._table) - ) - # Set db managed column information on the object - rows = await result.fetchall() - for row, model in zip(rows, models): - record_dict = dict(row) - for column in cls._db_managed_fields: - col_val = record_dict.get(column) - if col_val is not None: - setattr(model, column, col_val) + records = await get_backend().create_records(cls._config, values) + + # Set db managed column information on the object + for record, model in zip(records, models): + for column in cls._config.db_managed_fields: + col_val = record.get(column) + if col_val is not None: + setattr(model, column, col_val) + + for field in cls._config.primary_keys: + value = record.get(field.name) + if value is not None: + setattr(model, field.name, value) return models @@ -465,12 +495,14 @@ async def delete_records(cls, **kwargs: Any) -> None: exist on the database table """ - where_clause_list = [] + where_expressions = [] for field_name, db_field_value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) try: - db_field = getattr(cls._table.c, db_field_name) + db_field = getattr(cls._config.table.c, db_field_name) except AttributeError: raise DatabaseModelMissingField(cls.__name__, db_field_name) @@ -479,17 +511,9 @@ async def delete_records(cls, **kwargs: Any) -> None: else: exp = db_field == db_field_value - where_clause_list.append(exp) + where_expressions.append(exp) - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - query = cls._table.delete().where(and_(*where_clause_list)) - try: - await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) + return await get_backend().delete_records(cls._config, where_expressions) @classmethod async def update_record(cls, **kwargs: Any) -> "DatabaseModel": @@ -507,19 +531,21 @@ async def update_record(cls, **kwargs: Any) -> "DatabaseModel": """ where_expressions = [] primary_key_dict = {} - for primary_key in cls._primary_keys: + for primary_key in cls._config.primary_keys: primary_key_value = kwargs.pop(primary_key.name) where_expressions.append(primary_key == primary_key_value) primary_key_dict[primary_key.name] = primary_key_value modified_kwargs = {} for field_name, value in kwargs.items(): - db_field_name = cls._nested_attr_table_field_map.get(field_name, field_name) + db_field_name = cls._config.nested_attr_table_field_map.get( + field_name, field_name + ) modified_kwargs[db_field_name] = value updated_records = await cls.update(where_expressions, modified_kwargs) if len(updated_records) == 0: - raise DatabaseRecordNotFound(cls._table.name, **primary_key_dict) + raise DatabaseRecordNotFound(cls._config.table.name, **primary_key_dict) return updated_records[0] @classmethod @@ -541,184 +567,34 @@ async def update( DatabaseModelMisconfigured: The class is missing a database table """ - async with ( - await DBEngine.transaction(cls._database_info, is_conditional=False) - ) as conn: - query = ( - cls._table.update() - .where(and_(*where_expressions)) - .values(**values) - .returning(cls._table) + return [ + cls.from_dict(record) + for record in await get_backend().update_records( + cls._config, where_expressions=where_expressions, values=values ) - try: - results = await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - return [cls.from_dict(dict(record)) for record in await results.fetchall()] - - async def save(self, include_nested_models=False) -> None: - """Update the database record this object represents with its current state - - Args: - include_nested_models: If True, any nested models should get saved before - this object gets saved - - """ + ] + async def save(self) -> None: + """Update the database record this object represents with its current state""" dict_self = self.to_dict() - - primary_key_names = [primary_key.name for primary_key in self._primary_keys] - - for field in self._db_managed_fields: - if field in primary_key_names and dict_self[field] is not None: + for field in self._config.db_managed_fields: + if field in self._config.primary_key_names and dict_self[field] is not None: continue # Remove any fields that the database calculates del dict_self[field] - async with ( - await DBEngine.transaction(self._database_info, is_conditional=False) - ) as conn: - # If flag is set, first try to persist any nested models. This needs to - # happen inside of the transaction so if something fails everything gets - # rolled back - if include_nested_models: - for attr_name in self._nested_model_attributes: - try: - obj = getattr(self, attr_name) - if obj is not None: - await obj.save() - except NestedDatabaseModelNotResolved: - # If the object was never resolved than it already exists in the - # DB and the DB has the latest state - continue - - record = await conn.execute( - insert(self._table) - .values(dict_self) - .on_conflict_do_update(index_elements=primary_key_names, set_=dict_self) - .returning(self._table) - ) - - row = await record.fetchone() - - for field in self._db_managed_fields: - setattr(self, field, row[field]) - - def get_primary_id(self) -> Any: - """Standard interface for returning the id of a field - - This assumes that there is a single primary id, otherwise this returns `None` - - Returns: - The ID value for this DatabaseModel instance - - """ - if len(self._primary_keys) > 1: - return None - - return getattr(self, self._primary_keys[0].name) - - async def fetch(self, resolve_references: bool = False) -> None: - """Gets the latest of the object from the database and updates itself - - Args: - resolve_references: If True, resolve any nested key references - - """ - # Get the latest version of self - get_params = { - primary_key.name: getattr(self, primary_key.name) - for primary_key in self._primary_keys - } - if resolve_references: - new_self = await self.get_with_refs(**get_params) - else: - new_self = await self.get(**get_params) - - for attr_name, new_attr_val in new_self.dict().items(): - setattr(self, attr_name, new_attr_val) + record = await get_backend().upsert( + self._config, + dict_self, + ) + for field in self._config.db_managed_fields: + setattr(self, field, record[field]) async def delete(self) -> None: """Delete this record from the database""" - - async with ( - await DBEngine.transaction(self._database_info, is_conditional=False) - ) as conn: - where_expressions = [ - getattr(self._table.c, pkey.name) == getattr(self, pkey.name) - for pkey in self._primary_keys - ] - query = self._table.delete().where(and_(*where_expressions)) - try: - await conn.execute(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - @classmethod - def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": - """Instantiate a DatabaseModel object from a dict record - - Note: - This is the base implementation and is set up so classes that subclass this - one don't have to make this boilerplate if they don't need to - - Args: - _dict: The dictionary form of the DatabaseModel - - Returns: - The DatabaseModel object - - """ - modified_dict = {} - for key, value in _dict.items(): - modified_key = cls._nested_table_field_attr_map.get(key, key) - modified_dict[modified_key] = value - return cls(**modified_dict) - - def to_dict( - self, serialize: bool = False, include_keys: Optional[Sequence] = None - ) -> Dict[str, Any]: - """Create a dict from the DatabaseModel object - - Note: - This implementation is only valid if __base_props__ is set for the instance - - Args: - serialize: A flag determining whether or not to serialize enum types into - strings - include_keys: Set of keys that should be included in the results. If not - provided or empty, all keys will be included. - - Returns: - A dict of the DatabaseObject object - - Raises: - NotImplementedError: This function implementation is being used without - __base_props__ being set - - """ - _dict = {} - for prop_name, prop_value in self.dict().items(): - if serialize: - if isinstance(prop_value, Enum): - prop_value = prop_value.name - elif isinstance(prop_value, AEnum): - prop_value = prop_value.value - - if prop_name in self._nested_model_attributes: - # self.dict() will serialize any BaseModels into a dict so fetch the - # actual object from self - temp_prop_value = getattr(self, prop_name) - prop_name = self._nested_attr_table_field_map.get(prop_name, prop_name) - # temp_prop_value can be `None` if the nested key is optional - if temp_prop_value is not None: - prop_value = temp_prop_value.get_primary_id() - - if not include_keys or prop_name in include_keys: - _dict[prop_name] = prop_value - - return _dict + where_expressions = [ + getattr(self._config.table.c, pkey.name) == getattr(self, pkey.name) + for pkey in self._config.primary_keys + ] + return await get_backend().delete_records(self._config, where_expressions) diff --git a/pynocular/db_util.py b/pynocular/db_util.py index 57123d9..0504379 100644 --- a/pynocular/db_util.py +++ b/pynocular/db_util.py @@ -1,15 +1,18 @@ """Database utility functions""" +from functools import wraps import logging import re +from typing import Any, Coroutine -from databases import Database +from databases.core import Database import sqlalchemy as sa from sqlalchemy.sql.ddl import CreateTable, DropTable +from pynocular.backends.context import get_backend from pynocular.exceptions import InvalidSqlIdentifierErr -logger = logging.getLogger() +logger = logging.getLogger("pynocular") async def is_database_available(connection_string: str) -> bool: @@ -65,7 +68,9 @@ async def drop_table(db: Database, table: sa.Table) -> None: table: The table to create """ + logger.debug(f"Dropping table {table.name}") await db.execute(DropTable(table, if_exists=True)) + logger.debug(f"Dropped table {table.name}") async def setup_datetime_trigger(db: Database) -> None: @@ -181,3 +186,53 @@ def get_cleaned_db_name( raise InvalidSqlIdentifierErr(cleaned_name) return cleaned_name + + +async def gather(*coros: Coroutine, return_exceptions: bool = False) -> list[Any]: + """Helper function to run a collection of coroutines in sequence + + This should be used inside of database transaction instead of asyncio.gather to + avoid issues caused by multiple concurrent queries. + + See https://github.com/encode/databases/issues/125#issuecomment-511720013 + + Args: + return_exceptions: Flag that controls whether exceptions are returned in the + list instead of raised immediately. Defaults to False. + + Returns: + list of results from executing the coroutines + + """ + results = [] + for coro in coros: + try: + result = await coro + results.append(result) + except Exception as e: + if return_exceptions: + results.append(e) + else: + raise + + return results + + +def transaction(f): + """Helper decorator to wrap a function in a database transaction + + Args: + f: Function to wrap + + Returns: + wrapped function that will execute in a transaction + + """ + + @wraps(f) + async def wrapper(*args, **kwargs): + """Wrapper function""" + async with get_backend().transaction(): + return await f(*args, **kwargs) + + return wrapper diff --git a/pynocular/exceptions.py b/pynocular/exceptions.py index 7a707ea..8bd315d 100644 --- a/pynocular/exceptions.py +++ b/pynocular/exceptions.py @@ -306,22 +306,3 @@ def __init__(self, identifier: str) -> None: def __str__(self) -> str: """Returns the message describing the exception""" return f"Invalid identifier {self.identifier}" - - -class NestedDatabaseModelNotResolved(BaseException): - """Indicates a property was accessed before the reference was resolved""" - - def __init__(self, model_cls: str, nested_model_id_value: Any) -> None: - """Initialize NestedDatabaseModelNotResolved - - Args: - model_cls: The class name of the model that was being referenecd - nested_model_id_value: The value of the unique id for this nested model - - """ - msg = ( - f"Object {model_cls} with id {nested_model_id_value} was not resolved." - f" Please call `fetch()` before trying to access properties of {model_cls}" - ) - - super().__init__(msg, msg) diff --git a/pynocular/model.py b/pynocular/model.py deleted file mode 100644 index 3f8834b..0000000 --- a/pynocular/model.py +++ /dev/null @@ -1,600 +0,0 @@ -"""Contains DatabaseModel class""" - -import asyncio -from datetime import datetime -from enum import Enum, EnumMeta -from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING -from uuid import UUID as stdlib_uuid - -from pydantic import BaseModel, PositiveFloat, PositiveInt, UUID4 -from sqlalchemy import ( - Boolean, - Column, - Enum as SQLEnum, - Float, - Integer, - MetaData, - Table, - TIMESTAMP, - VARCHAR, -) -from sqlalchemy.dialects.postgresql import JSONB, UUID as sqlalchemy_uuid -from sqlalchemy.schema import FetchedValue -from sqlalchemy.sql.base import ImmutableColumnCollection -from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression - -from pynocular.backends.base import DatabaseModelConfig -from pynocular.backends.context import get_backend -from pynocular.exceptions import ( - DatabaseModelMisconfigured, - DatabaseModelMissingField, - DatabaseRecordNotFound, - InvalidMethodParameterization, -) -from pynocular.uuid_str import UUID_STR - - -class DatabaseModel(BaseModel): - """DatabaseModel defines a Pydantic model that abstracts away backend storage - - This allows us to use the same object for both database queries and HTTP requests. - Methods on the DatabaseModel call through to the active backend implementation. The - backend handle queries and storage. - """ - - if TYPE_CHECKING: - # Set by _process_config - _config: DatabaseModelConfig - - @staticmethod - def _process_config(cls, table_name: str) -> DatabaseModelConfig: - """Process configuration passed into the DatabaseModel subclass signature - - The primary job of this method is to generate a DatabaseModelConfig instance, - specifically a SQLAlchemy table definition for backend implementations to - leverage. - - Returns: - DatabaseModelConfig instance - - Raises: - DatabaseModelMisconfigured: When the class does not defined certain - properties; or cannot be converted to a Table - - """ - # We may have times where we need a compound primary key. - # We store each one into this list and have our query functions - # handle using it - primary_keys: List[Column] = [] - - # Some fields are exclusively produced by the database server - # For all save operations, we need to get those values from the database - # These are the server_default and server_onupdate functions in SQLAlchemy - db_managed_fields: List[str] = [] - - # The following tables track which attributes on the model are nested model - # references - # Some nested model attributes may have different names than their actual db table; - # For example; on an App we may have an `org` attribute but the db field is - # `organzation_id` - - # In order to manage this we also need maps from attribute name to table_field_name - # and back - nested_model_attributes: Set[str] = set() - nested_attr_table_field_map: Dict[str, str] = {} - nested_table_field_attr_map: Dict[str, str] = {} - - columns: List[Column] = [] - for field in cls.__fields__.values(): - name = field.name - is_nullable = not field.required - is_primary_key = field.field_info.extra.get("primary_key", False) - fetch_on_create = field.field_info.extra.get("fetch_on_create", False) - fetch_on_update = field.field_info.extra.get("fetch_on_update", False) - - if field.type_ is str: - type = VARCHAR - elif field.type_.__name__ == "ConstrainedStrValue": - # This is because pydantic is doing some kind of dynamic type construction. - # See: https://github.com/samuelcolvin/pydantic/blob/e985857e5a9ede8d346b010a5a039aa84a089826/pydantic/types.py#L245-L263 - length = field.field_info.max_length - type = VARCHAR(length) - elif ( - field.type_ in (int, PositiveInt) - or field.type_.__name__ == "ConstrainedIntValue" - ): - type = Integer - elif ( - field.type_ in (float, PositiveFloat) - or field.type_.__name__ == "ConstrainedFloatValue" - ): - type = Float - elif field.type_.__class__ == EnumMeta: - type = SQLEnum(field.type_) - elif field.type_ is bool: - type = Boolean - elif field.type_ in (dict, Dict): - type = JSONB(none_as_null=True) - elif field.type_ in (UUID4, stdlib_uuid, UUID_STR): - type = sqlalchemy_uuid() - elif field.type_ is datetime: - type = TIMESTAMP(timezone=True) - elif field.type_.__name__ == "NestedModel": - nested_model_attributes.add(name) - # If the field name on the NestedModel type is not None, use that for the - # column name - if field.type_.reference_field_name is not None: - nested_attr_table_field_map[name] = field.type_.reference_field_name - nested_table_field_attr_map[field.type_.reference_field_name] = name - name = field.type_.reference_field_name - - # Assume all IDs are UUIDs for now - type = sqlalchemy_uuid() - # TODO - how are people using this today? Is there a class we need to make or can we reuse one - # elif field.type_ is bit: - # type = Bit - else: - raise DatabaseModelMisconfigured(f"Unsupported type {field.type_}") - - column = Column( - name, type, primary_key=is_primary_key, nullable=is_nullable - ) - - if fetch_on_create: - column.server_default = FetchedValue() - db_managed_fields.append(name) - - if fetch_on_update: - column.server_onupdate = FetchedValue() - db_managed_fields.append(name) - - if is_primary_key: - primary_keys.append(column) - - columns.append(column) - - # Define metadata for the database connection on the class level so we don't - # have to recalculate the table for each database call - table = Table(table_name, MetaData(), *columns) - - return DatabaseModelConfig( - fields={**cls.__fields__}, - db_managed_fields=db_managed_fields, - nested_attr_table_field_map=nested_attr_table_field_map, - nested_model_attributes=nested_model_attributes, - nested_table_field_attr_map=nested_table_field_attr_map, - primary_keys=primary_keys, - table=table, - ) - - def __init_subclass__(cls, table_name: str, **kwargs) -> None: - """Hook for processing class configuration when DatabaseModel is subclassed - - Args: - table_name: Name of the database table - - """ - super().__init_subclass__(**kwargs) - cls._config = DatabaseModel._process_config(cls, table_name) - - @classmethod - @property - def table(cls) -> Table: - """Returns SQLAlchemy table object for the model""" - return cls._config.table - - @classmethod - @property - def columns(cls) -> ImmutableColumnCollection: - """Reference to the model's table's column collection""" - return cls.table.c - - @classmethod - def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": - """Instantiate a DatabaseModel object from a dict record - - Note: - This is the base implementation and is set up so classes that subclass this - one don't have to make this boilerplate if they don't need to - - Args: - _dict: The dictionary form of the DatabaseModel - - Returns: - The DatabaseModel object - - """ - modified_dict = {} - for key, value in _dict.items(): - modified_key = cls._config.nested_table_field_attr_map.get(key, key) - modified_dict[modified_key] = value - return cls(**modified_dict) - - def to_dict( - self, serialize: bool = False, include_keys: Optional[Sequence] = None - ) -> Dict[str, Any]: - """Create a dict from the DatabaseModel object - - Note: - This implementation is only valid if __base_props__ is set for the instance - - Args: - serialize: A flag determining whether or not to serialize enum types into - strings - include_keys: Set of keys that should be included in the results. If not - provided or empty, all keys will be included. - - Returns: - A dict of the DatabaseObject object - - Raises: - NotImplementedError: This function implementation is being used without - __base_props__ being set - - """ - _dict = {} - for prop_name, prop_value in self.dict().items(): - if serialize: - if isinstance(prop_value, Enum): - prop_value = prop_value.name - - if prop_name in self._config.nested_model_attributes: - # self.dict() will serialize any BaseModels into a dict so fetch the - # actual object from self - temp_prop_value = getattr(self, prop_name) - prop_name = self._config.nested_attr_table_field_map.get( - prop_name, prop_name - ) - # temp_prop_value can be `None` if the nested key is optional - if temp_prop_value is not None: - prop_value = temp_prop_value.get_primary_id() - - if not include_keys or prop_name in include_keys: - _dict[prop_name] = prop_value - - return _dict - - @classmethod - async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": - """Gets the DatabaseModel associated with any nested key references resolved - - Args: - args: The column id for the object's primary key - kwargs: The columns and ids that make up the object's composite primary key - - Returns: - A DatabaseModel object representing the record in the db if one exists - - """ - obj = await cls.get(*args, **kwargs) - gatherables = [ - (getattr(obj, prop_name)).fetch() - for prop_name in cls._config.nested_model_attributes - ] - await asyncio.gather(*gatherables) - - return obj - - @classmethod - async def get(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": - """Gets the DatabaseModel for the given primary key value(s) - - Args: - args: The column id for the object's primary key - kwargs: The columns and ids that make up the object's composite primary key - - Returns: - A DatabaseModel object representing the record in the db if one exists - - Raises: - InvalidMethodParameterization: An invalid parameter configuration was passed in. - This method should only receive one arg or >= one kwargs. Any other - combination of parameters is invalid. - - """ - if ( - (len(args) > 1) - or (len(args) == 1 and len(kwargs) > 0) - or (len(args) == 1 and len(cls._config.primary_keys) > 1) - or (len(args) == 0 and len(kwargs) == 0) - ): - raise InvalidMethodParameterization("get", args=args, kwargs=kwargs) - - if len(args) == 1: - primary_key_dict = {cls._config.primary_keys[0].name: args[0]} - else: - primary_key_dict = kwargs - - original_primary_key_dict = primary_key_dict.copy() - where_expressions = [] - for primary_key in cls._config.primary_keys: - primary_key_value = primary_key_dict.pop(primary_key.name) - where_expressions.append(primary_key == primary_key_value) - - records = await cls.select(where_expressions=where_expressions, limit=1) - if len(records) == 0: - raise DatabaseRecordNotFound( - cls._config.table.name, **original_primary_key_dict - ) - - return records[0] - - @classmethod - async def create(cls, **data) -> "DatabaseModel": - """Create a new instance of the this DatabaseModel and save it - - Args: - kwargs: The parameters for the instance - - Returns: - The new DatabaseModel instance - - """ - new = cls(**data) - await new.save() - - return new - - def get_primary_id(self) -> Any: - """Standard interface for returning the id of a field - - This assumes that there is a single primary id, otherwise this returns `None` - - Returns: - The ID value for this DatabaseModel instance - - """ - if len(self._config.primary_keys) > 1: - return None - - return getattr(self, self._config.primary_keys[0].name) - - async def fetch(self, resolve_references: bool = False) -> None: - """Gets the latest of the object from the database and updates itself - - Args: - resolve_references: If True, resolve any nested key references - - """ - # Get the latest version of self - get_params = { - primary_key.name: getattr(self, primary_key.name) - for primary_key in self._config.primary_keys - } - if resolve_references: - new_self = await self.get_with_refs(**get_params) - else: - new_self = await self.get(**get_params) - - for attr_name, new_attr_val in new_self.dict().items(): - setattr(self, attr_name, new_attr_val) - - @classmethod - async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: - """Fetches the DatabaseModel for based on the provided kwargs - - Args: - kwargs: The filterable key/value pairs for the where clause. These will be - `and`ed together - - Returns: - List of DatabaseModel objects - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - DatabaseModelMissingField: One of the fields provided in the query does not - exist on the database table - - """ - where_expressions = [] - for field_name, db_field_value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - - try: - db_field = getattr(cls._config.table.c, db_field_name) - except AttributeError: - raise DatabaseModelMissingField(cls.__name__, db_field_name) - - if isinstance(db_field_value, list): - exp = db_field.in_(db_field_value) - else: - exp = db_field == db_field_value - - where_expressions.append(exp) - - return await cls.select(where_expressions=where_expressions) - - @classmethod - async def select( - cls, - where_expressions: Optional[List[BinaryExpression]] = None, - order_by: Optional[List[UnaryExpression]] = None, - limit: Optional[int] = None, - ) -> List["DatabaseModel"]: - """Execute a SELECT on the DatabaseModel table with the given parameters - - Args: - where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the SELECT - order_by: A list of criteria for the order_by clause - limit: The number of instances to return - - Returns: - A list of DatabaseModel instances - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - - """ - records = await get_backend().select( - cls._config, - where_expressions=where_expressions, - order_by=order_by, - limit=limit, - ) - return [cls.from_dict(record) for record in records] - - @classmethod - async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel"]: - """Create new batch of records in one query - - This will mutate the provided models to include db managed column values. - - Args: - models: List of database models to persist - - Returns: - list of new database models that have been saved - - """ - values = [] - for model in models: - dict_obj = model.to_dict() - - # Remove any fields that the database calculates - for field in cls._config.db_managed_fields: - del dict_obj[field] - - # Remove keys for primary keys that don't have a value. This indicates that - # the backend will generate new values. - for field in cls._config.primary_keys: - if dict_obj.get(field.name) is None: - del dict_obj[field.name] - - values.append(dict_obj) - - records = await get_backend().create_records(cls._config, values) - - # Set db managed column information on the object - for record, model in zip(records, models): - for column in cls._config.db_managed_fields: - col_val = record.get(column) - if col_val is not None: - setattr(model, column, col_val) - - for field in cls._config.primary_keys: - value = record.get(field.name) - if value is not None: - setattr(model, field.name, value) - - return models - - @classmethod - async def delete_records(cls, **kwargs: Any) -> None: - """Execute a DELETE on a DatabaseModel with the provided kwargs - - Args: - kwargs: The filterable key/value pairs for the where clause. These will be - `and`ed together - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - DatabaseModelMissingField: One of the fields provided in the query does not - exist on the database table - - """ - where_expressions = [] - for field_name, db_field_value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - - try: - db_field = getattr(cls._config.table.c, db_field_name) - except AttributeError: - raise DatabaseModelMissingField(cls.__name__, db_field_name) - - if isinstance(db_field_value, list): - exp = db_field.in_(db_field_value) - else: - exp = db_field == db_field_value - - where_expressions.append(exp) - - return await get_backend().delete_records(cls._config, where_expressions) - - @classmethod - async def update_record(cls, **kwargs: Any) -> "DatabaseModel": - """Update a record associated with this DatabaseModel - - Notes: - the primary key must be in the kwargs - - Args: - kwargs: The values to update. - - Returns: - The updated DatabaseModel - - """ - where_expressions = [] - primary_key_dict = {} - for primary_key in cls._config.primary_keys: - primary_key_value = kwargs.pop(primary_key.name) - where_expressions.append(primary_key == primary_key_value) - primary_key_dict[primary_key.name] = primary_key_value - - modified_kwargs = {} - for field_name, value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - modified_kwargs[db_field_name] = value - - updated_records = await cls.update(where_expressions, modified_kwargs) - if len(updated_records) == 0: - raise DatabaseRecordNotFound(cls._config.table.name, **primary_key_dict) - return updated_records[0] - - @classmethod - async def update( - cls, where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any] - ) -> List["DatabaseModel"]: - """Execute an UPDATE on a DatabaseModel table with the given parameters - - Args: - where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the UPDATE - values: The field and values to update all records to that match the - where_expressions - - Returns: - The updated DatabaseModels - - Raises: - DatabaseModelMisconfigured: The class is missing a database table - - """ - return [ - cls.from_dict(record) - for record in await get_backend().update_records( - cls._config, where_expressions=where_expressions, values=values - ) - ] - - async def save(self) -> None: - """Update the database record this object represents with its current state""" - dict_self = self.to_dict() - for field in self._config.db_managed_fields: - if field in self._config.primary_key_names and dict_self[field] is not None: - continue - - # Remove any fields that the database calculates - del dict_self[field] - - record = await get_backend().upsert( - self._config, - dict_self, - ) - for field in self._config.db_managed_fields: - setattr(self, field, record[field]) - - async def delete(self) -> None: - """Delete this record from the database""" - where_expressions = [ - getattr(self._config.table.c, pkey.name) == getattr(self, pkey.name) - for pkey in self._config.primary_keys - ] - return await get_backend().delete_records(self._config, where_expressions) diff --git a/pyproject.toml b/pyproject.toml index abb95da..0c21bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,16 +13,14 @@ homepage = "https://github.com/NarrativeScience/pynocular" repository = "https://github.com/NarrativeScience/pynocular" [tool.poetry.dependencies] -python = "^3.6.5" -aenum = "^3.1.0" -aiocontextvars = "^0.2.2" -aiopg = {extras = ["sa"], version = "^1.3.1"} +python = "^3.9" pydantic = "^1.6" databases = {extras = ["postgresql"], version = "^0.5.5"} +psycopg2-binary = "^2.9.3" [tool.poetry.dev-dependencies] pre-commit = "^2.10.1" -pytest = "^6.2.2" +pytest = "^7.1.1" pytest-asyncio = "^0.15" black = "^22.3.0" cruft = "^2.9.0" @@ -34,6 +32,3 @@ skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.y [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" - -[tool.pytest.ini_options] -asyncio_mode = "auto" diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index ded70b4..c24608d 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -1,11 +1,59 @@ """Contains shared functional test fixtures""" import asyncio +import logging +import os +from databases import Database import pytest +from pynocular.db_util import create_new_database + +logger = logging.getLogger("pynocular") + @pytest.fixture(scope="session") def event_loop(): """Returns the event loop so we can define async, session-scoped fixtures""" return asyncio.get_event_loop() + + +@pytest.fixture(scope="session") +async def postgres_database(): + """Fixture that manages a Postgres database fixture + + Yields: + postgres database + + """ + db_host = os.environ.get("DB_HOST", "localhost") + db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) + db_user_password = os.environ.get("DB_USER_PASSWORD", "") + test_db_name = os.environ.get("TEST_DB_NAME", "test_db") + + maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" + db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" + + try: + await create_new_database(maintenance_connection_string, test_db_name) + except Exception as e: + # If this fails, assume its already created + logger.info(str(e)) + + database = Database(db_connection_string, timeout=5, command_timeout=5) + await database.connect() + try: + yield database + except Exception as e: + logger.info(str(e)) + finally: + logger.debug("Disconnecting") + await asyncio.wait_for(database.disconnect(), 2) + + try: + logger.debug(f"Dropping {test_db_name}") + async with Database(maintenance_connection_string) as db: + await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") + logger.debug(f"Dropped {test_db_name}") + except Exception as e: + logger.info(str(e)) diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index bb83bcb..81643a7 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -1,8 +1,6 @@ """Tests for DatabaseModel abstract class""" from asyncio import gather, sleep from datetime import datetime -import logging -import os from typing import Optional from uuid import uuid4 @@ -18,65 +16,10 @@ SQLDatabaseModelBackend, UUID_STR, ) -from pynocular.db_util import ( - add_datetime_trigger, - create_new_database, - create_table, - drop_table, -) +from pynocular.db_util import add_datetime_trigger, create_table, drop_table from pynocular.exceptions import DatabaseModelMissingField, DatabaseRecordNotFound -@pytest.fixture(scope="module") -async def postgres_backend(): - """Fixture that yields a Postgres backend - - Yields: - postgres backend - - """ - db_host = os.environ.get("DB_HOST", "localhost") - db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) - db_user_password = os.environ.get("DB_USER_PASSWORD", "") - test_db_name = os.environ.get("TEST_DB_NAME", "test_db") - - maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" - db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" - - try: - await create_new_database(maintenance_connection_string, test_db_name) - except Exception as e: - # If this fails, assume its already created - logging.info(str(e)) - - async with Database(db_connection_string) as db: - await create_table(db, Org.table) - await create_table(db, Topic.table) - await add_datetime_trigger(db, "organizations") - try: - yield SQLDatabaseModelBackend(db) - finally: - await drop_table(db, Topic.table) - await drop_table(db, Org.table) - - async with Database(maintenance_connection_string) as db: - try: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") - except Exception as e: - logging.info(str(e)) - - -@pytest.fixture() -async def memory_backend(): - """Fixture that yields an in-memory backend - - Returns: - in-memory backend - - """ - return MemoryDatabaseModelBackend() - - class Org(DatabaseModel, table_name="organizations"): """A test database model""" @@ -99,6 +42,35 @@ class Topic(DatabaseModel, table_name="topics"): name: str = Field(max_length=45) +@pytest.fixture(scope="module") +async def postgres_backend(postgres_database: Database): + """Fixture that creates tables before yielding a Postgres backend + + Returns: + postgres backend + + """ + await create_table(postgres_database, Org.table) + await create_table(postgres_database, Topic.table) + await add_datetime_trigger(postgres_database, Org.table.name) + try: + yield SQLDatabaseModelBackend(postgres_database) + finally: + await drop_table(postgres_database, Topic.table) + await drop_table(postgres_database, Org.table) + + +@pytest.fixture() +async def memory_backend(): + """Fixture that yields an in-memory backend + + Returns: + in-memory backend + + """ + return MemoryDatabaseModelBackend() + + @pytest.mark.parametrize( "_backend", [ diff --git a/tests/functional/test_db_util.py b/tests/functional/test_db_util.py index 72ec37f..00b5b5b 100644 --- a/tests/functional/test_db_util.py +++ b/tests/functional/test_db_util.py @@ -1,56 +1,21 @@ """Tests for the db_util module""" -import logging -import os from databases import Database import pytest -from pynocular.db_util import create_new_database, is_database_available - -db_user_password = str(os.environ.get("DB_USER_PASSWORD")) - - -@pytest.fixture(scope="module") -async def test_connection_string(): - """Fixture that yields a test connection string - - Yields: - postgres connection string - - """ - db_host = os.environ.get("DB_HOST", "localhost") - db_user_name = os.environ.get("DB_USER_NAME", os.environ.get("USER", "postgres")) - db_user_password = os.environ.get("DB_USER_PASSWORD", "") - test_db_name = os.environ.get("TEST_DB_NAME", "test_db") - - maintenance_connection_string = f"postgres://{db_user_name}:{db_user_password}@{db_host}:5432/postgres?sslmode=disable" - db_connection_string = f"postgresql://{db_user_name}:{db_user_password}@{db_host}:5432/{test_db_name}?sslmode=disable" - - try: - await create_new_database(maintenance_connection_string, test_db_name) - except Exception as e: - # If this fails, assume its already created - logging.info(str(e)) - - yield db_connection_string - - async with Database(maintenance_connection_string) as db: - try: - await db.execute(f"DROP DATABASE IF EXISTS {test_db_name}") - except Exception as e: - logging.info(str(e)) +from pynocular.db_util import is_database_available @pytest.mark.asyncio -async def test_is_database_available(test_connection_string) -> None: +async def test_is_database_available(postgres_database: Database) -> None: """Test successful database connection""" - available = await is_database_available(test_connection_string) + available = await is_database_available(str(postgres_database.url)) assert available is True @pytest.mark.asyncio -async def test_is_database_not_available() -> None: +async def test_is_database_not_available(postgres_database: Database) -> None: """Test db connection unavailable""" - invalid_connection_string = f"postgresql://postgres:{db_user_password}@localhost:5432/INVALID?sslmode=disable" + invalid_connection_string = str(postgres_database.url.replace(database="INVALID")) available = await is_database_available(invalid_connection_string) assert available is False diff --git a/tests/functional/test_transactions.py b/tests/functional/test_transactions.py index cad35b5..537b8fe 100644 --- a/tests/functional/test_transactions.py +++ b/tests/functional/test_transactions.py @@ -1,227 +1,184 @@ """Test that db transaction functionality works as expected""" -import asyncio -import os +import logging from uuid import uuid4 -from pydantic import BaseModel, Field +from databases import Database +from pydantic import Field import pytest -from pynocular.database_model import database_model, UUID_STR -from pynocular.db_util import create_new_database, create_table, drop_table -from pynocular.engines import DBEngine, DBInfo - -db_user_password = str(os.environ.get("DB_USER_PASSWORD")) -# DB to initially connect to so we can create a new db -existing_connection_string = str( - os.environ.get( - "EXISTING_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/postgres?sslmode=disable", - ) -) - -test_db_name = str(os.environ.get("TEST_DB_NAME", "test_db")) -test_connection_string = str( - os.environ.get( - "TEST_DB_CONNECTION_STRING", - f"postgresql://postgres:{db_user_password}@localhost:5432/{test_db_name}?sslmode=disable", - ) -) -testdb = DBInfo(test_connection_string) - - -@database_model("organizations", testdb) -class Org(BaseModel): +from pynocular.backends.context import backend, get_backend +from pynocular.backends.sql import SQLDatabaseModelBackend +from pynocular.database_model import DatabaseModel, UUID_STR +from pynocular.db_util import create_table, drop_table, gather, transaction + +logger = logging.getLogger("pynocular") + + +class Org(DatabaseModel, table_name="organizations"): """A test database model""" id: UUID_STR = Field(primary_key=True) name: str = Field(max_length=45) -class TestDatabaseTransactions: - """Test suite for testing transaction handling with DatabaseModels""" +@pytest.fixture() +async def postgres_backend(postgres_database: Database): + """Fixture that creates tables before yielding a Postgres backend - @classmethod - async def _setup_class(cls): - """Create the database and tables""" - try: - await create_new_database(existing_connection_string, test_db_name) - except Exception: - # If this fails, assume its already created - pass + Returns: + postgres backend - await create_table(testdb, Org._table) - conn = await (await DBEngine.get_engine(testdb)).acquire() - await conn.close() - - @classmethod - def setup_class(cls): - """Setup class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._setup_class()) - - @classmethod - async def _teardown_class(cls): - """Drop database tables""" - await drop_table(testdb, Org._table) - - @classmethod - def teardown_class(cls): - """Teardown class function""" - loop = asyncio.get_event_loop() - loop.run_until_complete(cls._teardown_class()) - - @pytest.mark.asyncio - async def test_gathered_creates(self) -> None: - """Test that we can update the db multiple times in a gather under a single transaction""" + """ + await create_table(postgres_database, Org.table) + try: + yield SQLDatabaseModelBackend(postgres_database) + finally: + await drop_table(postgres_database, Org.table) + + +@pytest.mark.asyncio +async def test_gathered_creates(postgres_backend) -> None: + """Test that we can update the db multiple times in a gather under a single transaction""" + with backend(postgres_backend): + async with get_backend().db.transaction(): + await gather( + Org.create(id=str(uuid4()), name="orgus borgus"), + Org.create(id=str(uuid4()), name="porgus orgus"), + ) + + all_orgs = await Org.select() + assert len(all_orgs) == 2 + + +@pytest.mark.asyncio +async def test_gathered_updates_raise_error(postgres_backend) -> None: + """Test that an error in one update rolls back the other when gathered""" + with backend(postgres_backend): try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await asyncio.gather( + async with get_backend().transaction(): + await gather( Org.create(id=str(uuid4()), name="orgus borgus"), - Org.create(id=str(uuid4()), name="porgus orgus"), + # The inputs aren't the right type which should throw an error + Org.create(id="blah", name=123), ) - all_orgs = await Org.select() - assert len(all_orgs) == 2 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - - @pytest.mark.asyncio - async def test_gathered_updates_raise_error(self) -> None: - """Test that an error in one update rolls back the other when gathered""" - try: - try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await asyncio.gather( - Org.create(id=str(uuid4()), name="orgus borgus"), - # The inputs aren't the right type which should throw an error - Org.create(id="blah", name=123), - ) - except Exception: - pass - - all_orgs = await Org.select() - assert len(all_orgs) == 0 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - - @pytest.mark.asyncio - async def test_serial_updates(self) -> None: - """Test that we can update the db serially under a single transaction""" - try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id=str(uuid4()), name="orgus borgus") - await Org.create(id=str(uuid4()), name="porgus orgus") + except Exception: + pass - all_orgs = await Org.select() - assert len(all_orgs) == 2 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) + all_orgs = await Org.select() + assert len(all_orgs) == 0 - @pytest.mark.asyncio - async def test_serial_updates_raise_error(self) -> None: - """Test that an error in one update rolls back the other when run serially""" - try: - try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id=str(uuid4()), name="orgus borgus") - await Org.create(id="blah", name=123) - except Exception: - pass - all_orgs = await Org.select() - assert len(all_orgs) == 0 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) +@pytest.mark.asyncio +async def test_serial_updates(postgres_backend) -> None: + """Test that we can update the db serially under a single transaction""" + with backend(postgres_backend): + async with get_backend().transaction(): + await Org.create(id=str(uuid4()), name="orgus borgus") + await Org.create(id=str(uuid4()), name="porgus orgus") + + all_orgs = await Org.select() + assert len(all_orgs) == 2 - @pytest.mark.asyncio - async def test_nested_updates(self) -> None: - """Test that we can perform nested update on the db under a single transaction""" + +@pytest.mark.asyncio +async def test_serial_updates_raise_error(postgres_backend) -> None: + """Test that an error in one update rolls back the other when run serially""" + with backend(postgres_backend): try: - async with await DBEngine.transaction(testdb, is_conditional=False): + async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") + await Org.create(id="blah", name=123) + except Exception: + pass + + all_orgs = await Org.select() + assert len(all_orgs) == 0 + + +@pytest.mark.asyncio +async def test_nested_updates(postgres_backend) -> None: + """Test that we can perform nested update on the db under a single transaction""" + with backend(postgres_backend): + async with get_backend().transaction(): + await Org.create(id=str(uuid4()), name="orgus borgus") + + async with get_backend().transaction(): + await Org.create(id=str(uuid4()), name="porgus orgus") - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id=str(uuid4()), name="porgus orgus") + all_orgs = await Org.select() + assert len(all_orgs) == 2 - all_orgs = await Org.select() - assert len(all_orgs) == 2 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - @pytest.mark.asyncio - async def test_nested_updates_raise_error(self) -> None: - """Test that an error in one update rolls back the other when it is nested""" +@pytest.mark.asyncio +async def test_nested_updates_raise_error(postgres_backend) -> None: + """Test that an error in one update rolls back the other when it is nested""" + with backend(postgres_backend): try: - try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id=str(uuid4()), name="orgus borgus") + async with get_backend().transaction(): + await Org.create(id=str(uuid4()), name="orgus borgus") + + async with get_backend().transaction(): + await Org.create(id="blah", name=123) - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id="blah", name=123) + except Exception: + pass - except Exception: - pass + all_orgs = await Org.select() + assert len(all_orgs) == 0 - all_orgs = await Org.select() - assert len(all_orgs) == 0 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - @pytest.mark.asyncio - async def test_nested_conditional_updates_raise_error(self) -> None: - """Test that an error in one update rolls back the other even if its a conditional transaction""" +@pytest.mark.asyncio +async def test_nested_conditional_updates_raise_error(postgres_backend) -> None: + """Test that an error in one update rolls back the other even if its a conditional transaction""" + with backend(postgres_backend): try: - try: - async with await DBEngine.transaction(testdb, is_conditional=False): - await Org.create(id=str(uuid4()), name="orgus borgus") + async with get_backend().transaction(): + await Org.create(id=str(uuid4()), name="orgus borgus") + + async with get_backend().transaction(): + await Org.create(id="blah", name=123) - async with await DBEngine.transaction(testdb, is_conditional=True): - await Org.create(id="blah", name=123) + except Exception: + pass - except Exception: - pass + all_orgs = await Org.select() + assert len(all_orgs) == 0 - all_orgs = await Org.select() - assert len(all_orgs) == 0 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - @pytest.mark.asyncio - async def test_open_transaction_decorator(self) -> None: - """Test that the open_transaction decorator will execute everything in a transaction""" +@pytest.mark.asyncio +async def test_open_transaction_decorator(postgres_backend) -> None: + """Test that the open_transaction decorator will execute everything in a transaction""" + with backend(postgres_backend): - @DBEngine.open_transaction(testdb) + @transaction async def write_than_raise_error(): await Org.create(id=str(uuid4()), name="orgus borgus") await Org.create(id=str(uuid4()), name="orgus porgus") try: - try: - await write_than_raise_error() - except Exception: - pass + await write_than_raise_error() + except Exception: + pass + + all_orgs = await Org.select() + assert len(all_orgs) == 2 - all_orgs = await Org.select() - assert len(all_orgs) == 2 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) - @pytest.mark.asyncio - async def test_open_transaction_decorator_rolls_back(self) -> None: - """Test that the open_transaction decorator will roll back everything in the function""" +@pytest.mark.asyncio +async def test_open_transaction_decorator_rolls_back(postgres_backend) -> None: + """Test that the open_transaction decorator will roll back everything in the function""" + with backend(postgres_backend): - @DBEngine.open_transaction(testdb) + @transaction async def write_than_raise_error(): await Org.create(id=str(uuid4()), name="orgus borgus") # This create will fail and the decorator should roll back the top one await Org.create(id="blah", name=123) try: - try: - await write_than_raise_error() - except Exception: - pass - - all_orgs = await Org.select() - assert len(all_orgs) == 0 - finally: - await asyncio.gather(*[org.delete() for org in all_orgs]) + await write_than_raise_error() + except Exception: + pass + + all_orgs = await Org.select() + assert len(all_orgs) == 0 diff --git a/tests/unit/test_evaluate_column_element.py b/tests/unit/test_evaluate_column_element.py new file mode 100644 index 0000000..46ff205 --- /dev/null +++ b/tests/unit/test_evaluate_column_element.py @@ -0,0 +1,37 @@ +"""Contains unit tests for the evaluate_column_element module""" + +from pydantic import Field +from sqlalchemy import or_ + +from pynocular.database_model import DatabaseModel +from pynocular.evaluate_column_element import evaluate_column_element +from pynocular.uuid_str import UUID_STR + + +class Org(DatabaseModel, table_name="organizations"): + """Model that represents the `organizations` table""" + + id: UUID_STR = Field(primary_key=True) + name: str = Field(max_length=45) + slug: str = Field(max_length=45) + flag1: bool = Field(default=True) + flag2: bool = Field(default=True) + flag3: bool = Field(default=True) + + +def test_evaluate_column_element__neq(): + """Should handle the is_not operator""" + assert not evaluate_column_element(Org.columns.name != "foo", {"name": "foo"}) + + +def test_evaluate_column_element__n_ary_or(): + """Should handle an OR with multiple arguments""" + assert evaluate_column_element( + or_(Org.columns.flag1, Org.columns.flag2, Org.columns.flag3), + {"flag1": False, "flag2": False, "flag3": True}, + ) + + +def test_evaluate_column_element__not(): + """Should handle a NOT operator""" + assert not evaluate_column_element(~Org.columns.flag1, {"flag1": True}) diff --git a/tests/unit/test_task_context_connection.py b/tests/unit/test_task_context_connection.py deleted file mode 100644 index 6bbcb9d..0000000 --- a/tests/unit/test_task_context_connection.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test for TaskContextConnection""" -from unittest.mock import Mock - -import pytest - -from pynocular.aiopg_transaction import LockedConnection, TaskContextConnection - - -@pytest.fixture() -def locked_connection(): - """Return a locked connection""" - return LockedConnection(Mock()) - - -@pytest.mark.asyncio() -async def test_task_context_connection_set_clear(locked_connection) -> None: - """Test that we can set and clear the connection""" - - context_conn = TaskContextConnection("key1") - context_conn.set(locked_connection) - test_conn = context_conn.get() - assert test_conn == locked_connection - - context_conn.clear() - # No connection should exist now - test_conn = context_conn.get() - assert test_conn is None - - -@pytest.mark.asyncio() -async def test_task_context_connection_shared(locked_connection) -> None: - """Test that we can share context across instances""" - - context_conn = TaskContextConnection("key1") - context_conn.set(locked_connection) - test_conn = context_conn.get() - assert test_conn == locked_connection - - # Create another instance that should share the connection - context_conn2 = TaskContextConnection("key1") - test_conn2 = context_conn2.get() - assert test_conn2 == locked_connection - - context_conn.clear() - # No connection should exist on either connection - test_conn = context_conn.get() - assert test_conn is None - test_conn2 = context_conn2.get() - assert test_conn2 is None From 357f0b948522c627ac70a9a34ace358c83c8a0f9 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 20:02:24 -0700 Subject: [PATCH 19/31] rm --- tests/unit/test_patch_models.py | 189 -------------------------------- 1 file changed, 189 deletions(-) delete mode 100644 tests/unit/test_patch_models.py diff --git a/tests/unit/test_patch_models.py b/tests/unit/test_patch_models.py deleted file mode 100644 index 55bddc7..0000000 --- a/tests/unit/test_patch_models.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tests for patch_database_model context manager""" -from typing import Optional -from uuid import uuid4 - -from pydantic import BaseModel, Field -import pytest -from sqlalchemy import or_ - -from pynocular.database_model import database_model, nested_model, UUID_STR -from pynocular.engines import DBInfo -from pynocular.patch_models import _evaluate_column_element, patch_database_model - -# With the `patch_database_model` we don't need a database connection -test_connection_string = "fake connection string" -testdb = DBInfo(test_connection_string) -name = "boo" - - -@database_model("users", testdb) -class User(BaseModel): - """Model that represents the `users` table""" - - id: UUID_STR = Field(primary_key=True) - username: str = Field(max_length=100) - - -@database_model("organizations", testdb) -class Org(BaseModel): - """Model that represents the `organizations` table""" - - id: UUID_STR = Field(primary_key=True) - name: str = Field(max_length=45) - slug: str = Field(max_length=45) - tech_owner: Optional[ - nested_model(User, reference_field="tech_owner_id") # noqa F821 - ] - business_owner: Optional[ - nested_model(User, reference_field="business_owner_id") # noqa F821 - ] - flag1: bool = Field(default=True) - flag2: bool = Field(default=True) - flag3: bool = Field(default=True) - - -class TestPatchDatabaseModel: - """Test class for patch_database_model""" - - @pytest.mark.asyncio - async def test_patch_database_model_without_models(self) -> None: - """Test that we can use `patch_database_model` without providing models""" - orgs = [ - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus"), - ] - - with patch_database_model(Org): - await Org.create_list(orgs) - # Also create one org through Org.create() - await Org.create( - id=str(uuid4()), name="nonorgus borgus", slug="orgus_borgus" - ) - all_orgs = await Org.select() - subset_orgs = await Org.get_list(name=orgs[0].name) - assert len(subset_orgs) <= len(all_orgs) - assert orgs[0] == subset_orgs[0] - - @pytest.mark.asyncio - async def test_patch_database_model_with_models(self) -> None: - """Test that we can use `patch_database_model` with models""" - orgs = [ - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus"), - Org(id=str(uuid4()), name="nonorgus borgus", slug="orgus_borgus"), - ] - - with patch_database_model(Org, models=orgs): - org = (await Org.get_list(name=orgs[0].name))[0] - org.name = "new test name" - await org.save() - org_get = await Org.get(org.id) - assert org_get.name == "new test name" - - @pytest.mark.asyncio - async def test_patch_database_model_with_nested_models(self) -> None: - """Test that we can use `patch_database_model` with nested models""" - users = [ - User(id=str(uuid4()), username="Bob"), - User(id=str(uuid4()), username="Sally"), - ] - orgs = [ - Org( - id=str(uuid4()), - name="orgus borgus", - slug="orgus_borgus", - tech_owner=users[0], - business_owner=users[1], - ), - Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus"), - Org(id=str(uuid4()), name="nonorgus borgus", slug="orgus_borgus"), - ] - - with patch_database_model(Org, models=orgs), patch_database_model( - User, models=users - ): - org = (await Org.get_list(name=orgs[0].name))[0] - org.name = "new test name" - users[0].username = "bberkley" - await org.save(include_nested_models=True) - org_get = await Org.get_with_refs(org.id) - assert org_get.name == "new test name" - assert org_get.tech_owner.username == "bberkley" - - @pytest.mark.asyncio - async def test_patch_database_model_with_delete(self) -> None: - """Test that we can use `delete` on a patched db model""" - orgs = [ - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus"), - Org(id=str(uuid4()), name="nonorgus borgus", slug="orgus_borgus"), - ] - - with patch_database_model(Org, models=orgs): - db_orgs = await Org.get_list() - assert len(db_orgs) == 3 - await orgs[0].delete() - db_orgs = await Org.get_list() - assert len(db_orgs) == 2 - - # Confirm the correct orgs are left - sorted_orgs = sorted(orgs[1:3], key=lambda x: x.id) - sorted_db_orgs = sorted(db_orgs, key=lambda x: x.id) - assert sorted_orgs == sorted_db_orgs - - @pytest.mark.asyncio - async def test_patch_database_model_with_delete_records(self) -> None: - """Test that we can use `delete_records` on a patched db model""" - orgs = [ - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus2"), - Org(id=str(uuid4()), name="nonorgus borgus", slug="nonorgus_borgus"), - ] - - with patch_database_model(Org, models=orgs): - db_orgs = await Org.get_list() - assert len(db_orgs) == 3 - await Org.delete_records(slug=["orgus_borgus2", "nonorgus_borgus"]) - db_orgs = await Org.get_list() - assert len(db_orgs) == 1 - - # Confirm the correct org is left - assert orgs[0] == db_orgs[0] - - @pytest.mark.asyncio - async def test_patch_database_model_with_update(self) -> None: - """Test that we can use `update` to update multiple models""" - orgs = [ - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), - Org(id=str(uuid4()), name="nonorgus borgus", slug="nonorgus_borgus"), - ] - - with patch_database_model(Org, models=orgs): - db_orgs = await Org.get_list() - assert len(db_orgs) == 3 - updated = await Org.update( - [Org.columns.name == "orgus borgus"], - values={"name": "foo", "slug": "bar"}, - ) - assert {org.id for org in updated} == {org.id for org in orgs[:2]} - assert all(org.name == "foo" and org.slug == "bar" for org in updated) - - -class TestEvaluateColumnElement: - """Test class for the _evaluate_column_element function""" - - def test_evaluate_column_element__neq(self) -> None: - """Should handle the is_not operator""" - assert not _evaluate_column_element(Org.columns.name != "foo", {"name": "foo"}) - - def test_evaluate_column_element__n_ary_or(self) -> None: - """Should handle an OR with multiple arguments""" - assert _evaluate_column_element( - or_(Org.columns.flag1, Org.columns.flag2, Org.columns.flag3), - {"flag1": False, "flag2": False, "flag3": True}, - ) - - def test_evaluate_column_element__not(self) -> None: - """Should handle a NOT operator""" - assert not _evaluate_column_element(~Org.columns.flag1, {"flag1": True}) From 091f0d0105dca60e21a29714f966485105e99579 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 20:07:33 -0700 Subject: [PATCH 20/31] rename --- README.md | 2 +- pynocular/__init__.py | 3 +- pynocular/backends/context.py | 2 +- pynocular/database_model.py | 2 +- pynocular/{db_util.py => util.py} | 42 ++++++++++++++++++++- pynocular/uuid_str.py | 43 ---------------------- tests/functional/conftest.py | 2 +- tests/functional/test_database_model.py | 37 +++++++++---------- tests/functional/test_db_util.py | 2 +- tests/functional/test_transactions.py | 22 +++++------ tests/unit/test_evaluate_column_element.py | 2 +- 11 files changed, 77 insertions(+), 82 deletions(-) rename pynocular/{db_util.py => util.py} (87%) delete mode 100644 pynocular/uuid_str.py diff --git a/README.md b/README.md index d0a9fc6..b60627b 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ With Pynocular you can use simple python code to create new databases and databa (although accessing private variables is not recommended). ```python -from pynocular.db_util import create_new_database, create_table +from pynocular.util import create_new_database, create_table from my_package import Org, db_info diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 2a45c8a..484ea71 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -2,8 +2,7 @@ __version__ = "2.0.0rc1" -from pynocular.backends.context import backend +from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend from pynocular.backends.sql import SQLDatabaseModelBackend from pynocular.database_model import DatabaseModel -from pynocular.uuid_str import is_valid_uuid, UUID_STR diff --git a/pynocular/backends/context.py b/pynocular/backends/context.py index 8c4ddf4..815f904 100644 --- a/pynocular/backends/context.py +++ b/pynocular/backends/context.py @@ -11,7 +11,7 @@ @contextmanager -def backend(backend: DatabaseModelBackend) -> None: +def set_backend(backend: DatabaseModelBackend) -> None: """Set the database backend in the aio context Args: diff --git a/pynocular/database_model.py b/pynocular/database_model.py index fe98db2..f1cb067 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -31,7 +31,7 @@ DatabaseRecordNotFound, InvalidMethodParameterization, ) -from pynocular.uuid_str import UUID_STR +from pynocular.util import UUID_STR class DatabaseModel(BaseModel): diff --git a/pynocular/db_util.py b/pynocular/util.py similarity index 87% rename from pynocular/db_util.py rename to pynocular/util.py index 0504379..4b4aab4 100644 --- a/pynocular/db_util.py +++ b/pynocular/util.py @@ -3,7 +3,8 @@ from functools import wraps import logging import re -from typing import Any, Coroutine +from typing import Any, Coroutine, Generator +from uuid import UUID as stdlib_uuid from databases.core import Database import sqlalchemy as sa @@ -15,6 +16,45 @@ logger = logging.getLogger("pynocular") +def is_valid_uuid(string: str) -> bool: + """Check if a string is a valid UUID + + Args: + string: the string to check + + Returns: + Whether or not the string is a well-formed UUIDv4 + + """ + try: + stdlib_uuid(string, version=4) + return True + except (TypeError, AttributeError, ValueError): + return False + + +class UUID_STR(str): + """A string that represents a UUID4 value""" + + @classmethod + def __get_validators__(cls) -> Generator: + """Get the validators for the given class""" + yield cls.validate + + @classmethod + def validate(cls, v: Any) -> str: + """Function to validate the value + + Args: + v: The value to validate + + """ + if isinstance(v, stdlib_uuid) or (isinstance(v, str) and is_valid_uuid(v)): + return str(v) + else: + raise ValueError("invalid UUID string") + + async def is_database_available(connection_string: str) -> bool: """Check if the database is available diff --git a/pynocular/uuid_str.py b/pynocular/uuid_str.py deleted file mode 100644 index d400959..0000000 --- a/pynocular/uuid_str.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Contains util functions""" - -from typing import Any, Generator -from uuid import UUID as stdlib_uuid - - -def is_valid_uuid(string: str) -> bool: - """Check if a string is a valid UUID - - Args: - string: the string to check - - Returns: - Whether or not the string is a well-formed UUIDv4 - - """ - try: - stdlib_uuid(string, version=4) - return True - except (TypeError, AttributeError, ValueError): - return False - - -class UUID_STR(str): - """A string that represents a UUID4 value""" - - @classmethod - def __get_validators__(cls) -> Generator: - """Get the validators for the given class""" - yield cls.validate - - @classmethod - def validate(cls, v: Any) -> str: - """Function to validate the value - - Args: - v: The value to validate - - """ - if isinstance(v, stdlib_uuid) or (isinstance(v, str) and is_valid_uuid(v)): - return str(v) - else: - raise ValueError("invalid UUID string") diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index c24608d..e1d44f7 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -7,7 +7,7 @@ from databases import Database import pytest -from pynocular.db_util import create_new_database +from pynocular.util import create_new_database logger = logging.getLogger("pynocular") diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index 81643a7..7cc0d58 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -10,14 +10,13 @@ import pytest from pynocular import ( - backend, DatabaseModel, MemoryDatabaseModelBackend, + set_backend, SQLDatabaseModelBackend, - UUID_STR, ) -from pynocular.db_util import add_datetime_trigger, create_table, drop_table from pynocular.exceptions import DatabaseModelMissingField, DatabaseRecordNotFound +from pynocular.util import add_datetime_trigger, create_table, drop_table, UUID_STR class Org(DatabaseModel, table_name="organizations"): @@ -81,7 +80,7 @@ async def memory_backend(): @pytest.mark.asyncio async def test_select(_backend) -> None: """Test that we can select the full set of DatabaseModels""" - with backend(_backend): + with set_backend(_backend): try: org = await Org.create( id=str(uuid4()), @@ -105,7 +104,7 @@ async def test_select(_backend) -> None: @pytest.mark.asyncio async def test_get_list(_backend) -> None: """Test that we can get_list and get a subset of DatabaseModels""" - with backend(_backend): + with set_backend(_backend): try: org1 = await Org.create( id=str(uuid4()), name="orgus borgus", slug="orgus_borgus", serial_id=1 @@ -138,7 +137,7 @@ async def test_get_list(_backend) -> None: @pytest.mark.asyncio async def test_get_list__none_filter_value(_backend) -> None: """Test that we can get_list based on a None filter value""" - with backend(_backend): + with set_backend(_backend): try: test_org = await Org.create( id=uuid4(), name="orgus borgus", slug="orgus_borgus", serial_id=None @@ -159,7 +158,7 @@ async def test_get_list__none_filter_value(_backend) -> None: @pytest.mark.asyncio async def test_get_list__none_json_value(_backend) -> None: """Test that we can get_list for a None value on a JSON field""" - with backend(_backend): + with set_backend(_backend): # The None value will be persisted as a SQL NULL value rather than a JSON-encoded # null value when the Topic is created, so the filter value None will work here try: @@ -186,7 +185,7 @@ async def test_get_list__none_json_value(_backend) -> None: @pytest.mark.asyncio async def test_create_new_record(_backend) -> None: """Test that we can create a database record""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 100 try: @@ -210,7 +209,7 @@ async def test_create_new_record(_backend) -> None: @pytest.mark.asyncio async def test_create_list(_backend) -> None: """Test that we can create a list of database records""" - with backend(_backend): + with set_backend(_backend): try: initial_orgs = [ Org(id=str(uuid4()), name="fake org 1", slug="fake-slug-1"), @@ -235,7 +234,7 @@ async def test_create_list(_backend) -> None: @pytest.mark.asyncio async def test_create_list__empty(_backend) -> None: """Should return empty list for input of empty list""" - with backend(_backend): + with set_backend(_backend): created_orgs = await Org.create_list([]) assert created_orgs == [] @@ -250,7 +249,7 @@ async def test_create_list__empty(_backend) -> None: @pytest.mark.asyncio async def test_update_new_record__save(_backend) -> None: """Test that we can update a database record using `save`""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 101 @@ -281,7 +280,7 @@ async def test_update_new_record__save(_backend) -> None: @pytest.mark.asyncio async def test_update_new_record__update_record(_backend) -> None: """Test that we can update a database record using `update_record`""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 100000 @@ -310,7 +309,7 @@ async def test_update_new_record__update_record(_backend) -> None: @pytest.mark.asyncio async def test_delete_new_record__delete(_backend) -> None: """Test that we can delete a database record using `delete`""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 102 @@ -339,7 +338,7 @@ async def test_delete_new_record__delete(_backend) -> None: @pytest.mark.asyncio async def test_delete_new_record__delete_records(_backend) -> None: """Test that we can delete a database record using `delete_records`""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 103 @@ -366,7 +365,7 @@ async def test_delete_new_record__delete_records(_backend) -> None: @pytest.mark.asyncio async def test_delete_new_record__delete_records_multi_kwargs(_backend) -> None: """Test that we can delete a database record using `delete_records` with multiple kwargs""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 104 @@ -393,7 +392,7 @@ async def test_delete_new_record__delete_records_multi_kwargs(_backend) -> None: @pytest.mark.asyncio async def test_bad_org_object_creation(_backend) -> None: """Test that we raise an Exception if the object is missing fields""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) with pytest.raises(ValidationError): @@ -410,7 +409,7 @@ async def test_bad_org_object_creation(_backend) -> None: @pytest.mark.asyncio async def test_raise_error_get_list_wrong_field(_backend) -> None: """Test that we raise an exception if we query for a wrong field on the object""" - with backend(_backend): + with set_backend(_backend): with pytest.raises(DatabaseModelMissingField): await Org.get_list(table_id="Table1") @@ -425,7 +424,7 @@ async def test_raise_error_get_list_wrong_field(_backend) -> None: @pytest.mark.asyncio async def test_setting_db_managed_columns(_backend) -> None: """Test that db managed columns get automatically set on save""" - with backend(_backend): + with set_backend(_backend): org = await Org.create( id=str(uuid4()), serial_id=105, name="fake_org105", slug="fake_org105" ) @@ -453,7 +452,7 @@ async def test_setting_db_managed_columns(_backend) -> None: @pytest.mark.asyncio async def test_fetch(_backend) -> None: """Test that we can fetch the latest state of a database record""" - with backend(_backend): + with set_backend(_backend): org_id = str(uuid4()) serial_id = 100 try: diff --git a/tests/functional/test_db_util.py b/tests/functional/test_db_util.py index 00b5b5b..544b279 100644 --- a/tests/functional/test_db_util.py +++ b/tests/functional/test_db_util.py @@ -3,7 +3,7 @@ from databases import Database import pytest -from pynocular.db_util import is_database_available +from pynocular.util import is_database_available @pytest.mark.asyncio diff --git a/tests/functional/test_transactions.py b/tests/functional/test_transactions.py index 537b8fe..e471176 100644 --- a/tests/functional/test_transactions.py +++ b/tests/functional/test_transactions.py @@ -6,10 +6,10 @@ from pydantic import Field import pytest -from pynocular.backends.context import backend, get_backend +from pynocular.backends.context import get_backend, set_backend from pynocular.backends.sql import SQLDatabaseModelBackend from pynocular.database_model import DatabaseModel, UUID_STR -from pynocular.db_util import create_table, drop_table, gather, transaction +from pynocular.util import create_table, drop_table, gather, transaction logger = logging.getLogger("pynocular") @@ -39,7 +39,7 @@ async def postgres_backend(postgres_database: Database): @pytest.mark.asyncio async def test_gathered_creates(postgres_backend) -> None: """Test that we can update the db multiple times in a gather under a single transaction""" - with backend(postgres_backend): + with set_backend(postgres_backend): async with get_backend().db.transaction(): await gather( Org.create(id=str(uuid4()), name="orgus borgus"), @@ -53,7 +53,7 @@ async def test_gathered_creates(postgres_backend) -> None: @pytest.mark.asyncio async def test_gathered_updates_raise_error(postgres_backend) -> None: """Test that an error in one update rolls back the other when gathered""" - with backend(postgres_backend): + with set_backend(postgres_backend): try: async with get_backend().transaction(): await gather( @@ -71,7 +71,7 @@ async def test_gathered_updates_raise_error(postgres_backend) -> None: @pytest.mark.asyncio async def test_serial_updates(postgres_backend) -> None: """Test that we can update the db serially under a single transaction""" - with backend(postgres_backend): + with set_backend(postgres_backend): async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") await Org.create(id=str(uuid4()), name="porgus orgus") @@ -83,7 +83,7 @@ async def test_serial_updates(postgres_backend) -> None: @pytest.mark.asyncio async def test_serial_updates_raise_error(postgres_backend) -> None: """Test that an error in one update rolls back the other when run serially""" - with backend(postgres_backend): + with set_backend(postgres_backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -98,7 +98,7 @@ async def test_serial_updates_raise_error(postgres_backend) -> None: @pytest.mark.asyncio async def test_nested_updates(postgres_backend) -> None: """Test that we can perform nested update on the db under a single transaction""" - with backend(postgres_backend): + with set_backend(postgres_backend): async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -112,7 +112,7 @@ async def test_nested_updates(postgres_backend) -> None: @pytest.mark.asyncio async def test_nested_updates_raise_error(postgres_backend) -> None: """Test that an error in one update rolls back the other when it is nested""" - with backend(postgres_backend): + with set_backend(postgres_backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -130,7 +130,7 @@ async def test_nested_updates_raise_error(postgres_backend) -> None: @pytest.mark.asyncio async def test_nested_conditional_updates_raise_error(postgres_backend) -> None: """Test that an error in one update rolls back the other even if its a conditional transaction""" - with backend(postgres_backend): + with set_backend(postgres_backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -148,7 +148,7 @@ async def test_nested_conditional_updates_raise_error(postgres_backend) -> None: @pytest.mark.asyncio async def test_open_transaction_decorator(postgres_backend) -> None: """Test that the open_transaction decorator will execute everything in a transaction""" - with backend(postgres_backend): + with set_backend(postgres_backend): @transaction async def write_than_raise_error(): @@ -167,7 +167,7 @@ async def write_than_raise_error(): @pytest.mark.asyncio async def test_open_transaction_decorator_rolls_back(postgres_backend) -> None: """Test that the open_transaction decorator will roll back everything in the function""" - with backend(postgres_backend): + with set_backend(postgres_backend): @transaction async def write_than_raise_error(): diff --git a/tests/unit/test_evaluate_column_element.py b/tests/unit/test_evaluate_column_element.py index 46ff205..404a2a9 100644 --- a/tests/unit/test_evaluate_column_element.py +++ b/tests/unit/test_evaluate_column_element.py @@ -5,7 +5,7 @@ from pynocular.database_model import DatabaseModel from pynocular.evaluate_column_element import evaluate_column_element -from pynocular.uuid_str import UUID_STR +from pynocular.util import UUID_STR class Org(DatabaseModel, table_name="organizations"): From 55fc708edf57d431442b6b3223910f5f5eb773c8 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 20:43:53 -0700 Subject: [PATCH 21/31] readme --- README.md | 333 +++++++----------------- pynocular/__init__.py | 2 +- tests/functional/test_database_model.py | 96 +++---- 3 files changed, 146 insertions(+), 285 deletions(-) diff --git a/README.md b/README.md index b60627b..1d95ff6 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ Pynocular is a lightweight ORM that lets you query your database using Pydantic models and asyncio. -With Pynocular you can decorate your existing Pydantic models to sync them with the corresponding table in your +With Pynocular, you can annotate your existing Pydantic models to sync them with the corresponding table in your database, allowing you to persist changes without ever having to think about the database. Transaction management is automatically handled for you so you can focus on the important parts of your code. This integrates seamlessly with frameworks that use Pydantic models such as FastAPI. Features: -- Fully supports asyncio to write to SQL databases +- Fully supports asyncio to write to SQL databases through the [databases](https://www.encode.io/databases/) library - Provides simple methods for basic SQLAlchemy support (create, delete, update, read) - Contains access to more advanced functionality such as custom SQLAlchemy selects - Contains helper functions for creating new database tables @@ -19,15 +19,23 @@ Features: Table of Contents: - [Installation](#installation) -- [Guide](#guide) - - [Basic Usage](#basic-usage) - - [Advanced Usage](#advanced-usage) - - [Creating database tables](#creating-database-tables) +- [Basic Usage](#basic-usage) + - [Defining models](#defining-models) + - [Creating a database and setting the backend](#creating-a-database-and-setting-the-backend) + - [Creating, reading, updating, and deleting database objects](#creating-reading-updating-and-deleting-database-objects) + - [Serialization](#serialization) + - [Special type arguments](#special-type-arguments) +- [Advanced Usage](#advanced-usage) + - [Tables with compound keys](#tables-with-compound-keys) + - [Batch operations on tables](#batch-operations-on-tables) + - [Complex queries](#complex-queries) + - [Creating database and tables](#creating-database-and-tables) + - [Unit testing with DatabaseModel](#unit-testing-with-databasemodel) - [Development](#development) ## Installation -Pynocular requires Python 3.6 or above. +Pynocular requires Python 3.9 or above. ```bash pip install pynocular @@ -35,37 +43,17 @@ pip install pynocular poetry add pynocular ``` -## Guide +## Basic Usage -### Basic Usage +### Defining models -Pynocular works by decorating your base Pydantic model with the function `database_model`. Once decorated -with the proper information, you can proceed to use that model to interface with your specified database table. - -The first step is to define a `DBInfo` object. This will contain the connection information to your database. +Pynocular works by augmenting Pydantic's `BaseModel` through the `DatabaseModel` class. Once you define a class that extends `DatabaseModel`, you can proceed to use that model to interface with your specified database table. ```python -from pynocular.engines import DatabaseType, DBInfo - - -# Example below shows how to connect to a locally-running Postgres database -connection_string = f"postgresql://{db_user_name}:{db_user_password}@localhost:5432/{db_name}?sslmode=disable" -) -db_info = DBInfo(connection_string) -``` - -#### Object Management +from pydantic import Field +from pynocular import DatabaseModel, UUID_STR -Once you define a `db_info` object, you are ready to decorate your Pydantic models and interact with your database! - -```python -from pydantic import BaseModel, Field -from pynocular.database_model import database_model, UUID_STR - -from my_package import db_info - -@database_model("organizations", db_info) -class Org(BaseModel): +class Org(DatabaseModel, table_name="organizations"): id: Optional[UUID_STR] = Field(primary_key=True, fetch_on_create=True) name: str = Field(max_length=45) @@ -74,9 +62,30 @@ class Org(BaseModel): created_at: Optional[datetime] = Field(fetch_on_create=True) updated_at: Optional[datetime] = Field(fetch_on_update=True) +``` -#### Object management +### Creating a database and setting the backend +The first step is to create a database pool and set the Pynocular backend. This will tell the models how to persist data. + +Use the [databases](https://www.encode.io/databases/) library to create a database connection using the dialect of your choice and pass the database object to `SQLDatabaseModelBackend`. + +```python +from pynocular import Database, set_backend, SQLDatabaseModelBackend + +async def main(): + # Example below shows how to connect to a locally-running Postgres database + connection_string = f"postgresql://{db_user_name}:{db_user_password}@localhost:5432/{db_name}?sslmode=disable" + async with Database(connection_string) as db: + with set_backend(SQLDatabaseModelBackend(db)): + print(await Org.get_list()) +``` + +### Creating, reading, updating, and deleting database objects + +Once you define a database model and set a backend, you are ready to interact with your database! + +```python # Create a new Org via `create` org = await Org.create(name="new org", slug="new-org") @@ -115,14 +124,13 @@ assert org3.name == "new org2" ``` -#### Serialization +### Serialization -DatabaseModels have their own serialization functions to convert to and from -dictionaries. +Database models have their own serialization functions to convert to and from dictionaries. ```python # Serializing org with `to_dict()` -org = Org.create(name="org serialize", slug="org-serialize") +org = await Org.create(name="org serialize", slug="org-serialize") org_dict = org.to_dict() expected_org_dict = { "id": "e64f6c7a-1bd1-4169-b482-189bd3598079", @@ -133,163 +141,37 @@ expected_org_dict = { } assert org_dict == expected_org_dict - # De-serializing org with `from_dict()` new_org = Org.from_dict(expected_org_dict) assert org == new_org ``` -#### Using Nested DatabaseModels - -Pynocular also supports basic object relationships. If your database tables have a -foreign key reference you can leverage that in your pydantic models to increase the -accessibility of those related objects. - -```python -from pydantic import BaseModel, Field -from pynocular.database_model import database_model, nested_model, UUID_STR - -from my_package import db_info - -@database_model("users", db_info) -class User(BaseModel): - - id: Optional[UUID_STR] = Field(primary_key=True, fetch_on_create=True) - username: str = Field(max_length=100) - - created_at: Optional[datetime] = Field(fetch_on_create=True) - updated_at: Optional[datetime] = Field(fetch_on_update=True) - -@database_model("organizations", db_info) -class Org(BaseModel): - - id: Optional[UUID_STR] = Field(primary_key=True, fetch_on_create=True) - name: str = Field(max_length=45) - slug: str = Field(max_length=45) - # `organizations`.`tech_owner_id` is a foreign key to `users`.`id` - tech_owner: Optional[nested_model(User, reference_field="tech_owner_id")] - # `organizations`.`business_owner_id` is a foreign key to `users`.`id` - business_owner: nested_model(User, reference_field="business_owner_id") - tag: Optional[str] = Field(max_length=100) - - created_at: Optional[datetime] = Field(fetch_on_create=True) - updated_at: Optional[datetime] = Field(fetch_on_update=True) - - -tech_owner = await User.create("tech owner") -business_owner = await User.create("business owner") - - -# Creating org with only business owner set -org = await Org.create( - name="org name", - slug="org-slug", - business_owner=business_owner -) - -assert org.business_owner == business_owner - -# Add tech owner -org.tech_owner = tech_owner -await org.save() - -# Fetch from the db and check ids -org2 = Org.get(org.id) -assert org2.tech_owner.id == tech_owner.id -assert org2.business_owner.id == business_owner.id - -# Swap user roles -org2.tech_owner = business_owner -org2.business_owner = tech_owner -await org2.save() -org3 = await Org.get(org2.id) -assert org3.tech_owner.id == business_owner.id -assert org3.business_owner.id == tech_owner.id - - -# Serialize org -org_dict = org3.to_dict() -expected_org_dict = { - "id": org3.id, - "name": "org name", - "slug": "org-slug", - "business_owner_id": tech_owner.id, - "tech_owner_id": business_owner.id, - "tag": None, - "created_at": org3.created_at, - "updated_at": org3.updated_at -} - -assert org_dict == expected_org_dict - -``` - -When using `DatabaseModel.get(..)`, any foreign references will need to be resolved before any properties besides the primary ID can be accessed. If you try to access a property before calling `fetch()` on the nested model, a `NestedDatabaseModelNotResolved` error will be thrown. - -```python -org_get = await Org.get(org3.id) -org_get.tech_owner.id # Does not raise `NestedDatabaseModelNotResolved` -org_get.tech_owner.username # Raises `NestedDatabaseModelNotResolved` - -org_get = await Org.get(org3.id) -await org_get.tech_owner.fetch() -org_get.tech_owner.username # Does not raise `NestedDatabaseModelNotResolved` -``` - -Alternatively, calling `DatabaseModel.get_with_refs()` instead of `DatabaseModel.get()` will -automatically fetch the referenced records and fully resolve those objects for you. +### Special type arguments -```python -org_get_with_refs = await Org.get_with_refs(org3.id) -org_get_with_refs.tech_owner.username # Does not raise `NestedDatabaseModelNotResolved` -``` - -There are some situations where none of the objects have been persisted to the -database yet. In this situation, you can call `Database.save(include_nested_models=True)` -on the object with the references and it will persist all of them in a transaction. - -```python -# We create the objects but dont persist them -tech_owner = User("tech owner") -business_owner = User("business owner") - -org = Org( - name="org name", - slug="org-slug", - business_owner=business_owner -) - -await org.save(include_nested_models=True) -``` - -#### Special Type arguments - -With Pynocular you can set fields to be optional and set by the database. This is useful +With Pynocular you can set fields to be optional and rely on the database server to set its value. This is useful if you want to let the database autogenerate your primary key or `created_at` and `updated_at` fields on your table. To do this you must: - Wrap the typehint in `Optional` - Provide keyword arguments of `fetch_on_create=True` or `fetch_on_update=True` to the `Field` class -### Advanced Usage +## Advanced Usage For most use cases, the basic usage defined above should suffice. However, there are certain situations where you don't necessarily want to fetch each object or you need to do more complex queries that are not exposed by the `DatabaseModel` interface. Below are some examples of how those situations can be addressed using Pynocular. -#### Tables with compound keys +### Tables with compound keys Pynocular supports tables that use multiple fields as its primary key such as join tables. ```python -from pydantic import BaseModel, Field -from pynocular.database_model import database_model, nested_model, UUID_STR +from pydantic import Field +from pynocular import DatabaseModel, UUID_STR -from my_package import db_info -@database_model("user_subscriptions", db_info) -class UserSubscriptions(BaseModel): +class UserSubscriptions(DatabaseModel, table_name="user_subscriptions"): user_id: UUID_STR = Field(primary_key=True, fetch_on_create=True) subscription_id: UUID_STR = Field(primary_key=True, fetch_on_create=True) @@ -314,9 +196,9 @@ user_sub_get.name = "change name" await user_sub_get.save() ``` -#### Batch operations on tables +### Batch operations on tables -Sometimes you want to insert a bunch of records into a database and you don't want to do an insert for each one. +Sometimes you want to perform a bulk insert of records into a database table. This can be handled by the `create_list` function. ```python @@ -352,116 +234,95 @@ org = await Org.get("05c0060c-ceb8-40f0-8faa-dfb91266a6cf") assert org.tag == "blue" ``` -#### Complex queries +### Complex queries Sometimes your application will require performing complex queries, such as getting the count of each unique field value for all records in the table. Because Pynocular is backed by SQLAlchemy, we can access table columns directly to write pure SQLAlchemy queries as well! ```python from sqlalchemy import func, select -from pynocular.engines import DBEngine +from pynocular import get_backend + async def generate_org_stats(): query = ( select([func.count(Org.column.id), Org.column.tag]) .group_by(Org.column.tag) .order_by(func.count().desc()) ) - async with await DBEngine.transaction(Org._database_info, is_conditional=False) as conn: + # Get the active backend and open a database transaction + async with get_backend().transaction(): result = await conn.execute(query) - return [dict(row) async for row in result] + return [dict(row) for row in result] ``` -NOTE: `DBengine.transaction` is used to create a connection to the database using the credentials passed in. -If `is_conditional` is `False`, then it will add the query to any transaction that is opened in the call chain. This allows us to make database calls -in different functions but still have them all be under the same database transaction. If there is no transaction opened in the call chain it will open -a new one and any subsequent calls underneath that context manager will be added to the new transaction. - -If `is_conditional` is `True` and there is no transaction in the call chain, then the connection will not create a new transaction. Instead, the query will be performed without a transaction. - ### Creating database and tables -With Pynocular you can use simple python code to create new databases and database tables. All you need is a working connection string to the database host, a `DatabaseInfo` object that contains the information of the database you want to create, and a properly decorated pydantic model. When you decorate a pydantic model with Pynocular, it creates a SQLAlchemy table as a private variable. This can be accessed via the `_table` property -(although accessing private variables is not recommended). +With Pynocular you can use simple Python code to create new databases and database tables. All you need is a working connection string to the database host and a properly defined `DatabaseModel` class. When you define a class that extends `DatabaseModel`, Pynocular creates a SQLAlchemy table under the hood. This can be accessed via the `table` property. ```python +from pynocular import Database from pynocular.util import create_new_database, create_table -from my_package import Org, db_info +from my_package import Org -connection_string = "postgresql://postgres:XXXX@localhost:5432/postgres?sslmode=disable" +async def main(): + connection_string = "postgresql://postgres:XXXX@localhost:5432/postgres" + await create_new_database(connection_string, "my_new_db") -# Creates a new database and "organizations" table in that database -await create_new_database(connection_string, db_info) -await create_table(db_info, Org._table) + connection_string = "postgresql://postgres:XXXX@localhost:5432/my_new_db" + async with Database(connection_string) as db: + # Creates a new database and "organizations" table in that database + await create_table(db, Org.table) ``` -### Unit Testing with DatabaseModels +### Unit testing with DatabaseModel -Pynocular comes with tooling to write unit tests against your DatabaseModels, giving you +Pynocular comes with tooling to write unit tests against your database models, giving you the ability to test your business logic without the extra work and latency involved in -managing a database. All you have to do is use the `patch_database_model` context -manager provided in Pynocular. +managing a database. All you have to do is set the backend using the `MemoryDatabaseModelBackend` instead of the SQL backend. You don't need to change any of your database model definitions. ```python -from pynocular.patch_models import patch_database_model +from pynocular import MemoryDatabaseModelBackend, set_backend from my_package import Org, User - -with patch_database_model(Org): +async def main(): orgs = [ Org(id=str(uuid4()), name="orgus borgus", slug="orgus_borgus"), Org(id=str(uuid4()), name="orgus borgus2", slug="orgus_borgus"), ] - await Org.create_list(orgs) - fetched_orgs = await Org.get_list(name=orgs[0].name) - assert orgs[0] == fetched_orgs[0] - -# patch_database_model also works with nested models -users = [ - User(id=str(uuid4()), username="Bob"), - User(id=str(uuid4()), username="Sally"), -] -orgs = [ - Org( - id=str(uuid4()), - name="orgus borgus", - slug="orgus_borgus", - tech_owner=users[0], - business_owner=users[1], - ), -] - -with patch_database_model(Org, models=orgs), patch_database_model( - User, models=users -): - org = await Org.get(orgs[0].id) - org.name = "new test name" - users[0].username = "bberkley" + with set_backend(MemoryDatabaseModelBackend()): + await Org.create_list(orgs) + fetched_orgs = await Org.get_list(name=orgs[0].name) + assert orgs[0] == fetched_orgs[0] - # Save the username update when saving the org model update - await org.save(include_nested_models=True) + users = [ + User(id=str(uuid4()), username="Bob"), + User(id=str(uuid4()), username="Sally"), + ] - # Get the org with the resolved nested model - org_get = await Org.get_with_refs(org.id) - assert org_get.name == "new test name" - assert org_get.tech_owner.username == "bberkley" + # You can also seed the backend with existing records + with MemoryDatabaseModelBackend( + records={ + "orgs": [o.to_dict() for o in orgs], + "users": [u.to_dict() for u in users], + } + ): + org = await Org.get(orgs[0].id) + org.name = "new test name" + await org.save() ``` ## Development -To develop Pynocular, install dependencies and enable the pre-commit hook. - -The example below is using Python 3.9 but you can replace this with any supported version of Python. - -Install Python 3.9 and activate it in your shell. +To develop Pynocular, install dependencies and enable the pre-commit hook. Make sure to install Python 3.9 and activate it in your shell. ```bash sudo yum install libffi-devel # Needed for ctypes to install poetry -pyenv install 3.9.7 -pyenv shell 3.9.7 +pyenv install 3.9.12 +pyenv shell 3.9.12 ``` Install dependencies and enable the pre-commit hook. diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 484ea71..9b4035a 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -4,5 +4,5 @@ from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend -from pynocular.backends.sql import SQLDatabaseModelBackend +from pynocular.backends.sql import Database, SQLDatabaseModelBackend from pynocular.database_model import DatabaseModel diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index 7cc0d58..c1ef7a7 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -71,16 +71,16 @@ async def memory_backend(): @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_select(_backend) -> None: +async def test_select(backend) -> None: """Test that we can select the full set of DatabaseModels""" - with set_backend(_backend): + with set_backend(backend): try: org = await Org.create( id=str(uuid4()), @@ -95,16 +95,16 @@ async def test_select(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_get_list(_backend) -> None: +async def test_get_list(backend) -> None: """Test that we can get_list and get a subset of DatabaseModels""" - with set_backend(_backend): + with set_backend(backend): try: org1 = await Org.create( id=str(uuid4()), name="orgus borgus", slug="orgus_borgus", serial_id=1 @@ -128,16 +128,16 @@ async def test_get_list(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_get_list__none_filter_value(_backend) -> None: +async def test_get_list__none_filter_value(backend) -> None: """Test that we can get_list based on a None filter value""" - with set_backend(_backend): + with set_backend(backend): try: test_org = await Org.create( id=uuid4(), name="orgus borgus", slug="orgus_borgus", serial_id=None @@ -149,16 +149,16 @@ async def test_get_list__none_filter_value(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_get_list__none_json_value(_backend) -> None: +async def test_get_list__none_json_value(backend) -> None: """Test that we can get_list for a None value on a JSON field""" - with set_backend(_backend): + with set_backend(backend): # The None value will be persisted as a SQL NULL value rather than a JSON-encoded # null value when the Topic is created, so the filter value None will work here try: @@ -176,16 +176,16 @@ async def test_get_list__none_json_value(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_create_new_record(_backend) -> None: +async def test_create_new_record(backend) -> None: """Test that we can create a database record""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 100 try: @@ -200,16 +200,16 @@ async def test_create_new_record(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_create_list(_backend) -> None: +async def test_create_list(backend) -> None: """Test that we can create a list of database records""" - with set_backend(_backend): + with set_backend(backend): try: initial_orgs = [ Org(id=str(uuid4()), name="fake org 1", slug="fake-slug-1"), @@ -225,31 +225,31 @@ async def test_create_list(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_create_list__empty(_backend) -> None: +async def test_create_list__empty(backend) -> None: """Should return empty list for input of empty list""" - with set_backend(_backend): + with set_backend(backend): created_orgs = await Org.create_list([]) assert created_orgs == [] @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_update_new_record__save(_backend) -> None: +async def test_update_new_record__save(backend) -> None: """Test that we can update a database record using `save`""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 101 @@ -271,16 +271,16 @@ async def test_update_new_record__save(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_update_new_record__update_record(_backend) -> None: +async def test_update_new_record__update_record(backend) -> None: """Test that we can update a database record using `update_record`""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 100000 @@ -300,16 +300,16 @@ async def test_update_new_record__update_record(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_delete_new_record__delete(_backend) -> None: +async def test_delete_new_record__delete(backend) -> None: """Test that we can delete a database record using `delete`""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 102 @@ -329,16 +329,16 @@ async def test_delete_new_record__delete(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_delete_new_record__delete_records(_backend) -> None: +async def test_delete_new_record__delete_records(backend) -> None: """Test that we can delete a database record using `delete_records`""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 103 @@ -356,16 +356,16 @@ async def test_delete_new_record__delete_records(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_delete_new_record__delete_records_multi_kwargs(_backend) -> None: +async def test_delete_new_record__delete_records_multi_kwargs(backend) -> None: """Test that we can delete a database record using `delete_records` with multiple kwargs""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 104 @@ -383,16 +383,16 @@ async def test_delete_new_record__delete_records_multi_kwargs(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_bad_org_object_creation(_backend) -> None: +async def test_bad_org_object_creation(backend) -> None: """Test that we raise an Exception if the object is missing fields""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) with pytest.raises(ValidationError): @@ -400,31 +400,31 @@ async def test_bad_org_object_creation(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_raise_error_get_list_wrong_field(_backend) -> None: +async def test_raise_error_get_list_wrong_field(backend) -> None: """Test that we raise an exception if we query for a wrong field on the object""" - with set_backend(_backend): + with set_backend(backend): with pytest.raises(DatabaseModelMissingField): await Org.get_list(table_id="Table1") @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_setting_db_managed_columns(_backend) -> None: +async def test_setting_db_managed_columns(backend) -> None: """Test that db managed columns get automatically set on save""" - with set_backend(_backend): + with set_backend(backend): org = await Org.create( id=str(uuid4()), serial_id=105, name="fake_org105", slug="fake_org105" ) @@ -443,16 +443,16 @@ async def test_setting_db_managed_columns(_backend) -> None: @pytest.mark.parametrize( - "_backend", + "backend", [ pytest.lazy_fixture("postgres_backend"), pytest.lazy_fixture("memory_backend"), ], ) @pytest.mark.asyncio -async def test_fetch(_backend) -> None: +async def test_fetch(backend) -> None: """Test that we can fetch the latest state of a database record""" - with set_backend(_backend): + with set_backend(backend): org_id = str(uuid4()) serial_id = 100 try: From cda1ff3cc015c987cfe1d62f74b193d09d19782a Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 20:48:29 -0700 Subject: [PATCH 22/31] nonested --- pynocular/backends/base.py | 3 - pynocular/database_model.py | 107 ++------------- pynocular/patch_models.py | 263 ------------------------------------ 3 files changed, 10 insertions(+), 363 deletions(-) delete mode 100644 pynocular/patch_models.py diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index 1a7d6dd..997eaf4 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -19,9 +19,6 @@ class DatabaseModelConfig: fields: Dict[str, Field] primary_keys: List[Column] db_managed_fields: List[str] - nested_model_attributes: Set[str] - nested_attr_table_field_map: Dict[str, str] - nested_table_field_attr_map: Dict[str, str] table: Table @property diff --git a/pynocular/database_model.py b/pynocular/database_model.py index f1cb067..5ff89b9 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -1,9 +1,8 @@ """Contains DatabaseModel class""" -import asyncio from datetime import datetime from enum import Enum, EnumMeta -from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING from uuid import UUID as stdlib_uuid from pydantic import BaseModel, PositiveFloat, PositiveInt, UUID4 @@ -72,18 +71,6 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: # These are the server_default and server_onupdate functions in SQLAlchemy db_managed_fields: List[str] = [] - # The following tables track which attributes on the model are nested model - # references - # Some nested model attributes may have different names than their actual db table; - # For example; on an App we may have an `org` attribute but the db field is - # `organzation_id` - - # In order to manage this we also need maps from attribute name to table_field_name - # and back - nested_model_attributes: Set[str] = set() - nested_attr_table_field_map: Dict[str, str] = {} - nested_table_field_attr_map: Dict[str, str] = {} - columns: List[Column] = [] for field in cls.__fields__.values(): name = field.name @@ -119,17 +106,6 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: type = sqlalchemy_uuid() elif field.type_ is datetime: type = TIMESTAMP(timezone=True) - elif field.type_.__name__ == "NestedModel": - nested_model_attributes.add(name) - # If the field name on the NestedModel type is not None, use that for the - # column name - if field.type_.reference_field_name is not None: - nested_attr_table_field_map[name] = field.type_.reference_field_name - nested_table_field_attr_map[field.type_.reference_field_name] = name - name = field.type_.reference_field_name - - # Assume all IDs are UUIDs for now - type = sqlalchemy_uuid() # TODO - how are people using this today? Is there a class we need to make or can we reuse one # elif field.type_ is bit: # type = Bit @@ -160,9 +136,6 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: return DatabaseModelConfig( fields={**cls.__fields__}, db_managed_fields=db_managed_fields, - nested_attr_table_field_map=nested_attr_table_field_map, - nested_model_attributes=nested_model_attributes, - nested_table_field_attr_map=nested_table_field_attr_map, primary_keys=primary_keys, table=table, ) @@ -204,11 +177,7 @@ def from_dict(cls, _dict: Dict[str, Any]) -> "DatabaseModel": The DatabaseModel object """ - modified_dict = {} - for key, value in _dict.items(): - modified_key = cls._config.nested_table_field_attr_map.get(key, key) - modified_dict[modified_key] = value - return cls(**modified_dict) + return cls(**_dict) def to_dict( self, serialize: bool = False, include_keys: Optional[Sequence] = None @@ -238,43 +207,11 @@ def to_dict( if isinstance(prop_value, Enum): prop_value = prop_value.name - if prop_name in self._config.nested_model_attributes: - # self.dict() will serialize any BaseModels into a dict so fetch the - # actual object from self - temp_prop_value = getattr(self, prop_name) - prop_name = self._config.nested_attr_table_field_map.get( - prop_name, prop_name - ) - # temp_prop_value can be `None` if the nested key is optional - if temp_prop_value is not None: - prop_value = temp_prop_value.get_primary_id() - if not include_keys or prop_name in include_keys: _dict[prop_name] = prop_value return _dict - @classmethod - async def get_with_refs(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": - """Gets the DatabaseModel associated with any nested key references resolved - - Args: - args: The column id for the object's primary key - kwargs: The columns and ids that make up the object's composite primary key - - Returns: - A DatabaseModel object representing the record in the db if one exists - - """ - obj = await cls.get(*args, **kwargs) - gatherables = [ - (getattr(obj, prop_name)).fetch() - for prop_name in cls._config.nested_model_attributes - ] - await asyncio.gather(*gatherables) - - return obj - @classmethod async def get(cls, *args: Any, **kwargs: Any) -> "DatabaseModel": """Gets the DatabaseModel for the given primary key value(s) @@ -349,22 +286,13 @@ def get_primary_id(self) -> Any: return getattr(self, self._config.primary_keys[0].name) - async def fetch(self, resolve_references: bool = False) -> None: - """Gets the latest of the object from the database and updates itself - - Args: - resolve_references: If True, resolve any nested key references - - """ - # Get the latest version of self + async def fetch(self) -> None: + """Gets the latest of the object from the database and updates itself""" get_params = { primary_key.name: getattr(self, primary_key.name) for primary_key in self._config.primary_keys } - if resolve_references: - new_self = await self.get_with_refs(**get_params) - else: - new_self = await self.get(**get_params) + new_self = await self.get(**get_params) for attr_name, new_attr_val in new_self.dict().items(): setattr(self, attr_name, new_attr_val) @@ -388,14 +316,10 @@ async def get_list(cls, **kwargs: Any) -> List["DatabaseModel"]: """ where_expressions = [] for field_name, db_field_value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - try: - db_field = getattr(cls._config.table.c, db_field_name) + db_field = getattr(cls._config.table.c, field_name) except AttributeError: - raise DatabaseModelMissingField(cls.__name__, db_field_name) + raise DatabaseModelMissingField(cls.__name__, field_name) if isinstance(db_field_value, list): exp = db_field.in_(db_field_value) @@ -497,14 +421,10 @@ async def delete_records(cls, **kwargs: Any) -> None: """ where_expressions = [] for field_name, db_field_value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - try: - db_field = getattr(cls._config.table.c, db_field_name) + db_field = getattr(cls._config.table.c, field_name) except AttributeError: - raise DatabaseModelMissingField(cls.__name__, db_field_name) + raise DatabaseModelMissingField(cls.__name__, field_name) if isinstance(db_field_value, list): exp = db_field.in_(db_field_value) @@ -536,14 +456,7 @@ async def update_record(cls, **kwargs: Any) -> "DatabaseModel": where_expressions.append(primary_key == primary_key_value) primary_key_dict[primary_key.name] = primary_key_value - modified_kwargs = {} - for field_name, value in kwargs.items(): - db_field_name = cls._config.nested_attr_table_field_map.get( - field_name, field_name - ) - modified_kwargs[db_field_name] = value - - updated_records = await cls.update(where_expressions, modified_kwargs) + updated_records = await cls.update(where_expressions, kwargs) if len(updated_records) == 0: raise DatabaseRecordNotFound(cls._config.table.name, **primary_key_dict) return updated_records[0] diff --git a/pynocular/patch_models.py b/pynocular/patch_models.py deleted file mode 100644 index c3a7b28..0000000 --- a/pynocular/patch_models.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Context manager for mocking db calls for DatabaseModels during tests""" -from contextlib import contextmanager -from typing import Any, Dict, List, Optional -from unittest.mock import patch -from uuid import uuid4 - -from sqlalchemy.sql.elements import BinaryExpression, UnaryExpression - -from pynocular.database_model import DatabaseModel -from pynocular.evaluate_column_element import evaluate_column_element - - -@contextmanager -def patch_database_model( - model_cls: DatabaseModel, - models: Optional[List[DatabaseModel]] = None, -) -> None: - """Patch a DatabaseModel class, seeding with a set of values - - Example: - with patch_database_model(Org, [Org(id="1", name="org 1"), ...]: - await Org.get_list(...) - - Args: - model_cls: A subclass of DatabaseModel that should be patched. - models: models that should be in the patched DB table. - - """ - models = list(models) if models is not None else [] - - def match(model: DatabaseModel, expression: BinaryExpression) -> bool: - """Function to match the value with the expected one in the expression - - Args: - model: The db model that represents a model in the "db". - expression: The expression object to compare to. - - Returns: - True if the expression operator is True. - - """ - return expression.operator( - model.get(expression.left.name), expression.right.value - ) - - async def select( - where_expressions: Optional[List[BinaryExpression]] = None, - order_by: Optional[List[UnaryExpression]] = None, - limit: Optional[int] = None, - ) -> List[DatabaseModel]: - """Mock select function for DatabaseModel - - Args: - where_expressions: The BinaryExpressions to use in the select where clause. - order_by: The order by expressions to be included in the select. This are - not supported for mocking at this time. - limit: The maximum number of objects to return. - - Returns: - List of DatabaseModels that match the parameters. - - """ - # This function currently does not support `order_by` parameter. - if where_expressions is None: - return models - - matched_models = [ - model - for model in models - if all( - evaluate_column_element(expr, model.to_dict()) - for expr in where_expressions - ) - ] - - if limit is None: - matched_models[:limit] - - return matched_models - - async def create_list(models) -> List[DatabaseModel]: - """Mock `create_list` function for DatabaseModel - - Args: - models: List of DatabaseModels to persist. - - Returns: - The list of new DatabaseModels that have been saved. - - """ - # Iterate through the list of orm objs and call save(). - for obj in models: - await obj.save() - - return models - - async def save(model, include_nested_models=False) -> None: - """Mock `save` function for DatabaseModel - - Args: - model: The model to save. - include_nested_models: If True, any nested models should get saved before - this object gets saved. - - """ - # If include_nested_models is True, call save on all nested model attributes. - # This requires that the nested models are also patched. - if include_nested_models: - for attr_name in model._nested_model_attributes: - obj = getattr(model, attr_name) - if obj is not None: - await obj.save() - - primary_keys = model_cls._primary_keys - # Put uuids into any primary key that isn't set yet. - for primary_key in primary_keys: - val = getattr(model, primary_key.name) - if val is None: - setattr(model, primary_key.name, str(uuid4())) - - # Pull the primary keys out of the class and the values out of the provided - # database model. Then build a where_expression list to get the model matching those - # primary keys. - where_expressions = [ - primary_key == getattr(model, primary_key.name) - for primary_key in primary_keys - ] - selected_models = [ - model - for model in models - if all( - evaluate_column_element(expr, model.to_dict()) - for expr in where_expressions - ) - ] - - if len(selected_models) == 0: - # Add a new model to the models since this model didn't exist before. - models.append(model) - else: - # Update the matching model. Since these are primary keys there should only - # ever be one model matching the given where_expressions. - matched_model = selected_models[0] - for attr, val in model.dict().items(): - setattr(matched_model, attr, val) - - async def update_record(**kwargs: Any) -> DatabaseModel: - """Mock `update_record` function for DatabaseModel - - Args: - kwargs: The values to update. - - Returns: - The updated DatabaseModel. - - """ - primary_keys = model_cls._primary_keys - - # Pull the primary keys out of the class and the values out of the provided - # kwargs. Then build a where_expression list to get the model matching those - # primary keys. - where_expressions = [ - primary_key == kwargs[primary_key.name] for primary_key in primary_keys - ] - selected_models = [ - model - for model in models - if all( - evaluate_column_element(expr, model.to_dict()) - for expr in where_expressions - ) - ] - - # Update the matching model. Since these are primary keys there should only - # ever be one model matching the given where_expressions. - model = selected_models[0] - for attr, val in kwargs.items(): - setattr(model, attr, val) - return model - - async def update( - where_expressions: Optional[List[BinaryExpression]], values: Dict[str, Any] - ) -> List[DatabaseModel]: - """Mock `update_record` function for DatabaseModel - - Args: - where_expressions: A list of BinaryExpressions for the table that will be - `and`ed together for the where clause of the UPDATE - values: The field and values to update all records to that match the - where_expressions - - Returns: - The updated DatabaseModels. - - """ - models = await select(where_expressions) - for model in models: - for attr, val in values.items(): - setattr(model, attr, val) - return models - - async def delete(model) -> None: - """Mock `delete` function for DatabaseModel""" - primary_keys = model_cls._primary_keys - - # Pull the primary keys out of the class and the values out of the provided - # database model. Then build a where_expression list to get the model matching those - # primary keys. - where_expressions = [ - primary_key == getattr(model, primary_key.name) - for primary_key in primary_keys - ] - - # Remove any models that match the given where_expression - models[:] = [ - model - for model in models - if not all( - evaluate_column_element(expr, model.to_dict()) - for expr in where_expressions - ) - ] - - async def delete_records(**kwargs: Any) -> None: - """Mock `delete_records` function for DatabaseModel - - Args: - kwargs: The values used to find the records that should be deleted - - """ - where_exp = [] - for key, value in kwargs.items(): - col = getattr(model_cls.columns, key) - if isinstance(value, list): - where_exp.append(col.in_(value)) - else: - where_exp.append(col == value) - - # Remove any models that match the given where_expression - models[:] = [ - model - for model in models - if not all( - evaluate_column_element(expr, model.to_dict()) for expr in where_exp - ) - ] - - # Add the patches. Note that create functionality is patched indirectly though - # 'save' already, but add a spy on it anyway so we can test calls against it. - with patch.object(model_cls, "select", select), patch.object( - model_cls, "save", save - ), patch.object(model_cls, "update_record", update_record), patch.object( - model_cls, "update", update - ), patch.object( - model_cls, "create_list", create_list - ), patch.object( - model_cls, "create", wraps=model_cls.create - ), patch.object( - model_cls, "delete", delete - ), patch.object( - model_cls, "delete_records", delete_records - ): - yield From a06263525d59f64568eea81683f7f985502cadd3 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Thu, 21 Apr 2022 21:02:26 -0700 Subject: [PATCH 23/31] readme --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d95ff6..60577df 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Features: - Provides simple methods for basic SQLAlchemy support (create, delete, update, read) - Contains access to more advanced functionality such as custom SQLAlchemy selects - Contains helper functions for creating new database tables -- Advanced transaction management system allows you to conditionally put requests in transactions +- Supports automatic and nested transactions Table of Contents: @@ -28,6 +28,7 @@ Table of Contents: - [Advanced Usage](#advanced-usage) - [Tables with compound keys](#tables-with-compound-keys) - [Batch operations on tables](#batch-operations-on-tables) + - [Transactions and asyncio.gather](#transactions-and-asynciogather) - [Complex queries](#complex-queries) - [Creating database and tables](#creating-database-and-tables) - [Unit testing with DatabaseModel](#unit-testing-with-databasemodel) @@ -234,6 +235,29 @@ org = await Org.get("05c0060c-ceb8-40f0-8faa-dfb91266a6cf") assert org.tag == "blue" ``` +### Transactions and asyncio.gather + +You should avoid using `asyncio.gather` within a database transaction. You can use Pynocular's `gather` function instead, which has the same interface but executes queries sequentially: + +```python +from pynocular import get_backend +from pynocular.util import gather + +async with get_backend().transaction(): + await gather( + Org.create(id="abc", name="foo"), + Org.create(id="def", name="bar"), + ) +``` + +The reason is that concurrent queries can interfere with each other and result in the error: + +```txt +asyncpg.exceptions._base.InterfaceError: cannot perform operation: another operation is in progress +``` + +See: https://github.com/encode/databases/issues/125#issuecomment-511720013 + ### Complex queries Sometimes your application will require performing complex queries, such as getting the count of each unique field value for all records in the table. From 92807d5cd544b976e8bb60828e7e78069749f626 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Fri, 22 Apr 2022 08:32:38 -0700 Subject: [PATCH 24/31] no tx on select --- pynocular/backends/sql.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py index ade5c5f..6881115 100644 --- a/pynocular/backends/sql.py +++ b/pynocular/backends/sql.py @@ -27,7 +27,7 @@ def __init__(self, db: Database): """Initialize a SQLDatabaseModelBackend Args: - db: Database object that has already established a connection + db: Database object that has already established a connection pool """ self.db = db @@ -65,22 +65,21 @@ async def select( InvalidFieldValue: The class is missing a database table """ - async with self.transaction(): - query = config.table.select() - if where_expressions is not None and len(where_expressions) > 0: - query = query.where(and_(*where_expressions)) - if order_by is not None and len(order_by) > 0: - query = query.order_by(*order_by) - if limit is not None and limit > 0: - query = query.limit(limit) - - try: - result = await self.db.fetch_all(query) - # The value was the wrong type. This usually happens with UUIDs. - except InvalidTextRepresentation as e: - raise InvalidFieldValue(message=e.diag.message_primary) - - return [dict(record) for record in result] + query = config.table.select() + if where_expressions is not None and len(where_expressions) > 0: + query = query.where(and_(*where_expressions)) + if order_by is not None and len(order_by) > 0: + query = query.order_by(*order_by) + if limit is not None and limit > 0: + query = query.limit(limit) + + try: + result = await self.db.fetch_all(query) + # The value was the wrong type. This usually happens with UUIDs. + except InvalidTextRepresentation as e: + raise InvalidFieldValue(message=e.diag.message_primary) + + return [dict(record) for record in result] async def create_records( self, config: DatabaseModelConfig, records: List[Dict[str, Any]] From ac5364191cc32a1f7630bc23cb73f5840a0cae1e Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Fri, 22 Apr 2022 09:41:37 -0700 Subject: [PATCH 25/31] memory server defaults --- pynocular/backends/memory.py | 112 +++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index 22f48f2..a7a7863 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -34,6 +34,70 @@ def __init__(self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None): # Serial primary key generator self._pk_generator = itertools.count(start=1) + def _set_primary_key_values( + self, + config: DatabaseModelConfig, + record: Dict[str, Any], + ) -> Dict[str, Any]: + """Set default values on a record for the primary keys + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + record: The record to update + + Returns: + updated record + + """ + for primary_key in config.primary_keys: + value = ( + next(self._pk_generator) + if isinstance(primary_key.type, Integer) + else str(uuid4()) + ) + record.setdefault(primary_key.name, value) + + return record + + @staticmethod + def _update_db_managed_fields( + config: DatabaseModelConfig, + record: Dict[str, Any], + fetch_on_create: bool = False, + fetch_on_update: bool = False, + ) -> Dict[str, Any]: + """Update record values for db managed fields + + Args: + config: DatabaseModelConfig instance that contains references to a table and + columns that can be used to build queries suited to the backend. + record: The record to update + fetch_on_create: Flag that controls whether the db managed field will be + updated if it has the option `fetch_on_create=True`. Defaults to False. + fetch_on_update: Flag that controls whether the db managed field will be + updated if it has the option `fetch_on_update=True`. Defaults to False. + + Raises: + NotImplementedError: if a field sets fetch_on_create or fetch_on_update to + true but its type is not supported + + Returns: + updated record + + """ + for name in config.db_managed_fields: + field = config.fields[name] + if (fetch_on_create and field.field_info.extra.get("fetch_on_create")) or ( + fetch_on_update and field.field_info.extra.get("fetch_on_update") + ): + if field.type_ == datetime: + record[name] = datetime.utcnow() + else: + raise NotImplementedError(field.type_) + + return record + def transaction(self) -> Any: """Create a new transaction @@ -110,13 +174,10 @@ async def create_records( """ for record in records: - for primary_key in config.primary_keys: - value = ( - next(self._pk_generator) - if isinstance(primary_key.type, Integer) - else str(uuid4()) - ) - record.setdefault(primary_key.name, value) + self._set_primary_key_values(config, record) + self._update_db_managed_fields( + config, record, fetch_on_create=True, fetch_on_update=True + ) self.records[config.table.name].extend(records) @@ -165,6 +226,7 @@ async def update_records( records = await self.select(config, where_expressions=where_expressions) for record in records: record.update(values) + self._update_db_managed_fields(config, record, fetch_on_update=True) return records @@ -199,41 +261,15 @@ async def upsert( and existing_records ): # All primary keys are already set and a record was found so update - - # Set default values for db managed fields - for name in config.db_managed_fields: - field = config.fields[name] - if field.type_ == datetime: - if field.field_info.extra.get("fetch_on_update"): - record[name] = datetime.utcnow() - else: - raise NotImplementedError(field.type_) - + self._update_db_managed_fields(config, record, fetch_on_update=True) records = await self.update_records(config, where_expressions, record) return records[0] - else: # Primary keys have not been set or there were no records found, so this is # a new record - - # Set default values for db managed fields - for name in config.db_managed_fields: - field = config.fields[name] - if field.type_ == datetime: - if field.field_info.extra.get( - "fetch_on_create" - ) or field.field_info.extra.get("fetch_on_update"): - record[name] = datetime.utcnow() - else: - raise NotImplementedError(field.type_) - - for primary_key in config.primary_keys: - value = ( - next(self._pk_generator) - if isinstance(primary_key.type, Integer) - else str(uuid4()) - ) - record.setdefault(primary_key.name, value) - + self._set_primary_key_values(config, record) + self._update_db_managed_fields( + config, record, fetch_on_create=True, fetch_on_update=True + ) self.records[config.table.name].append(record) return record From 0dfc3c1566e938d2c0fe3470a9bb164a9fe892f6 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Fri, 22 Apr 2022 12:05:25 -0700 Subject: [PATCH 26/31] count --- pynocular/__init__.py | 2 +- pynocular/backends/base.py | 5 ++++- pynocular/backends/memory.py | 7 ++++++- pynocular/backends/sql.py | 14 +++++++++++--- pynocular/database_model.py | 5 ++++- pyproject.toml | 2 +- tests/functional/test_database_model.py | 18 ++++++++++++++++++ 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 9b4035a..476f06c 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "2.0.0rc1" +__version__ = "2.0.0-rc2" from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend diff --git a/pynocular/backends/base.py b/pynocular/backends/base.py index 997eaf4..10fbd5b 100644 --- a/pynocular/backends/base.py +++ b/pynocular/backends/base.py @@ -91,7 +91,7 @@ async def create_records( @abstractmethod async def delete_records( self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] - ) -> None: + ) -> Optional[int]: """Delete a group of records Args: @@ -100,6 +100,9 @@ async def delete_records( where_expressions: A list of BinaryExpressions for the table that will be `and`ed together for the where clause of the backend query + Returns: + number of records deleted (or None if the backend does not support) + """ pass diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index a7a7863..4080631 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -185,7 +185,7 @@ async def create_records( async def delete_records( self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] - ) -> None: + ) -> int: """Delete a group of records Args: @@ -194,7 +194,11 @@ async def delete_records( where_expressions: A list of BinaryExpressions for the table that will be `and`ed together for the where clause of the backend query + Returns: + number of records deleted + """ + start_count = len(self.records[config.table.name]) self.records[config.table.name][:] = [ record for record in self.records[config.table.name] @@ -202,6 +206,7 @@ async def delete_records( evaluate_column_element(expr, record) for expr in where_expressions ) ] + return start_count - len(self.records[config.table.name]) async def update_records( self, diff --git a/pynocular/backends/sql.py b/pynocular/backends/sql.py index 6881115..97fa1ff 100644 --- a/pynocular/backends/sql.py +++ b/pynocular/backends/sql.py @@ -107,7 +107,7 @@ async def create_records( async def delete_records( self, config: DatabaseModelConfig, where_expressions: List[BinaryExpression] - ) -> None: + ) -> Optional[int]: """Delete a group of records Args: @@ -116,11 +116,19 @@ async def delete_records( where_expressions: A list of BinaryExpressions for the table that will be `and`ed together for the where clause of the backend query + Returns: + number of records deleted + """ async with self.transaction(): - query = config.table.delete().where(and_(*where_expressions)) + query = ( + config.table.delete() + .where(and_(*where_expressions)) + .returning(*config.primary_keys) + ) try: - await self.db.execute(query) + result = await self.db.fetch_all(query) + return len(result) # The value was the wrong type. This usually happens with UUIDs. except InvalidTextRepresentation as e: raise InvalidFieldValue(message=e.diag.message_primary) diff --git a/pynocular/database_model.py b/pynocular/database_model.py index 5ff89b9..335594b 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -406,13 +406,16 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel return models @classmethod - async def delete_records(cls, **kwargs: Any) -> None: + async def delete_records(cls, **kwargs: Any) -> Optional[int]: """Execute a DELETE on a DatabaseModel with the provided kwargs Args: kwargs: The filterable key/value pairs for the where clause. These will be `and`ed together + Returns: + number of records deleted (or None if the backend does not support) + Raises: DatabaseModelMisconfigured: The class is missing a database table DatabaseModelMissingField: One of the fields provided in the query does not diff --git a/pyproject.toml b/pyproject.toml index 0c21bbe..f4af51a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "2.0.0rc1" +version = "2.0.0-rc.2" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index c1ef7a7..597429e 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -382,6 +382,24 @@ async def test_delete_new_record__delete_records_multi_kwargs(backend) -> None: await Org.get(org_id) +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) +@pytest.mark.asyncio +async def test_delete_records__count(backend) -> None: + """Should delete records and return deleted count""" + with set_backend(backend): + for i in range(3): + await Org.create(id=str(uuid4()), serial_id=i, name=str(i), slug=str(i)) + + count = await Org.delete_records(name="2") + assert count == 1 + + @pytest.mark.parametrize( "backend", [ From 0c9ff6c456951e53d62818035120885793808934 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 25 Apr 2022 09:21:45 -0700 Subject: [PATCH 27/31] updates --- pynocular/__init__.py | 1 + pynocular/database_model.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 476f06c..fb106bf 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -6,3 +6,4 @@ from pynocular.backends.memory import MemoryDatabaseModelBackend from pynocular.backends.sql import Database, SQLDatabaseModelBackend from pynocular.database_model import DatabaseModel +from pynocular.util import UUID_STR diff --git a/pynocular/database_model.py b/pynocular/database_model.py index 335594b..c9d3390 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -97,7 +97,9 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: ): type = Float elif field.type_.__class__ == EnumMeta: - type = SQLEnum(field.type_) + type = SQLEnum( + field.type_, values_callable=lambda obj: [e.value for e in obj] + ) elif field.type_ is bool: type = Boolean elif field.type_ in (dict, Dict): From 5f89453e32eaf31e501f1ef9fbc3a7a74112039d Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 25 Apr 2022 09:22:28 -0700 Subject: [PATCH 28/31] version --- pynocular/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index fb106bf..3583681 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "2.0.0-rc2" +__version__ = "2.0.0-rc3" from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend diff --git a/pyproject.toml b/pyproject.toml index f4af51a..08dc430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "2.0.0-rc.2" +version = "2.0.0-rc.3" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", From bd5ddc349aa940e6a3f2ae25851d4f1d6e124d66 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 25 Apr 2022 13:01:54 -0700 Subject: [PATCH 29/31] serverdefault --- poetry.lock | 55 ++++++++++++++++++++++++- pynocular/backends/memory.py | 13 ++++-- pynocular/database_model.py | 32 ++++++++++---- pynocular/util.py | 11 ++++- pyproject.toml | 1 + tests/functional/conftest.py | 3 +- tests/functional/test_database_model.py | 6 +-- 7 files changed, 102 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index 732a792..81f2241 100644 --- a/poetry.lock +++ b/poetry.lock @@ -206,6 +206,19 @@ python-versions = ">=3.7" docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +[[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.9" @@ -300,6 +313,14 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "mypy-extensions" version = "0.4.3" @@ -399,6 +420,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[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.9.0" @@ -414,6 +443,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 = "pyparsing" version = "3.0.8" @@ -653,7 +690,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "5351d91486bad98d75d28fd806b9a5fc239575352a442d43a150a97b7551e0aa" +content-hash = "f64fc5621cd0d1fe310988c7a7e2f1b793daca2d307953c2ac24d5991f4722cd" [metadata.files] arrow = [ @@ -769,6 +806,10 @@ filelock = [ {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, ] +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.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, @@ -896,6 +937,10 @@ markupsafe = [ {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, @@ -990,6 +1035,10 @@ py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] +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.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, @@ -1027,6 +1076,10 @@ pydantic = [ {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, ] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] pyparsing = [ {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index 4080631..ec7fbb4 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -12,6 +12,7 @@ from pynocular.backends.base import DatabaseModelBackend, DatabaseModelConfig from pynocular.evaluate_column_element import evaluate_column_element +from pynocular.util import UUID_STR class MemoryDatabaseModelBackend(DatabaseModelBackend): @@ -22,7 +23,7 @@ class MemoryDatabaseModelBackend(DatabaseModelBackend): """ def __init__(self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None): - """Initialize a SQLDatabaseModelBackend + """Initialize a MemoryDatabaseModelBackend Args: records: Optional map of table name to list of records to bootstrap the @@ -88,11 +89,15 @@ def _update_db_managed_fields( """ for name in config.db_managed_fields: field = config.fields[name] - if (fetch_on_create and field.field_info.extra.get("fetch_on_create")) or ( - fetch_on_update and field.field_info.extra.get("fetch_on_update") - ): + if ( + fetch_on_create + and field.field_info.extra.get("fetch_on_create") + and record.get(name) is None + ) or (fetch_on_update and field.field_info.extra.get("fetch_on_update")): if field.type_ == datetime: record[name] = datetime.utcnow() + elif field.type_ == UUID_STR: + record[name] = str(uuid4()) else: raise NotImplementedError(field.type_) diff --git a/pynocular/database_model.py b/pynocular/database_model.py index c9d3390..f9a11a1 100644 --- a/pynocular/database_model.py +++ b/pynocular/database_model.py @@ -14,6 +14,7 @@ Integer, MetaData, Table, + text, TIMESTAMP, VARCHAR, ) @@ -114,18 +115,28 @@ def _process_config(cls, table_name: str) -> DatabaseModelConfig: else: raise DatabaseModelMisconfigured(f"Unsupported type {field.type_}") - column = Column( - name, type, primary_key=is_primary_key, nullable=is_nullable - ) - + server_default = None if fetch_on_create: - column.server_default = FetchedValue() + if field.type_ in (UUID4, stdlib_uuid, UUID_STR): + server_default = text("uuid_generate_v4()") + else: + server_default = FetchedValue() db_managed_fields.append(name) + server_onupdate = None if fetch_on_update: - column.server_onupdate = FetchedValue() + server_onupdate = FetchedValue() db_managed_fields.append(name) + column = Column( + name, + type, + primary_key=is_primary_key, + nullable=is_nullable, + server_default=server_default, + server_onupdate=server_onupdate, + ) + if is_primary_key: primary_keys.append(column) @@ -386,7 +397,7 @@ async def create_list(cls, models: List["DatabaseModel"]) -> List["DatabaseModel # Remove keys for primary keys that don't have a value. This indicates that # the backend will generate new values. for field in cls._config.primary_keys: - if dict_obj.get(field.name) is None: + if field.name in dict_obj and dict_obj[field.name] is None: del dict_obj[field.name] values.append(dict_obj) @@ -507,7 +518,12 @@ async def save(self) -> None: dict_self, ) for field in self._config.db_managed_fields: - setattr(self, field, record[field]) + existing_value = getattr(self, field, None) + column: Column = self._config.table.c[field] + if ( + column.server_default is not None and existing_value is None + ) or column.server_default is None: + setattr(self, field, record[field]) async def delete(self) -> None: """Delete this record from the database""" diff --git a/pynocular/util.py b/pynocular/util.py index 4b4aab4..0d04821 100644 --- a/pynocular/util.py +++ b/pynocular/util.py @@ -113,6 +113,16 @@ async def drop_table(db: Database, table: sa.Table) -> None: logger.debug(f"Dropped table {table.name}") +async def setup_uuid(db: Database) -> None: + """Set up UUID support + + Args: + db: an async database connection + + """ + await db.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";') + + async def setup_datetime_trigger(db: Database) -> None: """Set up created_at/updated_at datetime trigger @@ -120,7 +130,6 @@ async def setup_datetime_trigger(db: Database) -> None: db: an async database connection """ - await db.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";') await db.execute('CREATE EXTENSION IF NOT EXISTS "plpgsql";') await db.execute( """ diff --git a/pyproject.toml b/pyproject.toml index 08dc430..233a3d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ pytest-asyncio = "^0.15" black = "^22.3.0" cruft = "^2.9.0" pytest-lazy-fixture = "^0.6.3" +flake8 = "^4.0.1" [tool.cruft] skip = ["pyproject.toml", "pynocular", "tests", "README.md", ".circleci/config.yml"] diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index e1d44f7..49d7eb4 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -7,7 +7,7 @@ from databases import Database import pytest -from pynocular.util import create_new_database +from pynocular.util import create_new_database, setup_uuid logger = logging.getLogger("pynocular") @@ -42,6 +42,7 @@ async def postgres_database(): database = Database(db_connection_string, timeout=5, command_timeout=5) await database.connect() + await setup_uuid(database) try: yield database except Exception as e: diff --git a/tests/functional/test_database_model.py b/tests/functional/test_database_model.py index 597429e..ef3ac11 100644 --- a/tests/functional/test_database_model.py +++ b/tests/functional/test_database_model.py @@ -22,7 +22,7 @@ class Org(DatabaseModel, table_name="organizations"): """A test database model""" - id: UUID_STR = Field(primary_key=True) + id: Optional[UUID_STR] = Field(primary_key=True, fetch_on_create=True) serial_id: Optional[int] name: str = Field(max_length=45) slug: str = Field(max_length=45) @@ -443,9 +443,7 @@ async def test_raise_error_get_list_wrong_field(backend) -> None: async def test_setting_db_managed_columns(backend) -> None: """Test that db managed columns get automatically set on save""" with set_backend(backend): - org = await Org.create( - id=str(uuid4()), serial_id=105, name="fake_org105", slug="fake_org105" - ) + org = await Org.create(serial_id=105, name="fake_org105", slug="fake_org105") try: assert org.created_at is not None From 4577f8a3cb8690d29418ba7639e926a1fdad46a2 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Mon, 25 Apr 2022 13:02:12 -0700 Subject: [PATCH 30/31] version --- pynocular/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 3583681..39b253d 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "2.0.0-rc3" +__version__ = "2.0.0-rc4" from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend diff --git a/pyproject.toml b/pyproject.toml index 233a3d6..42d7672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "2.0.0-rc.3" +version = "2.0.0-rc.4" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", From 05594e40fbe48e0a5e869a4967284206799dd7e5 Mon Sep 17 00:00:00 2001 From: Jonathan Drake Date: Tue, 24 May 2022 13:41:44 -0700 Subject: [PATCH 31/31] Adds support for transactions to the in-memory backend (#22) * tx * cruft * docs --- .cruft.json | 2 +- .pre-commit-config.yaml | 17 ++++ pynocular/__init__.py | 2 +- pynocular/backends/__init__.py | 0 pynocular/backends/memory.py | 131 ++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/__init__.py | 0 tests/functional/__init__.py | 0 tests/functional/test_transactions.py | 116 +++++++++++++++++++---- tests/unit/__init__.py | 0 10 files changed, 240 insertions(+), 30 deletions(-) create mode 100644 pynocular/backends/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/functional/__init__.py create mode 100644 tests/unit/__init__.py diff --git a/.cruft.json b/.cruft.json index 58ab012..0eeeb93 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,6 +1,6 @@ { "template": "https://github.com/NarrativeScience/cookiecutter-python-lib", - "commit": "f31f5912ab949296517c6d65fc666b11926a5cf8", + "commit": "06d791b4e3ac2362c595a9bcf0617f84e546ec3c", "context": { "cookiecutter": { "author_name": "", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50a63f6..f2eb669 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,23 @@ repos: # Need to define stages explicitly since `default_stages` was not being respected stages: [commit] + - repo: https://github.com/lk16/detect-missing-init + rev: v0.1.4 + hooks: + - id: detect-missing-init + args: [--create, --track] + + - repo: https://github.com/bgimby-ns/pydocstyle + rev: 305f311b + hooks: + - id: pydocstyle + name: Lint Python docstrings (pydocstyle) + exclude: > + (?x)^( + .*__init__.py$| + .*setup.py$ + )$ + - repo: local hooks: - id: codespell diff --git a/pynocular/__init__.py b/pynocular/__init__.py index 39b253d..a35ff6e 100644 --- a/pynocular/__init__.py +++ b/pynocular/__init__.py @@ -1,6 +1,6 @@ """Lightweight ORM that lets you query your database using Pydantic models and asyncio""" -__version__ = "2.0.0-rc4" +__version__ = "2.0.0-rc5" from pynocular.backends.context import get_backend, set_backend from pynocular.backends.memory import MemoryDatabaseModelBackend diff --git a/pynocular/backends/__init__.py b/pynocular/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pynocular/backends/memory.py b/pynocular/backends/memory.py index ec7fbb4..19a3528 100644 --- a/pynocular/backends/memory.py +++ b/pynocular/backends/memory.py @@ -1,9 +1,13 @@ """Contains the MemoryDatabaseModelBackend class""" +import asyncio from collections import defaultdict +from copy import deepcopy from datetime import datetime +import functools import itertools -from typing import Any, Dict, List, Optional +from types import TracebackType +from typing import Any, Callable, Dict, Generator, List, Optional, Type from uuid import uuid4 from sqlalchemy import Integer @@ -15,6 +19,114 @@ from pynocular.util import UUID_STR +class MemoryConnection: + """In-memory connection + + This mirrors the databases library implementation. + """ + + def __init__( + self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None + ) -> None: + """In-memory connection + + Args: + records: Optional map of table name to list of records to bootstrap the + in-memory database + + """ + self.records = records or defaultdict(list) + self._tmp_records = None + self._transaction_lock = asyncio.Lock() + self._transaction_stack: list[MemoryTransaction] = [] + + def backup_records(self) -> None: + """Backup the records in the connection to a temporary variable""" + self._tmp_records = deepcopy(self.records) + + def clear_backup(self) -> None: + """Clear the backup""" + self._tmp_records = None + + def restore_records(self) -> None: + """Restore the original copy of records""" + self.records = deepcopy(self._tmp_records) + + +class MemoryTransaction: + """In-memory transaction + + This mirrors the databases library implementation. + """ + + def __init__(self, connection: MemoryConnection) -> None: + """In-memory transaction + + Args: + connection: Connection instance containing records + + """ + self._connection = connection + + async def __aenter__(self) -> "MemoryTransaction": + """Called when entering `async with database.transaction()`""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException] = None, + exc_value: BaseException = None, + traceback: TracebackType = None, + ) -> None: + """Called when exiting `async with database.transaction()`""" + if exc_type is not None: + await self.rollback() + else: + await self.commit() + + def __await__(self) -> Generator: + """Called if using the low-level `transaction = await database.transaction()`""" + return self.start().__await__() + + def __call__(self, func: Callable) -> Callable: + """Called if using `@database.transaction()` as a decorator.""" + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + async with self: + return await func(*args, **kwargs) + + return wrapper + + async def start(self) -> "MemoryTransaction": + """Start a transaction""" + async with self._connection._transaction_lock: + is_root = not self._connection._transaction_stack + if is_root: + self._connection.backup_records() + self._connection._transaction_stack.append(self) + return self + + async def commit(self) -> None: + """Commit the transaction on success""" + async with self._connection._transaction_lock: + assert self._connection._transaction_stack[-1] is self + self._connection._transaction_stack.pop() + is_root = not self._connection._transaction_stack + if is_root: + self._connection.clear_backup() + + async def rollback(self) -> None: + """Rollback the transaction in case of failure""" + async with self._connection._transaction_lock: + assert self._connection._transaction_stack[-1] is self + self._connection._transaction_stack.pop() + is_root = not self._connection._transaction_stack + if is_root: + self._connection.restore_records() + + class MemoryDatabaseModelBackend(DatabaseModelBackend): """In-memory database model backend @@ -31,10 +143,16 @@ def __init__(self, records: Optional[Dict[str, List[Dict[str, Any]]]] = None): """ super().__init__() - self.records = records or defaultdict(list) + # Create a "connection" to hold records and interface with transactions + self._connection = MemoryConnection(records) # Serial primary key generator self._pk_generator = itertools.count(start=1) + @property + def records(self) -> Dict[str, List[Dict[str, Any]]]: + """Map of table name to list of records""" + return self._connection.records + def _set_primary_key_values( self, config: DatabaseModelConfig, @@ -103,12 +221,9 @@ def _update_db_managed_fields( return record - def transaction(self) -> Any: - """Create a new transaction - - This fails as a warning that the in-memory backend does not support transactions. - """ - raise NotImplementedError() + def transaction(self) -> MemoryTransaction: + """Create a new transaction""" + return MemoryTransaction(self._connection) async def select( self, diff --git a/pyproject.toml b/pyproject.toml index 42d7672..fd15afd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynocular" -version = "2.0.0-rc.4" +version = "2.0.0-rc.5" description = "Lightweight ORM that lets you query your database using Pydantic models and asyncio" authors = [ "RJ Santana ", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functional/test_transactions.py b/tests/functional/test_transactions.py index e471176..20b5313 100644 --- a/tests/functional/test_transactions.py +++ b/tests/functional/test_transactions.py @@ -7,6 +7,7 @@ import pytest from pynocular.backends.context import get_backend, set_backend +from pynocular.backends.memory import MemoryDatabaseModelBackend from pynocular.backends.sql import SQLDatabaseModelBackend from pynocular.database_model import DatabaseModel, UUID_STR from pynocular.util import create_table, drop_table, gather, transaction @@ -25,6 +26,9 @@ class Org(DatabaseModel, table_name="organizations"): async def postgres_backend(postgres_database: Database): """Fixture that creates tables before yielding a Postgres backend + Args: + postgres_database: Postgres database instance + Returns: postgres backend @@ -36,11 +40,29 @@ async def postgres_backend(postgres_database: Database): await drop_table(postgres_database, Org.table) +@pytest.fixture() +async def memory_backend(): + """Fixture that yields an in-memory backend + + Returns: + in-memory backend + + """ + return MemoryDatabaseModelBackend() + + +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_gathered_creates(postgres_backend) -> None: +async def test_gathered_creates(backend) -> None: """Test that we can update the db multiple times in a gather under a single transaction""" - with set_backend(postgres_backend): - async with get_backend().db.transaction(): + with set_backend(backend): + async with get_backend().transaction(): await gather( Org.create(id=str(uuid4()), name="orgus borgus"), Org.create(id=str(uuid4()), name="porgus orgus"), @@ -50,10 +72,17 @@ async def test_gathered_creates(postgres_backend) -> None: assert len(all_orgs) == 2 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_gathered_updates_raise_error(postgres_backend) -> None: +async def test_gathered_updates_raise_error(backend) -> None: """Test that an error in one update rolls back the other when gathered""" - with set_backend(postgres_backend): + with set_backend(backend): try: async with get_backend().transaction(): await gather( @@ -68,10 +97,17 @@ async def test_gathered_updates_raise_error(postgres_backend) -> None: assert len(all_orgs) == 0 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_serial_updates(postgres_backend) -> None: +async def test_serial_updates(backend) -> None: """Test that we can update the db serially under a single transaction""" - with set_backend(postgres_backend): + with set_backend(backend): async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") await Org.create(id=str(uuid4()), name="porgus orgus") @@ -80,10 +116,17 @@ async def test_serial_updates(postgres_backend) -> None: assert len(all_orgs) == 2 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_serial_updates_raise_error(postgres_backend) -> None: +async def test_serial_updates_raise_error(backend) -> None: """Test that an error in one update rolls back the other when run serially""" - with set_backend(postgres_backend): + with set_backend(backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -95,10 +138,17 @@ async def test_serial_updates_raise_error(postgres_backend) -> None: assert len(all_orgs) == 0 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_nested_updates(postgres_backend) -> None: +async def test_nested_updates(backend) -> None: """Test that we can perform nested update on the db under a single transaction""" - with set_backend(postgres_backend): + with set_backend(backend): async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -109,10 +159,17 @@ async def test_nested_updates(postgres_backend) -> None: assert len(all_orgs) == 2 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_nested_updates_raise_error(postgres_backend) -> None: +async def test_nested_updates_raise_error(backend) -> None: """Test that an error in one update rolls back the other when it is nested""" - with set_backend(postgres_backend): + with set_backend(backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -127,10 +184,17 @@ async def test_nested_updates_raise_error(postgres_backend) -> None: assert len(all_orgs) == 0 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_nested_conditional_updates_raise_error(postgres_backend) -> None: +async def test_nested_conditional_updates_raise_error(backend) -> None: """Test that an error in one update rolls back the other even if its a conditional transaction""" - with set_backend(postgres_backend): + with set_backend(backend): try: async with get_backend().transaction(): await Org.create(id=str(uuid4()), name="orgus borgus") @@ -145,10 +209,17 @@ async def test_nested_conditional_updates_raise_error(postgres_backend) -> None: assert len(all_orgs) == 0 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_open_transaction_decorator(postgres_backend) -> None: +async def test_open_transaction_decorator(backend) -> None: """Test that the open_transaction decorator will execute everything in a transaction""" - with set_backend(postgres_backend): + with set_backend(backend): @transaction async def write_than_raise_error(): @@ -164,10 +235,17 @@ async def write_than_raise_error(): assert len(all_orgs) == 2 +@pytest.mark.parametrize( + "backend", + [ + pytest.lazy_fixture("postgres_backend"), + pytest.lazy_fixture("memory_backend"), + ], +) @pytest.mark.asyncio -async def test_open_transaction_decorator_rolls_back(postgres_backend) -> None: +async def test_open_transaction_decorator_rolls_back(backend) -> None: """Test that the open_transaction decorator will roll back everything in the function""" - with set_backend(postgres_backend): + with set_backend(backend): @transaction async def write_than_raise_error(): diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29