Compare commits

..

2 Commits

30 changed files with 766 additions and 262 deletions

View File

@ -8,13 +8,16 @@ RUN echo Creating dev user with UID $DEV_USER_UID && \
USER dev
ENV PATH="/home/dev/.local/bin:${PATH}"
# Don't write bytecode cache files
ENV PYTHONDONTWRITEBYTECODE=1
# Install pipenv to generate requirements.txt from Pipfile
RUN pip install pipenv
# Generate requirements.txt and install dependencies with pip
WORKDIR /app
COPY Pipfile Pipfile.lock ./
RUN pipenv lock --requirements --dev > tmp_requirements.txt && \
RUN pipenv requirements --dev > tmp_requirements.txt && \
pip install -r tmp_requirements.txt
# Set default command

View File

@ -6,12 +6,13 @@ name = "pypi"
[packages]
gunicorn = "~=20.1"
werkzeug = "~=2.0"
flask = "~=2.0"
flask = "~=2.2"
pyyaml = "*"
sqlalchemy = "~=1.4"
pymysql = "*"
alembic = "~=1.7"
python-dateutil = "*"
validataclass = "~=0.6"
[dev-packages]

249
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "f63a254c353bc5c8e6cbe2a920f8402ba0da5bec775bfff91f64fa485e9bf95c"
"sha256": "84613c292abfb9f596faadd525137db1053dfe39d650638455c11ac684a91487"
},
"pipfile-spec": 6,
"requires": {
@ -18,88 +18,87 @@
"default": {
"alembic": {
"hashes": [
"sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b",
"sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58"
"sha256:0a024d7f2de88d738d7395ff866997314c837be6104e90c5724350313dee4da4",
"sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa"
],
"index": "pypi",
"version": "==1.7.7"
"version": "==1.8.1"
},
"click": {
"hashes": [
"sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e",
"sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"
"sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
"sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
],
"markers": "python_version >= '3.7'",
"version": "==8.1.2"
"version": "==8.1.3"
},
"flask": {
"hashes": [
"sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264",
"sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8"
"sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b",
"sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"
],
"index": "pypi",
"version": "==2.1.1"
"version": "==2.2.2"
},
"greenlet": {
"hashes": [
"sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3",
"sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711",
"sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd",
"sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073",
"sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708",
"sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67",
"sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23",
"sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1",
"sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08",
"sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd",
"sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2",
"sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa",
"sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8",
"sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40",
"sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab",
"sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6",
"sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc",
"sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b",
"sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e",
"sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963",
"sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3",
"sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d",
"sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d",
"sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe",
"sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28",
"sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3",
"sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e",
"sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c",
"sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d",
"sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0",
"sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497",
"sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee",
"sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713",
"sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58",
"sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a",
"sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06",
"sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88",
"sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965",
"sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f",
"sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4",
"sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5",
"sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c",
"sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a",
"sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1",
"sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43",
"sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627",
"sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b",
"sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168",
"sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d",
"sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5",
"sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478",
"sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf",
"sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce",
"sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c",
"sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"
"sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4",
"sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc",
"sha256:07c58e169bbe1e87b8bbf15a5c1b779a7616df9fd3e61cadc9d691740015b4f8",
"sha256:095a980288fe05adf3d002fbb180c99bdcf0f930e220aa66fcd56e7914a38202",
"sha256:0b181e9aa6cb2f5ec0cacc8cee6e5a3093416c841ba32c185c30c160487f0380",
"sha256:1626185d938d7381631e48e6f7713e8d4b964be246073e1a1d15c2f061ac9f08",
"sha256:184416e481295832350a4bf731ba619a92f5689bf5d0fa4341e98b98b1265bd7",
"sha256:1dd51d2650e70c6c4af37f454737bf4a11e568945b27f74b471e8e2a9fd21268",
"sha256:1ec2779774d8e42ed0440cf8bc55540175187e8e934f2be25199bf4ed948cd9e",
"sha256:2cf45e339cabea16c07586306a31cfcc5a3b5e1626d365714d283732afed6809",
"sha256:2fb0aa7f6996879551fd67461d5d3ab0c3c0245da98be90c89fcb7a18d437403",
"sha256:44b4817c34c9272c65550b788913620f1fdc80362b209bc9d7dd2f40d8793080",
"sha256:466ce0928e33421ee84ae04c4ac6f253a3a3e6b8d600a79bd43fd4403e0a7a76",
"sha256:4f166b4aca8d7d489e82d74627a7069ab34211ef5ebb57c300ec4b9337b60fc0",
"sha256:510c3b15587afce9800198b4b142202b323bf4b4b5f9d6c79cb9a35e5e3c30d2",
"sha256:5b756e6730ea59b2745072e28ad27f4c837084688e6a6b3633c8b1e509e6ae0e",
"sha256:5fbe1ab72b998ca77ceabbae63a9b2e2dc2d963f4299b9b278252ddba142d3f1",
"sha256:6200a11f003ec26815f7e3d2ded01b43a3810be3528dd760d2f1fa777490c3cd",
"sha256:65ad1a7a463a2a6f863661329a944a5802c7129f7ad33583dcc11069c17e622c",
"sha256:694ffa7144fa5cc526c8f4512665003a39fa09ef00d19bbca5c8d3406db72fbe",
"sha256:6f5d4b2280ceea76c55c893827961ed0a6eadd5a584a7c4e6e6dd7bc10dfdd96",
"sha256:7532a46505470be30cbf1dbadb20379fb481244f1ca54207d7df3bf0bbab6a20",
"sha256:76a53bfa10b367ee734b95988bd82a9a5f0038a25030f9f23bbbc005010ca600",
"sha256:77e41db75f9958f2083e03e9dd39da12247b3430c92267df3af77c83d8ff9eed",
"sha256:7a43bbfa9b6cfdfaeefbd91038dde65ea2c421dc387ed171613df340650874f2",
"sha256:7b41d19c0cfe5c259fe6c539fd75051cd39a5d33d05482f885faf43f7f5e7d26",
"sha256:7c5227963409551ae4a6938beb70d56bf1918c554a287d3da6853526212fbe0a",
"sha256:870a48007872d12e95a996fca3c03a64290d3ea2e61076aa35d3b253cf34cd32",
"sha256:88b04e12c9b041a1e0bcb886fec709c488192638a9a7a3677513ac6ba81d8e79",
"sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b",
"sha256:903fa5716b8fbb21019268b44f73f3748c41d1a30d71b4a49c84b642c2fed5fa",
"sha256:9537e4baf0db67f382eb29255a03154fcd4984638303ff9baaa738b10371fa57",
"sha256:9951dcbd37850da32b2cb6e391f621c1ee456191c6ae5528af4a34afe357c30e",
"sha256:9b2f7d0408ddeb8ea1fd43d3db79a8cefaccadd2a812f021333b338ed6b10aba",
"sha256:9c88e134d51d5e82315a7c32b914a58751b7353eb5268dbd02eabf020b4c4700",
"sha256:9fae214f6c43cd47f7bef98c56919b9222481e833be2915f6857a1e9e8a15318",
"sha256:a3a669f11289a8995d24fbfc0e63f8289dd03c9aaa0cc8f1eab31d18ca61a382",
"sha256:aa741c1a8a8cc25eb3a3a01a62bdb5095a773d8c6a86470bde7f607a447e7905",
"sha256:b0877a9a2129a2c56a2eae2da016743db7d9d6a05d5e1c198f1b7808c602a30e",
"sha256:bcb6c6dd1d6be6d38d6db283747d07fda089ff8c559a835236560a4410340455",
"sha256:caff52cb5cd7626872d9696aee5b794abe172804beb7db52eed1fd5824b63910",
"sha256:cbc1eb55342cbac8f7ec159088d54e2cfdd5ddf61c87b8bbe682d113789331b2",
"sha256:cd16a89efe3a003029c87ff19e9fba635864e064da646bc749fc1908a4af18f3",
"sha256:ce5b64dfe8d0cca407d88b0ee619d80d4215a2612c1af8c98a92180e7109f4b5",
"sha256:d58a5a71c4c37354f9e0c24c9c8321f0185f6945ef027460b809f4bb474bfe41",
"sha256:db41f3845eb579b544c962864cce2c2a0257fe30f0f1e18e51b1e8cbb4e0ac6d",
"sha256:db5b25265010a1b3dca6a174a443a0ed4c4ab12d5e2883a11c97d6e6d59b12f9",
"sha256:dd0404d154084a371e6d2bafc787201612a1359c2dee688ae334f9118aa0bf47",
"sha256:de431765bd5fe62119e0bc6bc6e7b17ac53017ae1782acf88fcf6b7eae475a49",
"sha256:df02fdec0c533301497acb0bc0f27f479a3a63dcdc3a099ae33a902857f07477",
"sha256:e8533f5111704d75de3139bf0b8136d3a6c1642c55c067866fa0a51c2155ee33",
"sha256:f2f908239b7098799b8845e5936c2ccb91d8c2323be02e82f8dcb4a80dcf4a25",
"sha256:f8bfd36f368efe0ab2a6aa3db7f14598aac454b06849fb633b762ddbede1db90",
"sha256:ffe73f9e7aea404722058405ff24041e59d31ca23d1da0895af48050a07b6932"
],
"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')))))",
"version": "==1.1.2"
"version": "==1.1.3"
},
"gunicorn": {
"hashes": [
@ -119,19 +118,19 @@
},
"jinja2": {
"hashes": [
"sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119",
"sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"
"sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
"sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.1"
"version": "==3.1.2"
},
"mako": {
"hashes": [
"sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba",
"sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"
"sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f",
"sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"
],
"markers": "python_version >= '3.7'",
"version": "==1.2.0"
"version": "==1.2.2"
},
"markupsafe": {
"hashes": [
@ -197,6 +196,7 @@
},
"pyyaml": {
"hashes": [
"sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf",
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
"sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
"sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
@ -208,26 +208,32 @@
"sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
"sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
"sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
"sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782",
"sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
"sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
"sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
"sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
"sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
"sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1",
"sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
"sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
"sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
"sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
"sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
"sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
"sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d",
"sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
"sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
"sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7",
"sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
"sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
"sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
"sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358",
"sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
"sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
"sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
"sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
"sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f",
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
],
@ -236,11 +242,11 @@
},
"setuptools": {
"hashes": [
"sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8",
"sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592"
"sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82",
"sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"
],
"markers": "python_version >= '3.7'",
"version": "==62.1.0"
"version": "==65.3.0"
},
"six": {
"hashes": [
@ -252,53 +258,66 @@
},
"sqlalchemy": {
"hashes": [
"sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6",
"sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b",
"sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6",
"sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e",
"sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a",
"sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b",
"sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1",
"sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5",
"sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99",
"sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4",
"sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51",
"sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a",
"sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7",
"sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f",
"sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157",
"sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c",
"sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403",
"sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8",
"sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62",
"sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d",
"sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a",
"sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd",
"sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c",
"sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903",
"sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6",
"sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606",
"sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f",
"sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041",
"sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd",
"sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc",
"sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70",
"sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec",
"sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03",
"sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044",
"sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228",
"sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182"
"sha256:0002e829142b2af00b4eaa26c51728f3ea68235f232a2e72a9508a3116bd6ed0",
"sha256:0005bd73026cd239fc1e8ccdf54db58b6193be9a02b3f0c5983808f84862c767",
"sha256:0292f70d1797e3c54e862e6f30ae474014648bc9c723e14a2fda730adb0a9791",
"sha256:036d8472356e1d5f096c5e0e1a7e0f9182140ada3602f8fff6b7329e9e7cfbcd",
"sha256:05f0de3a1dc3810a776275763764bb0015a02ae0f698a794646ebc5fb06fad33",
"sha256:0990932f7cca97fece8017414f57fdd80db506a045869d7ddf2dda1d7cf69ecc",
"sha256:13e397a9371ecd25573a7b90bd037db604331cf403f5318038c46ee44908c44d",
"sha256:14576238a5f89bcf504c5f0a388d0ca78df61fb42cb2af0efe239dc965d4f5c9",
"sha256:199a73c31ac8ea59937cc0bf3dfc04392e81afe2ec8a74f26f489d268867846c",
"sha256:2082a2d2fca363a3ce21cfa3d068c5a1ce4bf720cf6497fb3a9fc643a8ee4ddd",
"sha256:22ff16cedab5b16a0db79f1bc99e46a6ddececb60c396562e50aab58ddb2871c",
"sha256:2307495d9e0ea00d0c726be97a5b96615035854972cc538f6e7eaed23a35886c",
"sha256:2ad2b727fc41c7f8757098903f85fafb4bf587ca6605f82d9bf5604bd9c7cded",
"sha256:2d6495f84c4fd11584f34e62f9feec81bf373787b3942270487074e35cbe5330",
"sha256:361f6b5e3f659e3c56ea3518cf85fbdae1b9e788ade0219a67eeaaea8a4e4d2a",
"sha256:3e2ef592ac3693c65210f8b53d0edcf9f4405925adcfc031ff495e8d18169682",
"sha256:4676d51c9f6f6226ae8f26dc83ec291c088fe7633269757d333978df78d931ab",
"sha256:4ba7e122510bbc07258dc42be6ed45997efdf38129bde3e3f12649be70683546",
"sha256:5102fb9ee2c258a2218281adcb3e1918b793c51d6c2b4666ce38c35101bb940e",
"sha256:5323252be2bd261e0aa3f33cb3a64c45d76829989fa3ce90652838397d84197d",
"sha256:58bb65b3274b0c8a02cea9f91d6f44d0da79abc993b33bdedbfec98c8440175a",
"sha256:59bdc291165b6119fc6cdbc287c36f7f2859e6051dd923bdf47b4c55fd2f8bd0",
"sha256:5facb7fd6fa8a7353bbe88b95695e555338fb038ad19ceb29c82d94f62775a05",
"sha256:639e1ae8d48b3c86ffe59c0daa9a02e2bfe17ca3d2b41611b30a0073937d4497",
"sha256:8eb8897367a21b578b26f5713833836f886817ee2ffba1177d446fa3f77e67c8",
"sha256:90484a2b00baedad361402c257895b13faa3f01780f18f4a104a2f5c413e4536",
"sha256:9c56e19780cd1344fcd362fd6265a15f48aa8d365996a37fab1495cae8fcd97d",
"sha256:b67fc780cfe2b306180e56daaa411dd3186bf979d50a6a7c2a5b5036575cbdbb",
"sha256:c0dcf127bb99458a9d211e6e1f0f3edb96c874dd12f2503d4d8e4f1fd103790b",
"sha256:c23d64a0b28fc78c96289ffbd0d9d1abd48d267269b27f2d34e430ea73ce4b26",
"sha256:ccfd238f766a5bb5ee5545a62dd03f316ac67966a6a658efb63eeff8158a4bbf",
"sha256:cd767cf5d7252b1c88fcfb58426a32d7bd14a7e4942497e15b68ff5d822b41ad",
"sha256:ce8feaa52c1640de9541eeaaa8b5fb632d9d66249c947bb0d89dd01f87c7c288",
"sha256:d2e054aed4645f9b755db85bc69fc4ed2c9020c19c8027976f66576b906a74f1",
"sha256:e16c2be5cb19e2c08da7bd3a87fed2a0d4e90065ee553a940c4fc1a0fb1ab72b",
"sha256:e4b12e3d88a8fffd0b4ca559f6d4957ed91bd4c0613a4e13846ab8729dc5c251",
"sha256:e570cfc40a29d6ad46c9aeaddbdcee687880940a3a327f2c668dd0e4ef0a441d",
"sha256:eb30cf008850c0a26b72bd1b9be6730830165ce049d239cfdccd906f2685f892",
"sha256:f37fa70d95658763254941ddd30ecb23fc4ec0c5a788a7c21034fc2305dab7cc",
"sha256:f5ebeeec5c14533221eb30bad716bc1fd32f509196318fb9caa7002c4a364e4c",
"sha256:f5fa526d027d804b1f85cdda1eb091f70bde6fb7d87892f6dd5a48925bc88898"
],
"index": "pypi",
"version": "==1.4.35"
"version": "==1.4.41"
},
"validataclass": {
"hashes": [
"sha256:154021e42be7168aa6465e3c8d05b7add3ac5a78a5c1b02f17efdf886c345d9e",
"sha256:db198754479aac855a62a4195f2ba6d0d0893cac10a949946566169ca92d4895"
],
"index": "pypi",
"version": "==0.6.2"
},
"werkzeug": {
"hashes": [
"sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6",
"sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74"
"sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f",
"sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5"
],
"index": "pypi",
"version": "==2.1.1"
"version": "==2.2.2"
}
},
"develop": {}

View File

@ -0,0 +1,5 @@
{
"dev": {
"api_host": "http://localhost:5000"
}
}

28
api_tests/tasks.http Normal file
View File

@ -0,0 +1,28 @@
### Fetch all tasks
GET {{api_host}}/api/tasks
### Fetch one task
GET {{api_host}}/api/tasks/1
### Create new task
POST {{api_host}}/api/tasks
Content-Type: application/json
{
"title": "Some test task"
}
### Update task
PATCH {{api_host}}/api/tasks/1
Content-Type: application/json
{
"description": "Update!"
}
### Delete task
DELETE {{api_host}}/api/tasks/10

View File

@ -1,5 +1,5 @@
FLASK_APP=tofu_api.app
FLASK_ENV=development
FLASK_DEBUG=true
FLASK_CONFIG_FILE=config.dev.yml
# Show SQLAlchemy 2.0 deprecation warnings

View File

@ -1,5 +1,5 @@
from tofu_api.common.rest import BaseBlueprint
from .tasks import TaskApiBlueprint
from .tasks import TaskBlueprint
class TofuApiBlueprint(BaseBlueprint):
@ -12,4 +12,4 @@ class TofuApiBlueprint(BaseBlueprint):
url_prefix = '/api'
def init_blueprint(self) -> None:
self.register_blueprint(TaskApiBlueprint(self.app))
self.register_blueprint(TaskBlueprint(self.app))

View File

@ -1 +1,2 @@
from .task_api import TaskApiBlueprint
from .task_blueprint import TaskBlueprint
from .task_handler import TaskHandler

View File

@ -1,104 +0,0 @@
from flask import jsonify
from flask.views import MethodView
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from tofu_api.common.rest import BaseBlueprint
from tofu_api.models import Task
class TaskApiBlueprint(BaseBlueprint):
"""
Blueprint for the tasks REST API.
"""
# Blueprint settings
name = 'rest_api_tasks'
import_name = __name__
url_prefix = '/tasks'
def init_blueprint(self) -> None:
"""
Register URL rules.
"""
db_session = self.app.dependencies.get_db_session()
self.add_url_rule(
'',
view_func=self.create_view_func(TaskCollectionView, db_session=db_session),
methods=['GET', 'POST'],
)
self.add_url_rule(
'/<int:task_id>',
view_func=self.create_view_func(TaskItemView, db_session=db_session),
methods=['GET', 'PATCH', 'DELETE'],
)
class TaskBaseView(MethodView):
"""
Base class for view classes for the `/tasks` endpoint.
"""
# TODO: Use a handler class instead of accessing the database session directly
db_session: Session
def __init__(self, *, db_session: Session):
self.db_session = db_session
class TaskCollectionView(TaskBaseView):
"""
View class for `/tasks` endpoint.
"""
def get(self):
"""
Get list of all tasks.
"""
task_list = self.db_session.query(Task).all()
return jsonify({
'count': len(task_list),
'items': [task.to_dict() for task in task_list],
}), 200
def post(self):
"""
Create a new task.
"""
# TODO: Parse request data and create real data
new_task = Task(
title='Do stuff'
)
self.db_session.add(new_task)
self.db_session.commit()
return jsonify(new_task.to_dict()), 201
class TaskItemView(TaskBaseView):
"""
View class for `/tasks/<int:task_id>` endpoint.
"""
def get(self, task_id: int):
"""
Get a single task by ID.
"""
task = self.db_session.query(Task).get(task_id)
if task is None:
raise NotFound(f'Task with ID {task_id} not found!')
return jsonify(task.to_dict()), 200
def patch(self, task_id: int):
"""
Update a single task by ID.
"""
# TODO: Implement
raise NotImplementedError
def delete(self, task_id: int):
"""
Delete a single task by ID.
"""
# TODO: Implement
raise NotImplementedError

View File

@ -0,0 +1,30 @@
from tofu_api.common.rest import BaseBlueprint
from .task_views import TaskCollectionView, TaskItemView
class TaskBlueprint(BaseBlueprint):
"""
Blueprint for the tasks REST API.
"""
# Blueprint settings
name = 'rest_api_tasks'
import_name = __name__
url_prefix = '/tasks'
def init_blueprint(self) -> None:
"""
Register URL rules.
"""
task_handler = self.app.dependencies.get_task_handler()
self.add_url_rule(
'',
view_func=TaskCollectionView.as_view(task_handler=task_handler),
methods=['GET', 'POST'],
)
self.add_url_rule(
'/<int:task_id>',
view_func=TaskItemView.as_view(task_handler=task_handler),
methods=['GET', 'PATCH', 'DELETE'],
)

View File

@ -0,0 +1,50 @@
from tofu_api.models import Task
from tofu_api.repositories import TaskRepository
from .validators import TaskCreateData, TaskUpdateData
class TaskHandler:
"""
Handles operations on tasks.
"""
task_repository: TaskRepository
def __init__(self, *, task_repository: TaskRepository):
self.task_repository = task_repository
def fetch_task(self, task_id: int) -> Task:
"""
Fetches a single task by its ID from the database.
Raises an ObjectNotFoundError if the task was not found.
"""
return self.task_repository.fetch_by_id(task_id)
def fetch_all_tasks(self) -> list[Task]:
"""
Fetches a list of all tasks.
"""
return self.task_repository.fetch_all()
def create_task(self, create_data: TaskCreateData) -> Task:
"""
Creates a new task, saves it to the database and returns it.
"""
task = Task()
task.update_from(create_data)
self.task_repository.save_resource(task)
return task
def update_task(self, task: Task, update_data: TaskUpdateData) -> Task:
"""
Updates a Task object with new data.
"""
task.update_from(update_data)
self.task_repository.save_resource(task)
return task
def delete_task(self, task: Task) -> None:
"""
Deletes a task from the database.
"""
self.task_repository.delete_resource(task)

View File

@ -0,0 +1,89 @@
from flask import jsonify
from validataclass.validators import DataclassValidator
from tofu_api.common.rest import BaseMethodView
from .task_handler import TaskHandler
from .validators import TaskCreateData, TaskUpdateData
class TaskBaseView(BaseMethodView):
"""
Base class for view classes for the `/tasks` endpoint.
"""
task_handler: TaskHandler
def __init__(self, *, task_handler: TaskHandler):
self.task_handler = task_handler
class TaskCollectionView(TaskBaseView):
"""
View class for `/api/tasks` endpoint.
"""
# Validators
task_create_validator = DataclassValidator(TaskCreateData)
def get(self):
"""
Get list of all tasks.
"""
task_list = self.task_handler.fetch_all_tasks()
return jsonify({
'items': [task.to_dict() for task in task_list],
'total_count': len(task_list),
}), 200
def post(self):
"""
Create a new task.
"""
# Parse request data
create_data: TaskCreateData = self.validate_request_data(self.task_create_validator)
# Create new task
new_task = self.task_handler.create_task(create_data)
# Return new task as JSON
return jsonify(new_task.to_dict()), 201
class TaskItemView(TaskBaseView):
"""
View class for `/api/tasks/<int:task_id>` endpoint.
"""
# Validators
task_update_validator = DataclassValidator(TaskUpdateData)
def get(self, task_id: int):
"""
Get a single task by ID.
"""
task = self.task_handler.fetch_task(task_id)
return jsonify(task.to_dict()), 200
def patch(self, task_id: int):
"""
Update a single task by ID.
"""
# Parse request data
update_data: TaskUpdateData = self.validate_request_data(self.task_update_validator)
# Fetch task and update
task = self.task_handler.fetch_task(task_id)
task = self.task_handler.update_task(task, update_data)
# Return updated task as JSON
return jsonify(task.to_dict()), 200
def delete(self, task_id: int):
"""
Delete a single task by ID.
"""
# Fetch task and delete
task = self.task_handler.fetch_task(task_id)
self.task_handler.delete_task(task)
return self.empty_response()

View File

@ -0,0 +1,23 @@
from validataclass.dataclasses import Default, DefaultUnset, ValidataclassMixin, validataclass
from validataclass.helpers import OptionalUnset
from validataclass.validators import StringValidator
@validataclass
class TaskCreateData(ValidataclassMixin):
"""
Dataclass for "create task" request data.
"""
title: str = StringValidator(min_length=1, max_length=200)
description: str = StringValidator(max_length=2000), Default('')
@validataclass
class TaskUpdateData(TaskCreateData):
"""
Dataclass for "update task" request data.
"""
title: OptionalUnset[str] = DefaultUnset
description: OptionalUnset[str] = DefaultUnset

View File

@ -1,3 +1,4 @@
import logging
import os
import sys
import warnings
@ -6,11 +7,12 @@ from flask import Flask
from tofu_api.api import TofuApiBlueprint
from tofu_api.common.config import Config
from tofu_api.common.json import JSONEncoder
from tofu_api.common.json import JSONProvider
from tofu_api.common.rest import RestApiErrorHandler
from tofu_api.dependencies import Dependencies
# Enable deprecation warnings in dev environment
if not sys.warnoptions and os.getenv('FLASK_ENV') == 'development':
if not sys.warnoptions and os.getenv('FLASK_DEBUG'):
warnings.filterwarnings('default', module='tofu_api.*')
@ -20,7 +22,7 @@ class App(Flask):
"""
# Override Flask classes
config_class = Config
json_encoder = JSONEncoder
json_provider_class = JSONProvider
# Set type hint for config
config: Config
@ -42,6 +44,10 @@ class App(Flask):
# Load app configuration from YAML file
self.config.from_yaml(os.getenv('FLASK_CONFIG_FILE', default='config.yml'))
# Configure logging and error handling
self.configure_logging()
self.configure_error_handling()
# Initialize DI container
self.dependencies = Dependencies()
@ -51,6 +57,24 @@ class App(Flask):
# Register blueprints
self.register_blueprint(TofuApiBlueprint(self))
def configure_logging(self) -> None:
"""
Configures the logging system.
"""
logging.basicConfig(
level=logging.DEBUG if self.debug else logging.INFO,
format='{asctime}.{msecs:03.0f} {levelname:>8} [{name}] {message}',
datefmt='%Y-%m-%d %H:%M:%S',
style='{',
)
def configure_error_handling(self) -> None:
"""
Registers error handlers to the app.
"""
error_handler = RestApiErrorHandler(debug_mode=self.debug)
error_handler.register_error_handlers(self)
def init_database(self) -> None:
"""
Initialize database connection and models.

View File

@ -0,0 +1 @@
from .base import AppException

View File

@ -0,0 +1,23 @@
from typing import Optional
class AppException(Exception):
"""
Base class for application specific exceptions that can also be used as API error responses.
"""
code: str = 'unspecified_error'
status_code: int = 400
message: str
def __init__(self, message: str, *, code: Optional[str] = None, status_code: Optional[int] = None):
if code is not None:
self.code = code
if status_code is not None:
self.status_code = status_code
self.message = message
def to_dict(self) -> dict:
return {
'code': self.code,
'message': self.message,
}

View File

@ -1 +1 @@
from .json_encoder import JSONEncoder
from .json_provider import JSONProvider

View File

@ -1,16 +1,16 @@
from datetime import datetime
from typing import Any
from flask.json import JSONEncoder as _FlaskJSONEncoder
from flask.json.provider import DefaultJSONProvider
__all__ = [
'JSONEncoder',
'JSONProvider',
]
class JSONEncoder(_FlaskJSONEncoder):
class JSONProvider(DefaultJSONProvider):
"""
Custom JSON encoder built on top of the Flask JSONEncoder class.
Custom JSON provider.
"""
def default(self, obj: Any) -> Any:
@ -25,5 +25,5 @@ class JSONEncoder(_FlaskJSONEncoder):
if hasattr(obj, 'to_dict'):
return obj.to_dict()
# Fallback to the Flask JSONEncoder
# Fallback to the default JSON provider
return super().default(obj)

View File

@ -1 +1,3 @@
from .base_blueprint import BaseBlueprint
from .base_method_view import BaseMethodView
from .error_handler import RestApiErrorHandler

View File

@ -1,16 +1,11 @@
from abc import ABC, abstractmethod
from typing import Callable, Type, TYPE_CHECKING
from typing import TYPE_CHECKING
from flask import Blueprint
from flask.views import View
if TYPE_CHECKING:
from tofu_api.app import App
__all__ = [
'BaseBlueprint',
]
class BaseBlueprint(Blueprint, ABC):
"""
@ -62,11 +57,3 @@ class BaseBlueprint(Blueprint, ABC):
Register child blueprints and URL rules.
"""
raise NotImplementedError
@staticmethod
def create_view_func(view_cls: Type[View], *args, **kwargs) -> Callable:
"""
Helper function to create a view function from a `View` class using `view_cls.as_view()`.
All arguments are passed to the constructor of the view class.
"""
return view_cls.as_view(view_cls.__name__, *args, **kwargs)

View File

@ -0,0 +1,32 @@
from typing import Any
import flask
from flask.typing import RouteCallable
from flask.views import MethodView
from validataclass.validators import Validator
class BaseMethodView(MethodView):
"""
Base class for REST API views.
"""
@property
def request(self) -> flask.Request:
return flask.request
@classmethod
def as_view(cls, *args, **kwargs) -> RouteCallable:
return super().as_view(cls.__name__, *args, **kwargs)
@staticmethod
def empty_response(code: int = 204) -> tuple[str, int]:
return '', code
def validate_request_data(self, validator: Validator) -> Any:
"""
Parses request data as JSON and validates it using a validataclass validator.
"""
# TODO error handling: wrong content type; empty body; invalid json; validation errors
parsed_json = self.request.json
return validator.validate(parsed_json)

View File

@ -0,0 +1,73 @@
import logging
from typing import Union
from flask import Flask, Response, jsonify
from werkzeug.exceptions import HTTPException
from werkzeug.http import HTTP_STATUS_CODES
from tofu_api.common import string_utils
from tofu_api.common.exceptions import AppException
from .exceptions import InternalServerError
T_Response = Union[Response, tuple[Response, int]]
class RestApiErrorHandler:
"""
Error handler class for REST API errors.
"""
# Dependencies
logger: logging.Logger
# Options
debug_mode: bool = False
# Lookup table for HTTP status codes to API error codes
_http_to_api_error_codes: dict[int, str]
def __init__(self, *, debug_mode: bool = False):
self.logger = logging.getLogger(type(self).__name__)
self.debug_mode = debug_mode
# Generate lookup table for HTTP status codes
self._http_to_api_error_codes = {
http_status: string_utils.str_to_snake_case(name)
for http_status, name in HTTP_STATUS_CODES.items()
}
def register_error_handlers(self, app: Flask) -> None:
"""
Registers error handlers for different types of exceptions to the app.
"""
app.register_error_handler(AppException, self.handle_app_exception)
app.register_error_handler(HTTPException, self.handle_http_exception)
app.register_error_handler(Exception, self.handle_generic_exception)
@staticmethod
def handle_app_exception(exception: AppException) -> T_Response:
"""
Handles exceptions of type `AppException` that were not handled by any more specific handler.
"""
return jsonify(exception.to_dict()), exception.status_code
def handle_http_exception(self, exception: HTTPException) -> T_Response:
"""
Handles exceptions of type `HTTPException`, i.e. any werkzeug HTTP exceptions.
"""
if exception.code >= 500:
self.logger.exception('HTTP exception with status code %s: %s', exception.code, type(exception).__name__)
return jsonify({
'code': self._http_to_api_error_codes.get(exception.code, 'unknown_http_error'),
'message': exception.description,
}), exception.code
def handle_generic_exception(self, exception: Exception) -> T_Response:
"""
Fallback handler for any exceptions not handled by any other handler.
"""
self.logger.exception('Uncaught exception: %s', type(exception).__name__)
wrapped_exception = InternalServerError('There was an uncaught error on the server.', inner_exception=exception)
return jsonify(wrapped_exception.to_dict(debug=self.debug_mode)), wrapped_exception.status_code

View File

@ -0,0 +1,30 @@
__all__ = [
'InternalServerError'
]
import traceback
from typing import Optional
from tofu_api.common.exceptions import AppException
class InternalServerError(AppException):
"""
Wrapper exception for any uncaught exception.
"""
status_code = 500
code = 'internal_server_error'
inner_exception: Optional[Exception] = None
def __init__(self, message: str, *, inner_exception: Optional[Exception] = None):
super().__init__(message)
self.inner_exception = inner_exception
def to_dict(self, *, debug: bool = False) -> dict:
data = super().to_dict()
if debug:
data['_debug'] = {
'exception': str(self.inner_exception),
'traceback': traceback.format_exception(self.inner_exception),
}
return data

View File

@ -0,0 +1,28 @@
__all__ = [
'SNAKE_CASE_CHARACTERS',
'is_snake_case',
'str_to_snake_case',
]
import string
SNAKE_CASE_CHARACTERS = string.ascii_lowercase + string.digits + '_'
def is_snake_case(input_str: str) -> bool:
"""
Returns True if the input string only consists of snake case characters (lowercase letters, digits, underscore).
"""
return all(c in SNAKE_CASE_CHARACTERS for c in input_str)
def str_to_snake_case(input_str: str) -> str:
"""
Converts any string to a snake case string: Whitespaces are replaced with an underscore, uppercase letters are
converted to lowercase, and any non-alphanumeric character is removed.
"""
# First, lowercase string and replace any consecutive whitespaces with a single underscore
almost_snake_case = '_'.join(input_str.lower().split())
# Now, remove all characters that are neither letters, digits, nor underscores
return ''.join(filter(lambda c: c in SNAKE_CASE_CHARACTERS, almost_snake_case))

View File

@ -1,6 +1,26 @@
from typing import TypeVar
from sqlalchemy.orm import Session
from tofu_api.api.tasks import TaskHandler
from tofu_api.common.database import SQLAlchemy
from tofu_api.repositories import TaskRepository
T_Dep_Callable = TypeVar('T_Dep_Callable')
def cache_dependency(func: T_Dep_Callable) -> T_Dep_Callable:
"""
Decorator to be used in `Dependencies` to cache dependencies inside the Dependencies instance.
"""
dep_name = func.__name__
def wrapped_func(self: 'Dependencies'):
if dep_name not in self._dependency_cache:
self._dependency_cache[dep_name] = func(self)
return self._dependency_cache.get(dep_name)
return wrapped_func
class Dependencies:
@ -15,10 +35,22 @@ class Dependencies:
# Database dependencies
@cache_dependency
def get_sqlalchemy(self) -> SQLAlchemy:
if SQLAlchemy not in self._dependency_cache:
self._dependency_cache[SQLAlchemy] = SQLAlchemy()
return self._dependency_cache[SQLAlchemy]
return SQLAlchemy()
# No caching necessary here
def get_db_session(self) -> Session:
return self.get_sqlalchemy().session
# Repository classes
@cache_dependency
def get_task_repository(self) -> TaskRepository:
return TaskRepository(session=self.get_db_session())
# API Handler classes
@cache_dependency
def get_task_handler(self) -> TaskHandler:
return TaskHandler(task_repository=self.get_task_repository())

View File

@ -1,16 +1,17 @@
from typing import Any, Iterable, Optional
from sqlalchemy import Column, Integer, inspect
from sqlalchemy.orm import InstanceState, as_declarative
from tofu_api.common.database import Col, MetaData
__all__ = [
'BaseModel',
]
from typing import Any, Iterable, Optional, Union
@as_declarative(name='BaseModel', metadata=MetaData())
from sqlalchemy import Column, Integer, inspect
from sqlalchemy.orm import InstanceState, as_declarative
from validataclass.dataclasses import ValidataclassMixin
from tofu_api.common.database import Col, MetaData
@as_declarative(metadata=MetaData())
class BaseModel:
"""
Declarative base class for database models.
@ -46,9 +47,9 @@ class BaseModel:
"""
Return the object's data as a dictionary.
By default, the dictionary will contain all table columns (with their column name as key) defined in the model. This can be
overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are listed in `fields` will be
included in the dictionary, except for fields listed in `exclude`.
By default, the dictionary will contain all table columns (with their column name as key) defined in the model.
This can be overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are
listed in `fields` will be included in the dictionary, except for fields listed in `exclude`.
"""
# Determine fields to include in dictionary (starting will all table columns)
included_fields = set(column.name for column in self.__table__.columns)
@ -60,3 +61,15 @@ class BaseModel:
return {
field: getattr(self, field) for field in included_fields
}
def update_from(self, data: Union[dict, ValidataclassMixin]) -> None:
"""
Updates the object with data from either a dictionary or a validataclass object (requires the ValidataclassMixin).
"""
if isinstance(data, ValidataclassMixin):
data = data.to_dict()
# TODO: Is it a good idea to just iterate over data and setattr? Or should we check __table__.columns?
for key, value in data.items():
if hasattr(self, key):
setattr(self, key, value)

View File

@ -0,0 +1,2 @@
from .base_repository import BaseRepository
from .task_repository import TaskRepository

View File

@ -0,0 +1,94 @@
__all__ = [
'BaseRepository',
'T_Model',
]
from abc import ABC, abstractmethod
from typing import Generic, Optional, Type, TypeVar
from sqlalchemy import select
from sqlalchemy.orm import Session
from tofu_api.models import BaseModel
from .exceptions import ObjectNotFoundException
T_Model = TypeVar('T_Model', bound=BaseModel)
class BaseRepository(Generic[T_Model], ABC):
"""
Base class for repositories.
"""
# Database session
session: Session
@property
@abstractmethod
def model_cls(self) -> Type[T_Model]:
"""
Set this to the model class.
"""
raise NotImplementedError
def __init__(self, *, session: Session):
self.session = session
@staticmethod
def _or_raise(resource: Optional[T_Model], exception_msg: Optional[str] = None) -> T_Model:
if resource is None:
raise ObjectNotFoundException(exception_msg)
return resource
def fetch_by_id(self, resource_id: int) -> T_Model:
"""
Fetches a resource by ID.
Raises an ObjectNotFoundException if no resource with the ID was found.
"""
resource = self.session.get(self.model_cls, resource_id)
return self._or_raise(resource, f'Resource with ID {resource_id} was not found.')
def fetch_all(self) -> list[T_Model]:
"""
Fetches all resources of the repository type.
"""
return self.session.scalars(
select(self.model_cls)
).all()
def commit_session(self) -> None:
"""
Commits the current database session.
"""
self.commit_session()
def rollback_session(self) -> None:
"""
Rolls back the current database session.
"""
self.rollback_session()
def save_resource(self, *resources: T_Model, commit: bool = True) -> None:
"""
Saves one or multiple resources to the database by adding them to the session and committing the session.
Set `commit` to False to skip committing (the session will still be flushed, though).
"""
for resource in resources:
self.session.add(resource)
self.session.flush()
if commit:
self.session.commit()
def delete_resource(self, *resources: T_Model, commit: bool = True) -> None:
"""
Deletes one or multiple resources from the database.
Set `commit` to False to skip committing (the session will still be flushed, though).
"""
for resource in resources:
self.session.delete(resource)
self.session.flush()
if commit:
self.session.commit()

View File

@ -0,0 +1,9 @@
from tofu_api.common.exceptions import AppException
class ObjectNotFoundException(AppException):
"""
Exception raised when a database object was not found, i.e. does not exist or is inaccessible for the user.
"""
status_code = 404
code = 'not_found'

View File

@ -0,0 +1,9 @@
from tofu_api.models import Task
from .base_repository import BaseRepository
class TaskRepository(BaseRepository[Task]):
"""
Repository for tasks.
"""
model_cls = Task