diff options
24 files changed, 1055 insertions, 184 deletions
@@ -18,6 +18,8 @@ pyyaml = "~=5.1" pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"} sentry-sdk = "~=0.14" gitpython = "~=3.1.7" +markdown = "~=3.3.4" +python-frontmatter = "~=1.0" [dev-packages] coverage = "~=5.0" @@ -34,6 +36,7 @@ mccabe = "~=0.6.1" pep8-naming = "~=0.9" pre-commit = "~=2.1" unittest-xml-reporting = "~=3.0" +pyfakefs = "~=4.4.0" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 8c91e0db..6125e538 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "07f534656de1dd12baafc39bcdb68f143bbe195f8220211724427e15244dc187" + "sha256": "dc85f4f1b6ef58c2832d79542d6e88729cd2464bdf9324fef4d0757561f07ca7" }, "pipfile-spec": 6, "requires": { @@ -18,33 +18,34 @@ "default": { "asgiref": { "hashes": [ - "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", - "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" + "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", + "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" ], "markers": "python_version >= '3.5'", - "version": "==3.2.10" + "version": "==3.3.1" }, "certifi": { "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.6.20" + "version": "==2020.12.5" }, "chardet": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], - "version": "==3.0.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.0.0" }, "django": { "hashes": [ - "sha256:2d14be521c3ae24960e5e83d4575e156a8c479a75c935224b671b1c6e66eddaf", - "sha256:313d0b8f96685e99327785cc600a5178ca855f8e6f4ed162e671e8c3cf749739" + "sha256:2afe4900667bcceac792fa34b4fb25448c4fd950d8b32c5508b3442c4b10442a", + "sha256:6f13c3e8109236129c49d65a42fbf30c928e66b05ca6862246061b9343ecbaf2" ], "index": "pypi", - "version": "==3.0.10" + "version": "==3.0.13" }, "django-environ": { "hashes": [ @@ -103,11 +104,11 @@ }, "gitpython": { "hashes": [ - "sha256:138016d519bf4dd55b22c682c904ed2fd0235c3612b2f8f65ce218ff358deed8", - "sha256:a03f728b49ce9597a6655793207c6ab0da55519368ff5961e4a74ae475b9fa8e" + "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b", + "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61" ], "index": "pypi", - "version": "==3.1.9" + "version": "==3.1.14" }, "idna": { "hashes": [ @@ -117,6 +118,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, + "importlib-metadata": { + "hashes": [ + "sha256:742add720a20d0467df2f444ae41704000f50e1234f46174b51f9c6031a1bd71", + "sha256:b74159469b464a99cb8cc3e21973e4d96e05d3024d337313fedb618a6e86e6f4" + ], + "markers": "python_version < '3.8'", + "version": "==3.7.3" + }, "libsass": { "hashes": [ "sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b", @@ -135,18 +144,29 @@ ], "version": "==0.20.1" }, + "markdown": { + "hashes": [ + "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49", + "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c" + ], + "index": "pypi", + "version": "==3.3.4" + }, "psycopg2-binary": { "hashes": [ "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", + "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", + "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", + "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", @@ -173,16 +193,25 @@ "index": "pypi", "version": "==2.8.6" }, + "python-frontmatter": { + "hashes": [ + "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08", + "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd" + ], + "index": "pypi", + "version": "==1.0.0" + }, "pytz": { "hashes": [ - "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", - "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", + "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" ], - "version": "==2020.1" + "version": "==2021.1" }, "pyuwsgi": { "hashes": [ "sha256:0bd14517398f494d828d77a9bf72b5a6cbef0112e1cc05e9a0080fa8828ccfa0", + "sha256:149675b2e020b0e833e8b871a545751ca346cbfed85c8fd2b320a01d40dc3d8f", "sha256:285e263a9094389f13cfdefd033a4e99fbed3ad120dba9ac5093846cc03ac5ab", "sha256:297d1d0b8c472374b12eda7f17a9f5de67cf516612e42b71a7636afb9d1e3974", "sha256:5439f0f3ef5d6bf1622f341662d04c1d92b88889db40b295419e5fe75a7c7d45", @@ -192,6 +221,7 @@ "sha256:90e4235020048456ad867aefc383cdf5528b7f6e327555ceec579c428a828759", "sha256:94d4287b155aa789ce4b6f671c981f7d6c58fc3113330e2f29ac7926cb854645", "sha256:a425f562f382a097ca49df26b70d47d12f0cf0abf233610f3f58b1f7f780355e", + "sha256:ac79dead0685beab5ecfe0926426849a44c5572528f89bb17f6ecf5eb561024e", "sha256:bddeb8df77010d0f842068765a0b3155fdcfd847f14bc1ba89fc7e37914a13d2", "sha256:dac4a04dc0f69d641dba984e83214d2c2cc098496c5d5585e7d3f4e7a9190f84" ], @@ -201,36 +231,54 @@ }, "pyyaml": { "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", + "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", + "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", + "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", + "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", + "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", + "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", + "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", + "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", + "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", + "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", + "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", + "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", + "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", + "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", + "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", + "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", + "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", + "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", + "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], "index": "pypi", - "version": "==5.3.1" + "version": "==5.4.1" }, "requests": { "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.24.0" + "version": "==2.25.1" }, "sentry-sdk": { "hashes": [ - "sha256:1d91a0059d2d8bb980bec169578035c2f2d4b93cd8a4fb5b85c81904d33e221a", - "sha256:6222cf623e404c3e62b8e0e81c6db866ac2d12a663b7c1f7963350e3f397522a" + "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237", + "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b" ], "index": "pypi", - "version": "==0.18.0" + "version": "==0.20.3" }, "six": { "hashes": [ @@ -242,27 +290,36 @@ }, "smmap": { "hashes": [ - "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", - "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" + "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714", + "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.4" + "version": "==3.0.5" }, "sqlparse": { "hashes": [ - "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", - "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" + "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", + "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.3.1" + "markers": "python_version >= '3.5'", + "version": "==0.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "markers": "python_version < '3.8'", + "version": "==3.7.4.3" }, "urllib3": { "hashes": [ - "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", - "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" + "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", + "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.25.10" + "version": "==1.26.4" }, "whitenoise": { "hashes": [ @@ -271,6 +328,14 @@ ], "index": "pypi", "version": "==5.2.0" + }, + "zipp": { + "hashes": [ + "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", + "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" + ], + "markers": "python_version >= '3.6'", + "version": "==3.4.1" } }, "develop": { @@ -283,18 +348,18 @@ }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" + "version": "==20.3.0" }, "bandit": { "hashes": [ - "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", - "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065" + "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07", + "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608" ], - "version": "==1.6.2" + "version": "==1.7.0" }, "cfgv": { "hashes": [ @@ -306,43 +371,61 @@ }, "coverage": { "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" - ], - "index": "pypi", - "version": "==5.3" + "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", + "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", + "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45", + "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a", + "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03", + "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529", + "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a", + "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a", + "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2", + "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6", + "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759", + "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53", + "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a", + "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4", + "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff", + "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502", + "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793", + "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb", + "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905", + "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821", + "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b", + "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81", + "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0", + "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b", + "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3", + "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184", + "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701", + "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a", + "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82", + "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638", + "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5", + "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083", + "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6", + "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90", + "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465", + "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a", + "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3", + "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e", + "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066", + "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf", + "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b", + "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae", + "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669", + "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873", + "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b", + "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6", + "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb", + "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160", + "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c", + "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079", + "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", + "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" + ], + "index": "pypi", + "version": "==5.5" }, "distlib": { "hashes": [ @@ -360,19 +443,19 @@ }, "flake8": { "hashes": [ - "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", - "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" + "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff", + "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0" ], "index": "pypi", - "version": "==3.8.4" + "version": "==3.9.0" }, "flake8-annotations": { "hashes": [ - "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1", - "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df" + "sha256:40a4d504cdf64126ea0bdca39edab1608bc6d515e96569b7e7c3c59c84f66c36", + "sha256:eabbfb2dd59ae0e9835f509f930e79cd99fa4ff1026fe6ca073503a57407037c" ], "index": "pypi", - "version": "==2.4.1" + "version": "==2.6.1" }, "flake8-bandit": { "hashes": [ @@ -383,19 +466,19 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", - "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" + "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538", + "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703" ], "index": "pypi", - "version": "==20.1.4" + "version": "==20.11.1" }, "flake8-docstrings": { "hashes": [ - "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717", - "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc" + "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde", + "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b" ], "index": "pypi", - "version": "==1.5.0" + "version": "==1.6.0" }, "flake8-import-order": { "hashes": [ @@ -422,11 +505,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", - "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" + "sha256:52e5f2f987d3d5597538d5941153409ebcab571635835b78f522c7bf03ca23bc", + "sha256:76e36fbbfdc8e3c5017f9a216c2855a298be85bc0631e66777f4e6a07a859dc4" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.2.1" }, "flake8-todo": { "hashes": [ @@ -445,27 +528,27 @@ }, "gitpython": { "hashes": [ - "sha256:138016d519bf4dd55b22c682c904ed2fd0235c3612b2f8f65ce218ff358deed8", - "sha256:a03f728b49ce9597a6655793207c6ab0da55519368ff5961e4a74ae475b9fa8e" + "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b", + "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61" ], "index": "pypi", - "version": "==3.1.9" + "version": "==3.1.14" }, "identify": { "hashes": [ - "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", - "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d" + "sha256:39c0b110c9d0cd2391b6c38cd0ff679ee4b4e98f8db8b06c5d9d9e502711a1e1", + "sha256:efbf090a619255bc31c4fbba709e2805f7d30913fd4854ad84ace52bd276e2f6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.5" + "markers": "python_full_version >= '3.6.1'", + "version": "==2.2.0" }, "importlib-metadata": { "hashes": [ - "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", - "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" + "sha256:742add720a20d0467df2f444ae41704000f50e1234f46174b51f9c6031a1bd71", + "sha256:b74159469b464a99cb8cc3e21973e4d96e05d3024d337313fedb618a6e86e6f4" ], "markers": "python_version < '3.8'", - "version": "==2.0.0" + "version": "==3.7.3" }, "mccabe": { "hashes": [ @@ -484,11 +567,11 @@ }, "pbr": { "hashes": [ - "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea", - "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15" + "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", + "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" ], "markers": "python_version >= '2.6'", - "version": "==5.5.0" + "version": "==5.5.1" }, "pep8-naming": { "hashes": [ @@ -500,52 +583,78 @@ }, "pre-commit": { "hashes": [ - "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", - "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" + "sha256:94c82f1bf5899d56edb1d926732f4e75a7df29a0c8c092559c77420c9d62428b", + "sha256:de55c5c72ce80d79106e48beb1b54104d16495ce7f95b0c7b13d4784193a00af" ], "index": "pypi", - "version": "==2.7.1" + "version": "==2.11.1" }, "pycodestyle": { "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", + "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" + "version": "==2.7.0" }, "pydocstyle": { "hashes": [ - "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", - "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" + "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f", + "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d" ], - "markers": "python_version >= '3.5'", - "version": "==5.1.1" + "markers": "python_version >= '3.6'", + "version": "==6.0.0" + }, + "pyfakefs": { + "hashes": [ + "sha256:082d863e0e2a74351f697da404e329a91e18e5055942e59d1b836e8459b2c94c", + "sha256:1ac3b2845dabe69af56c20691b9347914581195ccdde352535fb7d4ff0055c19" + ], + "index": "pypi", + "version": "==4.4.0" }, "pyflakes": { "hashes": [ - "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", - "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + "sha256:910208209dcea632721cb58363d0f72913d9e8cf64dc6f8ae2e02a3609aba40d", + "sha256:e59fd8e750e588358f1b8885e5a4751203a0516e0ee6d34811089ac294c8806f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.2.0" + "version": "==2.3.0" }, "pyyaml": { "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", + "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", + "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", + "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", + "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", + "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", + "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", + "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", + "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", + "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", + "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", + "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", + "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", + "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", + "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", + "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", + "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", + "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", + "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", + "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], "index": "pypi", - "version": "==5.3.1" + "version": "==5.4.1" }, "six": { "hashes": [ @@ -557,60 +666,79 @@ }, "smmap": { "hashes": [ - "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", - "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" + "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714", + "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.4" + "version": "==3.0.5" }, "snowballstemmer": { "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", + "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" ], - "version": "==2.0.0" + "version": "==2.1.0" }, "stevedore": { "hashes": [ - "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", - "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" + "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", + "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" ], "markers": "python_version >= '3.6'", - "version": "==3.2.2" + "version": "==3.3.0" }, "toml": { "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==0.10.1" + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" }, "typed-ast": { "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", + "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", + "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", + "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", + "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", + "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", + "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", + "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", + "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", + "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", + "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", + "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", + "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", + "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", + "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", + "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", + "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", + "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", + "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", + "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", + "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", + "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", + "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", + "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", + "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", + "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", + "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", + "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", + "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", + "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" + ], + "markers": "python_version < '3.8'", + "version": "==1.4.2" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" ], "markers": "python_version < '3.8'", - "version": "==1.4.1" + "version": "==3.7.4.3" }, "unittest-xml-reporting": { "hashes": [ @@ -622,19 +750,19 @@ }, "virtualenv": { "hashes": [ - "sha256:3d427459dfe5ec3241a6bad046b1d10c0e445940e013c81946458987c7c7e255", - "sha256:9160a8f6196afcb8bb91405b5362651f302ee8e810fc471f5f9ce9a06b070298" + "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107", + "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.32" + "version": "==20.4.3" }, "zipp": { "hashes": [ - "sha256:64ad89efee774d1897a58607895d80789c59778ea02185dd846ac38394a8642b", - "sha256:eed8ec0b8d1416b2ca33516a37a08892442f3954dee131e92cfd92d8fe3e7066" + "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", + "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" ], "markers": "python_version >= '3.6'", - "version": "==3.3.0" + "version": "==3.4.1" } } } diff --git a/pydis_site/apps/content/__init__.py b/pydis_site/apps/content/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/content/__init__.py diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py new file mode 100644 index 00000000..1e300a48 --- /dev/null +++ b/pydis_site/apps/content/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + """Django AppConfig for content app.""" + + name = 'content' diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/content/migrations/__init__.py diff --git a/pydis_site/apps/content/resources/_info.yml b/pydis_site/apps/content/resources/_info.yml new file mode 100644 index 00000000..583cab18 --- /dev/null +++ b/pydis_site/apps/content/resources/_info.yml @@ -0,0 +1,2 @@ +name: Pages +description: Guides, articles, and pages hosted on the site. diff --git a/pydis_site/apps/content/resources/guides/_info.yml b/pydis_site/apps/content/resources/guides/_info.yml new file mode 100644 index 00000000..59c60a7b --- /dev/null +++ b/pydis_site/apps/content/resources/guides/_info.yml @@ -0,0 +1,2 @@ +name: Guides +description: Made by us, for you. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml b/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml new file mode 100644 index 00000000..7c9a2225 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml @@ -0,0 +1,2 @@ +name: Python Discord Guides +description: Guides related to the Python Discord server and community. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md new file mode 100644 index 00000000..f258ef74 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md @@ -0,0 +1,143 @@ +--- +title: How to Contribute a Page +description: Learn how to write and publish a page to this website. +icon_class: fas +icon: fa-info +relevant_links: + Contributing to Site: https://pythondiscord.com/pages/contributing/site/ + Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ +--- + +Pages, which include guides, articles, and other static content, are stored in markdown files in the `site` repository on Github. +If you are interested in writing or modifying pages seen here on the site, follow the steps below. + +For further assistance and help with contributing pages, send a message to the `#dev-contrib` channel in the Discord server! + +## Prerequisites +Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/contributing/site/). +It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/contributing/working-with-git/), as it is part of the contribution workflow. + +Additionally, please submit your proposed page or modification to a page as an [issue in the site repository](https://github.com/python-discord/site/issues), or discuss it in the `#dev-contrib` channel in the server. +As website changes require staff approval, discussing the page content beforehand helps with accelerating the contribution process, and avoids wasted work in the event the proposed page is not accepted. + +## Creating the Page +All pages are located in the `site` repo, at the path `pydis_site/apps/content/resources/`. This is the root folder, which corresponds to the URL `www.pythondiscord.com/pages/`. +For example, the file `pydis_site/apps/content/resources/hello-world.md` will result in a page available at `www.pythondiscord.com/pages/hello-world`. + +Nested folders represent page categories on the website. Each folder under the root folder must include a `_info.yml` file with the following: + +```yml +name: Category name +description: Category description +``` + +All the markdown files in this folder will then be under this category. + +## Writing the Page +Files representing pages are in `.md` (Markdown) format, with all-lowercase filenames and spaces replaced with `-` characters. + +Each page must include required metadata, and optionally additional metadata to modify the appearance of the page. +The metadata is written in YAML, and should be enclosed in triple dashes `---` *at the top of the markdown file*. + +**Example:** +```yaml +--- +title: How to Contribute a Page +description: Learn how to write and publish a page to this website. +icon_class: fas +icon: fa-info +relevant_links: + Contributing to Site: https://pythondiscord.com/pages/contributing/site/ + Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ +--- + +Pages, which include guides, articles, and other static content,... +``` + +### Required Fields +- **title:** Easily-readable title for your article. +- **description:** Short, 1-2 line description of the page's content. + +### Optional Fields +- **icon_class:** Favicon class for the category entry for the page. Default: `fab` +- **icon:** Favicon for the category entry for the page. Default: `fa-python` <i class="fab fa-python is-black" aria-hidden="true"></i> +- **relevant_links:** A YAML dictionary containing `text:link` pairs. See the example above. + +## Extended Markdown + +Apart from standard Markdown, certain additions are available: + +### Abbreviations +HTML `<abbr>` tags can be used in markdown using this format: + +**Markdown:** +```nohighlight +This website is HTML generated from YAML and Markdown. + +*[HTML]: Hyper Text Markup Language +*[YAML]: YAML Ain't Markup Language +``` + +**Output:** + +This website is <abbr title="Hyper Text Markup Language">HTML</abbr> +generated from <abbr title="YAML Ain't Markup Language">YAML</abbr> and Markdown. + +--- + +### Footnotes +**Markdown:** +```nohighlight +This footnote[^1] links to the bottom[^custom_label] of the page[^3]. + +[^1]: Footnote labels start with a caret `^`. +[^3]: The footnote link is numbered based on the order of the labels. +[^custom label]: Footnote labels can contain any text within square brackets. +``` + +**Output:** + +This footnote[^1] links to the bottom[^custom label] of the page[^3]. + +[^1]: Footnote labels start with a caret `^`. +[^3]: The footnote link is numbered based on the order of the labels. +[^custom label]: Footnote labels can contain any text within square brackets. + +--- + +### Tables + +**Markdown:** +```nohighlight +| This is header | This is another header | +| -------------- | ---------------------- | +| An item | Another item | +``` + +**Output:** + +| This is header | This is another header | +| -------------- | ---------------------- | +| An item | Another item | + +--- + +### Codeblock Syntax Highlighting +Syntax highlighting is provided by `highlight.js`. +To activate syntax highlighting, put the language directly after the starting backticks. + +**Markdown:** +````nohighlight +```python +import os + +path = os.path.join("foo", "bar") +``` +```` + +**Output:** +```python +import os + +path = os.path.join("foo", "bar") +``` diff --git a/pydis_site/apps/content/tests/__init__.py b/pydis_site/apps/content/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/content/tests/__init__.py diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py new file mode 100644 index 00000000..4e0cca34 --- /dev/null +++ b/pydis_site/apps/content/tests/helpers.py @@ -0,0 +1,84 @@ +from pyfakefs.fake_filesystem_unittest import TestCase + +# Valid markdown content with YAML metadata +MARKDOWN_WITH_METADATA = """ +--- +title: TestTitle +description: TestDescription +relevant_links: + Python Discord: https://pythondiscord.com + Discord: https://discord.com +--- +# This is a header. +""" + +MARKDOWN_WITHOUT_METADATA = """#This is a header.""" + +# Valid YAML in a _info.yml file +CATEGORY_INFO = """ +name: Category Name +description: Description +""" + +# The HTML generated from the above markdown data +PARSED_HTML = ( + '<h1 id="this-is-a-header">This is a header.' + '<a class="headerlink" href="#this-is-a-header" title="Permanent link">¶</a></h1>' +) + +# The YAML metadata parsed from the above markdown data +PARSED_METADATA = { + "title": "TestTitle", "description": "TestDescription", + "relevant_links": { + "Python Discord": "https://pythondiscord.com", + "Discord": "https://discord.com" + } +} + +# The YAML data parsed from the above _info.yml file +PARSED_CATEGORY_INFO = {"name": "Category Name", "description": "Description"} + + +class MockPagesTestCase(TestCase): + """ + TestCase with a fake filesystem for testing. + + Structure: + ├── _info.yml + ├── root.md + ├── root_without_metadata.md + ├── not_a_page.md + ├── tmp + | ├── _info.yml + | └── category_without_info + └── category + ├── _info.yml + ├── with_metadata.md + └── subcategory + ├── with_metadata.md + └── without_metadata.md + """ + + def setUp(self): + """Create the fake filesystem.""" + self.setUpPyfakefs() + + self.fs.create_file("_info.yml", contents=CATEGORY_INFO) + self.fs.create_file("root.md", contents=MARKDOWN_WITH_METADATA) + self.fs.create_file("root_without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA) + self.fs.create_file("not_a_page.md/_info.yml", contents=CATEGORY_INFO) + self.fs.create_file("category/_info.yml", contents=CATEGORY_INFO) + self.fs.create_file("category/with_metadata.md", contents=MARKDOWN_WITH_METADATA) + self.fs.create_file("category/subcategory/_info.yml", contents=CATEGORY_INFO) + self.fs.create_file( + "category/subcategory/with_metadata.md", contents=MARKDOWN_WITH_METADATA + ) + self.fs.create_file( + "category/subcategory/without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA + ) + + # There is always a `tmp` directory in the filesystem, so make it a category + # for testing purposes. + # See: https://jmcgeheeiv.github.io/pyfakefs/release/usage.html#os-temporary-directories + self.fs.create_file("tmp/_info.yml", contents=CATEGORY_INFO) + self.fs.create_dir("tmp/category_without_info") diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py new file mode 100644 index 00000000..58175d6f --- /dev/null +++ b/pydis_site/apps/content/tests/test_utils.py @@ -0,0 +1,91 @@ +from pathlib import Path + +from django.http import Http404 + +from pydis_site.apps.content import utils +from pydis_site.apps.content.tests.helpers import ( + MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA +) + + +class GetCategoryTests(MockPagesTestCase): + """Tests for the get_category function.""" + + def test_get_valid_category(self): + result = utils.get_category(Path("category")) + + self.assertEqual(result, {"name": "Category Name", "description": "Description"}) + + def test_get_nonexistent_category(self): + with self.assertRaises(Http404): + utils.get_category(Path("invalid")) + + def test_get_category_with_path_to_file(self): + # Valid categories are directories, not files + with self.assertRaises(Http404): + utils.get_category(Path("root.md")) + + def test_get_category_without_info_yml(self): + # Categories should provide an _info.yml file + with self.assertRaises(FileNotFoundError): + utils.get_category(Path("tmp/category_without_info")) + + +class GetCategoriesTests(MockPagesTestCase): + """Tests for the get_categories function.""" + + def test_get_root_categories(self): + result = utils.get_categories(Path(".")) + + info = PARSED_CATEGORY_INFO + self.assertEqual(result, {"category": info, "tmp": info, "not_a_page.md": info}) + + def test_get_categories_with_subcategories(self): + result = utils.get_categories(Path("category")) + + self.assertEqual(result, {"subcategory": PARSED_CATEGORY_INFO}) + + def test_get_categories_without_subcategories(self): + result = utils.get_categories(Path("category/subcategory")) + + self.assertEqual(result, {}) + + +class GetCategoryPagesTests(MockPagesTestCase): + """Tests for the get_category_pages function.""" + + def test_get_pages_in_root_category_successfully(self): + """The method should successfully retrieve page metadata.""" + root_category_pages = utils.get_category_pages(Path(".")) + self.assertEqual( + root_category_pages, {"root": PARSED_METADATA, "root_without_metadata": {}} + ) + + def test_get_pages_in_subcategories_successfully(self): + """The method should successfully retrieve page metadata.""" + category_pages = utils.get_category_pages(Path("category")) + + # Page metadata is properly retrieved + self.assertEqual(category_pages, {"with_metadata": PARSED_METADATA}) + + +class GetPageTests(MockPagesTestCase): + """Tests for the get_page function.""" + + def test_get_page(self): + cases = [ + ("Root page with metadata", "root.md", PARSED_HTML, PARSED_METADATA), + ("Root page without metadata", "root_without_metadata.md", PARSED_HTML, {}), + ("Page with metadata", "category/with_metadata.md", PARSED_HTML, PARSED_METADATA), + ("Page without metadata", "category/subcategory/without_metadata.md", PARSED_HTML, {}), + ] + + for msg, page_path, expected_html, expected_metadata in cases: + with self.subTest(msg=msg): + html, metadata = utils.get_page(Path(page_path)) + self.assertEqual(html, expected_html) + self.assertEqual(metadata, expected_metadata) + + def test_get_nonexistent_page_returns_404(self): + with self.assertRaises(Http404): + utils.get_page(Path("invalid")) diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py new file mode 100644 index 00000000..560378bc --- /dev/null +++ b/pydis_site/apps/content/tests/test_views.py @@ -0,0 +1,145 @@ +from pathlib import Path +from unittest import TestCase + +from django.http import Http404 +from django.test import RequestFactory, SimpleTestCase, override_settings +from pyfakefs import fake_filesystem_unittest + +from pydis_site.apps.content.tests.helpers import ( + MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA +) +from pydis_site.apps.content.views import PageOrCategoryView + + +# Set the module constant within Patcher to use the fake filesystem +# https://jmcgeheeiv.github.io/pyfakefs/master/usage.html#modules-to-reload +with fake_filesystem_unittest.Patcher() as _: + BASE_PATH = Path(".") + + +@override_settings(PAGES_PATH=BASE_PATH) +class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase): + """Tests for the PageOrCategoryView class.""" + + def setUp(self): + """Set test helpers, then set up fake filesystem.""" + self.factory = RequestFactory() + self.view = PageOrCategoryView.as_view() + self.ViewClass = PageOrCategoryView() + super().setUp() + + # Integration tests + def test_valid_page_or_category_returns_200(self): + cases = [ + ("Page at root", "root"), + ("Category page", "category"), + ("Page in category", "category/with_metadata"), + ("Subcategory page", "category/subcategory"), + ("Page in subcategory", "category/subcategory/with_metadata"), + ] + for msg, path in cases: + with self.subTest(msg=msg, path=path): + request = self.factory.get(f"/{path}") + response = self.view(request, location=path) + self.assertEqual(response.status_code, 200) + + def test_nonexistent_page_returns_404(self): + with self.assertRaises(Http404): + request = self.factory.get("/invalid") + self.view(request, location="invalid") + + # Unit tests + def test_get_template_names_returns_correct_templates(self): + category_template = "content/listing.html" + page_template = "content/page.html" + cases = [ + ("root", page_template), + ("root_without_metadata", page_template), + ("category/with_metadata", page_template), + ("category/subcategory/with_metadata", page_template), + ("category", category_template), + ("category/subcategory", category_template), + ] + + for path, expected_template in cases: + with self.subTest(path=path, expected_template=expected_template): + self.ViewClass.full_location = Path(path) + self.assertEqual(self.ViewClass.get_template_names(), [expected_template]) + + def test_get_template_names_with_nonexistent_paths_returns_404(self): + for path in ("invalid", "another_invalid", "nonexistent"): + with self.subTest(path=path): + self.ViewClass.full_location = Path(path) + with self.assertRaises(Http404): + self.ViewClass.get_template_names() + + def test_get_context_data_with_valid_page(self): + """The method should return required fields in the template context.""" + request = self.factory.get("/root") + self.ViewClass.setup(request) + self.ViewClass.dispatch(request, location="root") + + cases = [ + ("Context includes HTML page content", "page", PARSED_HTML), + ("Context includes page title", "page_title", PARSED_METADATA["title"]), + ( + "Context includes page description", + "page_description", + PARSED_METADATA["description"] + ), + ( + "Context includes relevant link names and URLs", + "relevant_links", + PARSED_METADATA["relevant_links"] + ), + ] + context = self.ViewClass.get_context_data() + for msg, key, expected_value in cases: + with self.subTest(msg=msg): + self.assertEqual(context[key], expected_value) + + def test_get_context_data_with_valid_category(self): + """The method should return required fields in the template context.""" + request = self.factory.get("/category") + self.ViewClass.setup(request) + self.ViewClass.dispatch(request, location="category") + + cases = [ + ( + "Context includes subcategory names and their information", + "categories", + {"subcategory": PARSED_CATEGORY_INFO} + ), + ( + "Context includes page names and their metadata", + "pages", + {"with_metadata": PARSED_METADATA} + ), + ( + "Context includes page description", + "page_description", + PARSED_CATEGORY_INFO["description"] + ), + ("Context includes page title", "page_title", PARSED_CATEGORY_INFO["name"]), + ] + + context = self.ViewClass.get_context_data() + for msg, key, expected_value in cases: + with self.subTest(msg=msg): + self.assertEqual(context[key], expected_value) + + def test_get_context_data_breadcrumbs(self): + """The method should return correct breadcrumbs.""" + request = self.factory.get("/category/subcategory/with_metadata") + self.ViewClass.setup(request) + self.ViewClass.dispatch(request, location="category/subcategory/with_metadata") + + context = self.ViewClass.get_context_data() + self.assertEquals( + context["breadcrumb_items"], + [ + {"name": PARSED_CATEGORY_INFO["name"], "path": "."}, + {"name": PARSED_CATEGORY_INFO["name"], "path": "category"}, + {"name": PARSED_CATEGORY_INFO["name"], "path": "category/subcategory"}, + ] + ) diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py new file mode 100644 index 00000000..c11b222a --- /dev/null +++ b/pydis_site/apps/content/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "content" +urlpatterns = [ + path("", views.PageOrCategoryView.as_view(), name='pages'), + path("<path:location>/", views.PageOrCategoryView.as_view(), name='page_category'), +] diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py new file mode 100644 index 00000000..726c991f --- /dev/null +++ b/pydis_site/apps/content/utils.py @@ -0,0 +1,57 @@ +from pathlib import Path +from typing import Dict, Tuple + +import frontmatter +import markdown +import yaml +from django.http import Http404 +from markdown.extensions.toc import TocExtension + + +def get_category(path: Path) -> Dict[str, str]: + """Load category information by name from _info.yml.""" + if not path.is_dir(): + raise Http404("Category not found.") + + return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8")) + + +def get_categories(path: Path) -> Dict[str, Dict]: + """Get information for all categories.""" + categories = {} + + for item in path.iterdir(): + if item.is_dir(): + categories[item.name] = get_category(item) + + return categories + + +def get_category_pages(path: Path) -> Dict[str, Dict]: + """Get all page names and their metadata at a category path.""" + pages = {} + + for item in path.glob("*.md"): + if item.is_file(): + pages[item.stem] = frontmatter.load(item).metadata + + return pages + + +def get_page(path: Path) -> Tuple[str, Dict]: + """Get one specific page.""" + if not path.is_file(): + raise Http404("Page not found.") + + metadata, content = frontmatter.parse(path.read_text(encoding="utf-8")) + html = markdown.markdown( + content, + extensions=[ + "extra", + # Empty string for marker to disable text searching for [TOC] + # By using a metadata key instead, we save time on long markdown documents + TocExtension(title="Table of Contents:", permalink=True, marker="") + ] + ) + + return str(html), metadata diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py new file mode 100644 index 00000000..70ea1c7a --- /dev/null +++ b/pydis_site/apps/content/views/__init__.py @@ -0,0 +1,3 @@ +from .page_category import PageOrCategoryView + +__all__ = ["PageOrCategoryView"] diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py new file mode 100644 index 00000000..eec4e7e5 --- /dev/null +++ b/pydis_site/apps/content/views/page_category.py @@ -0,0 +1,61 @@ +import typing as t +from pathlib import Path + +from django.conf import settings +from django.http import Http404 +from django.views.generic import TemplateView + +from pydis_site.apps.content import utils + + +class PageOrCategoryView(TemplateView): + """Handles pages and page categories.""" + + def dispatch(self, request: t.Any, *args, **kwargs) -> t.Any: + """Conform URL path location to the filesystem path.""" + self.location = Path(kwargs.get("location", "")) + self.full_location = settings.PAGES_PATH / self.location + + return super().dispatch(request, *args, **kwargs) + + def get_template_names(self) -> t.List[str]: + """Checks if the view uses the page template or listing template.""" + if self.full_location.is_dir(): + template_name = "content/listing.html" + elif self.full_location.with_suffix(".md").is_file(): + template_name = "content/page.html" + else: + raise Http404 + + return [template_name] + + def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]: + """Assign proper context variables based on what resource user requests.""" + context = super().get_context_data(**kwargs) + + if self.full_location.is_dir(): + context["categories"] = utils.get_categories(self.full_location) + context["pages"] = utils.get_category_pages(self.full_location) + + category = utils.get_category(self.full_location) + context["page_title"] = category["name"] + context["page_description"] = category["description"] + + context["path"] = f"{self.location}/" # Add trailing slash here to simplify template + elif self.full_location.with_suffix(".md").is_file(): + page, metadata = utils.get_page(self.full_location.with_suffix(".md")) + context["page"] = page + context["page_title"] = metadata["title"] + context["page_description"] = metadata["description"] + context["relevant_links"] = metadata.get("relevant_links", {}) + else: + raise Http404 + + context["breadcrumb_items"] = [ + { + "name": utils.get_category(settings.PAGES_PATH / location)["name"], + "path": str(location) + } for location in reversed(self.location.parents) + ] + + return context diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index d7db6ff1..3c716875 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('', HomeView.as_view(), name='home'), path('admin/', admin.site.urls), path('resources/', include('pydis_site.apps.resources.urls')), + path('pages/', include('pydis_site.apps.content.urls')), path('events/', include('pydis_site.apps.events.urls', namespace='events')), ] diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 67afdbcb..3abf556a 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -85,6 +85,7 @@ INSTALLED_APPS = [ 'pydis_site.apps.home', 'pydis_site.apps.staff', 'pydis_site.apps.resources', + 'pydis_site.apps.content', 'pydis_site.apps.events', 'django.contrib.admin', @@ -280,3 +281,10 @@ BULMA_SETTINGS = { "footer-padding": "1rem 1.5rem 1rem", } } + +# Information about site repository +SITE_REPOSITORY_OWNER = "python-discord" +SITE_REPOSITORY_NAME = "site" +SITE_REPOSITORY_BRANCH = "master" + +PAGES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources") diff --git a/pydis_site/static/css/content/page.css b/pydis_site/static/css/content/page.css new file mode 100644 index 00000000..57d7472b --- /dev/null +++ b/pydis_site/static/css/content/page.css @@ -0,0 +1,31 @@ +.breadcrumb-section { + padding: 1rem; +} + +i.has-icon-padding { + padding: 0 10px 25px 0; +} + +/* + * Move padding padding from <pre> tag to hljs <code> tags so the padding + * space is colored the same as the background of hljs <code> blocks. + */ +.content pre { + padding: 0; +} + +code.hljs { + padding: 1.75em 2em; +} + +/* + * Show header permalink on hover. + */ +.headerlink { + display: none; + padding-left: 0.5em; +} + +:is(h1, h2, h3, h4, h5, h6):hover > .headerlink { + display: inline; +} diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html new file mode 100644 index 00000000..19eec5d4 --- /dev/null +++ b/pydis_site/templates/content/base.html @@ -0,0 +1,36 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}{{ page_title }}{% endblock %} +{% block head %} + <meta property="og:title" content="Python Discord - {{ page_title }}" /> + <meta property="og:type" content="website" /> + <meta property="og:description" content="{{ page_description }}" /> + <link rel="stylesheet" href="{% static "css/content/page.css" %}"> +{% endblock %} + +{% block content %} + {% include "base/navbar.html" %} + + <section class="breadcrumb-section section"> + <div class="container"> + <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs"> + <ul> + {% for item in breadcrumb_items %} + <li><a href="{% url "content:page_category" location=item.path %}">{{ item.name }}</a></li> + {% endfor %} + <li class="is-active"><a href="#">{{ page_title }}</a></li> + </ul> + </nav> + </div> + </section> + + <section class="section"> + <div class="container"> + <div class="content"> + <h1 class="title">{{ page_title }}</h1> + {% block page_content %}{% endblock %} + </div> + </div> + </section> +{% endblock %} diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html new file mode 100644 index 00000000..6de306b0 --- /dev/null +++ b/pydis_site/templates/content/listing.html @@ -0,0 +1,27 @@ +{% extends 'content/base.html' %} + +{% block page_content %} + {% for category, data in categories.items %} + <div class="box" style="max-width: 800px;"> + <span class="icon is-size-4 is-medium"> + <i class="fas fa-folder is-size-3 is-black has-icon-padding" aria-hidden="true"></i> + </span> + + <a href="{% url "content:page_category" location=path|add:category %}"> + <span class="is-size-4 has-text-weight-bold">{{ data.name }}</span> + </a> + <p class="is-italic">{{ data.description }}</p> + </div> + {% endfor %} + {% for page, data in pages.items %} + <div class="box" style="max-width: 800px;"> + <span class="icon is-size-4 is-medium"> + <i class="{{ data.icon_class|default:"fab" }} {{ data.icon|default:"fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i> + </span> + <a href="{% url "content:page_category" location=path|add:page %}"> + <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span> + </a> + <p class="is-italic">{{ data.description }}</p> + </div> + {% endfor %} +{% endblock %} diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html new file mode 100644 index 00000000..06d74208 --- /dev/null +++ b/pydis_site/templates/content/page.html @@ -0,0 +1,31 @@ +{% extends 'content/base.html' %} + +{% block head %} + {{ block.super }} + <link rel="stylesheet" + href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark-reasonable.min.css"> + <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script> + <script>hljs.initHighlightingOnLoad();</script> +{% endblock %} + +{% block page_content %} + {% if relevant_links|length > 0 %} + <div class="columns is-variable is-8"> + <div class="column is-two-thirds"> + {{ page|safe }} + </div> + <div class="column"> + <div class="box"> + <p class="menu-label">Relevant links</p> + <ul class="menu-list"> + {% for value, link in relevant_links.items %} + <li><a class="has-text-link" href="{{link}}">{{ value }}</a></li> + {% endfor %} + </ul> + </div> + </div> + </div> + {% else %} + <div>{{ page|safe }}</div> + {% endif %} +{% endblock %} diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html index 6eb32c97..491bc55e 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -15,7 +15,7 @@ <h1>Resources</h1> <div class="tile is-ancestor"> - <a class="tile is-parent" href="/articles/category/guides"> + <a class="tile is-parent" href="{% url "content:page_category" location="guides" %}"> <article class="tile is-child box hero is-primary is-bold"> <p class="title is-size-1"><i class="fad fa-info-circle" aria-hidden="true"></i> Guides</p> <p class="subtitle is-size-4">Made by us, for you</p> |