.
diff --git a/Pipfile b/Pipfile
deleted file mode 100644
index 86c66b8..0000000
--- a/Pipfile
+++ /dev/null
@@ -1,24 +0,0 @@
-[[source]]
-url = "https://pypi.org/simple"
-verify_ssl = true
-name = "pypi"
-
-[packages]
-colorama = "*"
-rsa = "*"
-rich = "*"
-
-[dev-packages]
-flake8 = "*"
-autopep8 = "*"
-pre-commit = "*"
-ipython = "*"
-flake8-annotations = "*"
-flake8-bugbear = "*"
-flake8-import-order = "*"
-
-[scripts]
-server = "python -m app.server"
-client = "python -m app.client"
-precommit = "pre-commit install"
-lint = "pre-commit run --all-files"
diff --git a/Pipfile.lock b/Pipfile.lock
deleted file mode 100644
index c035509..0000000
--- a/Pipfile.lock
+++ /dev/null
@@ -1,365 +0,0 @@
-{
- "_meta": {
- "hash": {
- "sha256": "7f04c1487a4059230ca115cf467b2deff78e4d237e3378c1526b3a3ae6d7d591"
- },
- "pipfile-spec": 6,
- "requires": {},
- "sources": [
- {
- "name": "pypi",
- "url": "https://pypi.org/simple",
- "verify_ssl": true
- }
- ]
- },
- "default": {
- "colorama": {
- "hashes": [
- "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
- "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
- ],
- "index": "pypi",
- "version": "==0.4.4"
- },
- "commonmark": {
- "hashes": [
- "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60",
- "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"
- ],
- "version": "==0.9.1"
- },
- "pyasn1": {
- "hashes": [
- "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
- "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
- "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
- "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
- "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
- "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
- "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
- "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
- "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
- "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
- "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
- "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
- "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
- ],
- "version": "==0.4.8"
- },
- "pygments": {
- "hashes": [
- "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
- "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
- ],
- "markers": "python_version >= '3.5'",
- "version": "==2.10.0"
- },
- "rich": {
- "hashes": [
- "sha256:4949e73de321784ef6664ebbc854ac82b20ff60b2865097b93f3b9b41e30da27",
- "sha256:bbe04dd6ac09e4b00d22cb1051aa127beaf6e16c3d8687b026e96d3fca6aad52"
- ],
- "index": "pypi",
- "version": "==10.16.1"
- },
- "rsa": {
- "hashes": [
- "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17",
- "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb"
- ],
- "index": "pypi",
- "version": "==4.8"
- }
- },
- "develop": {
- "attrs": {
- "hashes": [
- "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4",
- "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"
- ],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
- "version": "==21.4.0"
- },
- "autopep8": {
- "hashes": [
- "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979",
- "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f"
- ],
- "index": "pypi",
- "version": "==1.6.0"
- },
- "backcall": {
- "hashes": [
- "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e",
- "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"
- ],
- "version": "==0.2.0"
- },
- "cfgv": {
- "hashes": [
- "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426",
- "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"
- ],
- "markers": "python_full_version >= '3.6.1'",
- "version": "==3.3.1"
- },
- "decorator": {
- "hashes": [
- "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374",
- "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7"
- ],
- "markers": "python_version >= '3.5'",
- "version": "==5.1.0"
- },
- "distlib": {
- "hashes": [
- "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b",
- "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"
- ],
- "version": "==0.3.4"
- },
- "filelock": {
- "hashes": [
- "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80",
- "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"
- ],
- "markers": "python_version >= '3.7'",
- "version": "==3.4.2"
- },
- "flake8": {
- "hashes": [
- "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d",
- "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"
- ],
- "index": "pypi",
- "version": "==4.0.1"
- },
- "flake8-annotations": {
- "hashes": [
- "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e",
- "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"
- ],
- "index": "pypi",
- "version": "==2.7.0"
- },
- "flake8-bugbear": {
- "hashes": [
- "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82",
- "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"
- ],
- "index": "pypi",
- "version": "==21.11.29"
- },
- "flake8-import-order": {
- "hashes": [
- "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543",
- "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"
- ],
- "index": "pypi",
- "version": "==0.18.1"
- },
- "identify": {
- "hashes": [
- "sha256:0192893ff68b03d37fed553e261d4a22f94ea974093aefb33b29df2ff35fed3c",
- "sha256:64d4885e539f505dd8ffb5e93c142a1db45480452b1594cacd3e91dca9a984e9"
- ],
- "markers": "python_full_version >= '3.6.1'",
- "version": "==2.4.1"
- },
- "ipython": {
- "hashes": [
- "sha256:cb6aef731bf708a7727ab6cde8df87f0281b1427d41e65d62d4b68934fa54e97",
- "sha256:fc60ef843e0863dd4e24ab2bb5698f071031332801ecf8d1aeb4fb622056545c"
- ],
- "index": "pypi",
- "version": "==7.30.1"
- },
- "jedi": {
- "hashes": [
- "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d",
- "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"
- ],
- "markers": "python_version >= '3.6'",
- "version": "==0.18.1"
- },
- "matplotlib-inline": {
- "hashes": [
- "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee",
- "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"
- ],
- "markers": "python_version >= '3.5'",
- "version": "==0.1.3"
- },
- "mccabe": {
- "hashes": [
- "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
- "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
- ],
- "version": "==0.6.1"
- },
- "nodeenv": {
- "hashes": [
- "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b",
- "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"
- ],
- "version": "==1.6.0"
- },
- "parso": {
- "hashes": [
- "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0",
- "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"
- ],
- "markers": "python_version >= '3.6'",
- "version": "==0.8.3"
- },
- "pexpect": {
- "hashes": [
- "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
- "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
- ],
- "markers": "sys_platform != 'win32'",
- "version": "==4.8.0"
- },
- "pickleshare": {
- "hashes": [
- "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca",
- "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"
- ],
- "version": "==0.7.5"
- },
- "platformdirs": {
- "hashes": [
- "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca",
- "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"
- ],
- "markers": "python_version >= '3.7'",
- "version": "==2.4.1"
- },
- "pre-commit": {
- "hashes": [
- "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e",
- "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"
- ],
- "index": "pypi",
- "version": "==2.16.0"
- },
- "prompt-toolkit": {
- "hashes": [
- "sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6",
- "sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506"
- ],
- "markers": "python_full_version >= '3.6.2'",
- "version": "==3.0.24"
- },
- "ptyprocess": {
- "hashes": [
- "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35",
- "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"
- ],
- "version": "==0.7.0"
- },
- "pycodestyle": {
- "hashes": [
- "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20",
- "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"
- ],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
- "version": "==2.8.0"
- },
- "pyflakes": {
- "hashes": [
- "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c",
- "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"
- ],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==2.4.0"
- },
- "pygments": {
- "hashes": [
- "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
- "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
- ],
- "markers": "python_version >= '3.5'",
- "version": "==2.10.0"
- },
- "pyyaml": {
- "hashes": [
- "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
- "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
- "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
- "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
- "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
- "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
- "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
- "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
- "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
- "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
- "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
- "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
- "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
- "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
- "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
- "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
- "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
- "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
- "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
- "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
- "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
- "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
- "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
- "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
- "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
- "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
- "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
- "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
- "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
- "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
- "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
- "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
- "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
- ],
- "markers": "python_version >= '3.6'",
- "version": "==6.0"
- },
- "six": {
- "hashes": [
- "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
- "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
- ],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.16.0"
- },
- "toml": {
- "hashes": [
- "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"
- },
- "traitlets": {
- "hashes": [
- "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7",
- "sha256:2d313cc50a42cd6c277e7d7dc8d4d7fedd06a2c215f78766ae7b1a66277e0033"
- ],
- "markers": "python_version >= '3.7'",
- "version": "==5.1.1"
- },
- "virtualenv": {
- "hashes": [
- "sha256:7f9e9c2e878d92a434e760058780b8d67a7c5ec016a66784fe4b0d5e50a4eb5c",
- "sha256:efd556cec612fd826dc7ef8ce26a6e4ba2395f494244919acd135fb5ceffa809"
- ],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
- "version": "==20.11.2"
- },
- "wcwidth": {
- "hashes": [
- "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
- "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
- ],
- "version": "==0.2.5"
- }
- }
-}
diff --git a/README.md b/README.md
index be138a2..a7cf577 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,28 @@
-# ZeroCom 🚀
+ZeroCOM 🚀
-[![Twitter: janaSunrise](https://img.shields.io/twitter/follow/janaSunrise.svg?style=social)](https://twitter.com/janaSunrise)
+Powerful chat application, built using Python. ✨
-A secure and advanced chat application in Python.
+
+
+
+
+
-## Installation
+
+
+
+
+
+
+
+
+## 🚀 Installation
+
+**Python 3.7 or above is required!**
The project uses pipenv for dependencies. Here's how to install the dependencies:
@@ -14,12 +32,11 @@ pipenv sync -d
## Usage
-The server uses a configuration file (`config.ini`) located in the root of the project to
-run it. It is configured to run in `127.0.0.1:5700` TCP port by default. You can change things
-as you need and configure according to you.
+The server uses a configuration file (`config.ini`) located in the root of the project to run it.
+It is configured to run in `127.0.0.1:5700` TCP port by default. You can change things as you need
+and configure according to you.
-To connect to the ZeroCom server, It is essential to have a ZeroCom client to establish
-connection and use it.
+To connect to the ZeroCom server, It is essential to have a ZeroCom client to establish connection and use it.
#### Running the server
@@ -73,16 +90,10 @@ And you can also use nested tags together as following, `[blue]Blueeee [bold]bol
And finally, You can use emojis easily! Here's a example: `Star emoji - :star:`, and `:star:`
gets converted into ⭐
-## Future plans, Bugs and Issues
-
-For the future plans and more, check out the project board: https://github.com/janaSunrise/ZeroCOM/projects.
-
-To check the bugs and issues currently in the code, check here: https://github.com/janaSunrise/ZeroCOM/issues.
-
## 🤝 Contributing
-Contributions, issues and feature requests are welcome. After cloning & setting up project locally, you can just submit
-a PR to this repo and it will be deployed once it's accepted.
+Contributions, issues and feature requests are welcome. After cloning & setting up project locally, you can
+just submit a PR to this repo and it will be deployed once it's accepted.
⚠️ It’s good to have descriptive commit messages, or PR titles so that other contributors can understand about your
commit or the PR Created. Read [conventional commits](https://www.conventionalcommits.org/en/v1.0.0-beta.3/) before
@@ -93,7 +104,7 @@ making the commit message.
If you have various suggestions, questions or want to discuss things with our community, Have a look at
[Github discussions](https://github.com/janaSunrise/ZeroCom/discussions)!
-## Show your support
+## 👇 Show your support
We love people's support in growing and improving. Be sure to leave a ⭐️ if you like the project and
also be sure to contribute, if you're interested!
diff --git a/app/__main__.py b/app/__main__.py
deleted file mode 100644
index 633724f..0000000
--- a/app/__main__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-if __name__ == "__main__":
- print("This module is not designed to run on it's own.")
- print("Please run the server or client whichever is needed.")
diff --git a/app/client.py b/app/client.py
deleted file mode 100644
index f3c9b25..0000000
--- a/app/client.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import errno
-import select
-import sys
-import time
-
-from .models.client import Client
-from .utils.contextmanagers import Timer
-
-if __name__ == "__main__":
- if len(sys.argv) != 5:
- print("Usage: python -m client ")
- sys.exit(1)
-
- _, SERVER_IP, PORT, USERNAME, PASSWORD = sys.argv
- PORT = int(PORT)
-
- # Initialize the client object
- client = Client((SERVER_IP, PORT), USERNAME)
-
- # Connect and initialize
- client.connect()
- client.initialize()
-
- # Print the initial message logging.
- client.logger.flash("Welcome to the chat. CTRL+C to disconnect. Happy chatting!\n")
- client.logger.message("ME", "", end="")
-
- while True:
- SOCKETS = [sys.stdin, client.socket]
-
- try:
- ready_to_read, ready_to_write, in_error = select.select(SOCKETS, [], [])
- except KeyboardInterrupt:
- print()
- client.logger.info("Disconnecting, hold on.")
-
- with Timer(lambda x: client.logger.success(f"Disconnected successfully in {x}ms.")):
- client.disconnect()
-
- sys.exit(0)
-
- for run_sock in ready_to_read:
- if run_sock == client.socket:
- try:
- username, message = client.receive_message()
-
- print()
- client.logger.message(username, message)
- client.logger.message("ME", "", end="")
-
- sys.stdout.flush()
- except IOError as e:
- if e.errno != errno.EAGAIN and e.errno != errno.EWOULDBLOCK:
- client.logger.error(f"Error occured while reading: {str(e)}")
- sys.exit()
-
- continue
- else:
- message = sys.stdin.readline()
- client.logger.message("ME", "", end="")
- sys.stdout.flush()
-
- client.send_message(message)
diff --git a/app/config.py b/app/config.py
deleted file mode 100644
index 00dc8f7..0000000
--- a/app/config.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from textwrap import dedent
-
-from .utils import config_parser, get_bright_color
-
-# Constants.
-BANNER = dedent(f"""{get_bright_color("CYAN")}
- ____ _____
-/_ / ___ _______ / ___/__ __ _
- / /_/ -_) __/ _ \\/ /__/ _ \\/ ' \\
-/___/\\__/_/ \\___/\\___/\\___/_/_/_/
-""")
-
-# Server related config.
-IP = config_parser("server", "IP")
-PORT = config_parser("server", "port", cast=int)
-HEADER_LENGTH = config_parser("server", "HEADER_LEN", cast=int)
-MOTD = config_parser("server", "MOTD")
-
-# Authentication config.
-PASSWORD = config_parser("auth", "PASSWORD")
-
-# Max connections.
-MAX_CONNECTIONS = config_parser("server", "MAX_CONNECTIONS")
-MAX_CONNECTIONS = None if MAX_CONNECTIONS == "" else MAX_CONNECTIONS
diff --git a/app/constants.py b/app/constants.py
deleted file mode 100644
index 119ea0f..0000000
--- a/app/constants.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# Version
-VERSION = "1.0.0"
-
-# Config file
-CONFIG_FILE = "config.ini"
diff --git a/app/encryption/rsa.py b/app/encryption/rsa.py
deleted file mode 100644
index dde5d10..0000000
--- a/app/encryption/rsa.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import rsa
-from rsa.key import AbstractKey, PrivateKey, PublicKey
-
-
-class RSA:
- @classmethod
- def generate_keys(cls, size: int = 512) -> tuple:
- return rsa.newkeys(size)
-
- @classmethod
- def export_key_pkcs1(cls, public_key: PublicKey, format: str = "PEM") -> bytes:
- return PublicKey.save_pkcs1(public_key, format=format)
-
- @classmethod
- def load_key_pkcs1(cls, public_key_pem: bytes) -> AbstractKey:
- return PublicKey.load_pkcs1(public_key_pem)
-
- @classmethod
- def sign_message(cls, message: bytes, private_key: PrivateKey, algorithm: str = "SHA-1") -> bytes:
- return rsa.sign(message, private_key, algorithm)
diff --git a/app/mixins/logging.py b/app/mixins/logging.py
deleted file mode 100644
index 3c9dac2..0000000
--- a/app/mixins/logging.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from ..utils import Logger
-
-
-class LoggingMixin:
- @property
- def logger(self) -> Logger:
- try:
- return self._log
- except AttributeError:
- self._log = Logger()
- return self._log
diff --git a/app/models/client.py b/app/models/client.py
deleted file mode 100644
index 6c0ea95..0000000
--- a/app/models/client.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import socket
-import sys
-import time
-import typing as t
-
-from ..config import HEADER_LENGTH
-from ..encryption.rsa import RSA
-from ..mixins.logging import LoggingMixin
-from ..utils import on_startup
-
-
-class Client(LoggingMixin):
- __slots__ = (
- "host",
- "port",
- "username",
- "socket",
- "start_timer",
- "startup_duration",
- "PRIVATE_KEY",
- "PUBLIC_KEY",
- "motd"
- )
-
- def __init__(self, address: tuple, username: str) -> None:
- self.host, self.port = address
- self.username = username
-
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-
- self.start_timer = time.perf_counter()
- self.startup_duration = None
-
- self.PUBLIC_KEY, self.PRIVATE_KEY = RSA.generate_keys(512)
-
- self.motd = None
-
- @staticmethod
- def get_header(message: bytes) -> bytes:
- return f"{len(message):<{HEADER_LENGTH}}".encode()
-
- def connect(self) -> None:
- try:
- self.socket.connect((self.host, self.port))
- except ConnectionRefusedError:
- on_startup("Client")
-
- self.logger.error("Connection could not be established. Invalid HOST/PORT.")
- sys.exit(1)
-
- def disconnect(self) -> None:
- self.socket.close()
-
- def display_connected_banner(self) -> None:
- end = time.perf_counter()
- self.startup_duration = round((end - self.start_timer) * 1000, 2)
-
- on_startup("Client", self.startup_duration, motd=self.motd)
-
- self.logger.success(f"Connected to remote host at [{self.host}:{self.port}]")
-
- def initialize(self) -> None:
- # Send the specified uname.
- uname = self.username.encode()
- uname_header = self.get_header(uname)
-
- # Key auth
- exported_public_key = RSA.export_key_pkcs1(self.PUBLIC_KEY, "PEM")
- public_key_header = self.get_header(exported_public_key)
-
- # Send the message
- self.socket.send(uname_header + uname)
- self.socket.send(public_key_header + exported_public_key)
-
- # Receive the MOTD
- motd_len = int(self.socket.recv(HEADER_LENGTH).decode().strip())
- self.motd = self.socket.recv(motd_len).decode().strip()
-
- # Set blocking to false.
- self.socket.setblocking(False)
-
- # Display banner
- self.display_connected_banner()
-
- def receive_message(self) -> tuple:
- username_header = self.socket.recv(HEADER_LENGTH)
-
- if not len(username_header):
- self.logger.error("Server has closed the connection.")
- sys.exit(1)
-
- username_len = int(username_header.decode().strip())
- username = self.socket.recv(username_len).decode()
-
- msg_length = int(self.socket.recv(HEADER_LENGTH).decode().strip())
- msg = self.socket.recv(msg_length).decode()
-
- return username, msg
-
- def send_message(self, message: t.Optional[str] = None) -> None:
- if message:
- message_bytes = message.replace("\n", "").encode()
- message_header = self.get_header(message_bytes)
-
- # Key auth
- key_sign = RSA.sign_message(message_bytes, self.PRIVATE_KEY)
- key_sign_header = self.get_header(key_sign)
-
- self.socket.send(key_sign_header + key_sign)
- self.socket.send(message_header + message_bytes)
diff --git a/app/models/message.py b/app/models/message.py
deleted file mode 100644
index 7344892..0000000
--- a/app/models/message.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import typing as t
-
-
-class Message:
- def __init__(self, header: t.Optional[bytes], data: bytes) -> None:
- self.header = header
- self.data = data
-
- def __str__(self) -> str:
- return self.data.decode()
diff --git a/app/models/server.py b/app/models/server.py
deleted file mode 100644
index a88f8c2..0000000
--- a/app/models/server.py
+++ /dev/null
@@ -1,185 +0,0 @@
-import os
-import socket
-import sys
-import time
-import typing as t
-
-import rsa
-
-from .message import Message
-from .server_side_client import Client
-from ..config import HEADER_LENGTH, MOTD
-from ..mixins.logging import LoggingMixin
-from ..utils import get_color, on_startup
-
-
-class Server(LoggingMixin):
- __slots__ = (
- "sockets_list",
- "clients",
- "host",
- "port",
- "socket",
- "start_timer",
- "startup_duration",
- "backlog",
- "motd"
- )
-
- def __init__(self, address: tuple, backlog: t.Optional[int] = None) -> None:
- # List of sockets and clients
- self.sockets_list = []
- self.clients = {}
-
- # Address to run the server on
- self.host, self.port = address
-
- # Initialize the main sockets.
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- if os.name == "posix":
- # REUSE_ADDR works differently on windows
- self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-
- # Initialize startup timer and calculate duration
- self.start_timer = time.perf_counter()
- self.startup_duration = None
-
- # Get the backlog (Max number of connections at a time)
- self.backlog = backlog
-
- # MOTD of the server
- self.motd = MOTD
-
- def connect(self) -> None:
- try:
- self.socket.bind((self.host, self.port))
- except OSError as exc:
- self.socket.close()
-
- on_startup("Server")
- self.logger.error(f"Server could not be initialized. Error: {exc}")
-
- sys.exit(1)
- else:
- end = time.perf_counter()
- duration = round((end - self.start_timer) * 1000, 2)
-
- on_startup("Server", duration, self.host, self.port)
-
- # Listening backlog
- if not self.backlog:
- self.socket.listen()
- else:
- self.socket.listen(int(self.backlog))
-
- # Set socket to non-blocking
- self.socket.setblocking(False)
-
- # Add socket to the list of sockets.
- self.sockets_list.append(self.socket)
-
- self.logger.success("Server started. Listening for connections.")
-
- def disconnect(self) -> None:
- for current_socket in self.sockets_list:
- current_socket.close()
-
- def remove_specified_socket(self, sock: socket.socket) -> None:
- self.sockets_list.remove(sock)
- del self.clients[sock]
-
- def remove_errored_sockets(self, errored_sockets: list) -> None:
- for current_socket in errored_sockets:
- client = self.clients[current_socket]
- self.logger.warning(
- f"{get_color('YELLOW')}Exception occurred. Location: {client.username} [{client.address}]"
- )
-
- self.remove_specified_socket(current_socket)
-
- def receive_message(self, client_socket: socket.socket) -> t.Optional[Message]:
- try:
- message_header = client_socket.recv(HEADER_LENGTH)
- if not len(message_header):
- return
-
- message_length = int(message_header.decode().strip())
-
- return Message(message_header, client_socket.recv(message_length))
- except Exception as exc:
- self.logger.error(f"Exception occurred: {exc}")
-
- def process_connection(self) -> None:
- client_socket, address = self.socket.accept()
-
- username = self.receive_message(client_socket)
- pub_key = self.receive_message(client_socket)
-
- client = Client(client_socket, address, username, pub_key)
-
- if not username:
- self.logger.error(f"New connection failed from {client.address}.")
- return
-
- if not pub_key:
- self.logger.error(f"New connection failed from {client.address}. No key auth found. {pub_key}")
- return
-
- self.sockets_list.append(client_socket)
- self.clients[client_socket] = client
-
- # Log successful connection
- self.logger.success(
- f"{get_color('GREEN')}Accepted new connection requested by {client.username} [{client.address}]."
- )
-
- # Send the data to Client
- motd = self.motd.encode()
- motd_header = client.get_header(motd)
-
- # Sent the MOTD
- client.socket.send(motd_header + motd)
-
- def broadcast_message(self, sock: socket.socket, client: Client, message: Message) -> None:
- for client_socket in self.clients:
- if client_socket != sock:
- sender_information = client.username_header + client.raw_username
- message_to_send = message.header + message.data
-
- client_socket.send(sender_information + message_to_send)
-
- def process_message(self, client_socket: socket.socket) -> None:
- # Receive Signature and message
- sign = self.receive_message(client_socket)
- message = self.receive_message(client_socket)
-
- # If disconnected
- if not message or not sign:
- client = self.clients[client_socket]
-
- self.logger.error(f"Connection closed [{client.username}@{client.address}]")
- self.remove_specified_socket(client_socket)
-
- return
-
- # Get the client
- client = self.clients[client_socket]
-
- # Verify key
- try:
- if rsa.verify(message.data, sign.data, client.pub_key):
- msg = message.data.decode()
-
- self.logger.message(client.username, msg)
- self.broadcast_message(client_socket, client, message)
- except rsa.VerificationError:
- self.logger.warning(
- f"Received incorrect verification from {client.address} [{client.username}] | "
- f"message:{message.data.decode()})"
- )
-
- warning = Message(None, "Messaging failed from user due to incorrect verification.".encode())
- warning.header = client.get_header(warning.data)
-
- self.broadcast_message(client_socket, client, warning)
- return
diff --git a/app/models/server_side_client.py b/app/models/server_side_client.py
deleted file mode 100644
index 01153bd..0000000
--- a/app/models/server_side_client.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import socket
-import typing as t
-
-from .message import Message
-from ..config import HEADER_LENGTH
-from ..encryption.rsa import RSA
-
-
-class Client:
- __slots__ = (
- "socket",
- "ip",
- "port",
- "address",
- "username_header",
- "raw_username",
- "username",
- "pub_key_header",
- "pub_key_pem",
- "pub_key",
- )
-
- def __init__(
- self,
- client_socket: socket.socket,
- address: t.Union[list, tuple],
- username: Message,
- pub_key: Message
- ) -> None:
- self.socket = client_socket
-
- self.ip, self.port = address
- self.address = f"{address[0]}:{address[1]}"
-
- self.username_header = username.header
- self.raw_username = username.data
- self.username = self.raw_username.decode()
-
- if pub_key:
- self.pub_key_header = pub_key.header
- self.pub_key_pem = pub_key.data
-
- self.pub_key = RSA.load_key_pkcs1(self.pub_key_pem)
-
- @staticmethod
- def get_header(message: str) -> bytes:
- return f"{len(message):<{HEADER_LENGTH}}".encode()
diff --git a/app/server.py b/app/server.py
deleted file mode 100644
index 5747770..0000000
--- a/app/server.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import select
-import sys
-
-from .config import IP, MAX_CONNECTIONS, PORT
-from .models.server import Server
-from .utils.contextmanagers import Timer
-
-if __name__ == "__main__":
- # Initialize the socket
- server = Server((IP, PORT), MAX_CONNECTIONS)
-
- # Connect to the server
- server.connect()
-
- while True:
- try:
- ready_to_read, _, in_error = select.select(server.sockets_list, [], server.sockets_list)
- except KeyboardInterrupt:
- server.logger.info("Server stopping...")
-
- with Timer(lambda x: server.logger.success(f"Server stopped successfully in {x}ms.")):
- server.disconnect()
-
- sys.exit(0)
-
- for socket_ in ready_to_read:
- if socket_ == server.socket:
- server.process_connection()
- else:
- server.process_message(socket_)
-
- # Remove errored connections
- server.remove_errored_sockets(in_error)
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
deleted file mode 100644
index d750382..0000000
--- a/app/utils/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .colors import get_bright_color, get_color
-from .config_loader import config_parser
-from .console import clear_screen
-from .logger import Logger
-from .startup import on_startup
diff --git a/app/utils/colors.py b/app/utils/colors.py
deleted file mode 100644
index 329a10d..0000000
--- a/app/utils/colors.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import colorama
-
-colorama.init(autoreset=True)
-
-
-def get_color(color: str) -> str:
- return getattr(colorama.Fore, color.upper())
-
-
-def get_bright_color(color: str) -> str:
- return getattr(colorama.Style, "BRIGHT") + get_color(color) # noqa: B009
diff --git a/app/utils/config_loader.py b/app/utils/config_loader.py
deleted file mode 100644
index d2c8aed..0000000
--- a/app/utils/config_loader.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import typing as t
-from configparser import ConfigParser
-
-from ..constants import CONFIG_FILE
-
-# Define global parser
-parser = ConfigParser()
-
-# Load the config file.
-parser.read(CONFIG_FILE)
-
-# Utility function for string to boolean.
-TRUE_VALUES = {"y", "yes", "t", "true", "on", "1"}
-FALSE_VALUES = {"n", "no", "f", "false", "off", "0"}
-
-
-def strtobool(value: str) -> bool:
- value = value.lower()
-
- if value in TRUE_VALUES:
- return True
- elif value in FALSE_VALUES:
- return False
-
- raise ValueError(f"Invalid boolean value {value}")
-
-
-def config_parser(
- section: str,
- variable: str,
- cast: t.Type = str
-) -> t.Any:
- if cast is bool:
- cast = strtobool
-
- return cast(parser.get(section, variable))
diff --git a/app/utils/console.py b/app/utils/console.py
deleted file mode 100644
index 1180079..0000000
--- a/app/utils/console.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import os
-
-
-def clear_screen() -> None:
- if os.name == "nt":
- os.system("cls")
- else:
- os.system("clear")
diff --git a/app/utils/contextmanagers.py b/app/utils/contextmanagers.py
deleted file mode 100644
index 42007b1..0000000
--- a/app/utils/contextmanagers.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import time
-import typing as t
-from types import TracebackType
-
-
-class Timer:
- def __init__(self, display_func: t.Callable) -> None:
- self.display_func = display_func
- self.start_time = time.perf_counter()
-
- def __enter__(self) -> None:
- ...
-
- def __exit__(
- self,
- exc_type: t.Optional[TracebackType],
- exc_val: t.Optional[BaseException],
- exc_tb: t.Optional[TracebackType]
- ) -> None:
- end_time = time.perf_counter()
- duration = round((end_time - self.start_time) * 1000, 2)
-
- self.display_func(duration)
diff --git a/app/utils/logger.py b/app/utils/logger.py
deleted file mode 100644
index 26b5e28..0000000
--- a/app/utils/logger.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from datetime import datetime
-
-from colorama import Back
-from rich.console import Console
-
-from .colors import get_bright_color, get_color
-
-
-def get_log_color_mapping(color_key: str, symbol: str) -> str:
- color_map = log_color_mapping[color_key]
- return f"[{color_map}{symbol}{get_color('RESET')}]"
-
-
-# Color and log type mapping
-log_color_mapping = {
- "error": get_bright_color("RED"),
- "warning": get_bright_color("YELLOW"),
- "message": get_color("CYAN"),
- "success": get_bright_color("GREEN"),
- "info": get_bright_color("MAGENTA"),
- "critical": get_bright_color("RED") + Back.YELLOW,
- "flash": get_bright_color("BLUE"),
-}
-
-log_mapping = {
- "error": get_log_color_mapping("error", "%"),
- "warning": get_log_color_mapping("warning", "!"),
- "message": get_log_color_mapping("message", ">"),
- "success": get_log_color_mapping("success", "+"),
- "info": get_log_color_mapping("info", "#"),
- "critical": get_log_color_mapping("critical", "X"),
- "flash": get_log_color_mapping("flash", "-"),
-}
-
-
-class Logger:
- def __init__(self):
- self._console = Console()
-
- @staticmethod
- def _append_date(message: str) -> str:
- timestamp = datetime.now()
- timestamp = (
- f"{get_bright_color('CYAN')}"
- f"{timestamp.hour}:{timestamp.minute}:{timestamp.second}"
- f"{get_bright_color('RESET')}"
- )
-
- return f"[{timestamp}]{message}"
-
- def _print_log(self, log_type: str, message: str, date: bool = True) -> None:
- message_prefix = log_mapping[log_type]
- message = f"{message_prefix} {log_color_mapping[log_type]}{message}"
-
- if date:
- message = self._append_date(message)
-
- print(message)
-
- def error(self, message: str, date: bool = True) -> None:
- self._print_log("error", message, date)
-
- def warning(self, message: str, date: bool = True) -> None:
- self._print_log("warning", message, date)
-
- def success(self, message: str, date: bool = True) -> None:
- self._print_log("success", message, date)
-
- def info(self, message: str, date: bool = True) -> None:
- self._print_log("info", message, date)
-
- def critical(self, message: str, date: bool = True) -> None:
- self._print_log("critical", message, date)
-
- def flash(self, message: str, date: bool = True) -> None:
- self._print_log("flash", message, date)
-
- def message(self, username: str, user_message: str, date: bool = True, **kwargs) -> None:
- message_prefix = log_mapping["message"]
- message = f"{get_bright_color('YELLOW')} {username}{get_color('RESET')} {message_prefix} "
-
- if date:
- message = self._append_date(message)
-
- print(message, end="")
- self._console.print(user_message, **kwargs)
diff --git a/app/utils/startup.py b/app/utils/startup.py
deleted file mode 100644
index ecda8f0..0000000
--- a/app/utils/startup.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import typing as t
-from textwrap import dedent
-
-from .colors import get_bright_color
-from .console import clear_screen
-from ..constants import VERSION
-
-
-def on_startup(
- name: str,
- boot_duration: t.Optional[float] = None,
- ip: t.Optional[str] = None,
- port: t.Optional[str] = None,
- motd: t.Optional[str] = None
-) -> None:
- # Imports To prevent circular imports.
- from ..config import BANNER
-
- # Variables
- spaces_4 = " "
-
- # Generate initial message and add sections
- message = dedent(f"""{BANNER}
- {get_bright_color("GREEN")}ZeroCOM {name} Running. | {get_bright_color("YELLOW")}v{VERSION}
- """)
-
- if ip:
- msg = f"{spaces_4}{get_bright_color('CYAN')}Running on [IP] {ip}"
- if port:
- msg += f" | [PORT] {port}\n"
- else:
- msg += "\n"
-
- message += msg
-
- if boot_duration:
- message += f"{spaces_4}{get_bright_color('YELLOW')}TOOK {boot_duration}ms to start.\n"
-
- if motd:
- message += f"{spaces_4}{get_bright_color('CYAN')}MOTD: {motd}\n"
-
- # Clear and print screen
- clear_screen()
- print(message)
diff --git a/config.ini b/config.ini
deleted file mode 100644
index c493654..0000000
--- a/config.ini
+++ /dev/null
@@ -1,12 +0,0 @@
-[server]
-IP=127.0.0.1
-PORT=5700
-HEADER_LEN=4096
-
-MOTD=Welcome to Zerocom Chat!
-
-; Leave empty for system defined amount. Only integer allowed.
-MAX_CONNECTIONS=
-
-[auth]
-PASSWORD=12345678
diff --git a/config.toml b/config.toml
new file mode 100644
index 0000000..5539e9e
--- /dev/null
+++ b/config.toml
@@ -0,0 +1,11 @@
+[server.config]
+ip = "127.0.0.1"
+port = 5000
+
+motd = "Welcome to Zerocom Chat!"
+
+# Leave empty for system defined amount. Only integer allowed.
+max-connections = 0
+
+[server.auth]
+password = 12345678
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..3807978
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1008 @@
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "21.4.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
+
+[[package]]
+name = "autopep8"
+version = "1.6.0"
+description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycodestyle = ">=2.8.0"
+toml = "*"
+
+[[package]]
+name = "black"
+version = "22.3.0"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
+typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "cfgv"
+version = "3.3.1"
+description = "Validate configuration and produce human readable error messages."
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+
+[[package]]
+name = "click"
+version = "8.1.3"
+description = "Composable command line interface toolkit"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+description = "Colored terminal output for Python's logging module"
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+humanfriendly = ">=9.1"
+
+[package.extras]
+cron = ["capturer (>=2.4)"]
+
+[[package]]
+name = "commonmark"
+version = "0.9.1"
+description = "Python parser for the CommonMark Markdown spec"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+
+[[package]]
+name = "coverage"
+version = "6.4.1"
+description = "Code coverage measurement for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "distlib"
+version = "0.3.4"
+description = "Distribution utilities"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "filelock"
+version = "3.7.1"
+description = "A platform independent file lock."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
+testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
+
+[[package]]
+name = "flake8"
+version = "4.0.1"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""}
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
+
+[[package]]
+name = "flake8-annotations"
+version = "2.9.0"
+description = "Flake8 Type Annotation Checks"
+category = "dev"
+optional = false
+python-versions = ">=3.7,<4.0"
+
+[package.dependencies]
+attrs = ">=21.4,<22.0"
+flake8 = ">=3.7"
+typed-ast = {version = ">=1.4,<2.0", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "flake8-bugbear"
+version = "22.4.25"
+description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+attrs = ">=19.2.0"
+flake8 = ">=3.0.0"
+
+[package.extras]
+dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
+
+[[package]]
+name = "flake8-future-annotations"
+version = "0.0.5"
+description = "Verifies python 3.7+ files use from __future__ import annotations"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+flake8 = "*"
+
+[[package]]
+name = "flake8-tidy-imports"
+version = "4.8.0"
+description = "A flake8 plugin that helps you write tidier imports."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+flake8 = ">=3.8.0"
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+description = "Human friendly output for text interfaces using Python"
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+pyreadline = {version = "*", markers = "sys_platform == \"win32\" and python_version < \"3.8\""}
+pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
+
+[[package]]
+name = "identify"
+version = "2.5.1"
+description = "File identification library for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+license = ["ukkonen"]
+
+[[package]]
+name = "importlib-metadata"
+version = "4.2.0"
+description = "Read metadata from Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "isort"
+version = "5.10.1"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=3.6.1,<4.0"
+
+[package.extras]
+pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
+requirements_deprecated_finder = ["pipreqs", "pip-api"]
+colors = ["colorama (>=0.4.3,<0.5.0)"]
+plugins = ["setuptools"]
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mslex"
+version = "0.3.0"
+description = "shlex for windows"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "nodeenv"
+version = "1.6.0"
+description = "Node.js virtual environment builder"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "pathspec"
+version = "0.9.0"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[[package]]
+name = "pep8-naming"
+version = "0.13.0"
+description = "Check PEP-8 naming conventions, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+flake8 = ">=3.9.1"
+
+[[package]]
+name = "platformdirs"
+version = "2.5.2"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
+test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pre-commit"
+version = "2.19.0"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+toml = "*"
+virtualenv = ">=20.0.8"
+
+[[package]]
+name = "psutil"
+version = "5.9.1"
+description = "Cross-platform lib for process and system monitoring in Python."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pyasn1"
+version = "0.4.8"
+description = "ASN.1 types and codecs"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.8.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pyflakes"
+version = "2.4.0"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pygments"
+version = "2.12.0"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "pyparsing"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "dev"
+optional = false
+python-versions = ">=3.6.8"
+
+[package.extras]
+diagrams = ["railroad-diagrams", "jinja2"]
+
+[[package]]
+name = "pyreadline"
+version = "2.1"
+description = "A python implmementation of GNU readline."
+category = "main"
+optional = true
+python-versions = "*"
+
+[[package]]
+name = "pyreadline3"
+version = "3.4.1"
+description = "A python implementation of GNU readline."
+category = "main"
+optional = true
+python-versions = "*"
+
+[[package]]
+name = "pyright"
+version = "1.1.252"
+description = "Command line wrapper for pyright"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+nodeenv = ">=1.6.0"
+typing-extensions = {version = ">=3.7", markers = "python_version < \"3.8\""}
+
+[package.extras]
+all = ["twine (>=3.4.1)"]
+dev = ["twine (>=3.4.1)"]
+
+[[package]]
+name = "pytest"
+version = "7.1.2"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+py = ">=1.8.2"
+tomli = ">=1.0.0"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "3.0.0"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pyyaml"
+version = "6.0"
+description = "YAML parser and emitter for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "rich"
+version = "12.4.4"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
+optional = false
+python-versions = ">=3.6.3,<4.0.0"
+
+[package.dependencies]
+commonmark = ">=0.9.0,<0.10.0"
+pygments = ">=2.6.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
+
+[[package]]
+name = "rsa"
+version = "4.8"
+description = "Pure-Python RSA implementation"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4"
+
+[package.dependencies]
+pyasn1 = ">=0.1.3"
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "taskipy"
+version = "1.10.2"
+description = "tasks runner for python projects"
+category = "dev"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.dependencies]
+colorama = ">=0.4.4,<0.5.0"
+mslex = {version = ">=0.3.0,<0.4.0", markers = "sys_platform == \"win32\""}
+psutil = ">=5.7.2,<6.0.0"
+tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""}
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "main"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "typed-ast"
+version = "1.5.4"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "typing-extensions"
+version = "4.2.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "virtualenv"
+version = "20.14.1"
+description = "Virtual Python Environment builder"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.dependencies]
+distlib = ">=0.3.1,<1"
+filelock = ">=3.2,<4"
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+platformdirs = ">=2,<3"
+six = ">=1.9.0,<2"
+
+[package.extras]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
+
+[[package]]
+name = "zipp"
+version = "3.8.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = ">=3.7,<4"
+content-hash = "0ef4e3970209af881d3069458dcf8a134aca29daa7ebada08b6db3983d9d0a77"
+
+[metadata.files]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
+]
+autopep8 = [
+ {file = "autopep8-1.6.0-py2.py3-none-any.whl", hash = "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f"},
+ {file = "autopep8-1.6.0.tar.gz", hash = "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979"},
+]
+black = [
+ {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"},
+ {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"},
+ {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"},
+ {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"},
+ {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"},
+ {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"},
+ {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"},
+ {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"},
+ {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"},
+ {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"},
+ {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"},
+ {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"},
+ {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"},
+ {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"},
+ {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"},
+ {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"},
+ {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"},
+ {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"},
+ {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"},
+ {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"},
+ {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"},
+ {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"},
+ {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"},
+]
+cfgv = [
+ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
+ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
+]
+click = [
+ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+ {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+coloredlogs = [
+ {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
+ {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
+]
+commonmark = [
+ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
+ {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
+]
+coverage = [
+ {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"},
+ {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"},
+ {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"},
+ {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"},
+ {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"},
+ {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"},
+ {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"},
+ {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"},
+ {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"},
+ {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"},
+ {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"},
+ {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"},
+ {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"},
+ {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"},
+ {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"},
+ {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"},
+ {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"},
+ {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"},
+ {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"},
+ {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"},
+ {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"},
+ {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"},
+ {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"},
+ {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"},
+ {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"},
+ {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"},
+ {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"},
+ {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"},
+ {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"},
+ {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"},
+ {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"},
+ {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"},
+ {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"},
+ {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"},
+ {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"},
+ {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"},
+ {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"},
+ {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"},
+ {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"},
+ {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"},
+ {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"},
+]
+distlib = [
+ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
+ {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
+]
+filelock = [
+ {file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
+ {file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
+]
+flake8 = [
+ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+ {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
+]
+flake8-annotations = [
+ {file = "flake8-annotations-2.9.0.tar.gz", hash = "sha256:63fb3f538970b6a8dfd84125cf5af16f7b22e52d5032acb3b7eb23645ecbda9b"},
+ {file = "flake8_annotations-2.9.0-py3-none-any.whl", hash = "sha256:84f46de2964cb18fccea968d9eafce7cf857e34d913d515120795b9af6498d56"},
+]
+flake8-bugbear = [
+ {file = "flake8-bugbear-22.4.25.tar.gz", hash = "sha256:f7c080563fca75ee6b205d06b181ecba22b802babb96b0b084cc7743d6908a55"},
+ {file = "flake8_bugbear-22.4.25-py3-none-any.whl", hash = "sha256:ec374101cddf65bd7a96d393847d74e58d3b98669dbf9768344c39b6290e8bd6"},
+]
+flake8-future-annotations = [
+ {file = "flake8-future-annotations-0.0.5.tar.gz", hash = "sha256:da84011070c0d0f623a9c12bd738bea58bc65a3bf5eec20637020069310f8d84"},
+ {file = "flake8_future_annotations-0.0.5-py3-none-any.whl", hash = "sha256:28fc9ae9e5ece5c211b810d856d984f33c0290933f24ae570731a0751c4917c6"},
+]
+flake8-tidy-imports = [
+ {file = "flake8-tidy-imports-4.8.0.tar.gz", hash = "sha256:df44f9c841b5dfb3a7a1f0da8546b319d772c2a816a1afefcce43e167a593d83"},
+ {file = "flake8_tidy_imports-4.8.0-py3-none-any.whl", hash = "sha256:25bd9799358edefa0e010ce2c587b093c3aba942e96aeaa99b6d0500ae1bf09c"},
+]
+humanfriendly = [
+ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
+ {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
+]
+identify = [
+ {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
+ {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"},
+]
+importlib-metadata = [
+ {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"},
+ {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+isort = [
+ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
+ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
+]
+mccabe = [
+ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+mslex = [
+ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"},
+ {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"},
+]
+mypy-extensions = [
+ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+nodeenv = [
+ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
+ {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
+]
+packaging = [
+ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+pathspec = [
+ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
+ {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
+]
+pep8-naming = [
+ {file = "pep8-naming-0.13.0.tar.gz", hash = "sha256:9f38e6dcf867a1fb7ad47f5ff72c0ddae544a6cf64eb9f7600b7b3c0bb5980b5"},
+ {file = "pep8_naming-0.13.0-py3-none-any.whl", hash = "sha256:069ea20e97f073b3e6d4f789af2a57816f281ca64b86210c7d471117a4b6bfd0"},
+]
+platformdirs = [
+ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
+ {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
+]
+pluggy = [
+ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+pre-commit = [
+ {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"},
+ {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"},
+]
+psutil = [
+ {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"},
+ {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"},
+ {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"},
+ {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"},
+ {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"},
+ {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"},
+ {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"},
+ {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"},
+ {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"},
+ {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"},
+ {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"},
+ {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"},
+ {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"},
+ {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"},
+ {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"},
+ {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"},
+ {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"},
+ {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"},
+ {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"},
+ {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"},
+ {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"},
+ {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"},
+ {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"},
+ {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"},
+ {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"},
+ {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"},
+ {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"},
+ {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"},
+ {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"},
+ {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"},
+ {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"},
+ {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"},
+]
+py = [
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+pyasn1 = [
+ {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
+ {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
+ {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"},
+ {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"},
+ {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
+ {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"},
+ {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"},
+ {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"},
+ {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"},
+ {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"},
+ {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"},
+ {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"},
+ {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
+]
+pycodestyle = [
+ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+ {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
+]
+pyflakes = [
+ {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
+]
+pygments = [
+ {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"},
+ {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
+]
+pyparsing = [
+ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+ {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
+]
+pyreadline = [
+ {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"},
+ {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"},
+ {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"},
+]
+pyreadline3 = [
+ {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
+ {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
+]
+pyright = [
+ {file = "pyright-1.1.252-py3-none-any.whl", hash = "sha256:0f436ff34b32a9d67f81ffbe5087812ec84039bdd7ec82b471e54a9291b7eef5"},
+ {file = "pyright-1.1.252.tar.gz", hash = "sha256:df48c20fca2442f2f3016aa94943bc58221e14f071a23befd5248deff25726b5"},
+]
+pytest = [
+ {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
+ {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
+]
+pytest-cov = [
+ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+ {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
+]
+pyyaml = [
+ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+ {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+ {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+ {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+ {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+ {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+ {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+ {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+ {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+ {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+ {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+ {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+ {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+]
+rich = [
+ {file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"},
+ {file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"},
+]
+rsa = [
+ {file = "rsa-4.8-py3-none-any.whl", hash = "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb"},
+ {file = "rsa-4.8.tar.gz", hash = "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17"},
+]
+six = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+taskipy = [
+ {file = "taskipy-1.10.2-py3-none-any.whl", hash = "sha256:58d5382d90d5dd94ca8c612855377e5a98b9cb669c208ebb55d6a45946de3f9b"},
+ {file = "taskipy-1.10.2.tar.gz", hash = "sha256:eae4feb74909da3ad0ca0275802e1c2f56048612529bd763feb922d284d8a253"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tomli = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+typed-ast = [
+ {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
+ {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
+ {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
+ {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
+ {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
+ {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
+ {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
+ {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
+ {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
+ {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
+ {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
+ {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
+ {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
+ {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
+ {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
+ {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
+ {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
+ {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
+ {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
+ {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
+ {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
+ {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
+ {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
+ {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
+]
+typing-extensions = [
+ {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
+ {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
+]
+virtualenv = [
+ {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
+ {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"},
+]
+zipp = [
+ {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
+ {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a4a286d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,60 @@
+[tool.poetry]
+name = "ZeroCOM"
+version = "0.1.0"
+description = "Powerful chat application, built using Python."
+authors = ["Sunrit Jana ", "ItsDrike "]
+license = "GPL-3.0-or-later"
+
+[tool.poetry.dependencies]
+python = ">=3.7,<4"
+colorama = "^0.4.4"
+rsa = "^4.8"
+rich = "^12.4.4"
+toml = "^0.10.2"
+
+[tool.poetry.dev-dependencies]
+flake8 = "^4.0.1"
+flake8-annotations = "^2.9.0"
+flake8-bugbear = "^22.4.25"
+flake8-tidy-imports = "^4.8.0"
+flake8-future-annotations = "^0.0.5"
+pep8-naming = "^0.13.0"
+autopep8 = "^1.6.0"
+black = "^22.3.0"
+pre-commit = "^2.19.0"
+taskipy = "^1.10.2"
+isort = "^5.10.1"
+pyright = "^1.1.252"
+pytest = "^7.1.2"
+pytest-cov = "^3.0.0"
+
+[tool.black]
+line-length = 120
+extend-exclude = "^/.cache"
+
+[tool.isort]
+profile = "black"
+line_length = 120
+order_by_type = false
+case_sensitive = true
+skip = [".venv", ".git", ".cache"]
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+testpaths = ["tests"]
+addopts = "--strict-markers --cov=zerocom --cov-branch --cov-report=term-missing --cov-report html --no-cov-on-fail"
+
+
+[tool.taskipy.tasks]
+precommit = "pre-commit install"
+lint = "pre-commit run --all-files"
+format = "black . && isort ."
+test = "pytest -v --failed-first"
+retest = "pytest -v --last-failed"
+test-nocov = "pytest -v --no-cov --failed-first"
+server = "python -m zerocom.server"
+client = "python -m zerocom.client"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/app/__init__.py b/tests/__init__.py
similarity index 100%
rename from app/__init__.py
rename to tests/__init__.py
diff --git a/app/encryption/__init__.py b/tests/protocol/__init__.py
similarity index 100%
rename from app/encryption/__init__.py
rename to tests/protocol/__init__.py
diff --git a/tests/protocol/helpers.py b/tests/protocol/helpers.py
new file mode 100644
index 0000000..4a69c48
--- /dev/null
+++ b/tests/protocol/helpers.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+from typing import Optional
+from unittest.mock import Mock
+
+from zerocom.protocol.abc import BaseReader, BaseWriter
+
+
+class Reader(BaseReader):
+ """Testable concrete implementation of BaseReader ABC."""
+
+ def read(self, length: int) -> bytearray:
+ """Concrete implementation of abstract read method.
+
+ Since classes using abc.ABC can't be initialized if they have any abstract methods
+ which weren't overridden with a concrete implementation, this is a fake implementation,
+ without any actual logic, purely to allow the initialization of this class.
+
+ This method is expected to be mocked using ReadFunctionMock if it's expected to get called
+ during testing. If this method gets called without being mocked, it will raise NotImplementedError.
+ """
+ raise NotImplementedError(
+ "This concrete override of abstract read method isn't intended for actual use!\n"
+ " - If you're writing a new test, did you forget to mock it?\n"
+ " - If you're seeing this in an existing test, this method got called without the test expecting it,"
+ " this probably means you changed something in the code leading to this call, but you haven't updated"
+ " the tests to mock this function."
+ )
+
+
+class Writer(BaseWriter):
+ """Initializable concrete implementation of BaseWriter ABC."""
+
+ def write(self, data: bytearray) -> None:
+ """Concrete implementation of abstract write method.
+
+ Since classes using abc.ABC can't be initialized if they have any abstract methods
+ which weren't overridden with a concrete implementation, this is a fake implementation,
+ without any actual logic, purely to allow the initialization of this class.
+
+ This method is expected to be mocked using WriteFunctionMock if it's expected to get called
+ during testing. If this method gets called without being mocked, it will raise NotImplementedError.
+ """
+ raise NotImplementedError(
+ "This concrete override of abstract write method isn't intended for actual use!\n"
+ " - If you're writing a new test, did you forget to mock it?\n"
+ " - If you're seeing this in an existing test, this method got called without the test expecting it,"
+ " this probably means you changed something in the code leading to this call, but you haven't updated"
+ " the tests to mock this function."
+ )
+
+
+class ReadFunctionMock(Mock):
+ def __init__(self, *a, combined_data: Optional[bytearray] = None, **kw):
+ super().__init__(*a, **kw)
+ if combined_data is None:
+ combined_data = bytearray()
+ self.combined_data = combined_data
+
+ def __call__(self, length: int) -> bytearray:
+ """Override mock's __call__ to make it return part of our combined_data bytearray.
+
+ This allows us to define the combined data we want the mocked read function to be
+ returning, and have each call only take requested part (length) of that data.
+ """
+ self.return_value = self.combined_data[:length]
+ del self.combined_data[:length]
+ return super().__call__(length)
+
+ def assert_read_everything(self) -> None:
+ """Ensure that the passed combined_data was fully read and depleted by one, or more calls."""
+ if len(self.combined_data) != 0:
+ raise AssertionError(
+ f"Read function didn't deplete all of it's data, remaining data: {self.combined_data!r}"
+ )
+
+
+class WriteFunctionMock(Mock):
+ def __init__(self, *a, **kw):
+ super().__init__(*a, **kw)
+ self.combined_data = bytearray()
+
+ def __call__(self, data: bytearray) -> None:
+ """Override mock's __call__ to extend our combined_data bytearray.
+
+ This allows us to keep track of exactly what data was written by the mocked write function
+ in total, rather than only having tools like assert_called_with, which don't combine the
+ data of each call.
+ """
+ self.combined_data.extend(data)
+ return super().__call__(data)
+
+ def assert_has_data(self, data: bytearray, ensure_called: bool = True) -> None:
+ """Ensure that the total data to write by the mocked function matches expected data."""
+ if ensure_called:
+ self.assert_called()
+
+ if self.combined_data != data:
+ raise AssertionError(f"Write function mock expected data {data!r}, but was {self.call_data!r}")
diff --git a/tests/protocol/test_abc.py b/tests/protocol/test_abc.py
new file mode 100644
index 0000000..ac6a060
--- /dev/null
+++ b/tests/protocol/test_abc.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.protocol.helpers import ReadFunctionMock, Reader, WriteFunctionMock, Writer
+
+
+class TestReader:
+ @classmethod
+ def setup_class(cls):
+ """Initialize writer instance to be tested."""
+ cls.reader = Reader()
+
+ @pytest.fixture
+ def read_mock(self, monkeypatch: pytest.MonkeyPatch):
+ """Monkeypatch the read function with a mock which is returned."""
+ mock_f = ReadFunctionMock()
+ monkeypatch.setattr(self.reader.__class__, "read", mock_f)
+ yield mock_f
+
+ # Run this assertion after the test, to ensure that all specified data
+ # to be read, actually was read
+ mock_f.assert_read_everything()
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_value",
+ (
+ ([10], 10),
+ ([255], 255),
+ ([0], 0),
+ ),
+ )
+ def test_read_ubyte(self, read_bytes: list[int], expected_value: int, read_mock: ReadFunctionMock):
+ """Reading byte int should return an integer in a single unsigned byte."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_ubyte() == expected_value
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_value",
+ (
+ ([236], -20),
+ ([128], -128),
+ ([20], 20),
+ ([127], 127),
+ ),
+ )
+ def test_read_byte(self, read_bytes: list[int], expected_value: int, read_mock: ReadFunctionMock):
+ """Negative number bytes should be read from two's complement format."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_byte() == expected_value
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_value",
+ (
+ ([0], 0),
+ ([1], 1),
+ ([2], 2),
+ ([15], 15),
+ ([127], 127),
+ ([128, 1], 128),
+ ([129, 1], 129),
+ ([255, 1], 255),
+ ([192, 132, 61], 1000000),
+ ([255, 255, 255, 255, 7], 2147483647),
+ ),
+ )
+ def test_read_varint(self, read_bytes: list[int], expected_value: int, read_mock: ReadFunctionMock):
+ """Reading varint bytes results in correct values."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_varint() == expected_value
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_value",
+ (
+ ([0], 0),
+ ([154, 1], 154),
+ ([255, 255, 3], 2**16 - 1),
+ ),
+ )
+ def test_read_varint_max_size(self, read_bytes: list[int], expected_value: int, read_mock: ReadFunctionMock):
+ """Varint reading should be limitable to n max bytes and work with values in range."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_varint(max_size=2) == expected_value
+
+ def test_read_varnum_max_size_out_of_range(self, read_mock: ReadFunctionMock):
+ """Varint reading limited to n max bytes should raise an IOError for numbers out of this range."""
+ read_mock.combined_data = bytearray([128, 128, 4])
+ with pytest.raises(IOError):
+ self.reader.read_varint(max_size=2)
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_string",
+ (
+ ([len("test")] + list(map(ord, "test")), "test"),
+ ([len("a" * 100)] + list(map(ord, "a" * 100)), "a" * 100),
+ ([0], ""),
+ ),
+ )
+ def test_read_utf(self, read_bytes: list[int], expected_string: str, read_mock: ReadFunctionMock):
+ """Reading UTF string results in correct values."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_utf() == expected_string
+
+ @pytest.mark.parametrize(
+ "read_bytes,expected_bytes",
+ (
+ ([1, 1], [1]),
+ ([0], []),
+ ([5, 104, 101, 108, 108, 111], [104, 101, 108, 108, 111]),
+ ),
+ )
+ def test_read_bytearray(self, read_bytes: list[int], expected_bytes: list[int], read_mock: ReadFunctionMock):
+ """Writing a bytearray results in correct bytes."""
+ read_mock.combined_data = bytearray(read_bytes)
+ assert self.reader.read_bytearray() == bytearray(expected_bytes)
+
+
+class TestWriter:
+ @classmethod
+ def setup_class(cls):
+ """Initialize writer instance to be tested."""
+ cls.writer = Writer()
+
+ @pytest.fixture
+ def write_mock(self, monkeypatch: pytest.MonkeyPatch):
+ """Monkeypatch the write function with a mock which is returned."""
+ mock_f = WriteFunctionMock()
+ monkeypatch.setattr(self.writer.__class__, "write", mock_f)
+ return mock_f
+
+ def test_write_byte(self, write_mock: WriteFunctionMock):
+ """Writing byte int should store an integer in a single byte."""
+ self.writer.write_byte(15)
+ write_mock.assert_has_data(bytearray([15]))
+
+ def test_write_byte_negative(self, write_mock: WriteFunctionMock):
+ """Negative number bytes should be stored in two's complement format."""
+ self.writer.write_byte(-20)
+ write_mock.assert_has_data(bytearray([236]))
+
+ def test_write_byte_out_of_range(self):
+ """Signed bytes should only allow writes from -128 to 127."""
+ with pytest.raises(ValueError):
+ self.writer.write_byte(-129)
+ with pytest.raises(ValueError):
+ self.writer.write_byte(128)
+
+ def test_write_ubyte(self, write_mock: WriteFunctionMock):
+ """Writing unsigned byte int should store an integer in a single byte."""
+ self.writer.write_byte(80)
+ write_mock.assert_has_data(bytearray([80]))
+
+ def test_write_ubyte_out_of_range(self):
+ """Unsigned bytes should only allow writes from 0 to 255."""
+ with pytest.raises(ValueError):
+ self.writer.write_ubyte(256)
+ with pytest.raises(ValueError):
+ self.writer.write_ubyte(-1)
+
+ @pytest.mark.parametrize(
+ "number,expected_bytes",
+ (
+ (0, [0]),
+ (1, [1]),
+ (2, [2]),
+ (15, [15]),
+ (127, [127]),
+ (128, [128, 1]),
+ (129, [129, 1]),
+ (255, [255, 1]),
+ (1000000, [192, 132, 61]),
+ (2147483647, [255, 255, 255, 255, 7]),
+ ),
+ )
+ def test_write_varint(self, number: int, expected_bytes: list[int], write_mock: WriteFunctionMock):
+ """Writing varints results in correct bytes."""
+ self.writer.write_varint(number)
+ write_mock.assert_has_data(bytearray(expected_bytes))
+
+ def test_write_varint_out_of_range(self):
+ """Varint without max size should only work with positive integers."""
+ with pytest.raises(ValueError):
+ self.writer.write_varint(-1)
+
+ @pytest.mark.parametrize(
+ "number,expected_bytes",
+ (
+ (0, [0]),
+ (154, [154, 1]),
+ (2**16 - 1, [255, 255, 3]),
+ ),
+ )
+ def test_write_varint_max_size(self, number: int, expected_bytes: list[int], write_mock: WriteFunctionMock):
+ """Varints should be limitable to n max bytes and work with values in range."""
+ self.writer.write_varint(number, max_size=2)
+ write_mock.assert_has_data(bytearray(expected_bytes))
+
+ def test_write_varint_max_size_out_of_range(self):
+ """Varints limited to n max bytes should raise ValueErrors for numbers out of this range."""
+ with pytest.raises(ValueError):
+ self.writer.write_varint(2**16, max_size=2)
+
+ @pytest.mark.parametrize(
+ "string,expected_bytes",
+ (
+ ("test", [len("test")] + list(map(ord, "test"))),
+ ("a" * 100, [len("a" * 100)] + list(map(ord, "a" * 100))),
+ ("", [0]),
+ ),
+ )
+ def test_write_utf(self, string: str, expected_bytes: list[int], write_mock: WriteFunctionMock):
+ """Writing UTF string results in correct bytes."""
+ self.writer.write_utf(string)
+ write_mock.assert_has_data(bytearray(expected_bytes))
+
+ @pytest.mark.parametrize(
+ "input_bytes,expected_bytes",
+ (
+ ([1], [1, 1]),
+ ([], [0]),
+ ([104, 101, 108, 108, 111], [5, 104, 101, 108, 108, 111]),
+ ),
+ )
+ def test_write_bytearray(self, input_bytes: list[int], expected_bytes: list[int], write_mock: WriteFunctionMock):
+ """Writing a bytearray results in correct bytes."""
+ self.writer.write_bytearray(bytearray(input_bytes))
+ write_mock.assert_has_data(bytearray(expected_bytes))
diff --git a/tests/protocol/test_buffer.py b/tests/protocol/test_buffer.py
new file mode 100644
index 0000000..11152e0
--- /dev/null
+++ b/tests/protocol/test_buffer.py
@@ -0,0 +1,76 @@
+import pytest
+
+from zerocom.protocol.buffer import Buffer
+
+
+def test_write():
+ """Writing into the buffer should store data."""
+ buf = Buffer()
+ buf.write(b"Hello")
+ assert buf, bytearray(b"Hello")
+
+
+def test_read():
+ """Reading from buffer should return stored data."""
+ buf = Buffer(b"Reading is cool")
+ data = buf.read(len(buf))
+ assert data == b"Reading is cool"
+
+
+def test_read_multiple():
+ """Multiple reads should deplete the data."""
+ buf = Buffer(b"Something random")
+ data = buf.read(9)
+ assert data == b"Something"
+ data = buf.read(7)
+ assert data == b" random"
+
+
+def test_no_data_read():
+ """Reading more data than available should raise IOError."""
+ buf = Buffer(b"Blip")
+ with pytest.raises(IOError):
+ buf.read(len(buf) + 1)
+
+
+def test_reset():
+ """Resetting should treat already read data as new unread data."""
+ buf = Buffer(b"Will it reset?")
+ data = buf.read(len(buf))
+ buf.reset()
+ data2 = buf.read(len(buf))
+ assert data == data2
+ assert data == b"Will it reset?"
+
+
+def test_clear():
+ """Clearing should remove all stored data from buffer."""
+ buf = Buffer(b"Will it clear?")
+ buf.clear()
+ assert buf == bytearray()
+
+
+def test_clear_resets_position():
+ """Clearing should reset reading position for new data to be read."""
+ buf = Buffer(b"abcdef")
+ buf.read(3)
+ buf.clear()
+ buf.write(b"012345")
+ data = buf.read(3)
+ assert data == b"012"
+
+
+def test_clear_read_only():
+ """Clearing should allow just removing the already read data."""
+ buf = Buffer(b"0123456789")
+ buf.read(5)
+ buf.clear(only_already_read=True)
+ assert buf == bytearray(b"56789")
+
+
+def test_flush():
+ """Flushing should read all available data and clear out the buffer."""
+ buf = Buffer(b"Foobar")
+ data = buf.flush()
+ assert data == b"Foobar"
+ assert buf == bytearray()
diff --git a/tests/protocol/test_connection.py b/tests/protocol/test_connection.py
new file mode 100644
index 0000000..28f4bd8
--- /dev/null
+++ b/tests/protocol/test_connection.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import socket
+from typing import Optional
+from unittest.mock import MagicMock
+
+import pytest
+
+from tests.protocol.helpers import ReadFunctionMock, WriteFunctionMock
+from zerocom.protocol.connection import SocketConnection
+
+
+class MockSocket(MagicMock):
+ spec_set = socket.socket
+
+ def __init__(self, *args, read_data: Optional[bytearray] = None, **kw) -> None:
+ super().__init__(*args, **kw)
+ self.recv = ReadFunctionMock(combined_data=read_data)
+ self.send = WriteFunctionMock()
+
+
+def test_read():
+ data = bytearray("hello", "utf-8")
+ conn = SocketConnection(MockSocket(read_data=data.copy()))
+
+ out = conn.read(5)
+
+ conn.socket.recv.assert_read_everything()
+ assert out == data
+
+
+def test_read_more_data_than_sent():
+ conn = SocketConnection(MockSocket(read_data=bytearray("test", "utf-8")))
+ with pytest.raises(IOError):
+ conn.read(10)
+
+
+def test_write():
+ data = bytearray("hello", "utf-8")
+ conn = SocketConnection(MockSocket())
+
+ conn.write(data)
+
+ conn.socket.send.assert_has_data(data)
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 788fff6..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,21 +0,0 @@
-[flake8]
-max_line_length=120
-import-order-style=pycharm
-application-import-names=app
-exclude=
- .venv/**,
- .git/**
-ignore=
- # Ignore missing return type annotations for special methods
- ANN204, ANN206, ANN202
- # Ignore missing type annotations
- ANN101 # Init
- ANN102 # cls
- ANN002, # *Args
- ANN003, # **Kwargs
- # Allow lambdas
- E731,
- # Allow markdown inline HTML
- MD033,
- # allow __init__ imports
- F401
diff --git a/zerocom/__init__.py b/zerocom/__init__.py
new file mode 100644
index 0000000..2ffa2e1
--- /dev/null
+++ b/zerocom/__init__.py
@@ -0,0 +1,3 @@
+from zerocom.utils.log import setup_logging
+
+setup_logging()
diff --git a/zerocom/client.py b/zerocom/client.py
new file mode 100644
index 0000000..6c26f6d
--- /dev/null
+++ b/zerocom/client.py
@@ -0,0 +1,57 @@
+import errno
+import logging
+import select
+import sys
+from typing import NoReturn
+
+from zerocom.network.client import Client
+
+log = logging.getLogger(__name__)
+
+
+def run_client(host: str, port: int, username: str) -> NoReturn:
+ client = Client(username, (host, port))
+
+ print("Welcome to the chat. CTRL+C to disconnect. Happy chatting.")
+ print("\nME: ", end="")
+
+ while True:
+ io_descriptors = [sys.stdin, client.socket]
+ try:
+ ready_read, ready_write, ready_error = select.select(io_descriptors, [], [])
+ except KeyboardInterrupt:
+ log.info("Connection ended")
+ client.socket.close()
+ sys.exit(0)
+
+ for notified_descriptor in ready_read:
+ if notified_descriptor == client.socket:
+ try:
+ msg = client.receive()
+ except IOError as e:
+ if e.errno != errno.EAGAIN and e.errno != errno.EWOULDBLOCK:
+ log.critical(f"Error occurred while reading: {e!r}")
+ sys.exit(1)
+ continue
+ else:
+ log.info(f"Received message: {msg}")
+ print("ME: ", end="")
+ sys.stdout.flush()
+ else:
+ message = sys.stdin.readline()
+ client.send(message)
+
+ print("ME: ", end="")
+ sys.stdout.flush()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 5:
+ log.critical("Usage: python -m client ")
+ sys.exit(1)
+
+ _, SERVER_IP, PORT, USERNAME, PASSWORD = sys.argv
+ PORT = int(PORT)
+
+ # NOTE: Password is currently unused, because it's not yet implemented
+ run_client(SERVER_IP, PORT, USERNAME)
diff --git a/zerocom/config.py b/zerocom/config.py
new file mode 100644
index 0000000..53e32c8
--- /dev/null
+++ b/zerocom/config.py
@@ -0,0 +1,36 @@
+import os
+from textwrap import dedent
+
+import toml
+
+# Hard-coded constants
+VERSION = "0.1.0"
+BANNER = dedent(
+ """
+ ____ _____
+/_ / ___ _______ / ___/__ __ _
+ / /_/ -_) __/ _ \\/ /__/ _ \\/ ' \\
+/___/\\__/_/ \\___/\\___/\\___/_/_/_/
+"""
+)
+
+# Logging setting
+DEBUG = bool(os.environ.get("ZEROCOM_DEBUG", 0))
+LOG_FILE = os.environ.get("ZEROCOM_LOG_FILE", None)
+LOG_FILE_MAX_SIZE = int(os.environ.get("ZEROCOM_LOG_FILE_SIZE_MAX", 1_048_576)) # in bytes (default: 1MiB)
+
+# Config file location, in this case it's `config.toml` in root
+CONFIG_FILE = os.environ.get("ZEROCOM_CONFIG_FILE", "config.toml")
+config = toml.load(CONFIG_FILE)
+server_config = config["server"]["config"]
+
+
+class Config:
+ IP = server_config["ip"]
+ PORT = server_config["port"]
+ MOTD = server_config["motd"]
+
+ PASSWORD = config["server"]["auth"]["password"]
+
+ # Load the max connections, It's `None` if 0 is specified.
+ MAX_CONNECTIONS = server_config["max-connections"] if server_config["max-connections"] != 0 else None
diff --git a/app/mixins/__init__.py b/zerocom/network/__init__.py
similarity index 100%
rename from app/mixins/__init__.py
rename to zerocom/network/__init__.py
diff --git a/zerocom/network/client.py b/zerocom/network/client.py
new file mode 100644
index 0000000..7da345e
--- /dev/null
+++ b/zerocom/network/client.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import logging
+import socket
+from queue import Queue
+
+import rsa
+from rsa.key import PublicKey
+
+from zerocom.protocol.connection import SocketConnection
+
+log = logging.getLogger(__name__)
+
+
+class Client:
+ def __init__(self, username: str, server_address: tuple[str, int], timeout: float = 3):
+ self.username = username
+ self.server_host, self.server_port = server_address
+ self.timeout = timeout
+
+ # Queue for incoming messages
+ self.queue = Queue()
+
+ # Generate the keys needed for RSA encryption
+ self.public_key, self.private_key = rsa.newkeys(2048)
+
+ # Connect and transmit username
+ socket = self._make_socket(server_address, timeout)
+ self.connection = SocketConnection(socket)
+
+ self.connection.write_utf(self.username)
+ self.connection.write_utf(PublicKey.save_pkcs1(self.public_key, "PEM").decode())
+
+ @property
+ def socket(self) -> socket.socket:
+ return self.connection.socket
+
+ @staticmethod
+ def _make_socket(server_address: tuple[str, int], timeout: float = 3) -> socket.socket:
+ sock = socket.create_connection(server_address, timeout=timeout)
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ return sock
+
+ def send(self, message: str) -> None:
+ message = message.replace("\n", "") # TODO: Consider moving to removesuffix (3.9+)
+ key_sign = rsa.sign(message.encode(), self.private_key, "SHA-1")
+
+ self.connection.write_utf(key_sign.decode())
+
+ log.info(f"Sending message {message} to server.")
+ self.connection.write_utf(message)
+
+ def receive(self) -> tuple[str, str]:
+ username = self.connection.read_utf()
+ msg = self.connection.read_utf()
+
+ return username, msg
diff --git a/zerocom/network/server.py b/zerocom/network/server.py
new file mode 100644
index 0000000..4ab8343
--- /dev/null
+++ b/zerocom/network/server.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import logging
+import os
+import socket
+from dataclasses import dataclass
+from typing import Optional, cast
+
+import rsa
+from rsa.key import PublicKey
+
+from zerocom.protocol.connection import SocketConnection
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class ProcessedClient:
+ __slots__ = ("conn", "address", "username", "public_key")
+
+ conn: SocketConnection
+ address: tuple[str, int]
+
+ username: str
+ public_key: PublicKey
+
+ @property
+ def socket(self) -> socket.socket:
+ return self.conn.socket
+
+
+class Server:
+ def __init__(self, address: tuple[str, int], backlog: Optional[int] = None):
+ socket = self._make_socket(address, backlog)
+ self.connection = SocketConnection(socket)
+
+ self.host, self.port = address
+ self.connected_clients: dict[socket.socket, ProcessedClient] = {}
+
+ @property
+ def socket(self) -> socket.socket:
+ return self.connection.socket
+
+ @property
+ def socket_list(self) -> list[socket.socket]:
+ """Produce a list of all currently used sockets."""
+ sockets = [client.socket for client in self.connected_clients.values()]
+ sockets.append(self.socket)
+ return sockets
+
+ @staticmethod
+ def _make_socket(address: tuple[str, int], backlog: Optional[int] = None) -> socket.socket:
+ """Make server socket capable of accepting new connections."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+
+ if os.name == "posix":
+ # Allow address reuse (fixes errors when using same address after program restarts)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+ sock.setblocking(False)
+
+ try:
+ sock.bind(address)
+ except OSError as exc:
+ sock.close()
+ log.critical(f"Unable to bind server to {address} (maybe this address is already in use?)")
+ raise exc
+ else:
+ log.info(f"Server bound to {address}")
+
+ # Enable server to accept connections
+ if backlog is not None:
+ sock.listen(backlog)
+ else:
+ sock.listen()
+
+ log.info("Listening for connections...")
+
+ return sock
+
+ def process_connection(self) -> None:
+ client_socket, address = self.socket.accept()
+ conn = SocketConnection(client_socket)
+ log.debug(f"Accepted new connection from {address}")
+ try:
+ username = conn.read_utf()
+ public_key = conn.read_utf()
+ except IOError as exc:
+ log.debug(f"Processing new connection from {address} failed when reading username: {exc!r}")
+ log.error(f"Dropping connection from {address} - username wasn't send properly when connecting.")
+ client_socket.close()
+ return
+
+ client = ProcessedClient(conn, address, username, cast(PublicKey, PublicKey.load_pkcs1(public_key.encode())))
+ self.connected_clients[client_socket] = client
+
+ def process_message(self, client_socket: socket.socket) -> None:
+ client = self.connected_clients[client_socket]
+
+ try:
+ key_sign = client.conn.read_utf()
+ msg = client.conn.read_utf()
+ except IOError as exc:
+ log.debug(f"Processing message from {client} failed: {exc}")
+ log.error(f"Dropping connection from {client} - sent invalid message")
+ client.socket.close()
+ del self.connected_clients[client_socket]
+ return
+
+ log.info(f"Accepted message from {client}: {msg}")
+
+ # RSA verification + broadcasting.
+ try:
+ if rsa.verify(msg.encode(), key_sign.encode(), client.public_key):
+ log.info(f"Message from {client} verified")
+ self.broadcast(client.socket, msg)
+ except rsa.VerificationError:
+ log.error(f"Dropping connection from {client} - received incorrect verification")
+
+ # Broadcast a warning to all clients.
+ self.broadcast(client_socket, f"{client.username} has been kicked for incorrect verification.")
+
+ # Close the connection.
+ client.socket.close()
+ del self.connected_clients[client_socket]
+
+ def broadcast(self, client_socket: socket.socket, message: str) -> None:
+ for client_sock in self.connected_clients.values():
+ if client_sock.socket != client_socket:
+ client_sock.conn.write_utf(client_sock.username)
+ client_sock.conn.write_utf(message)
+
+ def disconnect_client(self, client_socket: socket.socket) -> None:
+ try:
+ client = self.connected_clients[client_socket]
+ except KeyError:
+ log.debug(f"Ignoring disconnect request for untracked client: {client_socket} (already disconnected?)")
+ return
+
+ log.info(f"Disconnecting {client}.")
+ del self.connected_clients[client_socket]
+ client_socket.close()
+
+ def stop(self) -> None:
+ """Disconnect all clients and close the server socket connection."""
+ for client_sock in self.connected_clients:
+ client_sock.close()
+
+ self.socket.close()
diff --git a/app/models/__init__.py b/zerocom/protocol/__init__.py
similarity index 100%
rename from app/models/__init__.py
rename to zerocom/protocol/__init__.py
diff --git a/zerocom/protocol/abc.py b/zerocom/protocol/abc.py
new file mode 100644
index 0000000..d85043c
--- /dev/null
+++ b/zerocom/protocol/abc.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import struct
+from abc import ABC, abstractmethod
+from itertools import count
+from typing import Any, Optional, cast
+
+from zerocom.protocol.utils import enforce_range
+
+
+class BaseWriter(ABC):
+ """Base class holding write buffer/connection interactions."""
+
+ __slots__ = ()
+
+ @abstractmethod
+ def write(self, data: bytes) -> None:
+ ...
+
+ def _write_packed(self, fmt: str, *value: object) -> None:
+ """Write a value of given struct format in big-endian mode.
+
+ Available formats are listed in struct module's docstring.
+ """
+ self.write(struct.pack(">" + fmt, *value))
+
+ @enforce_range(typ="Byte (8-bit signed int)", byte_size=1, signed=True)
+ def write_byte(self, value: int) -> None:
+ """Write a single signed 8-bit integer.
+
+ Signed 8-bit integers must be within the range of -128 and 127. Going outside this range will raise a
+ ValueError.
+
+ Number is written in two's complement format.
+ """
+ self._write_packed("b", value)
+
+ @enforce_range(typ="Unsigned byte (8-bit unsigned int)", byte_size=1, signed=False)
+ def write_ubyte(self, value: int) -> None:
+ """Write a single unsigned 8-bit integer.
+
+ Unsigned 8-bit integers must be within range of 0 and 255. Going outside this range will raise a ValueError.
+ """
+ self._write_packed("B", value)
+
+ def write_varint(self, value: int, *, max_size: Optional[int] = None) -> None:
+ """Write an arbitrarily big unsigned integer in a variable length format.
+
+ This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes.
+
+ Will keep writing bytes until the value is depleted (fully sent). If `max_size` is specified, writing will be
+ limited up to integer values of max_size bytes, and trying to write bigger values will rase a ValueError. Note
+ that limiting to max_size of 4 (32-bit int) doesn't imply at most 4 bytes will be sent, and will in fact take 5
+ bytes at most, due to the variable encoding overhead.
+
+ Varnums use 7 least significant bits of each sent byte to encode the value, and the most significant bit to
+ indicate whether there is another byte after it. The least significant group is written first, followed by each
+ of the more significant groups, making varints little-endian, however in groups of 7 bits, not 8.
+ """
+ # We can't use enforce_range as decorator directly, because our byte_size varies
+ # instead run it manually from here as a check function
+ _wrapper = enforce_range(
+ typ=f"{max_size if max_size else 'unlimited'}-byte unsigned varnum",
+ byte_size=max_size if max_size else None,
+ signed=False,
+ )
+ _check_f = _wrapper(lambda self, value: None)
+ _check_f(self, value)
+
+ remaining = value
+ while True:
+ if remaining & ~0x7F == 0: # final byte
+ self.write_ubyte(remaining)
+ return
+ # Write only 7 least significant bits, with the first being 1.
+ # first bit here represents that there will be another value after
+ self.write_ubyte(remaining & 0x7F | 0x80)
+ # Subtract the value we've already sent (7 least significant bits)
+ remaining >>= 7
+
+ def write_utf(self, value: str, max_varint_size: int = 2) -> None:
+ """Write a UTF-8 encoded string, prefixed with a varshort of it's size (in bytes).
+
+ Will write n bytes, depending on the amount of bytes in the string + up to 3 bytes from prefix varshort,
+ holding this size (n). This means a maximum of 2**31-1 + 5 bytes can be written.
+
+ Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the
+ worst case of 4 bytes per every character, at most 8192 characters can be written, however this number
+ will usually be much bigger (up to 4x) since it's unlikely each character would actually take up 4 bytes. (All
+ of the ASCII characters only take up 1 byte).
+
+ If the given string is longer than this, ValueError will be raised for trying to write an invalid varshort.
+ """
+ data = bytearray(value, "utf-8")
+ self.write_varint(len(value), max_size=max_varint_size)
+ self.write(data)
+
+ def write_bytearray(self, data: bytearray) -> None:
+ """Write an arbitrary sequence of bytes, prefixed with a varint of it's size."""
+ self.write_varint(len(data))
+ self.write(data)
+
+
+class BaseReader(ABC):
+ """Base class holding read buffer/connection interactions."""
+
+ __slots__ = ()
+
+ @abstractmethod
+ def read(self, length: int) -> bytearray:
+ ...
+
+ def _read_unpacked(self, fmt: str) -> Any: # noqa: ANN401
+ """Read bytes and unpack them into given struct format in big-endian mode.
+
+
+ The amount of bytes to read will be determined based on the format string automatically.
+ i.e.: With format of "iii" (referring to 3 signed 32-bit ints), the read length is set as 3x4 (since a signed
+ 32-bit int takes 4 bytes), making the total length to read 12 bytes, returned as Tuple[int, int, int]
+
+ Available formats are listed in struct module's docstring.
+ """
+ length = struct.calcsize(fmt)
+ data = self.read(length)
+ unpacked = struct.unpack(">" + fmt, data)
+
+ if len(unpacked) == 1:
+ return unpacked[0]
+ return unpacked
+
+ def read_byte(self) -> int:
+ """Read a single signed 8-bit integer.
+
+ Will read 1 byte in two's complement format, getting int values between -128 and 127.
+ """
+ return self._read_unpacked("b")
+
+ def read_ubyte(self) -> int:
+ """Read a single unsigned 8-bit integer.
+
+ Will read 1 byte, getting int value between 0 and 255 directly.
+ """
+ return self._read_unpacked("B")
+
+ def read_varint(self, *, max_size: Optional[int] = None) -> int:
+ """Read an arbitrarily big unsigned integer in a variable length format.
+
+ This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes.
+
+ Will keep reading bytes until the value is depleted (fully sent). If `max_size` is specified, reading will be
+ limited up to integer values of max_size bytes, and trying to read bigger values will rase an IOError. Note
+ that limiting to max_size of 4 (32-bit int) doesn't imply at most 4 bytes will be sent, and will in fact take 5
+ bytes at most, due to the variable encoding overhead.
+
+ Varnums use 7 least significant bits of each sent byte to encode the value, and the most significant bit to
+ indicate whether there is another byte after it. The least significant group is written first, followed by each
+ of the more significant groups, making varints little-endian, however in groups of 7 bits, not 8.
+ """
+ value_max = (1 << (max_size * 8)) - 1 if max_size else None
+ result = 0
+ for i in count():
+ byte = self.read_ubyte()
+ # Read 7 least significant value bits in this byte, and shift them appropriately to be in the right place
+ # then simply add them (OR) as additional 7 most significant bits in our result
+ result |= (byte & 0x7F) << (7 * i)
+
+ # Ensure that we stop reading and raise an error if the size gets over the maximum
+ # (if the current amount of bits is higher than allowed size in bits)
+ if value_max and result > value_max:
+ max_size = cast(int, max_size)
+ raise IOError(f"Received varint was outside the range of {max_size}-byte ({max_size * 8}-bit) int.")
+
+ # If the most significant bit is 0, we should stop reading
+ if not byte & 0x80:
+ break
+
+ return result
+
+ def read_utf(self, max_varint_size: int = 2) -> str:
+ """Read a UTF-8 encoded string, prefixed with a varshort of it's size (in bytes).
+
+ Will read n bytes, depending on the prefix varint (amount of bytes in the string) + up to 3 bytes from prefix
+ varshort itself, holding this size (n). This means a maximum of 2**15-1 + 3 bytes can be read (and written).
+
+ Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the
+ worst case of 4 bytes per every character, at most 8192 characters can be written, however this number
+ will usually be much bigger (up to 4x) since it's unlikely each character would actually take up 4 bytes. (All
+ of the ASCII characters only take up 1 byte).
+ """
+ length = self.read_varint(max_size=max_varint_size)
+ bytes = self.read(length)
+ return bytes.decode("utf-8")
+
+ def read_bytearray(self) -> bytearray:
+ """Read an arbitrary sequence of bytes, prefixed with a varint of it's size."""
+ length = self.read_varint()
+ return self.read(length)
diff --git a/zerocom/protocol/buffer.py b/zerocom/protocol/buffer.py
new file mode 100644
index 0000000..210df6d
--- /dev/null
+++ b/zerocom/protocol/buffer.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from zerocom.protocol.abc import BaseReader, BaseWriter
+
+
+class Buffer(BaseReader, BaseWriter, bytearray):
+ """Buffer implementation for BaseReader and BaseWriter via python's bytearrays.
+
+ This class holds all basic interactions for writing/reading data into/from internal bytearray.
+ """
+
+ __slots__ = ("pos",)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.pos = 0
+
+ def write(self, data: bytes) -> None:
+ """Write new data into the buffer."""
+ self.extend(data)
+
+ def read(self, length: int) -> bytearray:
+ """Read data stored in the buffer.
+
+ Reading data doesn't remove that data, rather that data is treated as already read, and
+ next read will start from the first unread byte. If freeing the data is necessary, check the clear function.
+
+ Trying to read more data than is available will raise an IOError, however it will deplete the remaining data
+ and the partial data that was read will be a part of the error message. This behavior is here to mimic reading
+ from a socket connection.
+ """
+ end = self.pos + length
+
+ if end > len(self):
+ data = self[self.pos : len(self)]
+ bytes_read = len(self) - self.pos
+ self.pos = len(self)
+ raise IOError(
+ "Requested to read more data than available."
+ f" Read {bytes_read} bytes: {data}, out of {length} requested bytes."
+ )
+
+ try:
+ return self[self.pos : end]
+ finally:
+ self.pos = end
+
+ def clear(self, only_already_read: bool = False) -> None:
+ """
+ Clear out the stored data and reset position.
+
+ If `only_already_read` is True, only clear out the data which was already read, and reset the position.
+ This is mostly useful to avoid keeping large chunks of data in memory for no reason.
+ """
+ if only_already_read:
+ del self[: self.pos]
+ else:
+ super().clear()
+ self.pos = 0
+
+ def reset(self) -> None:
+ """Reset the position in the buffer."""
+ self.pos = 0
+
+ def flush(self) -> bytearray:
+ """Read all of the remaining data in the buffer and clear it out."""
+ data = self[self.pos : len(self)]
+ self.clear()
+ return data
diff --git a/zerocom/protocol/connection.py b/zerocom/protocol/connection.py
new file mode 100644
index 0000000..5375ad6
--- /dev/null
+++ b/zerocom/protocol/connection.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import socket
+from typing import Generic, TypeVar
+
+from zerocom.protocol.abc import BaseReader, BaseWriter
+
+T_SOCK = TypeVar("T_SOCK", bound=socket.socket)
+
+
+class SocketConnection(BaseReader, BaseWriter, Generic[T_SOCK]):
+ """Networked implementation for BaseReader and BaseWriter via python's sockets.
+
+ This class holds all basic interactions for writing/reading data (i.e. sending/receiving) data via sockets.
+ """
+
+ def __init__(self, socket: T_SOCK):
+ self.socket = socket
+
+ def read(self, length: int) -> bytearray:
+ result = bytearray()
+ while len(result) < length:
+ new = self.socket.recv(length - len(result))
+ if len(new) == 0:
+ if len(result) == 0:
+ raise IOError("Server did not respond with any information.")
+ raise IOError(f"Server stopped responding (got {len(result)} bytes, but expected {length} bytes).")
+ result.extend(new)
+
+ return result
+
+ def write(self, data: bytes) -> None:
+ self.socket.send(data)
+
+ def __del__(self):
+ self.socket.close()
diff --git a/zerocom/protocol/utils.py b/zerocom/protocol/utils.py
new file mode 100644
index 0000000..e0d0350
--- /dev/null
+++ b/zerocom/protocol/utils.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from functools import wraps
+from typing import Callable, Optional, TYPE_CHECKING, TypeVar, cast
+
+if TYPE_CHECKING:
+ from typing_extensions import ParamSpec
+
+ P = ParamSpec("P")
+else:
+ P = []
+
+R = TypeVar("R")
+
+
+def enforce_range(*, typ: str, byte_size: Optional[int], signed: bool) -> Callable:
+ """Decorator enforcing proper int value range, based on the number of max bytes (size).
+
+ If a value is outside of the automatically determined allowed range, a ValueError will be raised,
+ showing the given `typ` along with the allowed range info.
+
+ If the byte_size is None, infinite max size is assumed. Note that this is only possible with unsigned types,
+ since there's no point in enforcing infinite range.
+ """
+ if byte_size is None:
+ if signed is True:
+ raise ValueError("Enforcing infinite byte-size for signed type doesn't make sense (includes all numbers).")
+ value_max = float("inf")
+ value_max_s = "infinity"
+ value_min = 0
+ value_min_s = "0"
+ else:
+ if signed:
+ value_max = (1 << (byte_size * 8 - 1)) - 1
+ value_max_s = f"{value_max} (2**{byte_size * 8 - 1} - 1)"
+ value_min = -1 << (byte_size * 8 - 1)
+ value_min_s = f"{value_min} (-2**{byte_size * 8 - 1})"
+ else:
+ value_max = (1 << (byte_size * 8)) - 1
+ value_max_s = f"{value_max} (2**{byte_size * 8} - 1)"
+ value_min = 0
+ value_min_s = "0"
+
+ def wrapper(func: Callable[P, R]) -> Callable[P, R]:
+ @wraps(func)
+ def inner(*args: P.args, **kwargs: P.kwargs) -> R:
+ value = cast(int, args[1])
+ if value > value_max or value < value_min:
+ raise ValueError(f"{typ} must be within {value_min_s} and {value_max_s}, got {value}.")
+ return func(*args, **kwargs)
+
+ return inner
+
+ return wrapper
diff --git a/zerocom/server.py b/zerocom/server.py
new file mode 100644
index 0000000..0e99453
--- /dev/null
+++ b/zerocom/server.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import logging
+import select
+import sys
+from typing import NoReturn
+
+from zerocom.config import Config
+from zerocom.network.server import Server
+
+log = logging.getLogger(__name__)
+
+
+def start_server(host: str, port: int) -> NoReturn:
+ server = Server((host, port))
+
+ while True:
+ try:
+ ready_to_read, _, in_error = select.select(server.socket_list, [], server.socket_list)
+ except KeyboardInterrupt:
+ log.info("Stopping the server...")
+ server.stop()
+ sys.exit(0)
+
+ for socket_ in ready_to_read:
+ if socket_ is server.socket:
+ server.process_connection()
+ else:
+ server.process_message(socket_)
+
+ for socket_ in in_error:
+ if socket_ is not server.socket:
+ server.disconnect_client(socket_)
+ else:
+ log.error("Server socket in error!")
+
+
+if __name__ == "__main__":
+ start_server(Config.IP, Config.PORT)
diff --git a/zerocom/utils/__init__.py b/zerocom/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/zerocom/utils/log.py b/zerocom/utils/log.py
new file mode 100644
index 0000000..14ca260
--- /dev/null
+++ b/zerocom/utils/log.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import logging
+import logging.handlers
+from pathlib import Path
+
+from rich.logging import RichHandler
+
+import zerocom.config
+
+LOG_LEVEL = logging.DEBUG if zerocom.config.DEBUG else logging.INFO
+LOG_FILE = zerocom.config.LOG_FILE
+LOG_FILE_MAX_SIZE = zerocom.config.LOG_FILE_MAX_SIZE
+LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)7s | %(message)s"
+
+
+def setup_logging() -> None:
+ """Sets up logging library to use our log format and defines log levels."""
+ root_log = logging.getLogger()
+ log_formatter = logging.Formatter(LOG_FORMAT, datefmt="%Y-%m-%d %H:%M:%S")
+
+ rich_handler = RichHandler(show_time=False)
+ rich_handler.setFormatter(log_formatter)
+ root_log.addHandler(rich_handler)
+
+ if LOG_FILE is not None:
+ file_handler = logging.handlers.RotatingFileHandler(Path(LOG_FILE), maxBytes=LOG_FILE_MAX_SIZE)
+ file_handler.setFormatter(log_formatter)
+ root_log.addHandler(file_handler)
+
+ root_log.setLevel(LOG_LEVEL)