diff --git a/.gitignore b/.gitignore index ca24ce3..0034ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ venv/ docs/build dist/ .vscode/ +/*.py \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 4d4d80c..55e2f15 100644 --- a/poetry.lock +++ b/poetry.lock @@ -147,6 +147,17 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "attrs" version = "24.2.0" @@ -580,6 +591,160 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "lxml" +version = "5.3.0" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.6" +files = [ + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"}, + {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"}, + {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"}, + {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"}, + {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"}, + {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"}, + {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"}, + {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"}, + {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"}, + {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"}, + {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"}, + {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"}, + {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"}, + {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"}, + {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"}, + {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"}, + {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"}, + {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"}, + {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"}, + {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"}, + {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"}, + {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.11)"] + [[package]] name = "markupsafe" version = "3.0.1" @@ -917,6 +1082,130 @@ files = [ {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, ] +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pygments" version = "2.18.0" @@ -1146,6 +1435,17 @@ files = [ {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" version = "2.2.3" @@ -1356,4 +1656,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = ">=3.11.0,<4.0.0" -content-hash = "88f7e9a802c9a6c2aaae5160975c8b3eba88b622de02de57b4a1fccd1948ad45" +content-hash = "a6507929bd5ca310fdccfb1a4f781e77c1fe4ae831aeac158c5cf539dbb611fd" diff --git a/pyproject.toml b/pyproject.toml index ec30c12..a226ee0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ platformdirs = "^3.0.0" websockets = "^10.4" aiohttp = "^3.8.3" backoff = "^2.2.1" +lxml = "^5.3.0" +yarl = "^1.15.2" +pydantic = "^2.9.2" [tool.poetry.dev-dependencies] # Remember to update docs/build-requirements.txt! @@ -23,6 +26,9 @@ tomli = "^2.0.1" [tool.poetry.group.dev.dependencies] black = {version = "^23.1.0", allow-prereleases = true} +[tool.isort] +profile = "black" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/sechat/__init__.py b/sechat/__init__.py index 53b7c09..f81944f 100644 --- a/sechat/__init__.py +++ b/sechat/__init__.py @@ -1,7 +1,6 @@ -from sechat.server import Server -from sechat.bot import Bot +from sechat.credentials import Credentials from sechat.room import Room -from sechat.version import __version__ -from sechat.events import Event, EventType, MentionEvent, MessageEvent, UnknownEvent, EditEvent, ReplyEvent, DeleteEvent +from sechat.servers import Server -__all__ = ["Server", "Bot", "Room", "Event", "EventType", "MentionEvent", "MessageEvent", "UnknownEvent", "EditEvent", "ReplyEvent", "DeleteEvent"] \ No newline at end of file +__version__ = "2.0.0" +__all__ = ["Credentials", "Room", "Server"] diff --git a/sechat/bot.py b/sechat/bot.py deleted file mode 100644 index a2238b4..0000000 --- a/sechat/bot.py +++ /dev/null @@ -1,235 +0,0 @@ -from typing import Optional, cast -from logging import Logger, getLogger -from pathlib import Path -from os import PathLike, makedirs -from asyncio import create_task, Task -from functools import partial - -from platformdirs import user_cache_path -from aiohttp import ClientSession, CookieJar -from bs4 import BeautifulSoup, Tag -from hashlib import md5 - -from sechat.server import Server -from sechat.room import Room -from sechat.errors import LoginError -from sechat.version import __version__ - -class Bot: - def __init__( - self, - server: Server = Server.STACK_EXCHANGE, - useCookies: bool = True, - logger: Optional[Logger] = None, - cachePath: Optional[PathLike] = None, - ): - self.server = server - self.useCookies = useCookies - if logger: - self.logger = logger - else: - self.logger = getLogger("Bot") - if cachePath: - self.cachePath = Path(cachePath) - else: - self.cachePath = user_cache_path("sechat", None, __version__) - makedirs(self.cachePath, exist_ok=True) - - self.cookieJar = CookieJar() - self.roomTasks: dict[Room, Task] = {} - self.rooms: dict[int, Room] = {} - - def loadCookies(self, email: str, cookies: CookieJar): - cookiePath = ( - self.cachePath - / f"sechat_cookies_{md5(email.encode('utf-8')).hexdigest()}.dat" - ) - try: - cookies.load(cookiePath) - except FileNotFoundError: - self.logger.debug("No cookies found :(") - else: - return True - - def dumpCookies(self, email: str, cookies: CookieJar): - cookiePath = ( - self.cachePath - / f"sechat_cookies_{md5(email.encode('utf-8')).hexdigest()}.dat" - ) - cookies.save(cookiePath) - self.logger.debug(f"Dumped cookies to {cookiePath}") - - async def getChatFkey(self, session: ClientSession) -> Optional[str]: - async with session.get( - f"https://{self.server}/chats/join/favorite" - ) as response: - soup = BeautifulSoup( - await response.text(), - "html.parser", - ) - if content := soup.find(id="content"): - if form := cast(Tag, content).form: - if fkeyInput := form.find("input", attrs={"name": "fkey"}): - if fkey := cast(Tag, fkeyInput).get("value"): - if isinstance(fkey, list): - return "".join(fkey) - return fkey - - async def getChatUserId(self, session: ClientSession) -> Optional[int]: - async with session.get( - f"https://{self.server}/chats/join/favorite" - ) as response: - soup = BeautifulSoup( - await response.text(), - "html.parser", - ) - if links := soup.find(class_="topbar-menu-links"): - if link := cast(Tag, links).find("a"): - if href := cast(Tag, link).get("href"): - if isinstance(href, list): - href = "".join(href) - return int(href.split("/")[2]) - - async def scrapeFkey(self, session: ClientSession) -> Optional[str]: - async with session.get( - "https://meta.stackexchange.com/users/login" - ) as response: - soup = BeautifulSoup( - await response.text(), - "html.parser", - ) - if fkeyTag := soup.find(attrs={"name": "fkey"}): - fkey = cast(Tag, fkeyTag)["value"] - if isinstance(fkey, list): - return "".join(fkey) - return fkey - - async def doSELogin( - self, session: ClientSession, host: str, email: str, password: str, fkey: str - ) -> str: - async with session.post( - f"{host}/users/login-or-signup/validation/track", - data={ - "email": email, - "password": password, - "fkey": fkey, - "isSignup": "false", - "isLogin": "true", - "isPassword": "false", - "isAddLogin": "false", - "hasCaptcha": "false", - "ssrc": "head", - "submitButton": "Log in", - }, - ) as response: - return await response.text() - - async def loadProfile( - self, session: ClientSession, host: str, fkey: str, email: str, password: str - ): - async with session.post( - f"{host}/users/login", - params={"ssrc": "head", "returnurl": f"{host}"}, - data={ - "email": email, - "password": password, - "fkey": fkey, - "ssrc": "head", - }, - ) as response: - soup = BeautifulSoup( - await response.text(), - "html.parser", - ) - if head := soup.head: - if title := head.title: - if titleString := title.string: - if "Human verification" in titleString: - raise LoginError( - "Failed to load SE profile: Caught by captcha. (It's almost like I'm not human!) Wait around 5min and try again." - ) - else: - return - raise LoginError( - "Failed to load SE profile: Unable to ascertain success state." - ) - - async def universalLogin(self, session: ClientSession, host: str): - return await session.post(f"{host}/users/login/universal/request") - - def needsToLogin(self, email: str) -> bool: - if self.useCookies: - if self.loadCookies(email, self.cookieJar): - self.cookieJar._do_expiration() - return "acct" not in self.cookieJar._cookies.get( - ("stackexchange.com", "/"), {} - ) - return True - - async def authenticate(self, email: str, password: Optional[str], host: str): - if self.useCookies: - if self.loadCookies(email, self.cookieJar): - self.logger.debug("Loaded cookies") - self.cookieJar._do_expiration() - async with ClientSession() as session: - session.headers.update( - { - "User-Agent": f"Mozilla/5.0 (compatible; sechat/{__version__}; +http://pypi.org/project/sechat)" - } - ) - if "acct" not in self.cookieJar._cookies.get(("stackexchange.com", "/"), {}): - assert password is not None, "Cookie expired, must supply password!" - self.logger.debug("Logging into SE...") - self.logger.debug("Acquiring fkey...") - fkey = await self.scrapeFkey(session) - if not fkey: - raise LoginError("Failed to scrape site fkey.") - self.logger.debug(f"Acquired fkey: {fkey}") - self.logger.info(f"Logging into {host}...") - result = await self.doSELogin(session, host, fkey, email, password) - if result != "Login-OK": - raise LoginError(f"Site login failed!", result) - self.logger.debug(f"Logged into {host}!") - self.logger.debug("Loading profile...") - await self.loadProfile(session, host, fkey, email, password) - self.logger.debug("Loaded SE profile!") - self.logger.debug("Logging into the rest of the network...") - await self.universalLogin(session, host) - if self.useCookies: - self.logger.debug("Dumping cookies...") - self.dumpCookies(email, self.cookieJar) - - self.fkey = await self.getChatFkey(session) - self.userID = await self.getChatUserId(session) - if not self.fkey or not self.userID: - raise LoginError("Login failed. Bad email/password?") - self.logger.debug(f"Chat fkey is {self.fkey}, user ID is {self.userID}") - self.logger.info(f"Logged into {host}!") - - def _roomExited(self, room: Room, task: Task): - if (e := task.exception()) != None: - self.logger.critical( - f"An exception occured in the task of room {room.roomID}", exc_info=e - ) - - async def joinRoom(self, roomID: int, logger: Optional[Logger] = None) -> Room: - self.logger.info(f"Joining room {roomID}") - if not self.fkey or not self.userID: - raise RuntimeError("Not logged in") - assert roomID not in self.rooms, "Already in room" - room = Room(self.server, self.cookieJar, self.fkey, self.userID, roomID, logger) - task = create_task(room.loop(), name=room.logger.name) - task.add_done_callback(partial(self._roomExited, room)) - self.rooms[roomID] = room - self.roomTasks[room] = task - await room._connectedEvent.wait() - return room - - async def closeRoom(self, roomID: int): - self.logger.info(f"Leaving room {roomID}") - task = self.roomTasks.pop(self.rooms.pop(roomID)) - task.cancel() - await task - - async def closeAllRooms(self): - [await self.closeRoom(room) for room in list(self.rooms.keys())] diff --git a/sechat/credentials.py b/sechat/credentials.py new file mode 100644 index 0000000..bb4ac3e --- /dev/null +++ b/sechat/credentials.py @@ -0,0 +1,189 @@ +import pickle +from dataclasses import dataclass +from http.cookies import Morsel +from logging import getLogger +from os.path import exists +from typing import TYPE_CHECKING, cast + +from aiohttp import ClientSession, CookieJar +from bs4 import BeautifulSoup, Tag +from yarl import URL + +from sechat.errors import LoginError +from sechat.servers import Server + +if TYPE_CHECKING: + from _typeshed import FileDescriptorOrPath + +LOGIN_HOST = URL("https://meta.stackexchange.com") +COOKIE_ROOT = "stackexchange.com" +USER_AGENT = "Mozilla/5.0 (compatible; sechat/2.0.0; +https://pypi.org/project/sechat)" +logger = getLogger(__name__) + + +@dataclass +class Credentials: + server: Server + acct: Morsel[str] + prov: Morsel[str] + chatusr: Morsel[str] + user_id: int + + @property + def cookies(self): + return { + "acct": self.acct, + "prov": self.prov, + ( + "sechatusr" if self.server == Server.STACK_EXCHANGE else "chatusr" + ): self.chatusr, + } + + @property + def headers(self): + return {"User-Agent": USER_AGENT, "Referer": self.server} + + def session(self): + return ClientSession(self.server, cookies=self.cookies, headers=self.headers) + + @staticmethod + async def authenticate( + email: str, password: str, *, server: Server = Server.META_STACK_EXCHANGE + ) -> "Credentials": + logger.info(f"Logging into {server}") + chat_user_cookie = "sechatusr" if server == Server.STACK_EXCHANGE else "chatusr" + + async with ClientSession( + LOGIN_HOST, headers={"User-Agent": USER_AGENT} + ) as qa_session: + async with qa_session.get("/users/login") as response: + response.raise_for_status() + soup = BeautifulSoup(await response.read(), "lxml") + assert isinstance(login_form := soup.find(id="login-form"), Tag) + assert isinstance( + fkey_input := login_form.find(attrs={"name": "fkey"}), Tag + ) + assert isinstance(qa_fkey := fkey_input["value"], str) + logger.debug(f"QA fkey is {qa_fkey}") + async with qa_session.post( + "/users/login-or-signup/validation/track", + data={ + "isSignup": "false", + "isLogin": "true", + "isPassword": "false", + "isAddLogin": "false", + "fkey": qa_fkey, + "ssrc": "head", + "email": email, + "password": password, + "oauthversion": "", + "oauthserver": "", + }, + ) as response: + if response.status != 200: + raise LoginError( + "Failed to login!", response.status, await response.text() + ) + async with qa_session.post( + "/users/login", + data={ + "fkey": qa_fkey, + "ssrc": "login", + "email": email, + "password": password, + "oauth_version": "", + "oauth_server": "", + }, + allow_redirects=False, + ) as response: + if response.status != 302: + raise LoginError("Login failed! Incorrect username or password?") + if (redirect_target := URL(response.headers["Location"])).path != "/": + raise LoginError( + f"Login failed! Redirected to {redirect_target}; caught by captcha?" + ) + logger.debug(f"Logged in to {LOGIN_HOST}") + + qa_cookies = cast(CookieJar, qa_session.cookie_jar)._cookies[(COOKIE_ROOT, "")] + acct = qa_cookies["acct"] + prov = qa_cookies["prov"] + + async with ClientSession( + server, headers={"User-Agent": USER_AGENT} + ) as chat_session: + chat_session.cookie_jar.update_cookies( + cookies={"acct": acct, "prov": prov}, + response_url=URL.build(scheme="https", host=COOKIE_ROOT), + ) + async with chat_session.get("/") as response: + response.raise_for_status() + if chat_user_cookie not in response.cookies: + raise LoginError( + f"Login failed! {chat_user_cookie} not in cookies returned from {server}" + ) + chatusr = response.cookies[chat_user_cookie] + + soup = BeautifulSoup(await response.read(), "lxml") + assert isinstance( + topbar_menu_links := soup.find(class_="topbar-menu-links"), Tag + ) + assert isinstance(profile_link := topbar_menu_links.find("a"), Tag) + assert isinstance(url := profile_link["href"], str) + url = URL(url) + if url.host == "stackexchange.com": + raise LoginError( + "The supplied credentials were not accepted by chat! Bad username or password?" + ) + user_id = int(url.parts[2]) + + logger.info(f"Logged into QA and chat") + return Credentials( + server=server, acct=acct, prov=prov, chatusr=chatusr, user_id=user_id + ) + + @staticmethod + async def load(path: "FileDescriptorOrPath"): + logger.info(f"Reading credentials from {path}") + with open(path, "rb") as file: + credentials = pickle.load(file) + assert isinstance(credentials, Credentials) + async with credentials.session() as session, session.get("/") as response: + soup = BeautifulSoup(await response.read(), "lxml") + assert isinstance( + topbar_menu_links := soup.find(class_="topbar-menu-links"), Tag + ) + assert isinstance(profile_link := topbar_menu_links.find("a"), Tag) + assert isinstance(url := profile_link["href"], str) + url = URL(url) + if url.host == "stackexchange.com": + logger.info(f"Credentials in {path} are expired!") + return None + user_id = int(url.parts[2]) + if user_id != credentials.user_id: + logger.warning( + f"Credentials in {path} have an incorrect user id! ({user_id} expected, {credentials.user_id} loaded)" + ) + return None + return credentials + + @staticmethod + async def load_or_authenticate( + path: "FileDescriptorOrPath", + email: str, + password: str, + *, + server: Server = Server.STACK_EXCHANGE, + ): + if exists(path): + try: + credentials = await Credentials.load(path) + except Exception as e: + logger.error(f"Failed to load credentials from {path}", exc_info=e) + else: + if credentials is not None: + return credentials + credentials = await Credentials.authenticate(email, password, server=server) + with open(path, "wb") as file: + pickle.dump(credentials, file) + logger.info(f"Saved credentials to {file}") + return credentials diff --git a/sechat/events.py b/sechat/events.py index 9c04237..b759259 100644 --- a/sechat/events.py +++ b/sechat/events.py @@ -1,11 +1,10 @@ -from typing import Any, Optional -from enum import Enum -from dataclasses import dataclass, field, InitVar -from datetime import datetime -from collections import defaultdict -from html import unescape - -class EventType(Enum): +from typing import Annotated, Any, Literal, Optional +from enum import IntEnum + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +class EventType(IntEnum): MESSAGE = 1 EDIT = 2 JOIN = 3 @@ -33,81 +32,53 @@ class EventType(Enum): USER_NAME_OR_AVATAR_CHANGE = 34 -class EventBase: - pass - - -class UnknownEvent(EventBase): - def __init__(self, **kwargs): - self.eventType = EventType(kwargs["event_type"]) - self.args = kwargs - - -@dataclass -class Event(EventBase): - event_type: InitVar[int] - time_stamp: InitVar[int] +class Event(BaseModel): id: int - timestamp: datetime = field(init=False) - - def __post_init__(self, event_type, time_stamp): - self.eventType = EventType(event_type) - self.timestamp = datetime.fromtimestamp(time_stamp) - - -@dataclass -class RoomEvent(Event): room_id: int - room_name: int + room_name: str -@dataclass -class MessageEvent(RoomEvent): - content: str +class BaseMessageEvent(Event): message_id: int user_id: int user_name: str + parent_id: Optional[int] = None show_parent: Any = None # idfk what this is target_user_id: int = 0 + message_stars: int = 0 message_owner_stars: int = 0 message_edits: int = 0 - def __post_init__(self, event_type, time_stamp): - super().__post_init__(event_type, time_stamp) - self.content = unescape(self.content) + +class DeleteEvent(BaseMessageEvent): + event_type: Literal[EventType.DELETE] + + +class MessageEvent(BaseMessageEvent): + event_type: Literal[EventType.MESSAGE] + content: str -@dataclass class EditEvent(MessageEvent): - pass + event_type: Literal[EventType.EDIT] + -@dataclass class MentionEvent(MessageEvent): - pass - -@dataclass + event_type: Literal[EventType.MENTION] + + class ReplyEvent(MessageEvent): - pass - -@dataclass -class DeleteEvent(RoomEvent): - user_id: int - user_name: int - message_id: int - message_edits: int = 0 - target_user_id: Optional[int] = None - parent_id: Optional[int] = None - show_parent: bool = False - -EVENT_CLASSES = defaultdict( - lambda: UnknownEvent, - { - EventType.MESSAGE: MessageEvent, - EventType.MENTION: MentionEvent, - EventType.REPLY: ReplyEvent, - EventType.EDIT: EditEvent, - EventType.DELETE: DeleteEvent, - }, + event_type: Literal[EventType.REPLY] + + +class UnknownEvent(Event): + event_type: EventType + model_config = ConfigDict(extra="allow") + + +Events = DeleteEvent | MessageEvent | EditEvent | MentionEvent | ReplyEvent +_EventAdapter = TypeAdapter[Event]( + Annotated[Events, Field(discriminator="event_type")] | UnknownEvent ) diff --git a/sechat/room.py b/sechat/room.py index 260e6bd..8429dd3 100644 --- a/sechat/room.py +++ b/sechat/room.py @@ -1,370 +1,168 @@ -from typing import Optional, Any, TypeVar -from collections.abc import Callable, Coroutine, Collection -from time import monotonic, time -from logging import Logger, getLogger -from asyncio import gather, sleep, wait_for, CancelledError, Event - import json import re -from websockets.client import connect -from websockets.exceptions import ConnectionClosed -from aiohttp import ClientConnectionError, ClientSession, CookieJar +from functools import partialmethod +from logging import getLogger +from time import monotonic, time +from typing import Any, AsyncGenerator, Optional, cast -from backoff import on_exception as backoff, runtime +from aiohttp import ClientSession +from backoff import on_exception, runtime +from bs4 import BeautifulSoup, Tag +from yarl import URL -from sechat.server import Server -from sechat.events import EventBase, MentionEvent, EventType, EVENT_CLASSES -from sechat.errors import RatelimitError, OperationFailedError -from sechat.version import __version__ +from sechat.credentials import Credentials +from sechat.errors import OperationFailedError, RatelimitError +from sechat.events import Event, _EventAdapter, MentionEvent, ReplyEvent -T = TypeVar("T", bound=EventBase) -EventHandler = Callable[["Room", T], Coroutine] +RESET_INTERVAL = 60 * 60 * 2 +BACKOFF_RESPONSE = re.compile(r"You can perform this action again in (\d+) seconds?\.") class Room: - def __init__( - self, - server: Server, - cookies: CookieJar, - fkey: str, - userID: int, - roomID: int, - logger: Optional[Logger] = None, - ): - if logger: - self.logger = logger - else: - self.logger = getLogger(f"Room-{roomID}") - self._connectedEvent = Event() - self.server = server - self.cookies = cookies + class join: + def __init__(self, credentials: Credentials, room_id: int): + self.credentials = credentials + self.room_id = room_id + + async def __aenter__(self): + self.session = self.credentials.session() + async with self.session.get("/chats/join/favorite") as response: + soup = BeautifulSoup(await response.read(), "lxml") + assert isinstance(fkey_input := soup.find(id="fkey"), Tag) + assert isinstance(fkey := fkey_input.attrs["value"], str) + self.room = Room(self.room_id, self.session, fkey) + return self.room + + async def __aexit__(self, *args): + await self.room.close() + + def __init__(self, room_id: int, session: ClientSession, fkey: str): + self.logger = getLogger(__name__).getChild(str(room_id)) + self.room_id = room_id + self.session = session self.fkey = fkey - self.userID = userID - self.roomID = roomID - self.lastPing = time() - self.handlers: dict[EventType, set[EventHandler]] = { - eventType: set() for eventType in EventType - } - self.register(self._mentionHandler, EventType.MENTION) - def __hash__(self): - return hash(self.roomID) - - async def shutdown(self): - self.logger.info("Shutting down...") - try: - await wait_for( - self.request( - f"https://{self.server}/chats/leave/{self.roomID}" - ), - 3, - ) - await wait_for(self.session.close(), 3) - except TimeoutError: - pass - self.logger.debug("Shutdown completed!") + async def close(self): + await self._request(f"/chats/leave/{self.room_id}", {"quiet": "true"}) + await self.session.close() - async def _mentionHandler(self, _, event: MentionEvent): - try: - await self.session.post( - f"https://{self.server}/messages/ack", - data={"id": event.message_id, "fkey": self.fkey}, - ) - except: - pass - - async def getSocketUrls(self): + async def _socket_urls(self): while True: - try: - async with self.session.post( - f"https://{self.server}/ws-auth", - data={"fkey": self.fkey, "roomid": self.roomID}, - ) as r: - url = (await r.json())["url"] + f"?l={int(time())}" - self.logger.info(f"Connecting to {url}") - yield url - except ClientConnectionError: - self.logger.warning( - "An error occured while fetching the socket, trying again in 3s" - ) - await sleep(3) - - async def loop(self): - self.session = ClientSession(cookie_jar=self.cookies) - self.session.headers.update( - { - "User-Agent": f"Mozilla/5.0 (compatible; automated;) sechat/{__version__} (logged in as user {self.userID}; +http://pypi.org/project/sechat)" - } - ) - try: - async for url in self.getSocketUrls(): - async with connect(url, origin=f"http://{self.server}", close_timeout=3, ping_interval=None) as socket: # type: ignore It doesn't like the origin header for some reason - self._connectedEvent.set() - self.logger.info("Connected!") - connectedAt = monotonic() + async with self.session.post( + "/ws-auth", data={"fkey": self.fkey, "roomid": self.room_id} + ) as response: + response.raise_for_status() + url = URL((await response.json())["url"]).with_query(l=int(time())) + yield url + + async def events(self) -> AsyncGenerator[Event, None]: + async with ClientSession( + headers=self.session.headers, cookie_jar=self.session.cookie_jar + ) as ws_session: + async for url in self._socket_urls(): + async with ws_session.ws_connect( + url, origin=str(self.session._base_url) + ) as connection: + self.logger.info(f"Connected to {url}, fkey is {self.fkey}") + connected_at = monotonic() while True: + if monotonic() - connected_at >= RESET_INTERVAL: + self.logger.debug("Resetting socket after reset interval") + break try: - data = await wait_for(socket.recv(), timeout=45) - except ConnectionClosed: - self.logger.warning( - "Connection was closed. Attempting to reconnect..." + message = cast( + dict, await connection.receive_json(timeout=45) ) - break - except TimeoutError: + except Exception as e: self.logger.warning( - "No data recieved in a while, the connection may have dropped. Attempting to reconnect..." + "An exception occured while receiving data:", exc_info=e ) break - except CancelledError: - raise - except Exception: - self.logger.critical( - "An error occurred while recieving data!" - ) - raise - if data is not None and data != "": - try: - data = json.loads(data) - except (json.JSONDecodeError, TypeError): - self.logger.warning( - f"Recieved malformed packet: {data}" - ) - continue - await self.process(data) - if monotonic() - connectedAt > 60 * 60 * 2: - self.logger.info(f"Connected for 2 hours, resetting socket") - break - finally: - await self.shutdown() - - async def process(self, data: dict): - if f"r{self.roomID}" in data: - data = data[f"r{self.roomID}"] - if data != {}: - if "e" in data: - for event in data["e"]: - if not isinstance(event, dict): - continue - self.logger.debug(f"Got event data: {event}") - for result in await gather( - *[ - i - async for i in self.handle( - EventType(event["event_type"]), event - ) - ], - return_exceptions=True, + if ( + (body := message.get(f"r{self.room_id}")) is not None + and body != {} + and (events := body.get("e")) is not None ): - if isinstance(result, Exception): - self.logger.error( - f"An exception occured in a handler:", - exc_info=result, + for event_data in events: + self.logger.debug( + f"Recieved event data: {event_data!r}" ) - - async def handle(self, eventType: EventType, eventData: dict): - event = EVENT_CLASSES[eventType](**eventData) - for handler in self.handlers[eventType]: - try: - yield handler(self, event) - except Exception: - self.logger.exception( - f"Exception occured in handler for {eventType.name}" - ) - - def register(self, handler: EventHandler, eventType: EventType): - self.handlers[eventType].add(handler) - - def unregister(self, handler: EventHandler, eventType: EventType): - self.handlers[eventType].remove(handler) - - def on(self, eventType: EventType) -> Callable[[EventHandler], EventHandler]: - def _on(handler: EventHandler): - self.register(handler, eventType) - return handler - - return _on - - @backoff(runtime, RatelimitError, value=lambda e: e.retryAfter, jitter=None) - async def request(self, uri: str, data: dict[str, Any] = {}): - while True: - try: - response = await self.session.post( - uri, - data=data | {"fkey": self.fkey}, - headers={ - "Referer": f"https://{self.server}/rooms/{self.roomID}" - }, - ) - except ClientConnectionError: - self.logger.warning("Connection error, retrying in 3s") - await sleep(3) - else: - break - if response.status == 409: - match = re.match( - r"You can perform this action again in (\d+)", await response.text() - ) - if match is None: - self.logger.warning( - f"Unable to extract retry value from response: {await response.text()}" - ) - raise RatelimitError(1) - raise RatelimitError(int(match.group(1))) - elif response.status != 200: - raise OperationFailedError(response.status, await response.text()) - return response - - async def bookmark(self, start: int, end: int, title: str): - result = await ( - await self.request( - f"https://{self.server}/conversation/new", - { - "roomId": self.roomID, - "firstMessageId": start, - "lastMessageId": end, - "title": title, - }, - ) - ).text() + event = _EventAdapter.validate_python(event_data) + if isinstance(event, (MentionEvent, ReplyEvent)): + await self._request( + "/messages/ack", {"id": str(event.message_id)} + ) + yield event + + @on_exception(runtime, RatelimitError, value=lambda e: e.retryAfter, jitter=None) + async def _request(self, url: str, data: dict[str, Any] = {}): + async with self.session.post(url, data=data | {"fkey": self.fkey}) as response: + text = await response.text() + match response.status: + case 409: + if (match := BACKOFF_RESPONSE.fullmatch(text)) is None: + self.logger.warning(f"Got 409 with malformed response: {text}") + raise RatelimitError(1) + raise RatelimitError(int(match.group(1))) + case 200: + return text + case _: + raise OperationFailedError(response.status, text) + + async def _json_request(self, url: str, data: dict[str, Any] = {}): + response = await self._request(url, data) try: - result = json.loads(result) - except json.JSONDecodeError: - raise OperationFailedError(result) - else: - if not result.get("ok", False): - raise OperationFailedError(result) - return True - - async def removeBookmark(self, title: str): - self.logger.info(f"Removing bookmark {title}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/conversation/delete/{self.roomID}/{title}" - ) - ).text() - ) - != "ok" - ): - raise OperationFailedError(result) - - async def send(self, message: str) -> int: - assert len(message) >= 1, "Message cannot be empty!" - self.logger.info(f'Sending message "{message}"') - result = await ( - await self.request( - f"https://{self.server}/chats/{self.roomID}/messages/new", - {"text": message}, - ) - ).text() - try: - result = json.loads(result) - except json.JSONDecodeError: - raise OperationFailedError(result) - return result["id"] - - async def reply(self, target: int, message: str) -> int: - return await self.send(f":{target} {message}") - - async def edit(self, messageID: int, newMessage: str): - assert len(newMessage) >= 1, "Message cannot be empty!" - self.logger.info(f'Editing message {messageID} to "{newMessage}"') - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}", - {"text": newMessage}, - ) - ).text() - ) - != "ok" - ): - raise OperationFailedError(result) - - async def delete(self, messageID: int): - self.logger.info(f"Deleting message {messageID}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}/delete" - ) - ).text() + return json.loads(response) + except json.JSONDecodeError as e: + raise OperationFailedError("Failed to decode response", response) from e + + async def _ok_request(self, url: str, data: dict[str, Any] = {}): + if (response := await self._request(url, data)) != "ok": + raise OperationFailedError(response) + + async def send(self, message: str, reply_to: Optional[int] = None): + if not len(message): + raise ValueError("Cannot send an empty message!") + if reply_to is not None: + message = f":{reply_to} " + message + return ( + await self._json_request( + f"/chats/{self.room_id}/messages/new", {"text": message} ) - != "ok" - ): - raise OperationFailedError(result) + )["id"] - async def star(self, messageID: int): - self.logger.info(f"Starring message {messageID}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}/star" - ) - ).text() - ) - != "ok" - ): - raise OperationFailedError(result) + async def edit(self, message_id: int, new_body: str): + await self._ok_request(f"/messages/{message_id}", {"text": new_body}) - async def pin(self, messageID: int): - self.logger.info(f"Pinning message {messageID}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}/owner-star" - ) - ).text() - ) - != "ok" - ): - raise OperationFailedError(result) + async def _message_unary_op(self, op: str, message_id: int): + await self._ok_request(f"/messages/{message_id}/{op}") - async def unpin(self, messageID: int): - self.logger.info(f"Unpinning message {messageID}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}/unowner-star" - ) - ).text() - ) - != "ok" - ): - raise OperationFailedError(result) + star = partialmethod(_message_unary_op, "star") + pin = partialmethod(_message_unary_op, "owner-star") + unpin = partialmethod(_message_unary_op, "unowner-star") + clear_stars = partialmethod(_message_unary_op, "unstar") - async def clearStars(self, messageID: int): - self.logger.info(f"Clearing stars on message {messageID}") - if not ( - result := ( - await ( - await self.request( - f"https://{self.server}/messages/{messageID}/unstar" - ) - ).text() + async def move_messages(self, message_ids: set[int], target_room: int): + if ( + result := await self._request( + f"/admin/movePosts/{self.room_id}", + {"to": target_room, "ids": ",".join(map(str, message_ids))}, ) - != "ok" + ) != str(len(message_ids)): + raise OperationFailedError("Failed to move some messages", result) + + async def bookmark(self, start_message: int, end_message: int, bookmark_title: str): + payload = { + "roomId": self.room_id, + "firstMessageId": start_message, + "lastMessageId": end_message, + "title": bookmark_title, + } + if not (result := await self._json_request("/conversation/new", payload)).get( + "ok", False ): - raise OperationFailedError(result) + raise OperationFailedError("Failed to create bookmark", result) - async def moveMessages(self, messageIDs: Collection[int], roomID: int): - messageIDs = set(messageIDs) - self.logger.info(f"Moving messages {messageIDs} to room {roomID}") - if ( - result := ( - await ( - await self.request( - f"https://{self.server}/admin/movePosts/{self.roomID}", - {"to": roomID, "ids": ",".join(map(str, messageIDs))}, - ) - ).text() - ) - ) != str(len(messageIDs)): - raise OperationFailedError(result) + async def delete_bookmark(self, title: str): + await self._ok_request(f"/conversation/delete/{self.room_id}/{title}") diff --git a/sechat/server.py b/sechat/server.py deleted file mode 100644 index 68b63a9..0000000 --- a/sechat/server.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import StrEnum - -class Server(StrEnum): - STACK_EXCHANGE = 'chat.stackexchange.com', - STACK_OVERFLOW = 'chat.stackoverflow.com', - META_STACK_EXCHANGE = 'chat.meta.stackexchange.com' diff --git a/sechat/servers.py b/sechat/servers.py new file mode 100644 index 0000000..2fc8d5f --- /dev/null +++ b/sechat/servers.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class Server(StrEnum): + STACK_EXCHANGE = "https://chat.stackexchange.com" + STACK_OVERFLOW = "https://chat.stackoverflow.com" + META_STACK_EXCHANGE = "https://chat.meta.stackexchange.com" diff --git a/sechat/tests.py b/sechat/tests.py deleted file mode 100644 index b02029e..0000000 --- a/sechat/tests.py +++ /dev/null @@ -1,157 +0,0 @@ -import asyncio -import os -import unittest - -from sechat import Bot, Room, EventType, MentionEvent, MessageEvent - -bot = Bot() -bot2 = Bot() -EMAIL1, PASSWORD1, EMAIL2, PASSWORD2 = os.environ["EMAIL1"], os.environ["PASSWORD1"], None, None - -def setUpModule(): - global EMAIL2, PASSWORD2 - try: - EMAIL2, PASSWORD2 = os.environ["EMAIL2"], os.environ["PASSWORD2"] - except KeyError: - EMAIL2 = None - -class T00LoginTestCase(unittest.IsolatedAsyncioTestCase): - @classmethod - async def asyncSetUp(cls): - await bot.authenticate(EMAIL1, PASSWORD1, "https://codegolf.stackexchange.com") - - def testProps(self): - self.assertIsNotNone(bot.fkey) - self.assertIsNotNone(bot.userID) - -class T01RoomTestCase(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUp(cls): - cls.room = bot.joinRoom(1) - @classmethod - async def asyncTearDown(cls): - await cls.room.send("Stage 1 complete. DO NOT resume sending messages.") - await asyncio.sleep(3) - - async def test00ConnectionProcess(self): - self.assertIsNotNone(self.room.fkey) - await asyncio.sleep(1) - await self.room.send("Currently testing message functionality. Please DO NOT send any other messages in this room until I say the tests are completed. The test will begin in 3 seconds. Thank you.") - await asyncio.sleep(3) - async def test01Messaging(self): - await self.room.send("Message 1") - await asyncio.sleep(3) - await self.room.send("Cooldown check 1") - await self.room.send("Cooldown check 2") - await self.room.send("Cooldown check 3") - await asyncio.sleep(3) - async def test02Editing(self): - ident = await self.room.send("Before edit") - await asyncio.sleep(2) - await self.room.edit(ident, "After edit") - await asyncio.sleep(2) - async def test03Deleting(self): - ident = await self.room.send("Going to be deleted") - await asyncio.sleep(2) - await self.room.delete(ident) - await asyncio.sleep(2) - ''' - def test04Transcript(self): - messages = [i["content"] if "content" in i else False for i in self.room.getRecentMessages()[-7:]] - print(messages) - self.assertEqual(messages[0], "Message 1") - self.assertEqual(messages[3], "Cooldown check 3") - self.assertEqual(messages[5], "After edit") - self.assertFalse(messages[6]) - ''' - - -@unittest.skipIf(EMAIL2 is None, "Don't have two bots to test this with") -class T02MultiUserTestCase(unittest.IsolatedAsyncioTestCase): - @classmethod - async def asyncSetUp(cls): - await bot2.authenticate(EMAIL2, PASSWORD2, "https://codegolf.stackexchange.com") # type: ignore Pylance doesn't realise skipIf is a typeguard - cls.room = bot.joinRoom(1) - cls.room2 = bot2.joinRoom(1) - cls.gotMessage = False - cls.gotReply = False - @classmethod - async def asyncTearDown(cls): - bot2.closeAllRooms() - await cls.room.send("Stage 2 complete. DO NOT resume sending messages.") - await asyncio.sleep(2) - - async def onMessage(self, room: Room, event: MessageEvent): - if event.content == "Test message": - self.gotMessage = True - async def onMention(self, room, event): - self.gotReply = True - print(event) - - async def test00Starring(self): - ident = await self.room.send("This message will be starred") - await asyncio.sleep(2) - await self.room2.star(ident) - await asyncio.sleep(2) - await self.room2.send("Unstarring message") - await self.room2.star(ident) - await asyncio.sleep(4) - async def test01MessageEvents(self): - self.room.register(self.onMessage, EventType.MESSAGE) - await self.room2.send("Test message") - await asyncio.sleep(2) - self.room.unregister(self.onMessage, EventType.MESSAGE) - self.assertTrue(self.gotMessage) - async def test02ReplyEvents(self): - self.room.register(self.onMention, EventType.MENTION) - ident = await self.room.send("Test message") - await self.room2.reply(ident, "Test reply") - await asyncio.sleep(2) - self.room.unregister(self.onMention, EventType.MENTION) - self.assertTrue(self.gotReply) - - -class T03ROTestCase(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.room = bot.joinRoom(1) - cls.ident = None - @classmethod - async def asyncTearDown(cls): - await asyncio.sleep(2) - async def test00PinMessages(self): - self.ident = await self.room.send("This message will be pinned") - await asyncio.sleep(2) - await self.room.pin(self.ident) - await asyncio.sleep(2) - await self.room.unpin(self.ident) - await asyncio.sleep(2) - await self.room.send("Clearing stars on message") - await self.room.clearStars(self.ident) - @unittest.skip("No privs yet") - async def test02MoveMessages(self): - ident = await self.room.send("This message will be moved to https://chat.stackexchange.com/rooms/120733/osp-testing") - await asyncio.sleep(2) - await self.room.moveMessages([ident], 120733) - - -class T04LeaveTestCase(unittest.IsolatedAsyncioTestCase): - @classmethod - async def asyncSetUp(cls): - cls.room = bot.joinRoom(1) - await cls.room.send("Testing complete. You may now resume sending messages.") - def setUp(self): - self.room = bot.joinRoom(1) - await asyncio.sleep(2) - def test01LeaveRoom(self): - bot.closeAllRooms(1, True) - with self.assertRaises(ValueError): - bot.closeAllRooms(1) - def test02LeaveAllRooms(self): - bot.closeAllRooms(True) - - @unittest.skip("Don't want to trigger the captcha") - def test03Logout(self): - bot.logout() - self.assertIsNone(bot.fkey) - self.assertIsNone(bot.userID) diff --git a/sechat/version.py b/sechat/version.py deleted file mode 100644 index 5a6bc65..0000000 --- a/sechat/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "2.0.0" \ No newline at end of file