aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock508
-rw-r--r--bot/decorators.py23
-rw-r--r--bot/errors.py6
-rw-r--r--bot/exts/backend/branding/__init__.py6
-rw-r--r--bot/exts/backend/branding/_cog.py887
-rw-r--r--bot/exts/backend/branding/_constants.py51
-rw-r--r--bot/exts/backend/branding/_decorators.py27
-rw-r--r--bot/exts/backend/branding/_errors.py2
-rw-r--r--bot/exts/backend/branding/_repository.py236
-rw-r--r--bot/exts/backend/branding/_seasons.py175
-rw-r--r--bot/exts/backend/error_handler.py7
12 files changed, 1048 insertions, 881 deletions
diff --git a/Pipfile b/Pipfile
index 0a94fb888..86add29cb 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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: