diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c02c114 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: Build and deploy to PyPi + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index bcb962c..e99e217 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,8 @@ { - "python.pythonPath": "C:\\Users\\dnorh\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", - "restructuredtext.confPath": "${workspaceFolder}\\docs", "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.linting.flake8Args": [ "--ignore=E501" - ] + ], + "esbonio.sphinx.confDir": "${workspaceFolder}/docs" } \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index 5baab51..08b42a6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -16,58 +16,59 @@ "default": { "beautifulsoup4": { "hashes": [ - "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf", - "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891" + "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30", + "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693" ], "index": "pypi", - "version": "==4.10.0" + "version": "==4.11.1" }, "certifi": { "hashes": [ - "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", - "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14", + "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382" ], - "version": "==2021.10.8" + "markers": "python_version >= '3.6'", + "version": "==2022.9.24" }, "charset-normalizer": { "hashes": [ - "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", - "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" ], - "markers": "python_version >= '3'", - "version": "==2.0.11" + "markers": "python_version >= '3.6'", + "version": "==2.1.1" }, "idna": { "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" ], - "markers": "python_version >= '3'", - "version": "==3.3" + "markers": "python_version >= '3.5'", + "version": "==3.4" }, "requests": { "hashes": [ - "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", - "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", + "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" ], "index": "pypi", - "version": "==2.27.1" + "version": "==2.28.1" }, "soupsieve": { "hashes": [ - "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb", - "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9" + "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759", + "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d" ], "markers": "python_version >= '3.6'", - "version": "==2.3.1" + "version": "==2.3.2.post1" }, "urllib3": { "hashes": [ - "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", - "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e", + "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'", + "version": "==1.26.12" } }, "develop": { @@ -80,34 +81,35 @@ }, "autopep8": { "hashes": [ - "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979", - "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f" + "sha256:6f09e90a2be784317e84dc1add17ebfc7abe3924239957a37e5040e27d812087", + "sha256:ca9b1a83e53a7fad65d731dc7a2a2d50aa48f43850407c59f6a1a306c4201142" ], "index": "pypi", - "version": "==1.6.0" + "version": "==1.7.0" }, "babel": { "hashes": [ - "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", - "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" + "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51", + "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.9.1" + "markers": "python_version >= '3.6'", + "version": "==2.10.3" }, "certifi": { "hashes": [ - "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", - "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14", + "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382" ], - "version": "==2021.10.8" + "markers": "python_version >= '3.6'", + "version": "==2022.9.24" }, "charset-normalizer": { "hashes": [ - "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", - "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" ], - "markers": "python_version >= '3'", - "version": "==2.0.11" + "markers": "python_version >= '3.6'", + "version": "==2.1.1" }, "docutils": { "hashes": [ @@ -119,117 +121,89 @@ }, "flake8": { "hashes": [ - "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", - "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" + "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db", + "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248" ], "index": "pypi", - "version": "==4.0.1" + "version": "==5.0.4" }, "idna": { "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" ], - "markers": "python_version >= '3'", - "version": "==3.3" + "markers": "python_version >= '3.5'", + "version": "==3.4" }, "imagesize": { "hashes": [ - "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c", - "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d" + "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", + "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.3.0" + "version": "==1.4.1" }, "jinja2": { "hashes": [ - "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", - "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" ], - "markers": "python_version >= '3.6'", - "version": "==3.0.3" + "markers": "python_version >= '3.7'", + "version": "==3.1.2" }, "markupsafe": { "hashes": [ - "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", - "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", - "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", - "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", - "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", - "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", - "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", - "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", - "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", - "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", - "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", - "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", - "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", - "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", - "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", - "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", - "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", - "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", - "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", - "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", - "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", - "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", - "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", - "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", - "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", - "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", - "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", - "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", - "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", - "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", - "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", - "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", - "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", - "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", - "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", - "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", - "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", - "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", - "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", - "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", - "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", - "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", - "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", - "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", - "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", - "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", - "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", - "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", - "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", - "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", - "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", - "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", - "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", - "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", - "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", - "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", - "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", - "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", - "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", - "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", - "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", - "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", - "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", - "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", - "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", - "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", - "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", - "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", - "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.1" + "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", + "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", + "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", + "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", + "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", + "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", + "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", + "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", + "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", + "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", + "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", + "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", + "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", + "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", + "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", + "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", + "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", + "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", + "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", + "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", + "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", + "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", + "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", + "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", + "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", + "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", + "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", + "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", + "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", + "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", + "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", + "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", + "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", + "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", + "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", + "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", + "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", + "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", + "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", + "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.1" }, "mccabe": { "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" ], - "version": "==0.6.1" + "markers": "python_version >= '3.6'", + "version": "==0.7.0" }, "packaging": { "hashes": [ @@ -241,50 +215,50 @@ }, "pycodestyle": { "hashes": [ - "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", - "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" + "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785", + "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.8.0" + "markers": "python_version >= '3.6'", + "version": "==2.9.1" }, "pyflakes": { "hashes": [ - "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", - "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" + "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2", + "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.4.0" + "markers": "python_version >= '3.6'", + "version": "==2.5.0" }, "pygments": { "hashes": [ - "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65", - "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a" + "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1", + "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42" ], - "markers": "python_version >= '3.5'", - "version": "==2.11.2" + "markers": "python_version >= '3.6'", + "version": "==2.13.0" }, "pyparsing": { "hashes": [ - "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", - "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" + "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", + "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" ], - "markers": "python_version >= '3.6'", - "version": "==3.0.7" + "markers": "python_full_version >= '3.6.8'", + "version": "==3.0.9" }, "pytz": { "hashes": [ - "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", - "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" + "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197", + "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5" ], - "version": "==2021.3" + "version": "==2022.2.1" }, "requests": { "hashes": [ - "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", - "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", + "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" ], "index": "pypi", - "version": "==2.27.1" + "version": "==2.28.1" }, "snowballstemmer": { "hashes": [ @@ -295,11 +269,11 @@ }, "sphinx": { "hashes": [ - "sha256:5da895959511473857b6d0200f56865ed62c31e8f82dd338063b84ec022701fe", - "sha256:6caad9786055cb1fa22b4a365c1775816b876f91966481765d7d50e9f0dd35cc" + "sha256:7225c104dc06169eb73b061582c4bc84a9594042acae6c1582564de274b7df2f", + "sha256:9150a8ed2e98d70e778624373f183c5498bf429dd605cf7b63e80e2a166c35a5" ], "index": "pypi", - "version": "==4.4.0" + "version": "==5.2.2" }, "sphinx-rtd-theme": { "hashes": [ @@ -367,11 +341,11 @@ }, "urllib3": { "hashes": [ - "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", - "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e", + "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'", + "version": "==1.26.12" } } } diff --git a/README.md b/README.md index 0d29296..de05fad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # lectio.py -![](https://img.shields.io/github/license/dnorhoj/lectio.py) - +[![License](https://img.shields.io/github/license/dnorhoj/lectio.py)](LICENSE) +[![Documentation Status](https://readthedocs.org/projects/lectiopy/badge/?version=latest)](https://lectiopy.readthedocs.io/en/latest/?badge=latest) +[![PyPi version](https://img.shields.io/pypi/v/lectio.py.svg)](https://pypi.org/project/lectio.py/) +[![PyPi supported python versions](https://img.shields.io/pypi/pyversions/lectio.py.svg)](https://python.org/) *Please note: This library is nowhere close to done.* @@ -14,13 +16,37 @@ You can read the documentation ## How do i use this? -To use this repository feel free to check the documentation [here](https://lectiopy.rtfd.io/). +You can install this library via `pip`: + + pip install lectio.py + +For a quickstart guide as well as some examples, you can read the documentation [here](https://lectiopy.rtfd.io/). + +## Progress + +
+ Implementation progress + +* [x] Schedule +* [x] User info +* [ ] Absence +* [ ] Mail +* [ ] Assignments +* [ ] Homework +* [ ] Surveys (Probably not going to be implemented) +* [ ] Grades +* [ ] Search for students / teachers + +
## Todo -* [ ] Finish authentication check * [ ] Make a better README -* [ ] Start doing the hard part (basically everything) +* [ ] Quickstart, Examples, etc. + +## Known bugs + +* Not made to work with teacher accounts (as i have no way of testing anything) ## Contributing @@ -30,4 +56,4 @@ If you notice something that isn't working as intended you can start an issue. ## License -This repository uses the `GNU Lesser General Public License v3.0` you can read more about it in [LICENSE](LICENSE). \ No newline at end of file +This repository uses the `GNU Lesser General Public License v3.0` you can read more about it in [LICENSE](LICENSE). diff --git a/docs/conf.py b/docs/conf.py index 662a94a..7be8703 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,11 +18,11 @@ # -- Project information ----------------------------------------------------- project = 'lectio.py' -copyright = '2020, dnorhoj' +copyright = '2022, dnorhoj' author = 'dnorhoj' # The full version, including alpha/beta/rc tags -release = '0.0.1' +release = '0.2.0' # -- General configuration --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 7743660..6e60ec0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,30 @@ -Welcome to lectio.py's documentation! +Welcome to lectio.py ======================================= +Lectio.py is a Python library for reading and interacting with lectio.dk, +a Danish school management system. + +.. note:: + This library is not affiliated with lectio.dk or macom in any way. + Nor is it endorsed by them. It scrapes the website as there is no + official API. + +**Features:** + +- Pythonic API +- Easy to use + +Information +----------- + +**Useful links:** :doc:`/installation` | :doc:`/quickstart` +**Reference:** :doc:`/reference` | :doc:`/exceptions` + +Table of contents +----------------- + .. toctree:: :maxdepth: 2 - :caption: Contents: installation quickstart diff --git a/docs/installation.rst b/docs/installation.rst index 75a91e8..ca24c90 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,4 +1,20 @@ Installation ============ -TODO \ No newline at end of file +To install lectio.py and its dependencies, you can simply install it from PyPi:: + + pip install lectio.py + +or clone the repository and install it manually:: + + git clone https://github.com/dnorhoj/Lectio.py.git + cd lectio.py + python setup.py install + +You can check if the installation was successful by running:: + + python + + >>> import lectio + >>> lectio.__version__ + diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 474a449..8925be2 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -1,4 +1,31 @@ Quickstart ========== -TODO \ No newline at end of file +Let's start with a simple example, where we will get your schedule for today. + +Schedule +-------- + +Create a new file, and add the following code: + +.. code-block:: python + + from lectio import Lectio + from datetime import datetime, timedelta + + lec = Lectio('', '') + + # Get my user object + me = lec.me() + + # Get the schedule for today + schedule = me.get_schedule(datetime.now(), datetime.now() + timedelta(days=1)) + +We now have a list of all :class:`lectio.helpers.Module` in the ``schedule`` variable. Let's print it: + +.. code-block:: python + + for module in schedule: + print(module.title, module.teacher, module.room) + +**More to come...!** diff --git a/docs/reference.rst b/docs/reference.rst index 0683dfb..22c82e4 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -1,6 +1,53 @@ +.. currentmodule:: lectio + API Reference ============= -.. automodule:: lectio.lectio +Lectio +------ + +.. autoclass:: Lectio + :members: + :undoc-members: + + +School +------ + +.. autoclass:: lectio.models.school.School + :members: + :undoc-members: + +User +---- + +User +^^^^ + +.. autoclass:: lectio.models.user.User + :members: + :undoc-members: + +Me +^^^ + +.. autoclass:: lectio.models.user.Me + :members: + :undoc-members: + +User types +^^^^^^^^^^ + +.. autoclass:: lectio.models.user.UserType + :members: + :undoc-members: + +Misc +---- + +Module +^^^^^^ + +.. autoclass:: lectio.helpers.schedule.Module :members: :undoc-members: \ No newline at end of file diff --git a/lectio/__init__.py b/lectio/__init__.py index 8709e99..934296d 100644 --- a/lectio/__init__.py +++ b/lectio/__init__.py @@ -1,4 +1,7 @@ # flake8: noqa -from .lectio import Lectio, Module -from . import lectio -from . import exceptions +from .lectio import Lectio +from .helpers.schedule import Module +from .exceptions import * +from .models.user import User, UserType +from .models.school import School +from .models import * \ No newline at end of file diff --git a/lectio/exceptions.py b/lectio/exceptions.py index 1181afa..07b8990 100644 --- a/lectio/exceptions.py +++ b/lectio/exceptions.py @@ -1,3 +1,6 @@ +"""Here are all the custom Lectio.py exceptions as well as their explanation""" + + class LectioError(Exception): """Base lectio.py exception""" @@ -8,3 +11,11 @@ class UnauthenticatedError(LectioError): class IncorrectCredentialsError(LectioError): """Incorrect credentials error, mostly thrown in auto-login on session expired""" + + +class InstitutionDoesNotExistError(LectioError): + """The institution with the id you provided does not exist.""" + + +class UserDoesNotExistError(LectioError): + """The user does not exist.""" diff --git a/lectio/helpers/__init__.py b/lectio/helpers/__init__.py new file mode 100644 index 0000000..aa6e1c6 --- /dev/null +++ b/lectio/helpers/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .schedule import * diff --git a/lectio/helpers/schedule.py b/lectio/helpers/schedule.py new file mode 100644 index 0000000..6e14174 --- /dev/null +++ b/lectio/helpers/schedule.py @@ -0,0 +1,179 @@ +import re +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, List +from bs4 import BeautifulSoup + +if TYPE_CHECKING: + from ..lectio import Lectio + + +class Module: + """Lectio module object + + Represents a lectio module + + Args: + title (str|None): Optional description of module (not present in all modules) + subject (str|None): "Hold" from lectio, bascially which subject. + Example: `1.a Da` + teacher (str|None): Initials of teacher. + Example: `abcd` + room (str|None): Room name of module. + Example: `0.015` + extra_info (str|None): Extra info from module, includes homework and other info. + start_time (:class:`datetime.datetime`): Start time of module + end_time (:class:`datetime.datetime`): End time of module + status (int): 0=normal, 1=changed, 2=cancelled + url (str|None): Url for more info for the module + """ + + def __init__(self, **kwargs) -> None: + self.title = kwargs.get("title") + self.subject = kwargs.get("subject") + self.teacher = kwargs.get("teacher") + self.room = kwargs.get("room") + self.extra_info = kwargs.get("extra_info") + self.start_time = kwargs.get("start_time") + self.end_time = kwargs.get("end_time") + self.status = kwargs.get("status") + self.url = kwargs.get("url") + + def __repr__(self) -> str: + return f"Module({self.subject}, {self.start_time}, {self.end_time})" + + def display(self): + print(f"Title: {self.title}") + print(f"Subject(s): {self.subject}") + print(f"Teacher(s): {self.teacher}") + print(f"Room(s): {self.room}") + print(f"Starts at: {self.start_time}") + print(f"Ends at: {self.end_time}") + print(f"Status: {self.status}") + print(f"URL: {self.url}") + print(f"Extra info:\n\n{self.extra_info}") + + +def get_schedule(lectio: 'Lectio', params: List[str], start_date: datetime, end_date: datetime, strip_time: bool = True) -> List[Module]: + """Get lectio schedule for current or specific week. + + Get all modules in specified time range. + + Parameters: + lectio (:class:`lectio.Lectio`): Base lectio object + params (list): List of get parameters to add to request + start_date (:class:`datetime.datetime`): Start date + end_date (:class:`datetime.datetime`): End date + strip_time (bool): Whether to remove hours, minutes and seconds from date info, also adds 1 day to end time. + Basically just allows you to put in a random time of two days, and still get all modules from all the days including start and end date. + + Returns: + List[:class:`lectio.Module`]: List containing all modules in specified time range. + """ + + replacetime = {} + if strip_time: + end_date = end_date + timedelta(days=1) + replacetime = { + "hour": 0, + "minute": 0, + "second": 0, + } + + start_date = start_date.replace( + **replacetime, microsecond=0).isoformat() + end_date = end_date.replace(**replacetime, microsecond=0).isoformat() + + params = "&".join([ + "type=ShowListAll", + f"starttime={start_date}", + f"endtime={end_date}", + "dagsbemaerk=0", + *params + ]) + + schedule_request = lectio._request(f"SkemaAvanceret.aspx?{params}") + + soup = BeautifulSoup(schedule_request.text, 'html.parser') + + module_table = soup.find( + "table", class_="list texttop lf-grid") + + if not module_table: + return [] + + # Not a good way of checking if there are no modules, but it works + if module_table.find("div", {"class": "noRecord"}): + return [] + + modules = module_table.findChildren('tr', class_=None) + + schedule = [] + for module in modules: + a = module.findChild('a') + module = parse_additionalinfo( + a.attrs.get('data-additionalinfo')) + + # Add href to module + href = a.attrs.get('href') + if href: + module.url = f"https://www.lectio.dk{href}" + + schedule.append(module) + + return schedule + + +def parse_additionalinfo(info: str) -> Module: + module = Module() + + info_list = info.split('\n') + + # Parse module status + if info_list[0] == 'Ændret!': + module.status = 1 + info_list.pop(0) + elif info_list[0] == 'Aflyst!': + module.status = 2 + info_list.pop(0) + else: + module.status = 0 + + # Parse title + if not re.match(r'^[0-9]{1,2}\/[0-9]{1,2}-[0-9]{4} [0-9]{2}:[0-9]{2}', info_list[0]): + module.title = info_list[0] + info_list.pop(0) + + # Parse time + times = info_list[0].split(" til ") + info_list.pop(0) + module.start_time = datetime.strptime(times[0], "%d/%m-%Y %H:%M") + if len(times[1]) == 5: + module.end_time = datetime.strptime( + times[0][:-5] + times[1], "%d/%m-%Y %H:%M") + else: + module.end_time = datetime.strptime(times[1], "%d/%m-%Y %H:%M") + + # Parse subject(s) + subject = re.search(r"Hold: (.*)", info) + if subject: + info_list.pop(0) + module.subject = subject[1] + + # Parse teacher(s) + teacher = re.search(r"Lærere?: (.*)", info) + if teacher: + info_list.pop(0) + module.teacher = teacher[1] + + # Parse room(s) + room = re.search(r"Lokaler?: (.*)", info) + if room: + info_list.pop(0) + module.room = room[1] + + # Put any additional info into extra_info + if info_list: + info_list.pop(0) + module.extra_info = "\n".join(info_list) + + return module diff --git a/lectio/lectio.py b/lectio/lectio.py index cbd68c5..14f4da0 100644 --- a/lectio/lectio.py +++ b/lectio/lectio.py @@ -1,51 +1,11 @@ -from datetime import datetime, timedelta import re import requests from bs4 import BeautifulSoup -from . import exceptions - - -class Module: - """Lectio module object - Represents a lectio module - - Args: - title (str|None): Optional description of module (not present in all modules) - subject (str|None): "Hold" from lectio, bascially which subject. - Example: `1.a Da` - teacher (str|None): Initials of teacher. - Example: `abcd` - room (str|None): Room name of module. - Example: `0.015` - extra_info (str|None): Extra info from module, includes homework and other info. - start_time (:class:`datetime.datetime`): Start time of module - end_time (:class:`datetime.datetime`): End time of module - status (int): 0=normal, 1=changed, 2=cancelled - url (str|None): Url for more info for the module - """ +from . import exceptions - def __init__(self, **kwargs) -> None: - self.title = kwargs.get("title") - self.subject = kwargs.get("subject") - self.teacher = kwargs.get("teacher") - self.room = kwargs.get("room") - self.extra_info = kwargs.get("extra_info") - self.start_time = kwargs.get("start_time") - self.end_time = kwargs.get("end_time") - self.status = kwargs.get("status") - self.url = kwargs.get("url") - - def display(self): - print(f"Title: {self.title}") - print(f"Subject(s): {self.subject}") - print(f"Teacher(s): {self.teacher}") - print(f"Room(s): {self.room}") - print(f"Starts at: {self.start_time}") - print(f"Ends at: {self.end_time}") - print(f"Status: {self.status}") - print(f"URL: {self.url}") - print(f"Extra info:\n\n{self.extra_info}") +from .models.user import Me, User, UserType +from .models.school import School class Lectio: @@ -61,16 +21,17 @@ class Lectio: https://www.lectio.dk/lectio/123/login.aspx - Here, the `123` would be my institution id. + Here, the ``123`` would be my institution id. """ + __school: School = None + __me: Me = None + def __init__(self, inst_id: int) -> None: - self.__INST_ID = inst_id - self.__BASE_URL = f"https://www.lectio.dk/lectio/{str(inst_id)}" self.__CREDS = [] - self.__session = requests.Session() - # TODO: Check if inst_id is valid + + self.inst_id = inst_id def authenticate(self, username: str, password: str, save_creds: bool = True) -> bool: """Authenticates you on Lectio. @@ -87,19 +48,23 @@ def authenticate(self, username: str, password: str, save_creds: bool = True) -> save_creds (bool): Whether the credentials should be saved in the object (useful for auto relogin on logout) Raises: - lectio.IncorrectCredentialsError: When incorrect credentials passed + :class:`exceptions.IncorrectCredentialsError`: When incorrect credentials passed + :class:`exceptions.InstitutionDoesNotExistError`: When the institution id passed on creation of object is invalid Example:: - from lectio import Lectio, errors + from lectio import Lectio, exceptions lect = Lectio(123) try: - lect.authenticate("username", "password"): + lect.authenticate("username", "password") print("Authenticated") - except errors.IncorrectCredentialsError: + except exceptions.IncorrectCredentialsError: print("Not authenticated") + exit(1) + + ... """ self.__CREDS = [] @@ -109,180 +74,88 @@ def authenticate(self, username: str, password: str, save_creds: bool = True) -> # Call the actual authentication method self._authenticate(username, password) - # Check if authentication passed - self._request(self.__BASE_URL + "/forside.aspx") - - def _authenticate(self, username: str = None, password: str = None) -> bool: + def _authenticate(self, username: str = None, password: str = None): if username is None or password is None: if len(self.__CREDS) != 2: raise exceptions.UnauthenticatedError( - "Auto auth failed, did you authenticate?") + "No authentication details provided and no saved credentials found!") username, password = self.__CREDS self.log_out() # Clear session - login_page = self.__session.get(self.__BASE_URL + "/login.aspx") + URL = f"https://www.lectio.dk/lectio/{self.inst_id}/login.aspx" - if login_page.status_code != 200: - return False + login_page = self.__session.get(URL) + + if 'fejlhandled.aspx?title=Skolen+eksisterer+ikke' in login_page.url: + raise exceptions.InstitutionDoesNotExistError( + f"The institution with the id '{self._INST_ID}' does not exist!") parser = BeautifulSoup(login_page.text, "html.parser") - self.__session.post( - self.__BASE_URL + "/login.aspx", - data={ - "time": 0, - "__EVENTTARGET": "m$Content$submitbtn2", - "__EVENTARGUMENT": "", - "__SCROLLPOSITION": "", - "__VIEWSTATEX": parser.find(attrs={"name": "__VIEWSTATEX"})["value"], - "__VIEWSTATEY_KEY": "", - "__VIEWSTATE": "", - "__EVENTVALIDATION": parser.find(attrs={"name": "__EVENTVALIDATION"})["value"], - "m$Content$username": username, - "m$Content$password": password - } - ) - - def get_user_id(self) -> int: - """Gets your user id + r = self.__session.post(URL, data={ + "time": 0, + "__EVENTTARGET": "m$Content$submitbtn2", + "__EVENTARGUMENT": "", + "__SCROLLPOSITION": "", + "__VIEWSTATEX": parser.find(attrs={"name": "__VIEWSTATEX"})["value"], + "__VIEWSTATEY_KEY": "", + "__VIEWSTATE": "", + "__EVENTVALIDATION": parser.find(attrs={"name": "__EVENTVALIDATION"})["value"], + "m$Content$username": username, + "m$Content$password": password + }) + + if r.url == URL: + # Authentication failed + raise exceptions.IncorrectCredentialsError( + "Incorrect credentials provided!") + + def school(self) -> School: + """Returns a :class:`lectio.models.school.School` object for the given institution id. Returns: - int: User id + :class:`lectio.models.school.School`: The school object for the authenticated user. """ - r = self._request(self.__BASE_URL + "/forside.aspx") + if self.__school is None: + self.__school = School(self) - soup = BeautifulSoup(r.text, 'html.parser') + return self.__school - content = soup.find( - 'meta', {'name': 'msapplication-starturl'}).attrs.get('content') + def me(self) -> Me: + """Gets the authenticated user - user_id = int(re.match(r'.*id=([0-9]+)$', content)[1]) + Returns: + :class:`lectio.models.user.Me`: Own user object + """ - return user_id + if self.__me is None: + r = self._request("forside.aspx") - def get_schedule_for_student(self, elevid: int, start_date: datetime, end_date: datetime, strip_time: bool = True) -> any: - """Get lectio schedule for current or specific week. + soup = BeautifulSoup(r.text, 'html.parser') - Get all modules in specified time range. + content = soup.find( + 'meta', {'name': 'msapplication-starturl'}).attrs.get('content') - Parameters: - elevid (int): Student id - start_date (:class:`datetime.datetime`): Start date - end_date (:class:`datetime.datetime`): End date - strip_time (bool): Whether to remove hours, minutes and seconds from date info, also adds 1 day to end time. - Basically just allows you to put in a random time of two days, and still get all modules from all the days including start and end date. + user_id = re.match(r'.*id=([0-9]+)$', content)[1] - Returns: - list: List containing all modules in specified time range. - """ + # TODO; Add support for teachers + self.__me = Me(self, user_id, UserType.STUDENT) - replacetime = {} - if strip_time: - end_date = end_date + timedelta(days=1) - replacetime = { - "hour": 0, - "minute": 0, - "second": 0, - } - - start_date = start_date.replace( - **replacetime, microsecond=0).isoformat() - end_date = end_date.replace(**replacetime, microsecond=0).isoformat() - - params = ( - "type=ShowListAll&" - f"starttime={start_date}&" - f"endtime={end_date}&" - "dagsbemaerk=0&" - f"studentsel={elevid}" - ) - - schedule_request = self._request( - f"{self.__BASE_URL}/SkemaAvanceret.aspx?{params}") - - soup = BeautifulSoup(schedule_request.text, 'html.parser') - - modules = soup.find( - "table", class_="list texttop lf-grid").findChildren('tr', class_=None) - - schedule = [] - for module in modules: - a = module.findChild('a') - module = self._parse_additionalinfo( - a.attrs.get('data-additionalinfo')) - - href = a.attrs.get('href') - if href is not None: - module.url = f"https://www.lectio.dk{href}" - schedule.append(module) - - return schedule - - def _parse_additionalinfo(self, info: str) -> Module: - module = Module() - - info_list = info.split('\n') - - # Parse module status - if info_list[0] == 'Ændret!': - module.status = 1 - info_list.pop(0) - elif info_list[0] == 'Aflyst!': - module.status = 2 - info_list.pop(0) - else: - module.status = 0 - - # Parse title - if not re.match(r'^[0-9]{1,2}\/[0-9]{1,2}-[0-9]{4} [0-9]{2}:[0-9]{2}', info_list[0]): - module.title = info_list[0] - info_list.pop(0) - - # Parse time - times = info_list[0].split(" til ") - info_list.pop(0) - module.start_time = datetime.strptime(times[0], "%d/%m-%Y %H:%M") - if len(times[1]) == 5: - module.end_time = datetime.strptime( - times[0][:-5] + times[1], "%d/%m-%Y %H:%M") - else: - module.end_time = datetime.strptime(times[1], "%d/%m-%Y %H:%M") - - # Parse subject(s) - subject = re.search(r"Hold: (.*)", info) - if subject: - info_list.pop(0) - module.subject = subject[1] - - # Parse teacher(s) - teacher = re.search(r"Lærere?: (.*)", info) - if teacher: - info_list.pop(0) - module.teacher = teacher[1] - - # Parse room(s) - room = re.search(r"Lokaler?: (.*)", info) - if room: - info_list.pop(0) - module.room = room[1] - - # Put any additional info into extra_info - if info_list: - info_list.pop(0) - module.extra_info = "\n".join(info_list) - - return module + return self.__me def _request(self, url: str, method: str = "GET", **kwargs) -> requests.Response: - r = self.__session.request(method, url, **kwargs) - - if f"{self.__INST_ID}/login.aspx?prevurl=" in r.url: - self._authenticate() - r = self.__session.get(url) - if f"{self.__INST_ID}/login.aspx?prevurl=" in r.url: + r = self.__session.request( + method, f"https://www.lectio.dk/lectio/{str(self.inst_id)}/{url}", **kwargs) + + if f"{self.inst_id}/login.aspx?prevurl=" in r.url: + if not self._authenticate(): + raise exceptions.UnauthenticatedError("Unauthenticated") + r = self.__session.get( + f"https://www.lectio.dk/lectio/{str(self.inst_id)}/{url}") + if f"{self.inst_id}/login.aspx?prevurl=" in r.url: raise exceptions.IncorrectCredentialsError( "Could not restore session, probably incorrect credentials") diff --git a/lectio/models/__init__.py b/lectio/models/__init__.py new file mode 100644 index 0000000..2c2b9de --- /dev/null +++ b/lectio/models/__init__.py @@ -0,0 +1,3 @@ +# flake8: noqa +from .school import School +from .user import User, Me, UserType diff --git a/lectio/models/school.py b/lectio/models/school.py new file mode 100644 index 0000000..5f7b327 --- /dev/null +++ b/lectio/models/school.py @@ -0,0 +1,226 @@ +from typing import TYPE_CHECKING, List +from bs4 import BeautifulSoup +import re +from urllib.parse import quote + +from .user import User, UserType +from ..import exceptions + +if TYPE_CHECKING: + from .. import Lectio + + +class School: + """A school object. + + Represents a school. + + Note: + This class should not be instantiated directly, + but rather through the :meth:`lectio.Lectio.get_school` method. + + Args: + lectio (:class:`lectio.Lectio`): Lectio object + """ + + def __init__(self, lectio: 'Lectio') -> None: + self._lectio = lectio + self.__populate() + + def __populate(self) -> None: + r = self._lectio._request("forside.aspx") + + soup = BeautifulSoup(r.text, 'html.parser') + + self.name = soup.find( + "div", {"id": "s_m_masterleftDiv"}).text.strip().split("\n")[0].replace("\r", "") + + def get_user_by_id(self, user_id: str, user_type: UserType = UserType.STUDENT, check: bool = True) -> User: + """Gets a user by their id + + Args: + user_id (str): The id of the user + user_type (:class:`lectio.models.user.UserType`): The type of the user (student or teacher) + check (bool): Whether to check if the user exists (slower) + + Returns: + :class:`lectio.models.user.User`: User object + + Raises: + :class:`lectio.exceptions.UserDoesNotExistError`: When the user does not exist + """ + + if check: + # Check if user exists + r = self._lectio._request( + f"SkemaNy.aspx?type={user_type}&{user_type}id={user_id}") + + soup = BeautifulSoup(r.text, 'html.parser') + + if soup.title.string.strip().startswith("Fejl - Lectio"): + raise exceptions.UserDoesNotExistError( + f"The {UserType.get_str(user_type, True)} with the id '{user_id}' does not exist!") + + return User(self._lectio, user_id, user_type) + + def get_teachers(self) -> List[User]: + """Get all teachers + + Returns: + list(:class:`lectio.models.user.User`): List of teachers + """ + + r = self._lectio._request("FindSkema.aspx?type=laerer&sortering=id") + + soup = BeautifulSoup(r.text, 'html.parser') + + teachers = [] + + # Container containing all teachers + lst = soup.find("ul", {"class": "ls-columnlist mod-onechild"}) + + if lst is None: + return [] + + # Iterate and create user objects + for i in lst.find_all("li"): + user_id = int(i.a["href"].split("=")[-1]) + + user_name = i.a.contents[1].strip() + + initial_span = i.a.find('span') + + user_initials = None + if initial_span is not None: + user_initials = initial_span.text.strip() + + teachers.append(User(self._lectio, + user_id, + UserType.TEACHER, + lazy=True, + name=user_name, + initials=user_initials)) + + return teachers + + def search_for_teachers(self, query: str) -> List[User]: + """Search for teachers + + Note: + This method is not very reliable, and will sometimes return no results. + Also, the query has to be from the beginning of the name. + + Example: Searching for "John" will return "John Doe", but searching for "Doe" will not. + + Args: + query (str): Name to search for + + Returns: + list(:class:`lectio.models.user.User`): List of teachers + """ + + res = [] + + for teacher in self.get_teachers(): + if teacher.name.lower().startswith(query.lower()): + res.append(teacher) + + return res + + def get_students_by_letter(self, letter: str) -> List[User]: + """Get students by first letter of name + + Args: + letter (str): Letter to search for + + Returns: + list(:class:`lectio.models.user.User`): List of students + """ + + r = self._lectio._request( + "FindSkema.aspx?type=elev&forbogstav=" + quote(letter.upper())) + + soup = BeautifulSoup(r.text, 'html.parser') + + students = [] + + # Container containing all students + lst = soup.find("ul", {"class": "ls-columnlist mod-onechild"}) + + # Shouldn't happen in cases other than ``len(letter) > 1`` or if letter is not a valid character + if lst is None: + return [] + + # Iterate and create user objects + for i in lst.find_all("li"): + user_id = int(i.a["href"].split("=")[-1]) + + user_info = i.a.text.strip() + + # Search for name and class + search = re.search( + r"(?P.*) \((?P.*?) \d+?\)", user_info) + + if search is None: + continue + + students.append(User(self._lectio, + user_id, + lazy=True, + name=search.group("name"), + class_name=search.group("class_name"))) + + return students + + def search_for_students(self, query: str) -> List[User]: + """Search for user + + Note: + This method is not very reliable, and will sometimes return no results. + Also, the query has to be from the beginning of the name. + + Example: Searching for "John" will return "John Doe", but searching for "Doe" will not. + + Args: + query (str): Name to search for + + Returns: + list(:class:`lectio.User`): List of users + """ + + res = [] + + for student in self.get_students_by_letter(query[0]): + if student.name.lower().startswith(query.lower()): + res.append(student) + + return res + + def get_all_students(self) -> List[User]: + """Get all students + + Returns: + list(:class:`User`): List of students + """ + + res = [] + + for letter in "abcdefghijklmnopqrstuvwxyzæøå": + res.extend(self.get_students_by_letter(letter)) + + return res + + def search_for_users(self, query: str) -> List[User]: + """Search for user + + Args: + query (str): Name to search for + + Returns: + list(:class:`lectio.models.user.User`): List of users + """ + + return [*self.search_for_students(query), *self.search_for_teachers(query)] + + def __repr__(self) -> str: + return f"School({self.name})" diff --git a/lectio/models/user.py b/lectio/models/user.py new file mode 100644 index 0000000..bd3f78b --- /dev/null +++ b/lectio/models/user.py @@ -0,0 +1,196 @@ +from enum import Enum +from bs4 import BeautifulSoup +from typing import TYPE_CHECKING, List + +from ..helpers.schedule import get_schedule + +if TYPE_CHECKING: + from datetime import datetime + from ..helpers.schedule import Module + from ..lectio import Lectio + + +class UserType(Enum): + """User types enum + + Example: + >>> from lectio import Lectio + >>> from lectio.models.user import UserType + >>> lec = Lectio(123) + >>> lec.authenticate("username", "password") + >>> me = lec.me() + >>> print(me.type) + 0 + >>> print(me.type == UserType.STUDENT) + True + """ + + STUDENT = 0 + TEACHER = 1 + + def get_str(self) -> str: + """Get string representation of user type for lectio interface in english + + Returns: + str: String representation of user type + """ + + if self.value == self.STUDENT.value: + return "student" + elif self.value == self.TEACHER.value: + return "teacher" + + def __str__(self) -> str: + if self.value == self.STUDENT.value: + return "elev" + elif self.value == self.TEACHER.value: + return "laerer" + + +class User: + """Lectio user object + + Represents a lectio user + + Note: + This class should not be instantiated directly, + but rather through the :meth:`lectio.Lectio.get_user` + or :meth:`lectio.models.school.School.search_for_users` methods or similar. + + Args: + lectio (:class:`lectio.Lectio`): Lectio object + user_id (int): User id + user_type (:class:`lectio.models.user.UserType`): User type (UserType.STUDENT or UserType.TEACHER) + lazy (bool): Whether to not populate user object on instantiation (default: False) + + Attributes: + id (int): User id + type (:class:`lectio.models.user.UserType`): User type (UserType.STUDENT or UserType.TEACHER) + """ + + __name = None + __initials = None + __class_name = None + __image = None + + def __init__(self, lectio: 'Lectio', user_id: int, user_type: UserType = UserType.STUDENT, *, lazy=False, **user_data) -> None: + self._lectio = lectio + self.id = user_id + + self.type = user_type + + if not lazy: + self.__populate() + else: + self.__name = user_data.get("name") + self.__initials = user_data.get("initials") + self.__class_name = user_data.get("class_name") + self.__image = user_data.get("image") + + def __populate(self) -> None: + """Populate user object + + Populates the user object with data from lectio, such as name, class name, etc. + """ + + # TODO; Check if user is student or teacher + + # Get user's schedule for today + r = self._lectio._request( + f"SkemaNy.aspx?type={self.type}&{self.type}id={self.id}") + + soup = BeautifulSoup(r.text, "html.parser") + + title = soup.find("div", {"id": "s_m_HeaderContent_MainTitle"}).text + + title = " ".join(title.split()[1:]) + + if self.type == UserType.STUDENT: + self.__name = title.split(", ")[0] + self.__class_name = title.split(", ")[1].split(" - ")[0] + elif self.type == UserType.TEACHER: + self.__initials, self.__name, *_ = title.split(" - ") + + src = soup.find( + "img", {"id": "s_m_HeaderContent_picctrlthumbimage"}).get("src") + + self.__image = f"https://www.lectio.dk{src}&fullsize=1" + + def get_schedule(self, start_date: 'datetime', end_date: 'datetime', strip_time: bool = True) -> List['Module']: + """Get schedule for user + + Note: + As lectio is weird, you can only get a schedule for a range + that is less than one month. + If you specify a range greater than one month, you will get an empty return list. + + Args: + start_date (:class:`datetime.datetime`): Start date + end_date (:class:`datetime.datetime`): End date + strip_time (bool): Whether to remove hours, minutes and seconds from date info, also adds 1 day to end time. + Basically just allows you to put in a random time of two days, and still get all modules from all the days including start and end date. + """ + + return get_schedule( + self._lectio, + [f"{self.type.get_str()}sel={self.id}"], + start_date, + end_date, + strip_time + ) + + def __repr__(self) -> str: + return f"User({self.type.get_str().capitalize()}, {self.id})" + + @property + def name(self) -> str: + """str: User's name""" + + if not self.__name: + self.__populate() + + return self.__name + + @property + def image(self) -> str: + """str: User's image url""" + + if not self.__image: + self.__populate() + + return self.__image + + @property + def initials(self) -> str: + """str|None: User's initials (only for teachers)""" + + if self.type == UserType.STUDENT: + return None + + if not self.__initials: + self.__populate() + + return self.__initials + + @property + def class_name(self) -> str: + """str|None: User's class name (only for students)""" + + if self.type == UserType.TEACHER: + return None + + if not self.__class_name: + self.__populate() + + return self.__class_name + + def __eq__(self, __o: object) -> bool: + if not isinstance(__o, User): + return False + + return self.id == __o.id and self.type == __o.type + + +class Me(User): + # TODO: Add methods for getting grades, absences, etc. + pass diff --git a/setup.py b/setup.py index 032f5b7..9ed3735 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="lectio.py", - version="0.1.1", + version="0.2.0", author="dnorhoj", author_email="daniel.norhoj@gmail.com", description="Interact with lectio through python",