diff options
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 508 | ||||
| -rw-r--r-- | bot/decorators.py | 23 | ||||
| -rw-r--r-- | bot/errors.py | 6 | ||||
| -rw-r--r-- | bot/exts/backend/branding/__init__.py | 6 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_cog.py | 887 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_constants.py | 51 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_decorators.py | 27 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_errors.py | 2 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_repository.py | 236 | ||||
| -rw-r--r-- | bot/exts/backend/branding/_seasons.py | 175 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 7 | 
12 files changed, 1048 insertions, 881 deletions
@@ -21,6 +21,7 @@ lxml = "~=4.4"  markdownify = "==0.5.3"  more_itertools = "~=8.2"  python-dateutil = "~=2.8" +python-frontmatter = "~=1.0.0"  pyyaml = "~=5.1"  requests = "~=2.22"  sentry-sdk = "~=0.19" diff --git a/Pipfile.lock b/Pipfile.lock index f8cedb08f..240e2542e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "228ae55fe5700ac3827ba6b661933b60b1d06f44fea8bcbe8c5a769fa10ab2fd" +            "sha256": "0f60e21b90fbc90c75f5978e15ed584f7cab7cb358d24c0f1d6b132fbc8b1907"          },          "pipfile-spec": 6,          "requires": { @@ -18,11 +18,11 @@      "default": {          "aio-pika": {              "hashes": [ -                "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", -                "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c" +                "sha256:1d4305a5f78af3857310b4fe48348cdcf6c097e0e275ea88c2cd08570531a369", +                "sha256:e69afef8695f47c5d107bbdba21bdb845d5c249acb3be53ef5c2d497b02657c0"              ],              "index": "pypi", -            "version": "==6.7.1" +            "version": "==6.8.0"          },          "aiodns": {              "hashes": [ @@ -34,46 +34,46 @@          },          "aiohttp": {              "hashes": [ -                "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", -                "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", -                "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", -                "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", -                "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", -                "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", -                "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", -                "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", -                "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", -                "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", -                "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", -                "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", -                "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", -                "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", -                "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", -                "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", -                "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", -                "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", -                "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", -                "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", -                "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", -                "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", -                "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", -                "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", -                "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", -                "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", -                "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", -                "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", -                "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", -                "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", -                "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", -                "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", -                "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", -                "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", -                "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", -                "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", -                "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" +                "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", +                "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", +                "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", +                "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", +                "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", +                "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", +                "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", +                "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", +                "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", +                "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", +                "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", +                "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", +                "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", +                "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", +                "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", +                "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", +                "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", +                "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", +                "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", +                "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", +                "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", +                "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", +                "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", +                "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", +                "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", +                "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", +                "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", +                "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", +                "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", +                "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", +                "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", +                "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", +                "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", +                "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", +                "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", +                "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", +                "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"              ],              "index": "pypi", -            "version": "==3.7.4" +            "version": "==3.7.4.post0"          },          "aioping": {              "hashes": [ @@ -96,6 +96,7 @@                  "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573",                  "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"              ], +            "markers": "python_version >= '3.6'",              "version": "==3.3.1"          },          "alabaster": { @@ -122,6 +123,7 @@                  "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"              ],              "index": "pypi", +            "markers": "python_version ~= '3.7'",              "version": "==0.1.4"          },          "async-timeout": { @@ -129,6 +131,7 @@                  "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",                  "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"              ], +            "markers": "python_full_version >= '3.5.3'",              "version": "==3.0.1"          },          "attrs": { @@ -136,6 +139,7 @@                  "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",                  "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==20.3.0"          },          "babel": { @@ -143,6 +147,7 @@                  "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5",                  "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==2.9.0"          },          "beautifulsoup4": { @@ -205,17 +210,17 @@          },          "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"          },          "colorama": {              "hashes": [                  "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",                  "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"              ], -            "index": "pypi",              "markers": "sys_platform == 'win32'",              "version": "==0.4.4"          }, @@ -248,6 +253,7 @@                  "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",                  "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",              "version": "==0.16"          },          "emoji": { @@ -259,10 +265,10 @@          },          "fakeredis": {              "hashes": [ -                "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a", -                "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73" +                "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623", +                "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"              ], -            "version": "==1.4.5" +            "version": "==1.5.0"          },          "feedparser": {              "hashes": [ @@ -330,6 +336,7 @@                  "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",                  "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==1.1.0"          },          "humanfriendly": { @@ -337,6 +344,7 @@                  "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d",                  "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",              "version": "==9.1"          },          "idna": { @@ -344,6 +352,7 @@                  "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",                  "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==2.10"          },          "imagesize": { @@ -351,6 +360,7 @@                  "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",                  "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==1.2.0"          },          "jinja2": { @@ -358,50 +368,50 @@                  "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",                  "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",              "version": "==2.11.3"          },          "lxml": {              "hashes": [ -                "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d", -                "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37", -                "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01", -                "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2", -                "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644", -                "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75", -                "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80", -                "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2", -                "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780", -                "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98", -                "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308", -                "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf", -                "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388", -                "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d", -                "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3", -                "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8", -                "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af", -                "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2", -                "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e", -                "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939", -                "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03", -                "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d", -                "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a", -                "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5", -                "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a", -                "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711", -                "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf", -                "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089", -                "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505", -                "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b", -                "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f", -                "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc", -                "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e", -                "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931", -                "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc", -                "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe", -                "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e" +                "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", +                "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", +                "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", +                "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", +                "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", +                "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", +                "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", +                "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", +                "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", +                "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", +                "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", +                "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", +                "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", +                "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", +                "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", +                "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", +                "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", +                "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", +                "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f", +                "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee", +                "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec", +                "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969", +                "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28", +                "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a", +                "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", +                "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", +                "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", +                "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", +                "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", +                "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", +                "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", +                "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", +                "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", +                "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", +                "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23", +                "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"              ],              "index": "pypi", -            "version": "==4.6.2" +            "version": "==4.6.3"          },          "markdownify": {              "hashes": [ @@ -466,15 +476,16 @@                  "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",                  "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==1.1.1"          },          "more-itertools": {              "hashes": [ -                "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330", -                "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf" +                "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced", +                "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"              ],              "index": "pypi", -            "version": "==8.6.0" +            "version": "==8.7.0"          },          "multidict": {              "hashes": [ @@ -516,12 +527,14 @@                  "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",                  "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"              ], +            "markers": "python_version >= '3.6'",              "version": "==5.1.0"          },          "ordered-set": {              "hashes": [                  "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"              ], +            "markers": "python_version >= '3.5'",              "version": "==4.0.2"          },          "packaging": { @@ -529,6 +542,7 @@                  "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",                  "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==20.9"          },          "pamqp": { @@ -577,20 +591,23 @@                  "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",                  "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==2.20"          },          "pygments": {              "hashes": [ -                "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0", -                "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88" +                "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94", +                "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"              ], -            "version": "==2.8.0" +            "markers": "python_version >= '3.5'", +            "version": "==2.8.1"          },          "pyparsing": {              "hashes": [                  "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",                  "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"              ], +            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==2.4.7"          },          "python-dateutil": { @@ -601,6 +618,14 @@              "index": "pypi",              "version": "==2.8.1"          }, +        "python-frontmatter": { +            "hashes": [ +                "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08", +                "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd" +            ], +            "index": "pypi", +            "version": "==1.0.0" +        },          "pytz": {              "hashes": [                  "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", @@ -610,28 +635,45 @@          },          "pyyaml": {              "hashes": [ -                "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", -                "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", -                "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", -                "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", -                "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", -                "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", -                "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", -                "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", -                "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", -                "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", -                "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"          },          "redis": {              "hashes": [                  "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",                  "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",              "version": "==3.5.3"          },          "requests": { @@ -644,17 +686,18 @@          },          "sentry-sdk": {              "hashes": [ -                "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0", -                "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f" +                "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237", +                "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"              ],              "index": "pypi", -            "version": "==0.19.5" +            "version": "==0.20.3"          },          "six": {              "hashes": [                  "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",                  "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==1.15.0"          },          "snowballstemmer": { @@ -673,11 +716,11 @@          },          "soupsieve": {              "hashes": [ -                "sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd", -                "sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6" +                "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc", +                "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"              ],              "markers": "python_version >= '3.0'", -            "version": "==2.2" +            "version": "==2.2.1"          },          "sphinx": {              "hashes": [ @@ -692,6 +735,7 @@                  "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",                  "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.0.2"          },          "sphinxcontrib-devhelp": { @@ -699,6 +743,7 @@                  "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",                  "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.0.2"          },          "sphinxcontrib-htmlhelp": { @@ -706,6 +751,7 @@                  "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",                  "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.0.3"          },          "sphinxcontrib-jsmath": { @@ -713,6 +759,7 @@                  "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",                  "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.0.1"          },          "sphinxcontrib-qthelp": { @@ -720,6 +767,7 @@                  "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",                  "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.0.3"          },          "sphinxcontrib-serializinghtml": { @@ -727,6 +775,7 @@                  "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc",                  "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"              ], +            "markers": "python_version >= '3.5'",              "version": "==1.1.4"          },          "statsd": { @@ -747,10 +796,11 @@          },          "urllib3": {              "hashes": [ -                "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", -                "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" +                "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", +                "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"              ], -            "version": "==1.26.3" +            "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.26.4"          },          "yarl": {              "hashes": [ @@ -792,6 +842,7 @@                  "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",                  "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"              ], +            "markers": "python_version >= '3.6'",              "version": "==1.6.3"          }      }, @@ -808,6 +859,7 @@                  "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",                  "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==20.3.0"          },          "certifi": { @@ -822,69 +874,74 @@                  "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",                  "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"              ], +            "markers": "python_full_version >= '3.6.1'",              "version": "==3.2.0"          },          "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"          },          "coverage": {              "hashes": [ -                "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297", -                "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1", -                "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497", -                "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606", -                "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528", -                "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b", -                "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4", -                "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830", -                "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1", -                "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f", -                "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d", -                "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3", -                "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8", -                "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500", -                "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7", -                "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb", -                "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b", -                "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059", -                "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b", -                "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72", -                "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36", -                "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277", -                "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c", -                "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631", -                "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff", -                "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8", -                "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec", -                "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b", -                "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7", -                "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105", -                "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b", -                "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c", -                "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b", -                "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98", -                "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4", -                "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879", -                "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f", -                "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4", -                "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044", -                "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e", -                "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899", -                "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f", -                "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448", -                "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714", -                "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2", -                "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d", -                "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd", -                "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7", -                "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae" +                "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.3.1" +            "version": "==5.5"          },          "coveralls": {              "hashes": [ @@ -916,19 +973,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:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055", -                "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e" +                "sha256:40a4d504cdf64126ea0bdca39edab1608bc6d515e96569b7e7c3c59c84f66c36", +                "sha256:eabbfb2dd59ae0e9835f509f930e79cd99fa4ff1026fe6ca073503a57407037c"              ],              "index": "pypi", -            "version": "==2.5.0" +            "version": "==2.6.1"          },          "flake8-bugbear": {              "hashes": [ @@ -940,11 +997,11 @@          },          "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": [ @@ -986,16 +1043,18 @@          },          "identify": {              "hashes": [ -                "sha256:de7129142a5c86d75a52b96f394d94d96d497881d2aaf8eafe320cdbe8ac4bcc", -                "sha256:e0dae57c0397629ce13c289f6ddde0204edf518f557bfdb1e56474aa143e77c3" +                "sha256:1cfb05b578de996677836d5a2dde14b3dffde313cf7d2b3e793a0787a36e26dd", +                "sha256:9cc5f58996cd359b7b72f0a5917d8639de5323917e6952a3bfbf36301b576f40"              ], -            "version": "==1.5.14" +            "markers": "python_full_version >= '3.6.1'", +            "version": "==2.2.1"          },          "idna": {              "hashes": [                  "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",                  "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==2.10"          },          "mccabe": { @@ -1022,51 +1081,70 @@          },          "pre-commit": {              "hashes": [ -                "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0", -                "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4" +                "sha256:94c82f1bf5899d56edb1d926732f4e75a7df29a0c8c092559c77420c9d62428b", +                "sha256:de55c5c72ce80d79106e48beb1b54104d16495ce7f95b0c7b13d4784193a00af"              ],              "index": "pypi", -            "version": "==2.9.3" +            "version": "==2.11.1"          },          "pycodestyle": {              "hashes": [ -                "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", -                "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" +                "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", +                "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"              ], -            "version": "==2.6.0" +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", +            "version": "==2.7.0"          },          "pydocstyle": {              "hashes": [ -                "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", -                "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" +                "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f", +                "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"              ], -            "version": "==5.1.1" +            "markers": "python_version >= '3.6'", +            "version": "==6.0.0"          },          "pyflakes": {              "hashes": [ -                "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", -                "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" +                "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", +                "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"              ], -            "version": "==2.2.0" +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", +            "version": "==2.3.1"          },          "pyyaml": {              "hashes": [ -                "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", -                "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", -                "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", -                "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", -                "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", -                "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", -                "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", -                "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", -                "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", -                "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", -                "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": [ @@ -1081,6 +1159,7 @@                  "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",                  "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"              ], +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==1.15.0"          },          "snowballstemmer": { @@ -1095,21 +1174,24 @@                  "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",                  "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"              ], +            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",              "version": "==0.10.2"          },          "urllib3": {              "hashes": [ -                "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", -                "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" +                "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", +                "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"              ], -            "version": "==1.26.3" +            "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.26.4"          },          "virtualenv": {              "hashes": [ -                "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d", -                "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3" +                "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107", +                "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"              ], -            "version": "==20.4.2" +            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", +            "version": "==20.4.3"          }      }  } diff --git a/bot/decorators.py b/bot/decorators.py index 063c8f878..0b50cc365 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,4 +1,5 @@  import asyncio +import functools  import logging  import typing as t  from contextlib import suppress @@ -8,7 +9,7 @@ from discord import Member, NotFound  from discord.ext import commands  from discord.ext.commands import Cog, Context -from bot.constants import Channels, RedirectOutput +from bot.constants import Channels, DEBUG_MODE, RedirectOutput  from bot.utils import function  from bot.utils.checks import in_whitelist_check @@ -153,3 +154,23 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:                  await func(*args, **kwargs)          return wrapper      return decorator + + +def mock_in_debug(return_value: t.Any) -> t.Callable: +    """ +    Short-circuit function execution if in debug mode and return `return_value`. + +    The original function name, and the incoming args and kwargs are DEBUG level logged +    upon each call. This is useful for expensive operations, i.e. media asset uploads +    that are prone to rate-limits but need to be tested extensively. +    """ +    def decorator(func: t.Callable) -> t.Callable: +        @functools.wraps(func) +        async def wrapped(*args, **kwargs) -> t.Any: +            """Short-circuit and log if in debug mode.""" +            if DEBUG_MODE: +                log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") +                return return_value +            return await func(*args, **kwargs) +        return wrapped +    return decorator diff --git a/bot/errors.py b/bot/errors.py index ab0adcd42..3544c6320 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -35,3 +35,9 @@ class InvalidInfractedUser(Exception):          self.reason = reason          super().__init__(reason) + + +class BrandingMisconfiguration(RuntimeError): +    """Raised by the Branding cog when a misconfigured event is encountered.""" + +    pass diff --git a/bot/exts/backend/branding/__init__.py b/bot/exts/backend/branding/__init__.py index 81ea3bf49..20a747b7f 100644 --- a/bot/exts/backend/branding/__init__.py +++ b/bot/exts/backend/branding/__init__.py @@ -1,7 +1,7 @@  from bot.bot import Bot -from bot.exts.backend.branding._cog import BrandingManager +from bot.exts.backend.branding._cog import Branding  def setup(bot: Bot) -> None: -    """Loads BrandingManager cog.""" -    bot.add_cog(BrandingManager(bot)) +    """Load Branding cog.""" +    bot.add_cog(Branding(bot)) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 20df83a89..b07edbffd 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -1,566 +1,647 @@  import asyncio -import itertools +import contextlib  import logging  import random  import typing as t  from datetime import datetime, time, timedelta +from enum import Enum +from operator import attrgetter -import arrow  import async_timeout  import discord  from async_rediscache import RedisCache -from discord.ext import commands +from discord.ext import commands, tasks  from bot.bot import Bot -from bot.constants import Branding, Colours, Emojis, Guild, MODERATION_ROLES -from bot.exts.backend.branding import _constants, _decorators, _errors, _seasons +from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES +from bot.decorators import mock_in_debug +from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject  log = logging.getLogger(__name__) -class GitHubFile(t.NamedTuple): +class AssetType(Enum):      """ -    Represents a remote file on GitHub. +    Recognised Discord guild asset types. -    The `sha` hash is kept so that we can determine that a file has changed, -    despite its filename remaining unchanged. +    The value of each member corresponds exactly to a kwarg that can be passed to `Guild.edit`.      """ -    download_url: str -    path: str -    sha: str +    BANNER = "banner" +    ICON = "icon" -def pretty_files(files: t.Iterable[GitHubFile]) -> str: -    """Provide a human-friendly representation of `files`.""" -    return "\n".join(file.path for file in files) +def compound_hash(objects: t.Iterable[RemoteObject]) -> str: +    """ +    Join SHA attributes of `objects` into a single string. + +    Compound hashes are cached to check for change in any of the member `objects`. +    """ +    return "-".join(item.sha for item in objects) + + +def make_embed(title: str, description: str, *, success: bool) -> discord.Embed: +    """ +    Construct simple response embed. + +    If `success` is True, use green colour, otherwise red. + +    For both `title` and `description`, empty string are valid values ~ fields will be empty. +    """ +    colour = Colours.soft_green if success else Colours.soft_red +    return discord.Embed(title=title[:256], description=description[:2048], colour=colour) -def time_until_midnight() -> timedelta: +def extract_event_duration(event: Event) -> str:      """ -    Determine amount of time until the next-up UTC midnight. +    Extract a human-readable, year-agnostic duration string from `event`. -    The exact `midnight` moment is actually delayed to 5 seconds after, in order -    to avoid potential problems due to imprecise sleep. +    In the case that `event` is a fallback event, resolves to 'Fallback'.      """ -    now = datetime.utcnow() -    tomorrow = now + timedelta(days=1) -    midnight = datetime.combine(tomorrow, time(second=5)) +    if event.meta.is_fallback: +        return "Fallback" -    return midnight - now +    fmt = "%B %d"  # Ex: August 23 +    start_date = event.meta.start_date.strftime(fmt) +    end_date = event.meta.end_date.strftime(fmt) +    return f"{start_date} - {end_date}" -class BrandingManager(commands.Cog): + +def extract_event_name(event: Event) -> str:      """ -    Manages the guild's branding. - -    The purpose of this cog is to help automate the synchronization of the branding -    repository with the guild. It is capable of discovering assets in the repository -    via GitHub's API, resolving download urls for them, and delegating -    to the `bot` instance to upload them to the guild. - -    BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens -    once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single -    season. The daemon can be turned on and off via the `daemon` cmd group. The value set via -    its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will -    automatically start on the next bot start-up. Otherwise, it will wait to be started manually. - -    All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can -    also be invoked manually, via the following API: - -        branding list -            - Show all available seasons - -        branding set <season_name> -            - Set the cog's internal state to represent `season_name`, if it exists -            - If no `season_name` is given, set chronologically current season -            - This will not automatically apply the season's branding to the guild, -              the cog's state can be detached from the guild -            - Seasons can therefore be 'previewed' using this command - -        branding info -            - View detailed information about resolved assets for current season - -        branding refresh -            - Refresh internal state, i.e. synchronize with branding repository - -        branding apply -            - Apply the current internal state to the guild, i.e. upload the assets - -        branding cycle -            - If there are multiple available icons for current season, randomly pick -              and apply the next one - -    The daemon calls these methods autonomously as appropriate. The use of this cog -    is locked to moderation roles. As it performs media asset uploads, it is prone to -    rate-limits - the `apply` command should be used with caution. The `set` command can, -    however, be used freely to 'preview' seasonal branding and check whether paths have been -    resolved as appropriate. - -    While the bot is in debug mode, it will 'mock' asset uploads by logging the passed -    download urls and pretending that the upload was successful. Make use of this -    to test this cog's behaviour. +    Extract title-cased event name from the path of `event`. + +    An event with a path of 'events/black_history_month' will resolve to 'Black History Month'.      """ +    name = event.path.split("/")[-1]  # Inner-most directory name. +    words = name.split("_")  # Words from snake case. -    current_season: t.Type[_seasons.SeasonBase] +    return " ".join(word.title() for word in words) -    banner: t.Optional[GitHubFile] -    available_icons: t.List[GitHubFile] -    remaining_icons: t.List[GitHubFile] +class Branding(commands.Cog): +    """ +    Guild branding management. -    days_since_cycle: t.Iterator +    Extension responsible for automatic synchronisation of the guild's branding with the branding repository. +    Event definitions and assets are automatically discovered and applied as appropriate. -    daemon: t.Optional[asyncio.Task] +    All state is stored in Redis. The cog should therefore seamlessly transition across restarts and maintain +    a consistent icon rotation schedule for events with multiple icon assets. -    # Branding configuration -    branding_configuration = RedisCache() +    By caching hashes of banner & icon assets, we discover changes in currently applied assets and always keep +    the latest version applied. + +    The command interface allows moderators+ to control the daemon or request asset synchronisation, while +    regular users can see information about the current event and the overall event schedule. +    """ + +    # RedisCache[ +    #     "daemon_active": bool            | If True, daemon starts on start-up. Controlled via commands. +    #     "event_path": str                | Current event's path in the branding repo. +    #     "event_description": str         | Current event's Markdown description. +    #     "event_duration": str            | Current event's human-readable date range. +    #     "banner_hash": str               | SHA of the currently applied banner. +    #     "icons_hash": str                | Compound SHA of all icons in current rotation. +    #     "last_rotation_timestamp": float | POSIX UTC timestamp. +    # ] +    cache_information = RedisCache() + +    # Icons in current rotation. Keys (str) are download URLs, values (int) track the amount of times each +    # icon has been used in the current rotation. +    cache_icons = RedisCache() + +    # All available event names & durations. Cached by the daemon nightly; read by the calendar command. +    cache_events = RedisCache()      def __init__(self, bot: Bot) -> None: +        """Instantiate repository abstraction & allow daemon to start.""" +        self.bot = bot +        self.repository = BrandingRepository(bot) + +        self.bot.loop.create_task(self.maybe_start_daemon())  # Start depending on cache. + +    # region: Internal logic & state management + +    @mock_in_debug(return_value=True)  # Mocked in development environment to prevent API spam. +    async def apply_asset(self, asset_type: AssetType, download_url: str) -> bool:          """ -        Assign safe default values on init. +        Download asset from `download_url` and apply it to PyDis as `asset_type`. -        At this point, we don't have information about currently available branding. -        Most of these attributes will be overwritten once the daemon connects, or once -        the `refresh` command is used. +        Return a boolean indicating whether the application was successful.          """ -        self.bot = bot -        self.current_season = _seasons.get_current_season() +        log.info(f"Applying '{asset_type.value}' asset to the guild.") -        self.banner = None +        try: +            file = await self.repository.fetch_file(download_url) +        except Exception: +            log.exception(f"Failed to fetch '{asset_type.value}' asset.") +            return False -        self.available_icons = [] -        self.remaining_icons = [] +        await self.bot.wait_until_guild_available() +        pydis: discord.Guild = self.bot.get_guild(Guild.id) -        self.days_since_cycle = itertools.cycle([None]) +        timeout = 10  # Seconds. +        try: +            with async_timeout.timeout(timeout): +                await pydis.edit(**{asset_type.value: file}) +        except discord.HTTPException: +            log.exception("Asset upload to Discord failed.") +            return False +        except asyncio.TimeoutError: +            log.error(f"Asset upload to Discord timed out after {timeout} seconds.") +            return False +        else: +            log.trace("Asset uploaded successfully.") +            return True -        self.daemon = None -        self._startup_task = self.bot.loop.create_task(self._initial_start_daemon()) +    async def apply_banner(self, banner: RemoteObject) -> bool: +        """ +        Apply `banner` to the guild and cache its hash if successful. + +        Banners should always be applied via this method in order to ensure that the last hash is cached. + +        Return a boolean indicating whether the application was successful. +        """ +        success = await self.apply_asset(AssetType.BANNER, banner.download_url) -    async def _initial_start_daemon(self) -> None: -        """Checks is daemon active and when is, start it at cog load.""" -        if await self.branding_configuration.get("daemon_active"): -            self.daemon = self.bot.loop.create_task(self._daemon_func()) +        if success: +            await self.cache_information.set("banner_hash", banner.sha) -    @property -    def _daemon_running(self) -> bool: -        """True if the daemon is currently active, False otherwise.""" -        return self.daemon is not None and not self.daemon.done() +        return success -    async def _daemon_func(self) -> None: +    async def rotate_icons(self) -> bool:          """ -        Manage all automated behaviour of the BrandingManager cog. +        Choose and apply the next-up icon in rotation. + +        We keep track of the amount of times each icon has been used. The values in `cache_icons` can be understood +        to be iteration IDs. When an icon is chosen & applied, we bump its count, pushing it into the next iteration. -        Once a day, the daemon will perform the following tasks: -            - Update `current_season` -            - Poll GitHub API to see if the available branding for `current_season` has changed -            - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) -            - Check whether it's time to cycle guild icons +        Once the current iteration (lowest count in the cache) depletes, we move onto the next iteration. -        The internal loop runs once when activated, then periodically at the time -        given by `time_until_midnight`. +        In the case that there is only 1 icon in the rotation and has already been applied, do nothing. -        All method calls in the internal loop are considered safe, i.e. no errors propagate -        to the daemon's loop. The daemon itself does not perform any error handling on its own. +        Return a boolean indicating whether a new icon was applied successfully.          """ -        await self.bot.wait_until_guild_available() +        log.debug("Rotating icons.") -        while True: -            self.current_season = _seasons.get_current_season() -            branding_changed = await self.refresh() +        state = await self.cache_icons.to_dict() +        log.trace(f"Total icons in rotation: {len(state)}.") -            if branding_changed: -                await self.apply() +        if not state:  # This would only happen if rotation not initiated, but we can handle gracefully. +            log.warning("Attempted icon rotation with an empty icon cache. This indicates wrong logic.") +            return False -            elif next(self.days_since_cycle) == Branding.cycle_frequency: -                await self.cycle() +        if len(state) == 1 and 1 in state.values(): +            log.debug("Aborting icon rotation: only 1 icon is available and has already been applied.") +            return False -            until_midnight = time_until_midnight() -            await asyncio.sleep(until_midnight.total_seconds()) +        current_iteration = min(state.values())  # Choose iteration to draw from. +        options = [download_url for download_url, times_used in state.items() if times_used == current_iteration] -    async def _info_embed(self) -> discord.Embed: -        """Make an informative embed representing current season.""" -        info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) +        log.trace(f"Choosing from {len(options)} icons in iteration {current_iteration}.") +        next_icon = random.choice(options) -        # If we're in a non-evergreen season, also show active months -        if self.current_season is not _seasons.SeasonBase: -            title = f"{self.current_season.season_name} ({', '.join(str(m) for m in self.current_season.months)})" -        else: -            title = self.current_season.season_name +        success = await self.apply_asset(AssetType.ICON, next_icon) + +        if success: +            await self.cache_icons.increment(next_icon)  # Push the icon into the next iteration. + +            timestamp = datetime.utcnow().timestamp() +            await self.cache_information.set("last_rotation_timestamp", timestamp) + +        return success -        # Use the author field to show the season's name and avatar if available -        info_embed.set_author(name=title) +    async def maybe_rotate_icons(self) -> None: +        """ +        Call `rotate_icons` if the configured amount of time has passed since last rotation. -        banner = self.banner.path if self.banner is not None else "Unavailable" -        info_embed.add_field(name="Banner", value=banner, inline=False) +        We offset the calculated time difference into the future in order to avoid off-by-a-little-bit errors. +        Because there is work to be done before the timestamp is read and written, the next read will likely +        commence slightly under 24 hours after the last write. +        """ +        log.debug("Checking whether it's time for icons to rotate.") -        icons = pretty_files(self.available_icons) or "Unavailable" -        info_embed.add_field(name="Available icons", value=icons, inline=False) +        last_rotation_timestamp = await self.cache_information.get("last_rotation_timestamp") -        # Only display cycle frequency if we're actually cycling -        if len(self.available_icons) > 1 and Branding.cycle_frequency: -            info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") +        if last_rotation_timestamp is None:  # Maiden case ~ never rotated. +            await self.rotate_icons() +            return -        return info_embed +        last_rotation = datetime.fromtimestamp(last_rotation_timestamp) +        difference = (datetime.utcnow() - last_rotation) + timedelta(minutes=5) -    async def _reset_remaining_icons(self) -> None: -        """Set `remaining_icons` to a shuffled copy of `available_icons`.""" -        self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) +        log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).") -    async def _reset_days_since_cycle(self) -> None: +        if difference.days >= BrandingConfig.cycle_frequency: +            await self.rotate_icons() + +    async def initiate_icon_rotation(self, available_icons: t.List[RemoteObject]) -> None:          """ -        Reset the `days_since_cycle` iterator based on configured frequency. +        Set up a new icon rotation. -        If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey, -        the iterator will always yield None. This signals that the icon shouldn't be cycled. +        This function should be called whenever available icons change. This is generally the case when we enter +        a new event, but potentially also when the assets of an on-going event change. In such cases, a reset +        of `cache_icons` is necessary, because it contains download URLs which may have gotten stale. -        Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely. -        When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle. +        This function does not upload a new icon!          """ -        if len(self.available_icons) > 1 and Branding.cycle_frequency: -            sequence = range(1, Branding.cycle_frequency + 1) -        else: -            sequence = [None] +        log.debug("Initiating new icon rotation.") -        self.days_since_cycle = itertools.cycle(sequence) +        await self.cache_icons.clear() -    async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]: +        new_state = {icon.download_url: 0 for icon in available_icons} +        await self.cache_icons.update(new_state) + +        log.trace(f"Icon rotation initiated for {len(new_state)} icons.") + +        await self.cache_information.set("icons_hash", compound_hash(available_icons)) + +    async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> None:          """ -        Get files at `path` in the branding repository. +        Send the currently cached event description to `channel_id`. -        If `include_dirs` is False (default), only returns files at `path`. -        Otherwise, will return both files and directories. Never returns symlinks. +        When `is_notification` holds, a short contextual message for the #changelog channel is added. -        Return dict mapping from filename to corresponding `GitHubFile` instance. -        This may return an empty dict if the response status is non-200, -        or if the target directory is empty. +        We read event information from `cache_information`. The caller is therefore responsible for making +        sure that the cache is up-to-date before calling this function.          """ -        url = f"{_constants.BRANDING_URL}/{path}" -        async with self.bot.http_session.get( -            url, headers=_constants.HEADERS, params=_constants.PARAMS -        ) as resp: -            # Short-circuit if we get non-200 response -            if resp.status != _constants.STATUS_OK: -                log.error(f"GitHub API returned non-200 response: {resp}") -                return {} -            directory = await resp.json()  # Directory at `path` +        log.debug(f"Sending event information event to channel: {channel_id} ({is_notification=}).") -        allowed_types = {"file", "dir"} if include_dirs else {"file"} -        return { -            file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"]) -            for file in directory -            if file["type"] in allowed_types -        } +        await self.bot.wait_until_guild_available() +        channel: t.Optional[discord.TextChannel] = self.bot.get_channel(channel_id) -    async def refresh(self) -> bool: +        if channel is None: +            log.warning(f"Cannot send event information: channel {channel_id} not found!") +            return + +        log.trace(f"Destination channel: #{channel.name}.") + +        description = await self.cache_information.get("event_description") +        duration = await self.cache_information.get("event_duration") + +        if None in (description, duration): +            content = None +            embed = make_embed("No event in cache", "Is the daemon enabled?", success=False) + +        else: +            content = "Python Discord is entering a new event!" if is_notification else None +            embed = discord.Embed(description=description[:2048], colour=discord.Colour.blurple()) +            embed.set_footer(text=duration[:2048]) + +        await channel.send(content=content, embed=embed) + +    async def enter_event(self, event: Event) -> t.Tuple[bool, bool]:          """ -        Synchronize available assets with branding repository. +        Apply `event` assets and update information cache. -        If the current season is not the evergreen, and lacks at least one asset, -        we use the evergreen seasonal dir as fallback for missing assets. +        We cache `event` information to ensure that we: +        * Remember which event we're currently in across restarts +        * Provide an on-demand information embed without re-querying the branding repository -        Finally, if neither the seasonal nor fallback branding directories contain -        an asset, it will simply be ignored. +        An event change should always be handled via this function, as it ensures that the cache is populated. -        Return True if the branding has changed. This will be the case when we enter -        a new season, or when something changes in the current seasons's directory -        in the branding repository. +        The #changelog notification is omitted when `event` is fallback, or already applied. + +        Return a 2-tuple indicating whether the banner, and the icon, were applied successfully.          """ -        old_branding = (self.banner, self.available_icons) -        seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) +        log.info(f"Entering event: '{event.path}'.") -        # Only make a call to the fallback directory if there is something to be gained -        branding_incomplete = any( -            asset not in seasonal_dir -            for asset in (_constants.FILE_BANNER, _constants.FILE_AVATAR, _constants.SERVER_ICONS) -        ) -        if branding_incomplete and self.current_season is not _seasons.SeasonBase: -            fallback_dir = await self._get_files( -                _seasons.SeasonBase.branding_path, include_dirs=True -            ) -        else: -            fallback_dir = {} +        banner_success = await self.apply_banner(event.banner)  # Only one asset ~ apply directly. -        # Resolve assets in this directory, None is a safe value -        self.banner = ( -            seasonal_dir.get(_constants.FILE_BANNER) -            or fallback_dir.get(_constants.FILE_BANNER) -        ) +        await self.initiate_icon_rotation(event.icons)  # Prepare a new rotation. +        icon_success = await self.rotate_icons()  # Apply an icon from the new rotation. -        # Now resolve server icons by making a call to the proper sub-directory -        if _constants.SERVER_ICONS in seasonal_dir: -            icons_dir = await self._get_files( -                f"{self.current_season.branding_path}/{_constants.SERVER_ICONS}" -            ) -            self.available_icons = list(icons_dir.values()) +        # This will only be False in the case of a manual same-event re-synchronisation. +        event_changed = event.path != await self.cache_information.get("event_path") -        elif _constants.SERVER_ICONS in fallback_dir: -            icons_dir = await self._get_files( -                f"{_seasons.SeasonBase.branding_path}/{_constants.SERVER_ICONS}" -            ) -            self.available_icons = list(icons_dir.values()) +        # Cache event identity to avoid re-entry in case of restart. +        await self.cache_information.set("event_path", event.path) +        # Cache information shown in the 'about' embed. +        await self.populate_cache_event_description(event) + +        # Notify guild of new event ~ this reads the information that we cached above. +        if event_changed and not event.meta.is_fallback: +            await self.send_info_embed(Channels.change_log, is_notification=True)          else: -            self.available_icons = []  # This should never be the case, but an empty list is a safe value +            log.trace("Omitting #changelog notification. Event has not changed, or new event is fallback.") -        # GitHubFile instances carry a `sha` attr so this will pick up if a file changes -        branding_changed = old_branding != (self.banner, self.available_icons) +        return banner_success, icon_success -        if branding_changed: -            log.info(f"New branding detected (season: {self.current_season.season_name})") -            await self._reset_remaining_icons() -            await self._reset_days_since_cycle() +    async def synchronise(self) -> t.Tuple[bool, bool]: +        """ +        Fetch the current event and delegate to `enter_event`. -        return branding_changed +        This is a convenience function to force synchronisation via a command. It should generally only be used +        in a recovery scenario. In the usual case, the daemon already has an `Event` instance and can pass it +        to `enter_event` directly. -    async def cycle(self) -> bool: +        Return a 2-tuple indicating whether the banner, and the icon, were applied successfully.          """ -        Apply the next-up server icon. +        log.debug("Synchronise: fetching current event.") -        Returns True if an icon is available and successfully gets applied, False otherwise. -        """ -        if not self.available_icons: -            log.info("Cannot cycle: no icons for this season") -            return False +        current_event, available_events = await self.repository.get_current_event() -        if not self.remaining_icons: -            log.info("Reset & shuffle remaining icons") -            await self._reset_remaining_icons() +        await self.populate_cache_events(available_events) -        next_up = self.remaining_icons.pop(0) -        success = await self.set_icon(next_up.download_url) +        if current_event is None: +            log.error("Failed to fetch event. Cannot synchronise!") +            return False, False -        return success +        return await self.enter_event(current_event) -    async def apply(self) -> t.List[str]: +    async def populate_cache_events(self, events: t.List[Event]) -> None:          """ -        Apply current branding to the guild and bot. - -        This delegates to the bot instance to do all the work. We only provide download urls -        for available assets. Assets unavailable in the branding repo will be ignored. +        Clear `cache_events` and re-populate with names and durations of `events`. -        Returns a list of names of all failed assets. An asset is considered failed -        if it isn't found in the branding repo, or if something goes wrong while the -        bot is trying to apply it. +        For each event, we store its name and duration string. This is the information presented to users in the +        calendar command. If a format change is needed, it has to be done here. -        An empty list denotes that all assets have been applied successfully. +        The cache does not store the fallback event, as it is not shown in the calendar.          """ -        report = {asset: False for asset in ("banner", "icon")} +        log.debug("Populating events cache.") -        if self.banner is not None: -            report["banner"] = await self.set_banner(self.banner.download_url) +        await self.cache_events.clear() -        report["icon"] = await self.cycle() +        no_fallback = [event for event in events if not event.meta.is_fallback] +        chronological_events = sorted(no_fallback, key=attrgetter("meta.start_date")) -        failed_assets = [asset for asset, succeeded in report.items() if not succeeded] -        return failed_assets +        log.trace(f"Writing {len(chronological_events)} events (fallback omitted).") -    @commands.has_any_role(*MODERATION_ROLES) -    @commands.group(name="branding") -    async def branding_cmds(self, ctx: commands.Context) -> None: -        """Manual branding control.""" -        if not ctx.invoked_subcommand: -            await ctx.send_help(ctx.command) +        with contextlib.suppress(ValueError):  # Cache raises when updated with an empty dict. +            await self.cache_events.update({ +                extract_event_name(event): extract_event_duration(event) +                for event in chronological_events +            }) -    @branding_cmds.command(name="list", aliases=["ls"]) -    async def branding_list(self, ctx: commands.Context) -> None: -        """List all available seasons and branding sources.""" -        embed = discord.Embed(title="Available seasons", colour=Colours.soft_green) +    async def populate_cache_event_description(self, event: Event) -> None: +        """ +        Cache `event` description & duration. -        for season in _seasons.get_all_seasons(): -            if season is _seasons.SeasonBase: -                active_when = "always" -            else: -                active_when = f"in {', '.join(str(m) for m in season.months)}" +        This should be called when entering a new event, and can be called periodically to ensure that the cache +        holds fresh information in the case that the event remains the same, but its description changes. -            description = ( -                f"Active {active_when}\n" -                f"Branding: {season.branding_path}" -            ) -            embed.add_field(name=season.season_name, value=description, inline=False) +        The duration is stored formatted for the frontend. It is not intended to be used programmatically. +        """ +        log.debug("Caching event description & duration.") -        await ctx.send(embed=embed) +        await self.cache_information.set("event_description", event.meta.description) +        await self.cache_information.set("event_duration", extract_event_duration(event)) -    @branding_cmds.command(name="set") -    async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: +    # endregion +    # region: Daemon + +    async def maybe_start_daemon(self) -> None:          """ -        Manually set season, or reset to current if none given. +        Start the daemon depending on cache state. -        Season search is a case-less comparison against both seasonal class name, -        and its `season_name` attr. +        The daemon will only start if it has been explicitly enabled via a command. +        """ +        log.debug("Checking whether daemon should start.") -        This only pre-loads the cog's internal state to the chosen season, but does not -        automatically apply the branding. As that is an expensive operation, the `apply` -        command must be called explicitly after this command finishes. +        should_begin: t.Optional[bool] = await self.cache_information.get("daemon_active")  # None if never set! -        This means that this command can be used to 'preview' a season gathering info -        about its available assets, without applying them to the guild. +        if should_begin: +            self.daemon_loop.start() -        If the daemon is running, it will automatically reset the season to current when -        it wakes up. The season set via this command can therefore remain 'detached' from -        what it should be - the daemon will make sure that it's set back properly. +    def cog_unload(self) -> None:          """ -        if season_name is None: -            new_season = _seasons.get_current_season() -        else: -            new_season = _seasons.get_season(season_name) -            if new_season is None: -                raise _errors.BrandingError("No such season exists") +        Cancel the daemon in case of cog unload. -        if self.current_season is new_season: -            raise _errors.BrandingError(f"Season {self.current_season.season_name} already active") +        This is **not** done automatically! The daemon otherwise remains active in the background. +        """ +        log.debug("Cog unload: cancelling daemon.") -        self.current_season = new_season -        await self.branding_refresh(ctx) +        self.daemon_loop.cancel() -    @branding_cmds.command(name="info", aliases=["status"]) -    async def branding_info(self, ctx: commands.Context) -> None: +    async def daemon_main(self) -> None:          """ -        Show available assets for current season. +        Synchronise guild & caches with branding repository. -        This can be used to confirm that assets have been resolved properly. -        When `apply` is used, it attempts to upload exactly the assets listed here. +        Pull the currently active event from the branding repository and check whether it matches the currently +        active event in the cache. If not, apply the new event. + +        However, it is also possible that an event's assets change as it's active. To account for such cases, +        we check the banner & icons hashes against the currently cached values. If there is a mismatch, each +        specific asset is re-applied.          """ -        await ctx.send(embed=await self._info_embed()) +        log.info("Daemon main: checking current event.") -    @branding_cmds.command(name="refresh") -    async def branding_refresh(self, ctx: commands.Context) -> None: -        """Sync currently available assets with branding repository.""" -        async with ctx.typing(): -            await self.refresh() -            await self.branding_info(ctx) +        new_event, available_events = await self.repository.get_current_event() + +        await self.populate_cache_events(available_events) + +        if new_event is None: +            log.warning("Daemon main: failed to get current event from branding repository, will do nothing.") +            return + +        if new_event.path != await self.cache_information.get("event_path"): +            log.debug("Daemon main: new event detected!") +            await self.enter_event(new_event) +            return + +        await self.populate_cache_event_description(new_event)  # Cache fresh frontend info in case of change. -    @branding_cmds.command(name="apply") -    async def branding_apply(self, ctx: commands.Context) -> None: +        log.trace("Daemon main: event has not changed, checking for change in assets.") + +        if new_event.banner.sha != await self.cache_information.get("banner_hash"): +            log.debug("Daemon main: detected banner change.") +            await self.apply_banner(new_event.banner) + +        if compound_hash(new_event.icons) != await self.cache_information.get("icons_hash"): +            log.debug("Daemon main: detected icon change.") +            await self.initiate_icon_rotation(new_event.icons) +            await self.rotate_icons() +        else: +            await self.maybe_rotate_icons() + +    @tasks.loop(hours=24) +    async def daemon_loop(self) -> None:          """ -        Apply current season's branding to the guild. +        Call `daemon_main` every 24 hours. -        Use `info` to check which assets will be applied. Shows which assets have -        failed to be applied, if any. +        The scheduler maintains an exact 24-hour frequency even if this coroutine takes time to complete. If the +        coroutine is started at 00:01 and completes at 00:05, it will still be started at 00:01 the next day.          """ -        async with ctx.typing(): -            failed_assets = await self.apply() -            if failed_assets: -                raise _errors.BrandingError( -                    f"Failed to apply following assets: {', '.join(failed_assets)}" -                ) +        log.trace("Daemon loop: calling daemon main.") -            response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) -            await ctx.send(embed=response) +        try: +            await self.daemon_main() +        except Exception: +            log.exception("Daemon loop: failed with an unhandled exception!") -    @branding_cmds.command(name="cycle") -    async def branding_cycle(self, ctx: commands.Context) -> None: +    @daemon_loop.before_loop +    async def daemon_before(self) -> None:          """ -        Apply the next-up guild icon, if multiple are available. +        Call `daemon_loop` immediately, then block the loop until the next-up UTC midnight. -        The order is random. +        The first iteration is invoked directly such that synchronisation happens immediately after daemon start. +        We then calculate the time until the next-up midnight and sleep before letting `daemon_loop` begin.          """ -        async with ctx.typing(): -            success = await self.cycle() -            if not success: -                raise _errors.BrandingError("Failed to cycle icon") +        log.trace("Daemon before: performing start-up iteration.") + +        await self.daemon_loop() + +        log.trace("Daemon before: calculating time to sleep before loop begins.") +        now = datetime.utcnow() -            response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) -            await ctx.send(embed=response) +        # The actual midnight moment is offset into the future in order to prevent issues with imprecise sleep. +        tomorrow = now + timedelta(days=1) +        midnight = datetime.combine(tomorrow, time(minute=1)) -    @branding_cmds.group(name="daemon", aliases=["d", "task"]) -    async def daemon_group(self, ctx: commands.Context) -> None: -        """Control the background daemon.""" +        sleep_secs = (midnight - now).total_seconds() +        log.trace(f"Daemon before: sleeping {sleep_secs} seconds before next-up midnight: {midnight}.") + +        await asyncio.sleep(sleep_secs) + +    # endregion +    # region: Command interface (branding) + +    @commands.group(name="branding") +    async def branding_group(self, ctx: commands.Context) -> None: +        """Control the branding cog."""          if not ctx.invoked_subcommand:              await ctx.send_help(ctx.command) -    @daemon_group.command(name="status") -    async def daemon_status(self, ctx: commands.Context) -> None: -        """Check whether daemon is currently active.""" -        if self._daemon_running: -            remaining_time = (arrow.utcnow() + time_until_midnight()).humanize() -            response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) -            response.set_footer(text=f"Next refresh {remaining_time}") -        else: -            response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) +    @branding_group.command(name="about", aliases=("current", "event")) +    async def branding_about_cmd(self, ctx: commands.Context) -> None: +        """Show the current event's description and duration.""" +        await self.send_info_embed(ctx.channel.id, is_notification=False) -        await ctx.send(embed=response) +    @commands.has_any_role(*MODERATION_ROLES) +    @branding_group.command(name="sync") +    async def branding_sync_cmd(self, ctx: commands.Context) -> None: +        """ +        Force branding synchronisation. -    @daemon_group.command(name="start") -    async def daemon_start(self, ctx: commands.Context) -> None: -        """If the daemon isn't running, start it.""" -        if self._daemon_running: -            raise _errors.BrandingError("Daemon already running!") +        Show which assets have failed to synchronise, if any. +        """ +        async with ctx.typing(): +            banner_success, icon_success = await self.synchronise() -        self.daemon = self.bot.loop.create_task(self._daemon_func()) -        await self.branding_configuration.set("daemon_active", True) +        failed_assets = ", ".join( +            name +            for name, status in [("banner", banner_success), ("icon", icon_success)] +            if status is False +        ) -        response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) -        await ctx.send(embed=response) +        if failed_assets: +            resp = make_embed("Synchronisation unsuccessful", f"Failed to apply: {failed_assets}.", success=False) +            resp.set_footer(text="Check log for details.") +        else: +            resp = make_embed("Synchronisation successful", "Assets have been applied.", success=True) -    @daemon_group.command(name="stop") -    async def daemon_stop(self, ctx: commands.Context) -> None: -        """If the daemon is running, stop it.""" -        if not self._daemon_running: -            raise _errors.BrandingError("Daemon not running!") +        await ctx.send(embed=resp) -        self.daemon.cancel() -        await self.branding_configuration.set("daemon_active", False) +    # endregion +    # region: Command interface (branding calendar) + +    @branding_group.group(name="calendar", aliases=("schedule", "events")) +    async def branding_calendar_group(self, ctx: commands.Context) -> None: +        """ +        Show the current event calendar. -        response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) -        await ctx.send(embed=response) +        We draw event information from `cache_events` and use each key-value pair to create a field in the response +        embed. As such, we do not need to query the API to get event information. The cache is automatically +        re-populated by the daemon whenever it makes a request. A moderator+ can also explicitly request a cache +        refresh using the 'refresh' subcommand. -    async def _fetch_image(self, url: str) -> bytes: -        """Retrieve and read image from `url`.""" -        log.debug(f"Getting image from: {url}") -        async with self.bot.http_session.get(url) as resp: -            return await resp.read() +        Due to Discord limitations, we only show up to 25 events. This is entirely sufficient at the time of writing. +        In the case that we find ourselves with more than 25 events, a warning log will alert core devs. -    async def _apply_asset(self, target: discord.Guild, asset: _constants.AssetType, url: str) -> bool: +        In the future, we may be interested in a field-paginating solution.          """ -        Internal method for applying media assets to the guild. +        if ctx.invoked_subcommand: +            # If you're wondering why this works: when the 'refresh' subcommand eventually re-invokes +            # this group, the attribute will be automatically set to None by the framework. +            return + +        available_events = await self.cache_events.to_dict() +        log.trace(f"Found {len(available_events)} cached events available for calendar view.") + +        if not available_events: +            resp = make_embed("No events found!", "Cache may be empty, try `branding calendar refresh`.", success=False) +            await ctx.send(embed=resp) +            return + +        embed = discord.Embed(title="Current event calendar", colour=discord.Colour.blurple()) + +        # Because Discord embeds can only contain up to 25 fields, we only show the first 25. +        first_25 = list(available_events.items())[:25] -        This shouldn't be called directly. The purpose of this method is mainly generic -        error handling to reduce needless code repetition. +        if len(first_25) != len(available_events):  # Alert core devs that a paginating solution is now necessary. +            log.warning(f"There are {len(available_events)} events, but the calendar view can only display 25.") -        Return True if upload was successful, False otherwise. +        for name, duration in first_25: +            embed.add_field(name=name[:256], value=duration[:1024]) + +        embed.set_footer(text="Otherwise, the fallback season is used.") + +        await ctx.send(embed=embed) + +    @commands.has_any_role(*MODERATION_ROLES) +    @branding_calendar_group.command(name="refresh") +    async def branding_calendar_refresh_cmd(self, ctx: commands.Context) -> None:          """ -        log.info(f"Attempting to set {asset.name}: {url}") +        Refresh event cache and show current event calendar. -        kwargs = {asset.value: await self._fetch_image(url)} -        try: -            async with async_timeout.timeout(5): -                await target.edit(**kwargs) +        Supplementary subcommand allowing force-refreshing the event cache. Implemented as a subcommand because +        unlike the supergroup, it requires moderator privileges. +        """ +        log.info("Performing command-requested event cache refresh.") -        except asyncio.TimeoutError: -            log.info("Asset upload timed out") -            return False +        async with ctx.typing(): +            available_events = await self.repository.get_events() +            await self.populate_cache_events(available_events) -        except discord.HTTPException as discord_error: -            log.exception("Asset upload failed", exc_info=discord_error) -            return False +        await ctx.invoke(self.branding_calendar_group) + +    # endregion +    # region: Command interface (branding daemon) + +    @commands.has_any_role(*MODERATION_ROLES) +    @branding_group.group(name="daemon", aliases=("d",)) +    async def branding_daemon_group(self, ctx: commands.Context) -> None: +        """Control the branding cog's daemon.""" +        if not ctx.invoked_subcommand: +            await ctx.send_help(ctx.command) + +    @branding_daemon_group.command(name="enable", aliases=("start", "on")) +    async def branding_daemon_enable_cmd(self, ctx: commands.Context) -> None: +        """Enable the branding daemon.""" +        await self.cache_information.set("daemon_active", True) +        if self.daemon_loop.is_running(): +            resp = make_embed("Daemon is already enabled!", "", success=False)          else: -            log.info("Asset successfully applied") -            return True +            self.daemon_loop.start() +            resp = make_embed("Daemon enabled!", "It will now automatically awaken on start-up.", success=True) -    @_decorators.mock_in_debug(return_value=True) -    async def set_banner(self, url: str) -> bool: -        """Set the guild's banner to image at `url`.""" -        guild = self.bot.get_guild(Guild.id) -        if guild is None: -            log.info("Failed to get guild instance, aborting asset upload") -            return False +        await ctx.send(embed=resp) -        return await self._apply_asset(guild, _constants.AssetType.BANNER, url) +    @branding_daemon_group.command(name="disable", aliases=("stop", "off")) +    async def branding_daemon_disable_cmd(self, ctx: commands.Context) -> None: +        """Disable the branding daemon.""" +        await self.cache_information.set("daemon_active", False) -    @_decorators.mock_in_debug(return_value=True) -    async def set_icon(self, url: str) -> bool: -        """Sets the guild's icon to image at `url`.""" -        guild = self.bot.get_guild(Guild.id) -        if guild is None: -            log.info("Failed to get guild instance, aborting asset upload") -            return False +        if self.daemon_loop.is_running(): +            self.daemon_loop.cancel() +            resp = make_embed("Daemon disabled!", "It will not awaken on start-up.", success=True) +        else: +            resp = make_embed("Daemon is already disabled!", "", success=False) -        return await self._apply_asset(guild, _constants.AssetType.SERVER_ICON, url) +        await ctx.send(embed=resp) -    def cog_unload(self) -> None: -        """Cancels startup and daemon task.""" -        self._startup_task.cancel() -        if self.daemon is not None: -            self.daemon.cancel() +    @branding_daemon_group.command(name="status") +    async def branding_daemon_status_cmd(self, ctx: commands.Context) -> None: +        """Check whether the daemon is currently enabled.""" +        if self.daemon_loop.is_running(): +            resp = make_embed("Daemon is enabled", "Use `branding daemon disable` to stop.", success=True) +        else: +            resp = make_embed("Daemon is disabled", "Use `branding daemon enable` to start.", success=False) + +        await ctx.send(embed=resp) + +    # endregion diff --git a/bot/exts/backend/branding/_constants.py b/bot/exts/backend/branding/_constants.py deleted file mode 100644 index ca8e8c5f5..000000000 --- a/bot/exts/backend/branding/_constants.py +++ /dev/null @@ -1,51 +0,0 @@ -from enum import Enum, IntEnum - -from bot.constants import Keys - - -class Month(IntEnum): -    """All month constants for seasons.""" - -    JANUARY = 1 -    FEBRUARY = 2 -    MARCH = 3 -    APRIL = 4 -    MAY = 5 -    JUNE = 6 -    JULY = 7 -    AUGUST = 8 -    SEPTEMBER = 9 -    OCTOBER = 10 -    NOVEMBER = 11 -    DECEMBER = 12 - -    def __str__(self) -> str: -        return self.name.title() - - -class AssetType(Enum): -    """ -    Discord media assets. - -    The values match exactly the kwarg keys that can be passed to `Guild.edit`. -    """ - -    BANNER = "banner" -    SERVER_ICON = "icon" - - -STATUS_OK = 200  # HTTP status code - -FILE_BANNER = "banner.png" -FILE_AVATAR = "avatar.png" -SERVER_ICONS = "server_icons" - -BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" - -PARAMS = {"ref": "main"}  # Target branch -HEADERS = {"Accept": "application/vnd.github.v3+json"}  # Ensure we use API v3 - -# A GitHub token is not necessary for the cog to operate, -# unauthorized requests are however limited to 60 per hour -if Keys.github: -    HEADERS["Authorization"] = f"token {Keys.github}" diff --git a/bot/exts/backend/branding/_decorators.py b/bot/exts/backend/branding/_decorators.py deleted file mode 100644 index 6a1e7e869..000000000 --- a/bot/exts/backend/branding/_decorators.py +++ /dev/null @@ -1,27 +0,0 @@ -import functools -import logging -import typing as t - -from bot.constants import DEBUG_MODE - -log = logging.getLogger(__name__) - - -def mock_in_debug(return_value: t.Any) -> t.Callable: -    """ -    Short-circuit function execution if in debug mode and return `return_value`. - -    The original function name, and the incoming args and kwargs are DEBUG level logged -    upon each call. This is useful for expensive operations, i.e. media asset uploads -    that are prone to rate-limits but need to be tested extensively. -    """ -    def decorator(func: t.Callable) -> t.Callable: -        @functools.wraps(func) -        async def wrapped(*args, **kwargs) -> t.Any: -            """Short-circuit and log if in debug mode.""" -            if DEBUG_MODE: -                log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") -                return return_value -            return await func(*args, **kwargs) -        return wrapped -    return decorator diff --git a/bot/exts/backend/branding/_errors.py b/bot/exts/backend/branding/_errors.py deleted file mode 100644 index 7cd271af3..000000000 --- a/bot/exts/backend/branding/_errors.py +++ /dev/null @@ -1,2 +0,0 @@ -class BrandingError(Exception): -    """Exception raised by the BrandingManager cog.""" diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py new file mode 100644 index 000000000..3a9745ed5 --- /dev/null +++ b/bot/exts/backend/branding/_repository.py @@ -0,0 +1,236 @@ +import logging +import typing as t +from datetime import date, datetime + +import frontmatter + +from bot.bot import Bot +from bot.constants import Keys +from bot.errors import BrandingMisconfiguration + +# Base URL for requests into the branding repository. +BRANDING_URL = "https://api.github.com/repos/kwzrd/pydis-branding/contents" + +PARAMS = {"ref": "kwzrd/events-rework"}  # Target branch. +HEADERS = {"Accept": "application/vnd.github.v3+json"}  # Ensure we use API v3. + +# A GitHub token is not necessary. However, unauthorized requests are limited to 60 per hour. +if Keys.github: +    HEADERS["Authorization"] = f"token {Keys.github}" + +# Since event periods are year-agnostic, we parse them into `datetime` objects with a manually inserted year. +# Please note that this is intentionally a leap year in order to allow Feb 29 to be valid. +ARBITRARY_YEAR = 2020 + +# Format used to parse date strings after we inject `ARBITRARY_YEAR` at the end. +DATE_FMT = "%B %d %Y"  # Ex: July 10 2020 + +log = logging.getLogger(__name__) + + +class RemoteObject: +    """ +    Remote file or directory on GitHub. + +    The annotations match keys in the response JSON that we're interested in. +    """ + +    sha: str  # Hash helps us detect asset change. +    name: str  # Filename. +    path: str  # Path from repo root. +    type: str  # Either 'file' or 'dir'. +    download_url: t.Optional[str]  # If type is 'dir', this is None! + +    def __init__(self, dictionary: t.Dict[str, t.Any]) -> None: +        """Initialize by grabbing annotated attributes from `dictionary`.""" +        missing_keys = self.__annotations__.keys() - dictionary.keys() +        if missing_keys: +            raise KeyError(f"Fetched object lacks expected keys: {missing_keys}") +        for annotation in self.__annotations__: +            setattr(self, annotation, dictionary[annotation]) + + +class MetaFile(t.NamedTuple): +    """Attributes defined in a 'meta.md' file.""" + +    is_fallback: bool +    start_date: t.Optional[date] +    end_date: t.Optional[date] +    description: str  # Markdown event description. + + +class Event(t.NamedTuple): +    """Event defined in the branding repository.""" + +    path: str  # Path from repo root where event lives. This is the event's identity. +    meta: MetaFile +    banner: RemoteObject +    icons: t.List[RemoteObject] + +    def __str__(self) -> str: +        return f"<Event at '{self.path}'>" + + +class BrandingRepository: +    """ +    Branding repository abstraction. + +    This class represents the branding repository's main branch and exposes available events and assets +    as objects. It performs the necessary amount of validation to ensure that a misconfigured event +    isn't returned. Such events are simply ignored, and will be substituted with the fallback event, +    if available. Warning logs will inform core developers if a misconfigured event is encountered. + +    Colliding events cause no special behaviour. In such cases, the first found active event is returned. +    We work with the assumption that the branding repository checks for such conflicts and prevents them +    from reaching the main branch. + +    This class keeps no internal state. All `get_current_event` calls will result in GitHub API requests. +    The caller is therefore responsible for being responsible and caching information to prevent API abuse. + +    Requests are made using the HTTP session looked up on the bot instance. +    """ + +    def __init__(self, bot: Bot) -> None: +        self.bot = bot + +    async def fetch_directory(self, path: str, types: t.Container[str] = ("file", "dir")) -> t.Dict[str, RemoteObject]: +        """ +        Fetch directory found at `path` in the branding repository. + +        Raise an exception if the request fails, or if the response lacks the expected keys. + +        Passing custom `types` allows getting only files or directories. By default, both are included. +        """ +        full_url = f"{BRANDING_URL}/{path}" +        log.debug(f"Fetching directory from branding repository: '{full_url}'.") + +        async with self.bot.http_session.get(full_url, params=PARAMS, headers=HEADERS) as response: +            if response.status != 200: +                raise RuntimeError(f"Failed to fetch directory due to status: {response.status}") +            json_directory = await response.json() + +        return {file["name"]: RemoteObject(file) for file in json_directory if file["type"] in types} + +    async def fetch_file(self, download_url: str) -> bytes: +        """ +        Fetch file as bytes from `download_url`. + +        Raise an exception if the request does not succeed. +        """ +        log.debug(f"Fetching file from branding repository: '{download_url}'.") + +        async with self.bot.http_session.get(download_url, params=PARAMS, headers=HEADERS) as response: +            if response.status != 200: +                raise RuntimeError(f"Failed to fetch file due to status: {response.status}") +            return await response.read() + +    def parse_meta_file(self, raw_file: bytes) -> MetaFile: +        """ +        Parse a 'meta.md' file from raw bytes. + +        The caller is responsible for handling errors caused by misconfiguration. +        """ +        attrs, description = frontmatter.parse(raw_file, encoding="UTF-8") + +        if not description: +            raise BrandingMisconfiguration("No description found in 'meta.md'!") + +        if attrs.get("fallback", False): +            return MetaFile(is_fallback=True, start_date=None, end_date=None, description=description) + +        start_date_raw = attrs.get("start_date") +        end_date_raw = attrs.get("end_date") + +        if None in (start_date_raw, end_date_raw): +            raise BrandingMisconfiguration("Non-fallback event doesn't have start and end dates defined!") + +        # We extend the configured month & day with an arbitrary leap year, allowing a datetime object to exist. +        # This may raise errors if misconfigured. We let the caller handle such cases. +        start_date = datetime.strptime(f"{start_date_raw} {ARBITRARY_YEAR}", DATE_FMT).date() +        end_date = datetime.strptime(f"{end_date_raw} {ARBITRARY_YEAR}", DATE_FMT).date() + +        return MetaFile(is_fallback=False, start_date=start_date, end_date=end_date, description=description) + +    async def construct_event(self, directory: RemoteObject) -> Event: +        """ +        Construct an `Event` instance from an event `directory`. + +        The caller is responsible for handling errors caused by misconfiguration. +        """ +        contents = await self.fetch_directory(directory.path) + +        missing_assets = {"meta.md", "banner.png", "server_icons"} - contents.keys() + +        if missing_assets: +            raise BrandingMisconfiguration(f"Directory is missing following assets: {missing_assets}") + +        server_icons = await self.fetch_directory(contents["server_icons"].path, types=("file",)) + +        if len(server_icons) == 0: +            raise BrandingMisconfiguration("Found no server icons!") + +        meta_bytes = await self.fetch_file(contents["meta.md"].download_url) + +        meta_file = self.parse_meta_file(meta_bytes) + +        return Event(directory.path, meta_file, contents["banner.png"], list(server_icons.values())) + +    async def get_events(self) -> t.List[Event]: +        """ +        Discover available events in the branding repository. + +        Misconfigured events are skipped. May return an empty list in the catastrophic case. +        """ +        log.debug("Discovering events in branding repository.") + +        try: +            event_directories = await self.fetch_directory("events", types=("dir",))  # Skip files. +        except Exception: +            log.exception("Failed to fetch 'events' directory.") +            return [] + +        instances: t.List[Event] = [] + +        for event_directory in event_directories.values(): +            log.trace(f"Attempting to construct event from directory: '{event_directory.path}'.") +            try: +                instance = await self.construct_event(event_directory) +            except Exception as exc: +                log.warning(f"Could not construct event '{event_directory.path}'.", exc_info=exc) +            else: +                instances.append(instance) + +        return instances + +    async def get_current_event(self) -> t.Tuple[t.Optional[Event], t.List[Event]]: +        """ +        Get the currently active event, or the fallback event. + +        The second return value is a list of all available events. The caller may discard it, if not needed. +        Returning all events alongside the current one prevents having to query the API twice in some cases. + +        The current event may be None in the case that no event is active, and no fallback event is found. +        """ +        utc_now = datetime.utcnow() +        log.debug(f"Finding active event for: {utc_now}.") + +        # Construct an object in the arbitrary year for the purpose of comparison. +        lookup_now = date(year=ARBITRARY_YEAR, month=utc_now.month, day=utc_now.day) +        log.trace(f"Lookup object in arbitrary year: {lookup_now}.") + +        available_events = await self.get_events() +        log.trace(f"Found {len(available_events)} available events.") + +        for event in available_events: +            meta = event.meta +            if not meta.is_fallback and (meta.start_date <= lookup_now <= meta.end_date): +                return event, available_events + +        log.trace("No active event found. Looking for fallback event.") + +        for event in available_events: +            if event.meta.is_fallback: +                return event, available_events + +        log.warning("No event is currently active and no fallback event was found!") +        return None, available_events diff --git a/bot/exts/backend/branding/_seasons.py b/bot/exts/backend/branding/_seasons.py deleted file mode 100644 index 5f6256b30..000000000 --- a/bot/exts/backend/branding/_seasons.py +++ /dev/null @@ -1,175 +0,0 @@ -import logging -import typing as t -from datetime import datetime - -from bot.constants import Colours -from bot.exts.backend.branding._constants import Month -from bot.exts.backend.branding._errors import BrandingError - -log = logging.getLogger(__name__) - - -class SeasonBase: -    """ -    Base for Seasonal classes. - -    This serves as the off-season fallback for when no specific -    seasons are active. - -    Seasons are 'registered' simply by inheriting from `SeasonBase`. -    We discover them by calling `__subclasses__`. -    """ - -    season_name: str = "Evergreen" - -    colour: str = Colours.soft_green -    description: str = "The default season!" - -    branding_path: str = "seasonal/evergreen" - -    months: t.Set[Month] = set(Month) - - -class Christmas(SeasonBase): -    """Branding for December.""" - -    season_name = "Festive season" - -    colour = Colours.soft_red -    description = ( -        "The time is here to get into the festive spirit! No matter who you are, where you are, " -        "or what beliefs you may follow, we hope every one of you enjoy this festive season!" -    ) - -    branding_path = "seasonal/christmas" - -    months = {Month.DECEMBER} - - -class Easter(SeasonBase): -    """Branding for April.""" - -    season_name = "Easter" - -    colour = Colours.bright_green -    description = ( -        "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " -        "our version of Easter during the entire month of April." -    ) - -    branding_path = "seasonal/easter" - -    months = {Month.APRIL} - - -class Halloween(SeasonBase): -    """Branding for October.""" - -    season_name = "Halloween" - -    colour = Colours.orange -    description = "Trick or treat?!" - -    branding_path = "seasonal/halloween" - -    months = {Month.OCTOBER} - - -class Pride(SeasonBase): -    """Branding for June.""" - -    season_name = "Pride" - -    colour = Colours.pink -    description = ( -        "The month of June is a special month for us at Python Discord. It is very important to us " -        "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " -        "month of June, while some of you are participating in Pride festivals across the world, " -        "we will be celebrating individuality and commemorating the history and challenges " -        "of the LGBTQ+ community with a Pride event of our own!" -    ) - -    branding_path = "seasonal/pride" - -    months = {Month.JUNE} - - -class Valentines(SeasonBase): -    """Branding for February.""" - -    season_name = "Valentines" - -    colour = Colours.pink -    description = "Love is in the air!" - -    branding_path = "seasonal/valentines" - -    months = {Month.FEBRUARY} - - -class Wildcard(SeasonBase): -    """Branding for August.""" - -    season_name = "Wildcard" - -    colour = Colours.purple -    description = "A season full of surprises!" - -    months = {Month.AUGUST} - - -def get_all_seasons() -> t.List[t.Type[SeasonBase]]: -    """Give all available season classes.""" -    return [SeasonBase] + SeasonBase.__subclasses__() - - -def get_current_season() -> t.Type[SeasonBase]: -    """Give active season, based on current UTC month.""" -    current_month = Month(datetime.utcnow().month) - -    active_seasons = tuple( -        season -        for season in SeasonBase.__subclasses__() -        if current_month in season.months -    ) - -    if not active_seasons: -        return SeasonBase - -    return active_seasons[0] - - -def get_season(name: str) -> t.Optional[t.Type[SeasonBase]]: -    """ -    Give season such that its class name or its `season_name` attr match `name` (caseless). - -    If no such season exists, return None. -    """ -    name = name.casefold() - -    for season in get_all_seasons(): -        matches = (season.__name__.casefold(), season.season_name.casefold()) - -        if name in matches: -            return season - - -def _validate_season_overlap() -> None: -    """ -    Raise BrandingError if there are any colliding seasons. - -    This serves as a local test to ensure that seasons haven't been misconfigured. -    """ -    month_to_season = {} - -    for season in SeasonBase.__subclasses__(): -        for month in season.months: -            colliding_season = month_to_season.get(month) - -            if colliding_season: -                raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}") -            else: -                month_to_season[month] = season - - -_validate_season_overlap() diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 9cb54cdab..76ab7dfc2 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,7 +1,6 @@  import contextlib  import difflib  import logging -import random  import typing as t  from discord import Embed @@ -10,10 +9,9 @@ from sentry_sdk import push_scope  from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.constants import Colours, ERROR_REPLIES, Icons, MODERATION_ROLES +from bot.constants import Colours, Icons, MODERATION_ROLES  from bot.converters import TagNameConverter  from bot.errors import InvalidInfractedUser, LockedResourceError -from bot.exts.backend.branding._errors import BrandingError  from bot.utils.checks import InWhitelistCheckFailure  log = logging.getLogger(__name__) @@ -79,9 +77,6 @@ class ErrorHandler(Cog):                  await self.handle_api_error(ctx, e.original)              elif isinstance(e.original, LockedResourceError):                  await ctx.send(f"{e.original} Please wait for it to finish and try again later.") -            elif isinstance(e.original, BrandingError): -                await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original))) -                return              elif isinstance(e.original, InvalidInfractedUser):                  await ctx.send(f"Cannot infract that user. {e.original.reason}")              else:  |