diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..a131f84 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,19 @@ +# Read the Docs configuration file for MkDocs projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.7" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - requirements: docs/requirements.txt + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6f59ec6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python -sudo: false -stages: - - test - -python: - - "3.5" - - "3.6" - - "3.7" - - "3.8" - -matrix: - include: - - stage: test - python: "3.5" - dist: xenial - - python: "3.6" - dist: xenial - - python: "3.7" - dist: xenial - - python: "3.8" - dist: xenial - -script: - - python setup.py test \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..d833b76 --- /dev/null +++ b/Pipfile @@ -0,0 +1,18 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +httpx = "<=1.0.0" + +[dev-packages] +sphinx = "*" +mkdocs = "*" +mkdocs-material = "*" +pytest = "*" +pytest-asyncio = "*" +pytest-localserver = "*" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..01b09a6 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,827 @@ +{ + "_meta": { + "hash": { + "sha256": "6fde4e64eff8eeda09cb08d1dcb24e05d6fa644d4cc73fec2902ccd76a77e7f1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "anyio": { + "hashes": [ + "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", + "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.1" + }, + "certifi": { + "hashes": [ + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + ], + "markers": "python_version >= '3.6'", + "version": "==2024.8.30" + }, + "exceptiongroup": { + "hashes": [ + "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", + "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.2" + }, + "h11": { + "hashes": [ + "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", + "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" + ], + "markers": "python_version >= '3.7'", + "version": "==0.14.0" + }, + "httpcore": { + "hashes": [ + "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", + "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87" + ], + "markers": "python_version >= '3.7'", + "version": "==0.17.3" + }, + "httpx": { + "hashes": [ + "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", + "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==0.24.1" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" + }, + "sniffio": { + "hashes": [ + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", + "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + ], + "markers": "python_version < '3.8'", + "version": "==4.7.1" + } + }, + "develop": { + "alabaster": { + "hashes": [ + "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", + "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.13" + }, + "babel": { + "hashes": [ + "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363", + "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287" + ], + "markers": "python_version >= '3.7'", + "version": "==2.14.0" + }, + "certifi": { + "hashes": [ + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + ], + "markers": "python_version >= '3.6'", + "version": "==2024.8.30" + }, + "charset-normalizer": { + "hashes": [ + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.4.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "docutils": { + "hashes": [ + "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", + "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc" + ], + "markers": "python_version >= '3.7'", + "version": "==0.19" + }, + "exceptiongroup": { + "hashes": [ + "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", + "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.2" + }, + "ghp-import": { + "hashes": [ + "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", + "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343" + ], + "version": "==2.1.0" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" + }, + "imagesize": { + "hashes": [ + "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", + "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.1" + }, + "importlib-metadata": { + "hashes": [ + "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", + "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5" + ], + "markers": "python_version < '3.10'", + "version": "==6.7.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "jinja2": { + "hashes": [ + "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", + "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.4" + }, + "markdown": { + "hashes": [ + "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6", + "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "markupsafe": { + "hashes": [ + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" + }, + "mergedeep": { + "hashes": [ + "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", + "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" + ], + "markers": "python_version >= '3.6'", + "version": "==1.3.4" + }, + "mkdocs": { + "hashes": [ + "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1", + "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.5.3" + }, + "mkdocs-material": { + "hashes": [ + "sha256:92e4160d191cc76121fed14ab9f14638e43a6da0f2e9d7a9194d377f0a4e7f18", + "sha256:b44da35b0d98cd762d09ef74f1ddce5b6d6e35c13f13beb0c9d82a629e5f229e" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==9.2.7" + }, + "mkdocs-material-extensions": { + "hashes": [ + "sha256:27e2d1ed2d031426a6e10d5ea06989d67e90bb02acd588bc5673106b5ee5eedf", + "sha256:c767bd6d6305f6420a50f0b541b0c9966d52068839af97029be14443849fb8a1" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "paginate": { + "hashes": [ + "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", + "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591" + ], + "version": "==0.5.7" + }, + "pathspec": { + "hashes": [ + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.2" + }, + "platformdirs": { + "hashes": [ + "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", + "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.0" + }, + "pluggy": { + "hashes": [ + "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", + "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pygments": { + "hashes": [ + "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", + "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" + ], + "markers": "python_version >= '3.7'", + "version": "==2.17.2" + }, + "pymdown-extensions": { + "hashes": [ + "sha256:bded105eb8d93f88f2f821f00108cb70cef1269db6a40128c09c5f48bfc60ea4", + "sha256:d0c534b4a5725a4be7ccef25d65a4c97dba58b54ad7c813babf0eb5ba9c81591" + ], + "markers": "python_version >= '3.7'", + "version": "==10.2.1" + }, + "pytest": { + "hashes": [ + "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", + "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==7.4.4" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", + "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==0.21.2" + }, + "pytest-localserver": { + "hashes": [ + "sha256:8033a36fb382d2bc4850f9acfe2c3fb5654cd5f0d163af6daf47f290db7d5ff0", + "sha256:88a50e99637c84ab20680fd54b8dcfd53a36cbdf877013abd0e7442536a95bce" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==0.9.0.post0" + }, + "python-dateutil": { + "hashes": [ + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.9.0.post0" + }, + "pytz": { + "hashes": [ + "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", + "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725" + ], + "markers": "python_version < '3.9'", + "version": "==2024.2" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0.1" + }, + "pyyaml-env-tag": { + "hashes": [ + "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", + "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" + ], + "markers": "python_version >= '3.6'", + "version": "==0.1" + }, + "regex": { + "hashes": [ + "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad", + "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4", + "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd", + "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc", + "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d", + "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066", + "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec", + "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9", + "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e", + "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8", + "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e", + "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783", + "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6", + "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1", + "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c", + "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4", + "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1", + "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1", + "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7", + "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8", + "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe", + "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d", + "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b", + "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8", + "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c", + "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af", + "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49", + "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714", + "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542", + "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318", + "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e", + "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5", + "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc", + "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144", + "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453", + "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5", + "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61", + "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11", + "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a", + "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54", + "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73", + "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc", + "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347", + "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c", + "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66", + "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c", + "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93", + "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443", + "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc", + "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1", + "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892", + "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8", + "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001", + "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa", + "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90", + "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c", + "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0", + "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692", + "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4", + "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5", + "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690", + "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83", + "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66", + "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f", + "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f", + "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4", + "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee", + "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81", + "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95", + "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9", + "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff", + "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e", + "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5", + "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6", + "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7", + "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1", + "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394", + "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6", + "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742", + "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57", + "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b", + "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7", + "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b", + "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244", + "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af", + "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185", + "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8", + "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5" + ], + "markers": "python_version >= '3.6'", + "version": "==2022.10.31" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "sphinx": { + "hashes": [ + "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d", + "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==5.3.0" + }, + "sphinxcontrib-applehelp": { + "hashes": [ + "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", + "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.2" + }, + "sphinxcontrib-devhelp": { + "hashes": [ + "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", + "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.2" + }, + "sphinxcontrib-htmlhelp": { + "hashes": [ + "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", + "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" + ], + "markers": "python_version >= '3.6'", + "version": "==2.0.0" + }, + "sphinxcontrib-jsmath": { + "hashes": [ + "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", + "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.1" + }, + "sphinxcontrib-qthelp": { + "hashes": [ + "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", + "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.3" + }, + "sphinxcontrib-serializinghtml": { + "hashes": [ + "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", + "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" + ], + "markers": "python_version >= '3.5'", + "version": "==1.1.5" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", + "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + ], + "markers": "python_version < '3.8'", + "version": "==4.7.1" + }, + "urllib3": { + "hashes": [ + "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", + "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.7" + }, + "watchdog": { + "hashes": [ + "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a", + "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100", + "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8", + "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc", + "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae", + "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41", + "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0", + "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f", + "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c", + "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9", + "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3", + "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709", + "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83", + "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759", + "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9", + "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3", + "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7", + "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f", + "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346", + "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674", + "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397", + "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96", + "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d", + "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a", + "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64", + "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44", + "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33" + ], + "markers": "python_version >= '3.7'", + "version": "==3.0.0" + }, + "werkzeug": { + "hashes": [ + "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe", + "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612" + ], + "markers": "python_version >= '3.7'", + "version": "==2.2.3" + }, + "zipp": { + "hashes": [ + "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", + "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" + ], + "markers": "python_version >= '3.7'", + "version": "==3.15.0" + } + } +} diff --git a/README.rst b/README.rst index 90d4dc0..3b8278d 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ .. image:: https://img.shields.io/badge/license-BSD-blue.svg - :target: https://github.com/KonstantinTogoi/aiomailru/blob/master/LICENSE + :target: https://github.com/konstantintogoi/aiomailru/blob/master/LICENSE .. image:: https://img.shields.io/pypi/v/aiomailru.svg :target: https://pypi.python.org/pypi/aiomailru @@ -7,12 +7,6 @@ .. image:: https://img.shields.io/pypi/pyversions/aiomailru.svg :target: https://pypi.python.org/pypi/aiomailru -.. image:: https://readthedocs.org/projects/aiomailru/badge/?version=latest - :target: https://aiomailru.readthedocs.io/en/latest/ - -.. image:: https://travis-ci.org/KonstantinTogoi/aiomailru.svg - :target: https://travis-ci.org/KonstantinTogoi/aiomailru - .. index-start-marker1 aiomailru @@ -21,17 +15,13 @@ aiomailru aiomailru is a python `Mail.Ru API `_ wrapper. The main features are: -* authorization (`Authorization Code `_, `Implicit Flow `_, `Password Grant `_, `Refresh Token `_) -* `REST API `_ methods -* web scrapers - Usage ----- To use `Mail.Ru API `_ you need a registered app and `Mail.Ru `_ account. For more details, see -`aiomailru Documentation `_. +`aiomailru Documentation `_. Client application ~~~~~~~~~~~~~~~~~~ @@ -55,7 +45,7 @@ i.e. when you embed your app's info (private key) in publicly available code. Use :code:`access_token` and :code:`uid` that were received after authorization. For more details, see -`authorization instruction `_. +`authorization instruction `_. Server application ~~~~~~~~~~~~~~~~~~ @@ -77,7 +67,7 @@ Use :code:`ServerSession` when REST API is needed in: Use :code:`access_token` that was received after authorization. For more details, see -`authorization instruction `_. +`authorization instruction `_. Installation ------------ @@ -86,34 +76,13 @@ Installation $ pip install aiomailru -or - -.. code-block:: shell - - $ python setup.py install - Supported Python Versions ------------------------- -Python 3.5, 3.6, 3.7 and 3.8 are supported. +Python 3.7, 3.8, 3.9 are supported. .. index-end-marker1 -Test ----- - -Run all tests. - -.. code-block:: shell - - $ python setup.py test - -Run tests with PyTest. - -.. code-block:: shell - - $ python -m pytest [-k TEST_NAME] - License ------- diff --git a/aiomailru/__init__.py b/aiomailru/__init__.py index 10d628d..acbf847 100644 --- a/aiomailru/__init__.py +++ b/aiomailru/__init__.py @@ -1,33 +1,3 @@ -from . import api, exceptions, objects, parsers, sessions, utils -from .utils import parseaddr -from .exceptions import ( - Error, - OAuthError, - InvalidGrantError, - InvalidClientError, - APIError, - APIScrapperError, - CookieError, -) -from .sessions import ( - PublicSession, - TokenSession, - ClientSession, - ServerSession, - CodeSession, - CodeClientSession, - CodeServerSession, - ImplicitSession, - ImplicitClientSession, - ImplicitServerSession, - PasswordSession, - PasswordClientSession, - PasswordServerSession, - RefreshSession, - RefreshClientSession, - RefreshServerSession, -) -from .api import API - - -__version__ = '0.1.1.post1' +"""aiomailru.""" +from .api import API # noqa: F401 +from .auth import CodeGrant, PasswordGrant, RefreshGrant, full_scope # noqa: F401 diff --git a/aiomailru/api.py b/aiomailru/api.py index 9da262e..d0678c1 100644 --- a/aiomailru/api.py +++ b/aiomailru/api.py @@ -1,35 +1,108 @@ """My.Mail.Ru API.""" +from typing import Any, Dict, Generator, Tuple -from .sessions import TokenSession +from .session import TokenSession class API: - """Platform@Mail.Ru REST API.""" + """Platform@Mail.Ru REST API. + + Attributes: + session (TokenSesion): session. + + """ __slots__ = ('session', ) - def __init__(self, session: TokenSession): - self.session = session + def __init__( + self, + app_id: str, + session_key: str, + private_key: str = '', + secret_key: str = '', + uid: str = '', + ) -> None: + """Set session.""" + if uid and private_key: + secret = private_key + secure = '0' + if secret_key: + secret = secret_key + secure = '1' + uid = '' + self.session = TokenSession( + app_id=app_id, + session_key=session_key, + secret=secret, + secure=secure, + uid=uid, + ) + + def __await__(self) -> Generator['API', None, None]: + """Await self.""" + yield self + + async def __aenter__(self) -> 'API': + """Enter.""" + return self - def __getattr__(self, name): + async def __aexit__(self, *args: Tuple[Any, Any, Any]) -> None: + """Exit.""" + if not self.session.client.is_closed: + await self.session.client.aclose() + + def __getattr__(self, name: str) -> 'APIMethod': + """Return an API method.""" return APIMethod(self, name) - async def __call__(self, name, **params): + async def __call__(self, name: str, **params: Dict[str, Any]) -> 'APIMethod': # noqa + """Call an API method by its name. + + Args: + name (str): full method's name + params (Dict[str, Any]): query parameters + + Returns: + APIMethod + + """ return await getattr(self, name)(**params) class APIMethod: - """Platform@Mail.Ru REST API method.""" + """Platform@Mail.Ru REST API method. + + Attributes: + api (API): API instance + name (str): full method's name + + """ __slots__ = ('api', 'name') - def __init__(self, api: API, name: str): + def __init__(self, api: API, name: str) -> None: + """Set method name.""" self.api = api self.name = name - def __getattr__(self, name): - return APIMethod(self.api, self.name + '.' + name) + def __getattr__(self, name: str) -> 'APIMethod': + """Chain methods. + + Args: + name (str): method name + + """ + return APIMethod(self.api, f'{self.name}.{name}') + + async def __call__(self, **params: Dict[str, Any]) -> Dict[str, Any]: + """Execute a request. + + Args: + params (Dict[str, Any]): query parameters + + Returns: + Dict[str, Any] - async def __call__(self, **params): + """ params['method'] = self.name return await self.api.session.request(params=params) diff --git a/aiomailru/auth.py b/aiomailru/auth.py new file mode 100644 index 0000000..8cb60ff --- /dev/null +++ b/aiomailru/auth.py @@ -0,0 +1,267 @@ +"""Authurization.""" +import logging +from typing import Any, Dict, Tuple + +from httpx import AsyncClient + +log = logging.getLogger(__name__) + + +def full_scope() -> str: + """Full scope.""" + return ' '.join(['photos', 'guestbook', 'stream', 'messages', 'events']) + + +class Grant: + """Authorization Grant.""" + + __slots__ = ('_app_id', '_auth_client') + + def __init__(self, app_id: str) -> None: + """Set app info.""" + self._app_id = app_id + self._auth_client = AsyncClient( + default_encoding='application/x-www-formurlencoded', + follow_redirects=True, + ) + + async def __aenter__(self) -> 'Grant': + """Enter.""" + await self.authorize() + if not self._auth_client.is_closed: + await self._auth_client.aclose() + return self + + async def __aexit__(self, *args: Tuple[Any, Any, Any]) -> None: + """Exit.""" + if not self._auth_client.is_closed: + await self._auth_client.aclose() + + async def authorize(self) -> 'Grant': + """Authorizate.""" + return self + + +class CodeGrant(Grant): + """Session with authorization with OAuth 2.0 (Authorization Code Grant). + + The Authorization Code grant is used by confidential and public + clients to exchange an authorization code for an access token. + + .. _OAuth 2.0 Authorization Code Grant + https://oauth.net/2/grant-types/authorization-code/ + + .. _Авторизация для сайтов + https://api.mail.ru/docs/guides/oauth/sites/ + + .. _Авторизация для мобильных сайтов + https://api.mail.ru/docs/guides/oauth/mobile-web/ + + """ + + __slots__ = ( + '_code', + '_secret_key', + '_redirect_uri', + 'refresh_token', + 'session_key', + 'expires_in', + 'uid', + ) + + def __init__( + self, + app_id: str, + redirect_uri: str, + secret_key: str, + code: str, + ) -> None: + """Set attributes.""" + super().__init__(app_id) + self._redirect_uri = redirect_uri + self._secret_key = secret_key + self._code = code + + async def authorize(self) -> 'CodeGrant': + """Authorize with OAuth 2.0 (Authorization Code). + + Returns: + CodeGrant + + """ + resp = await self._auth_client.post( + 'https://connect.mail.ru/oauth/token', + data={ + 'client_id': self._app_id, + 'client_secret': self._secret_key, + 'grant_type': 'authorization_code', + 'redirect_uri': self._redirect_uri, + 'code': self._code, + }, + ) + resp.raise_for_status() + + try: + respjson: Dict[str, Any] = resp.json() + except Exception: + content = resp.read().decode() + log.error(f'GET {resp.url} {resp.status_code}: {content}') + raise + + try: + self.session_key = respjson['access_token'] + self.refresh_token = respjson['refresh_token'] + self.expires_in = respjson['expires_in'] + self.uid = respjson['x_mailru_vid'] + except KeyError as e: + raise KeyError(*e.args, respjson) from e + + return self + + +class PasswordGrant(Grant): + """Session with authorization with OAuth 2.0 (Password Grant). + + The Password grant type is a way to exchange a user's credentials + for an access token. + + .. _OAuth 2.0 Password Grant + https://oauth.net/2/grant-types/password/ + + .. _Авторизация по логину и паролю + https://api.mail.ru/docs/guides/oauth/client-credentials/ + + """ + + __slots__ = ( + '_email', + '_password', + '_secret_key', + '_scope', + 'session_key', + 'refresh_token', + 'expires_in', + 'uid', + ) + + def __init__( + self, + app_id: str, + username: str, + password: str, + secret_key: str, + scope: str, + ) -> None: + """Set attributes.""" + super().__init__(app_id) + self._secret_key = secret_key + self._username = username + self._password = password + self._scope = scope + + async def authorize(self) -> 'PasswordGrant': + """Authorize with OAuth 2.0 (Password Grant). + + Returns: + PasswordGrant + + """ + resp = await self._auth_client.post( + 'https://appsmail.ru/oauth/token', + data={ + 'grant_type': 'password', + 'client_id': self._app_id, + 'client_secret': self._secret_key, + 'username': self._username, + 'password': self._password, + 'scope': self._scope, + }, + ) + + resp.raise_for_status() + + try: + respjson: Dict[str, Any] = resp.json() + except Exception: + log.error(f'GET {resp.url} {resp.read().decode()}') + raise + + try: + self.session_key = respjson['access_token'] + self.refresh_token = respjson['refresh_token'] + self.expires_in = respjson['expires_in'] + self.uid = respjson['x_mailru_vid'] + except KeyError as e: + raise KeyError(*e.args, respjson) from e + + return self + + +class RefreshGrant(Grant): + """Session with authorization with OAuth 2.0 (Refresh Token). + + The Refresh Token grant type is used by clients to exchange + a refresh token for an access token when the access token has expired. + + .. _OAuth 2.0 Refresh Token + https://oauth.net/2/grant-types/refresh-token/ + + .. _Использование refresh_token + https://api.mail.ru/docs/guides/oauth/client-credentials/#refresh_token + + """ + + __slots__ = ( + '_secret_key', + '_refresh_token', + 'refresh_token', + 'session_key', + 'expires_in', + 'uid', + ) + + def __init__( + self, + app_id: str, + secret_key: str, + refresh_token: str, + ) -> None: + """Set attributes.""" + super().__init__(app_id) + self._refresh_token = refresh_token + self._secret_key = secret_key + + async def authorize(self) -> 'RefreshGrant': + """Authorize with OAuth 2.0 (Refresh Token). + + Returns: + RefreshGrant + + """ + resp = await self._auth_client.post( + 'https://appsmail.ru/oauth/token', + data={ + 'client_id': self._app_id, + 'grant_type': 'refresh_token', + 'refresh_token': self._refresh_token, + 'client_secret': self._secret_key, + }, + ) + + resp.raise_for_status() + + try: + respjson: Dict[str, Any] = resp.json() + except Exception: + log.error(f'GET {resp.url} {resp.read().decode()}') + raise + + try: + self.session_key = respjson['access_token'] + self.refresh_token = respjson['refresh_token'] + self.expires_in = respjson['expires_in'] + self.uid = respjson['x_mailru_vid'] + except KeyError as e: + raise KeyError(*e.args, respjson) from e + + return self diff --git a/aiomailru/browser.py b/aiomailru/browser.py deleted file mode 100644 index ac13cc0..0000000 --- a/aiomailru/browser.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging -import os -from pyppeteer import connect, launch - - -log = logging.getLogger(__name__) - - -class Browser: - """A wrapper around pyppeteer.browser.Browser.""" - - endpoint = os.environ.get('PYPPETEER_BROWSER_ENDPOINT') - viewport = os.environ.get('PYPPETEER_BROWSER_VIEWPORT', '800,600') - slow_mo = int(os.environ.get('PYPPETEER_BROWSER_SLOW_MO', '200')) - - def __init__(self, browser=None): - self.browser = browser - self.contexts = {} - - def __await__(self): - return self.start().__await__() - - async def start(self): - """Starts chrome process or connects to the existing chrome.""" - - if self.browser: - pass - elif self.endpoint: - browser_conn = {'browserWSEndpoint': self.endpoint} - log.debug('connecting: {}'.format(browser_conn)) - self.browser = await connect(browser_conn, slowmMo=self.slow_mo) - else: - log.debug('launching new browser..') - self.browser = await launch(slowMo=self.slow_mo) - - return self - - async def page(self, url, session_key, - cookies=(), force=False, context=None): - """Makes new page and returns its object. - - Args: - url (str): URL to navigate page to. The url should - include scheme, e.g. `https://`. - session_key (str): access token. - cookies (tuple): cookies for the page. - force (bool): `True` - to always return a new context. - context (pyppeteer.browser.BrowserContext): browser context. - - Returns: - page (pyppeteer.page.Page): page. - - """ - - if not self.browser: - await self.start() - - if context: - pass - elif (url, session_key) in self.contexts: - context = self.contexts[(url, session_key)] - elif force: - context = await self.browser.createIncognitoBrowserContext() - else: - context = await self.browser.createIncognitoBrowserContext() - self.contexts[(url, session_key)] = context - - blank_page = None - for target in context.targets(): - if target.url == 'about:blank': - blank_page = await target.page() - elif target.url == url: - page = await target.page() - break - else: - page = blank_page or await context.newPage() - viewport = ('width', 'height'), map(int, self.viewport.split(',')) - await page.setViewport(dict(zip(*viewport))) - await page.setCookie(*cookies) - await page.setRequestInterception(True) - - @page.on('request') - async def on_request(request): - if request.url.endswith('.png') or request.url.endswith('.jpg'): - await request.abort() - else: - await request.continue_() - - await page.goto(url) - - return page diff --git a/aiomailru/exceptions.py b/aiomailru/exceptions.py deleted file mode 100644 index 7a10811..0000000 --- a/aiomailru/exceptions.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Exceptions.""" - - -class Error(Exception): - """Base exception.""" - - ERROR = 'internal_error' - - @property - def error(self): - return self.args[0] - - def __init__(self, error: str or dict): - arg = error if isinstance(error, dict) else { - 'error': self.ERROR, - 'error_description': error, - } - super().__init__(arg) - - -class OAuthError(Error): - """OAuth error.""" - - ERROR = 'oauth_error' - - -class CustomOAuthError(OAuthError): - """Custom errors that raised when authorization failed.""" - - ERROR = {'error': '', 'error_description': ''} - - def __init__(self): - super().__init__(self.ERROR) - - -class InvalidGrantError(CustomOAuthError): - """Invalid user credentials.""" - - ERROR = { - 'error': 'invalid_grant', - 'error_description': 'invalid login or password', - } - - -class InvalidClientError(CustomOAuthError): - """Invalid client id.""" - - ERROR = { - 'error': 'invalid_client', - 'error_description': 'invalid client id', - } - - -class InvalidUserError(CustomOAuthError): - """Invalid user (blocked).""" - - ERROR = { - 'error': 'invalid_user', - 'error_description': 'user is blocked', - } - - -class ClientNotAvailableError(CustomOAuthError): - """Application is not available (in test mode).""" - - ERROR = { - 'error': 'client_not_available', - 'error_description': 'application is in the test mode' - } - - -class APIError(Error): - """API error.""" - - def __init__(self, error: dict): - super().__init__(error) - self.code = error['error']['error_code'] - self.msg = error['error']['error_msg'] - - def __str__(self): - return 'Error {code}: {msg}'.format(code=self.code, msg=self.msg) - - -class APIScrapperError(Error): - """Scraper error.""" - - code = 0 - - def __init__(self, msg: str): - super().__init__(msg) - self.msg = msg - - def __str__(self): - return 'Error {code}: {msg}'.format(code=self.code, msg=self.msg) - - -class CustomAPIError(APIError): - """Custom API error.""" - - ERROR = {'error': {'error_code': 0, 'error_msg': ''}} - - def __init__(self): - super().__init__(self.ERROR) - - -class EmptyResponseError(CustomAPIError): - ERROR = {'error': {'error_code': -1, 'error_msg': 'empty response'}} - - -class EmptyObjectsError(CustomAPIError): - ERROR = {'error': {'error_code': 202, 'error_msg': 'empty objects'}} - - -class EmptyGroupsError(CustomAPIError): - ERROR = {'error': {'error_code': 202, 'error_msg': 'empty groups'}} - - -class AccessDeniedError(CustomAPIError): - ERROR = {'error': { - 'error_code': 202, - 'error_msg': 'Access to this object is denied', - }} - - -class BlackListError(CustomAPIError): - ERROR = {'error': { - 'error_code': 202, - 'error_msg': 'Access to this object is denied: you are in blacklist', - }} - - -class CookieError(APIScrapperError): - code = 1 diff --git a/aiomailru/objects/__init__.py b/aiomailru/objects/__init__.py deleted file mode 100644 index 011e2d3..0000000 --- a/aiomailru/objects/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .event import Event -from .group import GroupItem diff --git a/aiomailru/objects/event.py b/aiomailru/objects/event.py deleted file mode 100644 index c1fcd20..0000000 --- a/aiomailru/objects/event.py +++ /dev/null @@ -1,259 +0,0 @@ -from collections import UserDict - - -class Event(UserDict): - """Event.""" - - class Scripts: - class Selectors: - event_class = 'b-history_event_active-area_shift' - subevent_class = 'b-history_event_active-area' - event = 'div.%s' % event_class - subevent = 'div.%s:not(.%s)' % (subevent_class, event_class) - - head = 'div.b-history-event_head' - action = '%s div.b-history-event__action' % head - author = '%s .b-history-event__ownername' % action - time = '%s div.b-history-event_time' % action - url = '%s a' % time - - text = ( - 'div.b-history-event__body ' - 'div.b-history-event__event-textbox2 ' - ) - status = ( - 'div.b-history-event__body ' - 'div.b-history-event__event-textbox_status ' - ) - links = '%s a' % status - - comments = 'div.b-comments__history' - - astat = 'n => n.getAttribute("data-astat")' - author = 'n => n.getAttribute("href")' - url = 'n => n.getAttribute("href")' - - links = ( - 'ns => ns.map(n => {' - 'return { href: n.getAttribute("href"), text: n.innerText };' - '})' - ) - text = 'n => n.innerText' - status = 'n => n.innerText' - - class Types: - clickable = ['1-1', '3-23', '5-39', '5-41'] - status = '3-23' - - s = Scripts - ss = Scripts.Selectors - t = Types - - def __init__(self, initialdata): - super().__init__(initialdata) - - def __repr__(self): - return repr(self.data) - - @classmethod - async def from_element(cls, element): - """Creates a new event from a DOM element. - - Args: - element (pyppeteer.element_handle.ElementHandle): the element. - - Returns: - event (Event): new instance of this class. - - """ - - ctx = element.executionContext - astat = await ctx.evaluate(cls.s.astat, element) - astat = Astat(*astat.split(':')) - comments = await element.J(cls.ss.comments) - - if astat.subtype in ['comment', 'like']: - data = { - 'time': astat.time, - 'author': {}, # fixed below - # TODO: scrape like/comment 'huid' - 'subevent': { - # TODO: scrape subevent 'thread_id' - 'authors': [], # fixed below - 'type_name': astat.corr_type_name, - # skip 'click_url', added below if present - 'likes_count': astat.likes_count, - # skip 'attachments', added below if present - # TODO: scrape subevent 'time' - # TODO: scrape subevent 'huid' - # TODO: scrape subevent 'generator' - 'user_text': '', # fixed below if present - # TODO: scrape subevent 'is_liked_by_me' - 'subtype': 'event', - 'is_commentable': 1 if comments else 0, - 'type': astat.corr_type, - 'is_likeable': 1 if comments else 0, - 'id': astat.corr_event_id, - # skip 'text_media', added below if present - 'comments_count': astat.comments_count, - # TODO: scrape subevent 'action_links' - }, - 'subtype': astat.subtype, - 'is_commentable': 0, - 'id': astat.id, - 'is_likeable': 0, - } - - element = await element.J(cls.ss.subevent) - element_type = astat.corr_type - element_body = await cls._from_element(element, element_type) - data['subevent'].update(element_body) - - else: - data = { - # TODO: scrape event 'thread_id' - 'authors': [], # fixed below - 'type_name': astat.type_name, - # skip 'click_url', added below if present - 'likes_count': astat.likes_count, - # skip 'attachments', added below if present - 'time': astat.time, - # TODO: scrape event 'huid' - # TODO: scape event 'generator' - 'user_text': '', # fixed below if present - # TODO: scrape event 'is_liked_by_me' - 'subtype': astat.subtype, - 'is_commentable': 1 if comments else 0, - 'type': astat.type, - 'is_likeable': 1 if comments else 0, - 'id': astat.id, - # skip 'text_media', added below if present - 'comments_count': astat.comments_count, - # TODO: scrape event 'action_links' - } - - element = await element.J(cls.ss.event) - element_type = astat.type - element_body = await cls._from_element(element, element_type) - data.update(element_body) - - event = cls(initialdata=data) - - return event - - @classmethod - async def _from_element(cls, elem, elem_type): - body, author, url = {}, {}, '' - - # scrape 'authors' (all but the stream owner) - author_ref = await elem.Jeval(cls.ss.author, cls.s.author) or '' - if author_ref: - author['link'] = author_ref.rstrip('?ref=ho') - body['authors'] = [author] if author else [] - - # scrape 'click_url' - if elem_type in cls.t.clickable: - url = await elem.Jeval(cls.ss.url, cls.s.url) - body['click_url'] = 'https://my.mail.ru%s' % url if url else url - - # scrape 'user_text' and 'text_media' - if elem_type == cls.t.status: - stts = await elem.J(cls.ss.status) - text = await elem.Jeval(cls.ss.status, cls.s.status) if stts else '' - links = await stts.JJeval(cls.ss.links, cls.s.links) - - for link in links: - text = text.replace(link['text'], link['href']) - - # scrape 'text_media' - content = {'type-id': 'text', 'contents': text} - link_media = [{'object': 'link', 'content': content} for _ in links] - text_media = [{'object': 'text', 'content': text}] - body['text_media'] = link_media + text_media - else: - text = await elem.J(cls.ss.text) - text = await elem.Jeval(cls.ss.text, cls.s.text) if text else '' - body['user_text'] = text - - return body - - -class Astat: - def __init__(self, user_world_id, event_type, event_id, - owner_world_id, corr_world_id, corr_event_id, - likes_count, comments_count, event_time, *_): - self.user_world_id = int(user_world_id or '0') - - self.event_type = event_type - self.event_id = event_id - self.event_time = int(event_time) - self.owner_world_id = owner_world_id - - self.corr_world_id = corr_world_id - self.corr_event_id = corr_event_id - - self.likes_count = int(likes_count or '0') - self.comments_count = int(comments_count or '0') - - @property - def id(self): - return self.event_id.lower() - - @property - def time(self): - return self.event_time - - @property - def type(self): - """Event type.""" - if self.subtype == 'event': - return '-'.join(self.event_type.split('-')[:2]) - else: - raise AttributeError() - - @property - def corr_type(self): - """Type of liked/commented subevent.""" - if self.subtype == 'event': - raise AttributeError() - else: - return '-'.join(self.event_type.split('-')[:2]) - - @property - def type_name(self): - return TYPE_NAMES.get(self.type, '') - - @property - def corr_type_name(self): - return TYPE_NAMES[self.corr_type] - - @property - def subtype(self): - type_code = self.event_type.split('-') - if len(type_code) < 3: - return 'event' - else: - return type_code[2].lower() - - -TYPE_NAMES = { - '1-1': 'photo_upload', - '1-2': 'video_upload', - '1-7': 'music_add', - '3-3': 'user_community_actions_enter', - '3-5': 'user_community_actions_leave', - '3-23': 'micropost', - '5-7': 'avatar_change', - '5-10': 'gift_send', - '5-11': 'gift_received', - '5-16': 'app_add', - '5-26': 'share', - '5-28': 'app_info2', - '5-37': 'gift_receive_multi', - '5-39': 'community_post', - '5-41': 'user_post', - '5-44': 'community_video_upload', - '5-47': 'community_photo_upload', - '5-50': '', # TODO: add name - # TODO: add missing types -} diff --git a/aiomailru/objects/group.py b/aiomailru/objects/group.py deleted file mode 100644 index b9aa4fc..0000000 --- a/aiomailru/objects/group.py +++ /dev/null @@ -1,36 +0,0 @@ -from collections import UserDict - - -class GroupItem(UserDict): - """Group item.""" - - class Scripts: - class Selectors: - url = 'a.groups__avatar' - url = 'n => n.getAttribute("href")' - - s = Scripts - ss = Scripts.Selectors - - def __init__(self, initialdata): - super().__init__(initialdata) - - def __repr__(self): - return repr(self.data) - - @classmethod - async def from_element(cls, element): - """Creates a new group item from a DOM element. - - Args: - element (pyppeteer.element_handle.ElementHandle): the element. - - Returns: - item (GroupItem): new instance of this class. - - """ - - url = await element.Jeval(cls.ss.url, cls.s.url) - data = {'link': url.strip('?ref=')} - group = cls(initialdata=data) - return group diff --git a/aiomailru/parsers.py b/aiomailru/parsers.py deleted file mode 100644 index ce25ea4..0000000 --- a/aiomailru/parsers.py +++ /dev/null @@ -1,35 +0,0 @@ -import html.parser -from collections import defaultdict - - -class FormParser(html.parser.HTMLParser): - """HTML form parser.""" - - @property - def form(self): - return self.url, self.inputs - - __slots__ = ('url', 'inputs') - - def __init__(self): - super().__init__() - self.url = '' - self.inputs = {} - - def handle_starttag(self, tag, attrs): - if tag == 'input': - attrs = defaultdict(str, attrs) - if attrs['type'].lower() != 'submit': - self.inputs[attrs['name']] = attrs['value'] - elif tag == 'form': - attrs = defaultdict(str, attrs) - if attrs['method'].lower() == 'post': - self.url = attrs['action'] - - -class AuthPageParser(FormParser): - """Authorization dialog parser.""" - - -class AccessPageParser(FormParser): - """Access dialog parser.""" diff --git a/aiomailru/scrapers.py b/aiomailru/scrapers.py deleted file mode 100644 index 8bb8c33..0000000 --- a/aiomailru/scrapers.py +++ /dev/null @@ -1,548 +0,0 @@ -"""My.Mail.Ru scrapers.""" - -import asyncio -import logging -import sys -from functools import wraps - -from .exceptions import ( - APIError, - APIScrapperError, - CookieError, - EmptyObjectsError, - EmptyGroupsError, - AccessDeniedError, - BlackListError, -) -from .api import API, APIMethod -from .browser import Browser -from .objects import Event, GroupItem -from .sessions import TokenSession - - -# monkey patching for python 3.5 -if sys.version_info[1] == 5: - from collections import OrderedDict - from pyppeteer.execution_context import JSHandle - - async def get_properties(self): - """Get all properties of this handle.""" - response = (await self._client.send('Runtime.getProperties', { - 'objectId': self._remoteObject.get('objectId', ''), - 'ownProperties': True, - })) - result = OrderedDict() - for prop in response['result']: - if prop.get('enumerable'): - key = prop.get('name') - value = prop.get('value') - result[key] = self._context._objectHandleFactory(value) - return result - - JSHandle.getProperties = get_properties - - -log = logging.getLogger(__name__) - - -class APIScraper(API, Browser): - """API scraper.""" - - __slots__ = ('browser', ) - - def __init__(self, session: TokenSession, browser=None): - API.__init__(self, session) - Browser.__init__(self, browser) - - def __getattr__(self, name): - return scrapers.get(name, APIMethod)(self, name) - - -class APIScraperMethod(APIMethod): - """API scraper's method.""" - - class Scripts: - """Common scripts.""" - class Selectors: - """Common selectors.""" - content = ( - ' html body' - ' div.l-content' - ' div.l-content__center' - ' div.l-content__center__inner' - ) - main_page = '%s div.b-community__main-page' % content - closed_signage = '%s div.mf_cc' % main_page - profile = '%s div.profile' % main_page - profile_content = '%s div.profile__contentBlock' % profile - - class SelectorTemplates: - """Common templates of selectors.""" - hidden = '%s[style="display: none;"]' - visible = '%s:not([style="display: none;"])' - - class ScriptTemplates: - """Common templates of scripts.""" - getattr = 'n => n.getAttribute("%s")' - selector = 'document.querySelector("%s")' - selector_all = 'document.querySelectorAll("%s")' - click = '{0}.click()'.format(selector) - computed_style = 'window.getComputedStyle({0})'.format(selector) - display = '{0}["display"]'.format(computed_style) - visible = '{0} != "none"'.format(display) - length = '{0}.length'.format(selector_all) - - scroll = 'window.scroll(0, document.body.scrollHeight)' - - s = Scripts - ss = Scripts.Selectors - sst = Scripts.ScriptTemplates - ssst = Scripts.Selectors.SelectorTemplates - - def __init__(self, api: APIScraper, name: str): - super().__init__(api, name) - self.page = None - - def __getattr__(self, name): - name = self.name + '.' + name - return scrapers.get(name, APIMethod)(self.api, name) - - async def __call__(self, scrape=False, **params): - call = self.call if scrape else super().__call__ - return await call(**params) - - async def init(self, **params): - pass - - async def call(self, **params): - await self.init(**params) - - -class APIScraperMultiMethod(APIScraperMethod): - - multiarg = 'uids' - empty_objects_error = EmptyObjectsError - ignored_errors = (APIError, ) - - async def __call__(self, scrape=False, **params): - call = self.multicall if scrape else super().__call__() - return await call(**params) - - async def multicall(self, **params): - args = params[self.multiarg].split(',') - result = [] - for arg in args: - params.pop(self.multiarg) - params.update({self.multiarg: arg}) - - # when `self.api.session.pass_error` is False - try: - resp = await self.call(**params) - except self.ignored_errors: - resp = self.empty_objects_error().error - - # when `self.api.session.pass_error` is True - if isinstance(resp, dict) and 'error_code' in resp: - pass - else: - result.append(resp[0]) - - if not result and self.empty_objects_error is not None: - if self.api.session.pass_error: - return self.empty_objects_error().error - else: - raise self.empty_objects_error - else: - return result - - -scraper = APIScraperMethod -multiscraper = APIScraperMultiMethod - - -def with_init(coro): - @wraps(coro) - async def wrapper(self: scraper, **kwargs): - if not self.api.session.cookies: - raise CookieError('Cookie jar is empty. Set cookies.') - init_result = await self.init(**kwargs) - if isinstance(init_result, dict): - return init_result - else: - return await coro(self, **kwargs) - - return wrapper - - -class GroupsGet(scraper): - """Returns a list of the communities to which the current user belongs.""" - - url = 'https://my.mail.ru/my/communities' - - class Scripts(scraper.s): - class Selectors(scraper.ss): - groups = ( - scraper.ss.content + - ' div.groups-catalog' - ' div.groups-catalog__mine-groups' - ' div.groups-catalog__small-groups' - ) - bar = '%s div.groups-catalog__groups-more' % groups - hidden_bar = scraper.ssst.hidden % bar - visible_bar = scraper.ssst.visible % bar - catalog = '%s div.groups__container' % groups - button = '%s span.ui-button-gray' % bar - progress_button = '%s span.progress' % bar - item = '%s div.groups__item' % catalog - - click = scraper.sst.click % Selectors.button - button_class = scraper.sst.getattr % 'class' - bar_css = scraper.sst.getattr % 'style' - loaded = '{0} > %d'.format(scraper.sst.length % Selectors.item) - - s = Scripts - ss = Scripts.Selectors - - async def init(self, limit=10, offset=0, ext=0): - info = await self.api.users.getInfo(uids='') - if isinstance(info, dict): - return info - url = self.url - log.debug('go to %s' % url) - self.page = await self.api.page( - url, - self.api.session.session_key, - self.api.session.cookies, - ) - _ = await self.page.screenshot() - return True - - @with_init - async def call(self, *, limit=10, offset=0, ext=0): - return await self.scrape(ext, limit, offset) - - async def scrape(self, ext, limit, offset): - """Appends groups from the `page` to the `groups` list.""" - log.debug('scrape subset: offset={0}, limit={1}'.format(offset, limit)) - - groups, cnt = [], 0 - async for group in self.Iterator(self, ext): - if cnt < offset: - continue - else: - groups.append(group) - cnt += 1 - - if len(groups) >= limit: - break - - return groups - - class Iterator: - """Yields groups from the beginning to the end.""" - - def __init__(self, method, ext): - self.counter = 0 - self.m = method - self.ext = ext - - self.catalog = None - self.load_more_hidden_bar = None - self.load_more_bar = True - self.elements = [] - - async def __aiter__(self): - self.catalog = await self.m.page.J(self.m.ss.catalog) - if self.catalog: - self.elements = await self.catalog.JJ(self.m.ss.item) - return self - - async def __anext__(self): - if self.counter >= self.offset: - if self.load_more_bar is None: - raise StopAsyncIteration - elif self.load_more_hidden_bar is not None: - raise StopAsyncIteration - else: - await self.load_more() - - if self.catalog is None: - raise StopAsyncIteration - - i = self.counter - self.counter += 1 - - element = self.elements[i] - item = await GroupItem.from_element(element) - link = item['link'].lstrip('/') - resp = await self.m.api.session.public_request([link]) - group = await self.m.api.users.getInfo(uids=resp['uid']) - return group[0] if self.ext else group[0]['uid'] - - @property - def offset(self): - return len(self.elements) - - async def load_more(self): - # click 'more' button - if await self.m.page.J(self.m.ss.button): - await self.m.page.evaluate(self.m.s.click) - # wait downloading - progress_button = True - while progress_button: - progress_button = await self.m.page.J(self.m.ss.progress_button) - # get groups' DOM elements - self.elements = await self.catalog.JJ(self.m.ss.item) - # get 'load more' bar - self.load_more_hidden_bar = await self.m.page.J(self.m.ss.hidden_bar) - self.load_more_bar = await self.m.page.J(self.m.ss.bar) - - -class GroupsGetInfo(multiscraper): - """Returns information about communities by their IDs.""" - - class Scripts(multiscraper.s): - class Selectors(multiscraper.ss): - pass - - s = Scripts - ss = Scripts.Selectors - - empty_objects_error = EmptyGroupsError - ignored_errors = (APIError, KeyError) # KeyError when group_info is absent - - async def init(self, uids=''): - info = await self.api.users.getInfo(uids=uids) - if isinstance(info, dict): - return info - url = info[0]['link'] - log.debug('go to %s' % url) - self.page = await self.api.page( - url, - self.api.session.session_key, - self.api.session.cookies, - True, - ) - _ = await self.page.screenshot() - return True - - @with_init - async def call(self, *, uids=''): - return await self.scrape(uids) - - async def scrape(self, uids): - """Returns additional information about a group. - - Object fields that are scraped here: - - 'is_closed' - information whether the group's stream events - are closed for current user. - - """ - - info = await self.api.users.getInfo(uids=uids) - signage = await self.page.J(self.ss.closed_signage) - is_closed = True if signage is not None else False - info[0]['group_info'].update({'is_closed': is_closed}) - return info - - -class GroupsJoin(scraper): - """With this method you can join the group.""" - - retry_interval = 1 - num_attempts = 10 - - class Scripts(scraper.s): - class Selectors(scraper.ss): - links = ( - scraper.ss.profile_content + - ' div.profile__activeLinks' - ' div.profile__activeLinks_community' - ) - join_span = '%s span.profile__activeLinks_button_enter' % links - sent_span = '%s span.profile__activeLinks_link_modarated' % links - approved_span = '%s span.profile__activeLinks_link_inGroup' % links - auth_span = '%s div.l-popup_community-authorization' % links - - join_span_visible = scraper.sst.visible % Selectors.join_span - sent_span_visible = scraper.sst.visible % Selectors.sent_span - approved_span_visible = scraper.sst.visible % Selectors.approved_span - - join_click = '{0}'.format(scraper.sst.click % Selectors.join_span) - - s = Scripts - ss = Scripts.Selectors - - async def init(self, group_id=''): - info = await self.api.users.getInfo(uids=group_id) - if isinstance(info, dict): - return info - url = info[0]['link'] - log.debug('go to %s' % url) - self.page = await self.api.page( - url, - self.api.session.session_key, - self.api.session.cookies, - True, - ) - _ = await self.page.screenshot() - return True - - @with_init - async def call(self, *, group_id=''): - return await self.scrape() - - async def scrape(self): - if await self.page.evaluate(self.s.join_span_visible): - return await self.join() - elif await self.page.evaluate(self.s.sent_span_visible): - return 1 - elif await self.page.evaluate(self.s.approved_span_visible): - return 1 - else: - raise APIScrapperError('A join button not found.') - - async def join(self): - for i in range(self.num_attempts): - await self.page.evaluate(self.s.join_click) - await asyncio.sleep(self.retry_interval) - - if await self.page.evaluate(self.s.sent_span_visible): - return 1 - elif await self.page.evaluate(self.s.approved_span_visible): - return 1 - - raise APIScrapperError('Failed to send join request.') - - -class StreamGetByAuthor(scraper): - """Returns a list of events from user or community stream by their IDs.""" - - class Scripts(scraper.s): - class Selectors(scraper.ss): - feed = '%s div.b-community__main-page__feed' % scraper.ss.main_page - stream = '%s div.b-history[data-state]' % feed - updating_stream = '%s[data-state=""]' % stream - loading_stream = '%s[data-state="loading"]' % stream - loaded_stream = '%s[data-state="loaded"]' % stream - ended_stream = '%s[data-state="noevents"]' % stream - content = '%s div.content-wrapper' % feed - event = '%s div.b-history-event[data-astat]' % stream - - s = Scripts - ss = Scripts.Selectors - - async def init(self, uid='', limit=10, skip=''): - info = await self.api.users.getInfo(uids=uid) - if isinstance(info, dict): - return info - url = info[0]['link'] - log.debug('go to %s' % url) - self.page = await self.api.page( - url, - self.api.session.session_key, - self.api.session.cookies, - ) - _ = await self.page.screenshot() - return True - - @with_init - async def call(self, *, uid='', limit=10, skip=''): - return await self.scrape(limit, skip) - - async def scrape(self, limit, skip): - """Returns a list of events from user or community stream.""" - - log.debug('scrape subset: skip={0}, limit={1}'.format(skip, limit)) - - try: - events = [] - async for event in self.Iterator(self): - if skip: - skip = skip if event['id'] != skip else False - else: - events.append(event) - - if len(events) >= limit: - break - except (AccessDeniedError, BlackListError) as e: - if self.api.session.pass_error: - return e.error - else: - raise e - - return events - - class Iterator: - """Yields stream events from the beginning to the end.""" - - def __init__(self, method): - self.counter = 0 - self.m = method - - self.ended_stream = None - self.elements = [] - self.events = [] - self.content = None - - async def __aiter__(self): - self.content = await self.m.page.J(self.m.ss.content) - if self.content is None: - log.debug('content is None ' + self.m.ss.content) - signage = await self.m.page.J(self.m.ss.closed_signage) - if signage: - raise AccessDeniedError() - else: - raise BlackListError() - self.elements = await self.content.JJ(self.m.ss.event) - for element in self.elements[self.offset:]: - self.events.append(await Event.from_element(element)) - return self - - async def __anext__(self): - if self.counter >= self.offset: - if await self.m.page.J(self.m.ss.ended_stream): - raise StopAsyncIteration - else: - await self.load_more() - - if self.content is None: - raise StopAsyncIteration - - i = self.counter - self.counter += 1 - - return self.events[i] - - @property - def offset(self): - return len(self.events) - - async def load_more(self): - await self.m.page.evaluate(self.m.s.scroll) # scroll - - # until stream's state is updated to 'loaded' - loading_stream, updating_stream = True, True - while loading_stream or updating_stream: - stream = False - # until stream's state is updated to 'loading' or 'updating' - while not stream and self.content: - stream = await self.m.page.waitForSelector(self.m.ss.stream) - self.content = await self.m.page.J(self.m.ss.content) - - loading_stream = await self.m.page.J(self.m.ss.loading_stream) - updating_stream = await self.m.page.J(self.m.ss.updating_stream) - - self.elements = await self.content.JJ(self.m.ss.event) - for element in self.elements[self.offset:]: - self.events.append(await Event.from_element(element)) - - -scrapers = { - 'groups': APIScraperMethod, - 'groups.get': GroupsGet, - 'groups.getInfo': GroupsGetInfo, - 'groups.join': GroupsJoin, - 'stream': APIScraperMethod, - 'stream.getByAuthor': StreamGetByAuthor, -} diff --git a/aiomailru/session.py b/aiomailru/session.py new file mode 100644 index 0000000..cc25d52 --- /dev/null +++ b/aiomailru/session.py @@ -0,0 +1,127 @@ +"""Sessions.""" +import hashlib +import logging +from typing import Any, Dict + +from httpx import AsyncClient + +log = logging.getLogger(__name__) + + +class Session: + """A wrapper for httpx.AsyncClient. + + Attributes: + client (AsyncClient): async client with default base url and encoding + + """ + + __slots__ = ('client',) + + def __init__(self) -> None: + """Set base url and encoding.""" + self.client = AsyncClient( + default_encoding='text/javascript; charset=utf-8', + base_url='http://appsmail.ru', + follow_redirects=True, + ) + + +class PublicSession(Session): + """Session for public API methods of Platform@Mail.Ru.""" + + async def request(self, params: Dict[str, Any], + ) -> Dict[str, Any]: + """Request public data. + + Args: + params (Dict[str, Any]): query parameters + + Returns: + Dict[str, Any] + + Raises: + HTTPStatusError: if one occurred + + """ + try: + resp = await self.client.get('platform/api', params=params) + except Exception: + log.error(f'GET {params["method"]} request failed') + raise + else: + log.info(f'GET {resp.url} {resp.status_code}') + + resp.raise_for_status() + + try: + return resp.json() + except Exception: + content = resp.read().decode() + log.error(f'GET {resp.url} {resp.status_code} {content}') + raise + + +class TokenSession(PublicSession): + """Session for executing authorized requests. + + Attributes: + app_id (str): client id + private_key (str): private key + secret_key (str): secret key + session_key (str): access token + uid (str): user id + + """ + + __slots__ = ('app_id', 'session_key', 'secret', 'secure', 'uid') + + def __init__( + self, + app_id: str, + session_key: str, + secret: str, + secure: str, + uid: str, + ) -> None: + """Set credentials.""" + super().__init__() + self.app_id = app_id + self.session_key = session_key + self.secret = secret + self.secure = secure + self.uid = uid + + def sign_params(self, params: Dict[str, Any]) -> str: + """Sign query string according to signature circuit. + + See https://api.mail.ru/docs/guides/restapi/#sig. + + Args: + params (Dict[str, Any]): query parameters + + Returns: + str + + """ + query = ''.join(k + '=' + str(params[k]) for k in sorted(params)) + query = self.uid + query + self.secret + sig = hashlib.md5(query.encode('UTF-8')).hexdigest() + return sig + + async def request(self, params: Dict[str, Any]) -> Dict[str, Any]: # noqa + """Request data. + + Args: + params (Dict[str, Any]): query parameters + + Returns: + Dict[str, Any] + + """ + params = {k: params[k] for k in params if params[k]} + params['session_key'] = self.session_key + params['app_id'] = self.app_id + params['secure'] = self.secure + params.update({'sig': self.sign_params(params)}) + return await super().request(params) diff --git a/aiomailru/sessions.py b/aiomailru/sessions.py deleted file mode 100644 index efc2d28..0000000 --- a/aiomailru/sessions.py +++ /dev/null @@ -1,656 +0,0 @@ -import asyncio -import hashlib -import logging - -import aiohttp -from yarl import URL - -from .exceptions import ( - Error, - OAuthError, - InvalidGrantError, - InvalidClientError, - InvalidUserError, - ClientNotAvailableError, - APIError, - EmptyResponseError, -) -from .parsers import AuthPageParser, AccessPageParser -from .utils import full_scope, parseaddr, SignatureCircuit, Cookie - - -log = logging.getLogger(__name__) - - -class Session: - """A wrapper around aiohttp.ClientSession.""" - - __slots__ = ('pass_error', 'session') - - def __init__(self, pass_error=False, session=None): - self.pass_error = pass_error - self.session = session or aiohttp.ClientSession() - - def __await__(self): - return self.authorize().__await__() - - async def __aenter__(self): - return await self.authorize() - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() - - async def authorize(self): - return self - - async def close(self): - await self.session.close() - - -class PublicSession(Session): - """Session for calling public API methods of Platform@Mail.Ru.""" - - PUBLIC_URL = 'http://appsmail.ru/platform' - CONTENT_TYPE = 'text/javascript; charset=utf-8' - - async def public_request(self, segments=(), params=None): - """Requests public data. - - Args: - segments (tuple): additional segments for URL path. - params (dict): URL parameters. - - Returns: - response (dict): JSON object response. - - """ - - url = self.PUBLIC_URL + '/' + '/'.join(segments) - - try: - async with self.session.get(url, params=params) as resp: - content = await resp.json(content_type=self.CONTENT_TYPE) - except aiohttp.ContentTypeError: - msg = 'got non-REST path: %s' % url - log.error(msg) - raise Error(msg) - - if self.pass_error: - response = content - elif 'error' in content: - log.error(content) - raise APIError(content) - elif content: - response = content - else: - log.error('got empty response: %s' % url) - raise EmptyResponseError() - - return response - - -class TokenSession(PublicSession): - """Session for executing authorized requests.""" - - API_URL = PublicSession.PUBLIC_URL + '/api' - ERROR_MSG = 'See https://api.mail.ru/docs/guides/restapi/#sig.' - - __slots__ = ('app_id', 'private_key', 'secret_key', 'session_key', 'uid') - - def __init__(self, app_id, private_key, secret_key, access_token, uid, - cookies=(), pass_error=False, session=None, **kwargs): - super().__init__(pass_error, session) - self.app_id = app_id - self.private_key = private_key - self.secret_key = secret_key - self.session_key = access_token - self.uid = uid - self.cookies = cookies - - @property - def cookies(self): - """HTTP cookies from cookie jar.""" - return [Cookie.from_morsel(m) for m in self.session.cookie_jar] - - @cookies.setter - def cookies(self, cookies): - loose_cookies = [] - - for cookie in cookies: - loose_cookie = Cookie.to_morsel(cookie) - loose_cookies.append((loose_cookie.key, loose_cookie)) - - self.session.cookie_jar.update_cookies(loose_cookies) - - @property - def sig_circuit(self): - if self.uid and self.private_key and self.app_id: - return SignatureCircuit.CLIENT_SERVER - elif self.secret_key and self.app_id: - return SignatureCircuit.SERVER_SERVER - else: - return SignatureCircuit.UNDEFINED - - @property - def required_params(self): - """Required parameters.""" - params = {'app_id': self.app_id, 'session_key': self.session_key} - if self.sig_circuit is SignatureCircuit.SERVER_SERVER: - params['secure'] = '1' - return params - - def params_to_str(self, params): - query = ''.join(k + '=' + str(params[k]) for k in sorted(params)) - - if self.sig_circuit is SignatureCircuit.CLIENT_SERVER: - return str(self.uid) + query + self.private_key - elif self.sig_circuit is SignatureCircuit.SERVER_SERVER: - return query + self.secret_key - else: - raise Error(self.ERROR_MSG) - - def sign_params(self, params): - """Signs the request parameters according to signature circuit. - - Args: - params (dict): request parameters - - Returns: - sig (str): signature - - """ - - query = self.params_to_str(params) - sig = hashlib.md5(query.encode('UTF-8')).hexdigest() - return sig - - async def request(self, segments=(), params=()): - """Sends a request. - - Args: - segments (tuple): additional segments for URL path. - params (dict): URL parameters, contains key 'method', e.g. - { - "method": "stream.getByAuthor", - "uid": "15410773191172635989", - "limit": 10, - } - - Returns: - response (dict): JSON object response. - - """ - - url = self.API_URL + '/' + '/'.join(segments) - - params = {k: params[k] for k in params if params[k]} - params.update(self.required_params) - params.update({'sig': self.sign_params(params)}) - - async with self.session.get(url, params=params) as resp: - content = await resp.json(content_type=self.CONTENT_TYPE) - - if self.pass_error: - response = content - elif 'error' in content: - log.error(content) - raise APIError(content) - elif content: - response = content - else: - log.error('got empty response: %s' % url) - raise EmptyResponseError() - - return response - - -class ClientSession(TokenSession): - """Session for executing requests in client applications.""" - - ERROR_MSG = 'Pass "uid" and "private_key" to use client-server circuit.' - - def __init__(self, app_id, private_key, access_token, uid, cookies=(), - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, '', access_token, uid, cookies, - pass_error, session) - - -class ServerSession(TokenSession): - """Session for executing requests in server applications.""" - - ERROR_MSG = 'Pass "secret_key" to use server-server circuit.' - - def __init__(self, app_id, secret_key, access_token, cookies=(), - pass_error=False, session=None, **kwargs): - super().__init__(app_id, '', secret_key, access_token, '', cookies, - pass_error, session) - - -class CodeSession(TokenSession): - """Session with authorization with OAuth 2.0 (Authorization Code Grant). - - The Authorization Code grant is used by confidential and public - clients to exchange an authorization code for an access token. - - .. _OAuth 2.0 Authorization Code Grant - https://oauth.net/2/grant-types/authorization-code/ - - .. _Авторизация для сайтов - https://api.mail.ru/docs/guides/oauth/sites/ - - .. _Авторизация для мобильных сайтов - https://api.mail.ru/docs/guides/oauth/mobile-web/ - - """ - - OAUTH_URL = 'https://connect.mail.ru/oauth/token' - - __slots__ = ('code', 'redirect_uri', 'refresh_token', 'expires_in') - - def __init__(self, app_id, private_key, secret_key, code, redirect_uri, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, secret_key, '', '', (), - pass_error, session, **kwargs) - self.code = code - self.redirect_uri = redirect_uri - - @property - def params(self): - """Authorization request's parameters.""" - return { - 'client_id': self.app_id, - 'client_secret': self.secret_key, - 'grant_type': 'authorization_code', - 'code': self.code, - 'redirect_uri': self.redirect_uri, - } - - async def authorize(self): - """Authorize with OAuth 2.0 (Authorization Code).""" - - async with self.session.post(self.OAUTH_URL, data=self.params) as resp: - content = await resp.json(content_type=self.CONTENT_TYPE) - - if 'error' in content: - log.error(content) - raise OAuthError(content) - elif content: - try: - self.refresh_token = content['refresh_token'] - self.expires_in = content['expires_in'] - self.session_key = content['access_token'] - self.uid = content['x_mailru_vid'] - except KeyError as e: - raise OAuthError('%r is missing in the response' % e.args[0]) - else: - raise OAuthError('got empty authorization response') - - return self - - -class CodeClientSession(CodeSession): - """`CodeSession` without `secret_key` argument.""" - - def __init__(self, app_id, private_key, code, redirect_uri, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, '', code, redirect_uri, - pass_error, session, **kwargs) - - -class CodeServerSession(CodeSession): - """`CodeSession` without `private_key` argument.""" - - def __init__(self, app_id, secret_key, code, redirect_uri, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, '', secret_key, code, redirect_uri, - pass_error, session, **kwargs) - - -class ImplicitSession(TokenSession): - """Session with authorization with OAuth 2.0 (Implicit Grant). - - The Implicit flow was a simplified OAuth flow previously recommended - for native apps and JavaScript apps where the access token was returned - immediately without an extra authorization code exchange step. - - .. _OAuth 2.0 Implicit Grant - https://oauth.net/2/grant-types/implicit/ - - .. _Авторизация для Standalone-приложений - https://api.mail.ru/docs/guides/oauth/standalone/ - - """ - - OAUTH_URL = 'https://connect.mail.ru/oauth/authorize' - REDIRECT_URI = 'http%3A%2F%2Fconnect.mail.ru%2Foauth%2Fsuccess.html' - - AUTHORIZE_NUM_ATTEMPTS = 1 - AUTHORIZE_RETRY_INTERVAL = 3 - - GET_AUTH_DIALOG_ERROR_MSG = 'Failed to open authorization dialog.' - POST_AUTH_DIALOG_ERROR_MSG = 'Form submission failed.' - GET_ACCESS_TOKEN_ERROR_MSG = 'Failed to receive access token.' - POST_ACCESS_DIALOG_ERROR_MSG = 'Failed to process access dialog.' - - __slots__ = ('email', 'passwd', 'scope', - 'refresh_token', 'expires_in', 'token_type') - - def __init__(self, app_id, private_key, secret_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, secret_key, '', '', (), - pass_error, session, **kwargs) - self.email = email - self.passwd = passwd - self.scope = scope or full_scope() - - @property - def params(self): - """Authorization request's parameters.""" - return { - 'client_id': self.app_id, - 'redirect_uri': self.REDIRECT_URI, - 'response_type': 'token', - 'scope': self.scope, - } - - async def authorize(self, num_attempts=None, retry_interval=None): - """Authorize with OAuth 2.0 (Implicit flow).""" - - num_attempts = num_attempts or self.AUTHORIZE_NUM_ATTEMPTS - retry_interval = retry_interval or self.AUTHORIZE_RETRY_INTERVAL - - for attempt_num in range(num_attempts): - log.debug('getting authorization dialog %s' % self.OAUTH_URL) - url, html = await self._get_auth_dialog() - - if 'Не указано приложение' in html: - raise InvalidClientError() - elif url.path == '/oauth/authorize': - log.debug('authorizing at %s' % url) - url, html = await self._post_auth_dialog(html) - - if url.path == '/oauth/authorize': - if 'fail' in url.query: - log.error('Invalid e-mail %s or password.' % self.email) - raise InvalidGrantError() - elif 'Необходим доступ к вашим данным' in html: - log.debug('giving rights at %s' % url) - url, html = await self._post_access_dialog(html) - - if 'Авторизация запрещена' in html: - log.debug('access denied') - raise ClientNotAvailableError() - elif url.path == '/oauth/success.html': - log.debug('getting access token') - await self._get_access_token() - log.debug('authorized successfully') - return self - elif url.path == '/recovery': - log.error('User %s is blocked.' % self.email) - raise InvalidUserError() - - await asyncio.sleep(retry_interval) - else: - log.error('%d login attempts exceeded.' % num_attempts) - raise OAuthError('%d login attempts exceeded.' % num_attempts) - - async def _get_auth_dialog(self): - """Returns url and html code of authorization dialog.""" - - async with self.session.get(self.OAUTH_URL, params=self.params) as resp: - if resp.status != 200: - log.error(self.GET_AUTH_DIALOG_ERROR_MSG) - raise OAuthError(self.GET_AUTH_DIALOG_ERROR_MSG) - else: - url, html = resp.url, await resp.text() - - return url, html - - async def _post_auth_dialog(self, html): - """Submits a form with e-mail, password and domain - to get access token and user id. - - Args: - html (str): authorization page's html code. - - Returns: - url (URL): redirected page's url. - html (str): redirected page's html code. - - """ - - parser = AuthPageParser() - parser.feed(html) - form_url, form_data = parser.form - parser.close() - - domain, login = parseaddr(self.email) - form_data['Login'] = login - form_data['Domain'] = domain + '.ru' - form_data['Password'] = self.passwd - - async with self.session.post(form_url, data=form_data) as resp: - if resp.status != 200: - log.error(self.POST_AUTH_DIALOG_ERROR_MSG) - raise OAuthError(self.POST_AUTH_DIALOG_ERROR_MSG) - else: - url, html = resp.url, await resp.text() - - return url, html - - async def _post_access_dialog(self, html): - """Clicks button 'allow' in a page with access dialog. - - Args: - html (str): html code of the page with access form. - - Returns: - url (URL): redirected page's URL. - html (str): redirected page's html code. - - """ - - parser = AccessPageParser() - parser.feed(html) - parser.close() - - form_url, form_data = parser.form - - async with self.session.post(form_url, data=form_data) as resp: - if resp.status != 200: - log.error(self.POST_ACCESS_DIALOG_ERROR_MSG) - raise OAuthError(self.POST_ACCESS_DIALOG_ERROR_MSG) - else: - url, html = resp.url, await resp.text() - - return url, html - - async def _get_access_token(self): - async with self.session.get(self.OAUTH_URL, params=self.params) as resp: - if resp.status != 200: - log.error(self.GET_ACCESS_TOKEN_ERROR_MSG) - raise OAuthError(self.GET_ACCESS_TOKEN_ERROR_MSG) - else: - location = URL(resp.history[-1].headers['Location']) - url = URL('?' + location.fragment) - - try: - self.session_key = url.query['access_token'] - self.expires_in = url.query.get('expires_in') - self.refresh_token = url.query['refresh_token'] - self.token_type = url.query.get('token_type') - self.uid = url.query.get('x_mailru_vid') - except KeyError as e: - raise OAuthError('%r is missing in the response' % e.args[0]) - - -class ImplicitClientSession(ImplicitSession): - """`ImplicitSession` without `secret_key` argument.""" - - def __init__(self, app_id, private_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, '', email, passwd, scope, - pass_error, session, **kwargs) - - -class ImplicitServerSession(ImplicitSession): - """`ImplicitSession` without `private_key` argument.""" - - def __init__(self, app_id, secret_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, '', secret_key, email, passwd, scope, - pass_error, session, **kwargs) - - -class PasswordSession(TokenSession): - """Session with authorization with OAuth 2.0 (Password Grant). - - The Password grant type is a way to exchange a user's credentials - for an access token. - - .. _OAuth 2.0 Password Grant - https://oauth.net/2/grant-types/password/ - - .. _Авторизация по логину и паролю - https://api.mail.ru/docs/guides/oauth/client-credentials/ - - """ - - OAUTH_URL = 'https://appsmail.ru/oauth/token' - - __slots__ = ('email', 'passwd', 'scope', 'refresh_token', 'expires_in') - - def __init__(self, app_id, private_key, secret_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, secret_key, '', '', (), - pass_error, session, **kwargs) - self.email = email - self.passwd = passwd - self.scope = scope or full_scope() - - @property - def params(self): - """Authorization request's parameters.""" - return { - 'grant_type': 'password', - 'client_id': self.app_id, - 'client_secret': self.secret_key, - 'username': self.email, - 'password': self.passwd, - 'scope': self.scope, - } - - async def authorize(self): - """Authorize with OAuth 2.0 (Password Grant).""" - - async with self.session.post(self.OAUTH_URL, data=self.params) as resp: - content = await resp.json(content_type=self.CONTENT_TYPE) - - if 'error' in content: - log.error(content) - raise OAuthError(content) - elif content: - try: - self.refresh_token = content['refresh_token'] - self.expires_in = content.get('expires_in') - self.session_key = content['access_token'] - self.uid = content.get('x_mailru_vid') - except KeyError as e: - raise OAuthError('%r is missing in the response' % e.args[0]) - else: - raise OAuthError('got empty authorization response') - - return self - - -class PasswordClientSession(PasswordSession): - """`PasswordSession` without `secret_key` argument.""" - - def __init__(self, app_id, private_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, '', email, passwd, scope, - pass_error, session, **kwargs) - - -class PasswordServerSession(PasswordSession): - """`PasswordSession` without `private_key` argument.""" - - def __init__(self, app_id, secret_key, email, passwd, scope, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, '', secret_key, email, passwd, scope, - pass_error, session, **kwargs) - - -class RefreshSession(TokenSession): - """Session with authorization with OAuth 2.0 (Refresh Token). - - The Refresh Token grant type is used by clients to exchange - a refresh token for an access token when the access token has expired. - - .. _OAuth 2.0 Refresh Token - https://oauth.net/2/grant-types/refresh-token/ - - .. _Использование refresh_token - https://api.mail.ru/docs/guides/oauth/client-credentials/#refresh_token - - """ - - OAUTH_URL = 'https://appsmail.ru/oauth/token' - - __slots__ = ('refresh_token', 'expires_in') - - def __init__(self, app_id, private_key, secret_key, refresh_token, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, secret_key, '', '', (), - pass_error, session, **kwargs) - self.refresh_token = refresh_token - - @property - def params(self): - """Authorization request's parameters.""" - return { - 'grant_type': 'refresh_token', - 'client_id': self.app_id, - 'refresh_token': self.refresh_token, - 'client_secret': self.secret_key, - } - - async def authorize(self): - """Authorize with OAuth 2.0 (Refresh Token).""" - - async with self.session.post(self.OAUTH_URL, data=self.params) as resp: - content = await resp.json(content_type=self.CONTENT_TYPE) - - if 'error' in content: - log.error(content) - raise OAuthError(content) - elif content: - try: - self.refresh_token = content['refresh_token'] - self.expires_in = content.get('expires_in') - self.session_key = content['access_token'] - self.uid = content.get('x_mailru_vid') - except KeyError as e: - raise OAuthError('%r is missing in the response' % e.args[0]) - else: - raise OAuthError('got empty authorization response') - - return self - - -class RefreshClientSession(RefreshSession): - """`RefreshSession` without `secret_key` argument.""" - - def __init__(self, app_id, private_key, refresh_token, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, private_key, '', refresh_token, - pass_error, session, **kwargs) - - -class RefreshServerSession(RefreshSession): - """`RefreshSession` without `private_key` argument.""" - - def __init__(self, app_id, secret_key, refresh_token, - pass_error=False, session=None, **kwargs): - super().__init__(app_id, '', secret_key, refresh_token, - pass_error, session, **kwargs) diff --git a/aiomailru/utils.py b/aiomailru/utils.py deleted file mode 100644 index 907ab61..0000000 --- a/aiomailru/utils.py +++ /dev/null @@ -1,130 +0,0 @@ -import http.cookies -import re -from datetime import datetime -from enum import Enum - -EMAIL_PATTERN = r"(^[a-zA-Z0-9_.+-]+)@([a-zA-Z0-9-]+)\.([a-zA-Z0-9-.]+$)" -PRIVILEGES = ['photos', 'guestbook', 'stream', 'messages', 'events'] - - -def full_scope(): - return ' '.join(PRIVILEGES) - - -class SignatureCircuit(Enum): - """Signature circuit. - - .. _Подпись запроса - https://api.mail.ru/docs/guides/restapi/#sig - - """ - - UNDEFINED = 0 - CLIENT_SERVER = 1 - SERVER_SERVER = 2 - - -def parseaddr(address): - """Converts an e-mail address to a tuple - (screen name, domain name). - - Args: - address (str): e-mail address - - Returns: - domain_name(str): domain name - screen_name (str): screen name - - """ - - pattern = re.compile(EMAIL_PATTERN) - match = pattern.match(address) - - if match is None: - raise ValueError("email address %r is not valid" % address) - - screen_name, domain_name, _ = match.groups() - return domain_name, screen_name - - -class Cookie(dict): - """Represents cookie in a browser.""" - - expires_fmt = '%a, %d %b %Y %H:%M:%S GMT' - - def __init__(self, *args): - super().__init__(*args) - - @classmethod - def from_morsel(cls, morsel): - """Converts a cookie morsel to dictionary. - - Args: - morsel (http.cookies.Morsel): cookie morsel - - Returns: - cookie (dict): cookie for the browser. - - """ - - domain = morsel['domain'] - expires = morsel['expires'] - path = morsel['path'] - size = len(morsel.key) + len(morsel.value) - http_only = True if morsel['httponly'] else False - secure = True if morsel['secure'] else False - - if expires: - session = False - expires = datetime.strptime(expires, cls.expires_fmt).timestamp() - else: - session = True - expires = None - - if not domain.startswith('.'): - domain = '.' + domain - - cookie = cls({ - 'name': morsel.key, - 'value': morsel.value, - 'domain': domain, - 'path': path, - 'expires': expires, - 'size': size, - 'httpOnly': http_only, - 'secure': secure, - 'session': session, - }) - - return cookie - - @classmethod - def to_morsel(cls, cookie): - """Converts a dictionary to cookie morsel. - - Args: - cookie (dict): cookie from the browser. - - Returns: - morsel (http.cookies.Morsel): cookie morsel - - """ - - morsel = http.cookies.Morsel() - morsel.set(cookie['name'], cookie['value'], cookie['value']) - - if cookie['expires']: - dt = datetime.fromtimestamp(cookie['expires']) - morsel['expires'] = dt.strftime(cls.expires_fmt) - else: - dt = datetime.fromtimestamp(0.) - morsel['expires'] = dt.strftime(cls.expires_fmt) - if cookie['path']: - morsel['path'] = cookie['path'] - if cookie['domain']: - morsel['domain'] = cookie['domain'] - if cookie['secure']: - morsel['secure'] = cookie['secure'] - if cookie['httpOnly']: - morsel['httponly'] = cookie['httpOnly'] - - return morsel diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index ed75576..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,15 +0,0 @@ -version: '3' - -services: - chrome: - image: browserless/chrome:1.9.0-chrome-stable - environment: - - CONNECTION_TIMEOUT=43200000 - - PREBOOT_CHROME=true - - KEEP_ALIVE=true - - CHROME_REFRESH_TIME=86400000 - - MAX_CONCURRENT_SESSIONS=20 - - MAX_QUEUE_LENGTH=200 - hostname: chrome - ports: - - 3000:3000 diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 0000000..ecc4da2 --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,64 @@ +# Authorization + +The preferred way to authorize is an `async with` statement. +After authorization the session will have the following attributes: + +* `session_key` aka `access_token` +* `refresh_token` +* `expires_in` +* `uid` + +## Authorization Code Grant + +```python +from aiomailru import CodeSession, API + +app_id = 123456 +private_key = '' +secret_key = 'xyz' + +async with CodeSession(app_id, private_key, secret_key, code, redirect_uri) as session: + api = API(session) + ... +``` + +About OAuth 2.0 Authorization Code Grant: https://oauth.net/2/grant-types/authorization-code/ + +For more details, see https://api.mail.ru/docs/guides/oauth/sites/ +and https://api.mail.ru/docs/guides/oauth/mobile-web/ + +## Password Grant + +```python +from aiomailru import PasswordSession, API + +app_id = 123456 +private_key = 'abcde' +secret_key = '' + +async with PasswordSession(app_id, private_key, secret_key, email, password, scope) as session: + api = API(session) + ... +``` + +About OAuth 2.0 Password Grant: https://oauth.net/2/grant-types/password/ + +For more details, see https://api.mail.ru/docs/guides/oauth/client-credentials/ + +## Refresh Token + +``` +from aiomailru import RefreshSession, API + +app_id = 123456 +private_key = '' +secret_key = 'xyz' + +async with RefreshSession(app_id, private_key, secret_key, refresh_token) as session: + api = API(session) + ... +``` + +About OAuth 2.0 Refresh Token: https://oauth.net/2/grant-types/refresh-token/ + +For more details, see https://api.mail.ru/docs/guides/oauth/client-credentials/#refresh_token diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 0000000..93f1acf --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,26 @@ +# Getting Started + +## Installation + +If you use pip, just type + +```shell +$ pip install aiomailru +``` + +You can install from the source code like + +```shell +$ git clone https://github.com/konstantintogoi/aiomailru.git +$ cd aiomailru +$ python setup.py install +``` + +## Account + +Sign up in [Mail.Ru](https://mail.ru). + +## Application + +After signing up visit Mail.Ru API [documentation page](https://api.mail.ru/docs/) +and create a new application: https://api.mail.ru/apps/my/add. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e830ac7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,68 @@ +[![LICENSE](https://img.shields.io/badge/license-BSD-blue.svg)](https://github.com/konstantintogoi/aiomailru/blob/master/LICENSE) +[![Last Release](https://img.shields.io/pypi/v/aiomailru.svg)](https://pypi.python.org/pypi/aiomailru) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/aiomailru.svg)](https://pypi.python.org/pypi/aiomailru) + +# aiomailru + +aiomailru is a python [Mail.Ru API](https://api.mail.ru/) wrapper. + +## Usage + +To use [Mail.Ru API](https://api.mail.ru/) you need a registered app and [Mail.Ru](https://mail.ru) account. +For more details, see [aiomailru Documentation](https://konstantintogoi.github.io/aiomailru). + +### Client application + +Use `ClientSession` when REST API is needed in: + +- a client component of the client-server application +- a standalone mobile/desktop application + +i.e. when you embed your app's info (private key) in publicly available code. + +```python +from aiomailru import ClientSession, API + +session = ClientSession(app_id, private_key, access_token, uid) +api = API(session) + +events = await api.stream.get() +friends = await api.friends.getOnline() +``` + +Use `access_token` and `uid` that were received after authorization. +For more details, see [authorization instruction](https://konstantintogoi.github.io/aiomailru/authorization.html). + +### Server application + +Use `ServerSession` when REST API is needed in: + +- a server component of the client-server application +- requests from your servers + +```python +from aiomailru import ServerSession, API + +session = ServerSession(app_id, secret_key, access_token) +api = API(session) + +events = await api.stream.get() +friends = await api.friends.getOnline() +``` + +Use `access_token` that was received after authorization. +For more details, see [authorization instruction](https://konstantintogoi.github.io/aiomailru/authorization.html). + +## Installation + +```shell +$ pip install aiomailru +``` + +## Supported Python Versions + +Python 3.7, 3.8, 3.9 are supported. + +## License + +aiomailru is released under the BSD 2-Clause License. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..f701512 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +recommonmark +sphinx_rtd_theme diff --git a/docs/rest_api.md b/docs/rest_api.md new file mode 100644 index 0000000..78e0689 --- /dev/null +++ b/docs/rest_api.md @@ -0,0 +1,29 @@ +# REST API + +List of all methods is available here: https://api.mail.ru/docs/reference/rest/. + +## Executing requests + +For executing API requests call an instance of `APIMethod` class. +You can get it as an attribute of `API` class instance or +as an attribute of other `APIMethod` class instance. + +```python +from aiomailru import API + +api = API(session) + +events = await api.stream.get() # events for current user +friends = await api.friends.get() # current user's friends +``` + +Under the hood each API request is enriched with parameters to generate signature: + +* `method` +* `app_id` +* `session_key` +* `secure` + +and with the following parameter after generating signature: + +* `sig`, see https://api.mail.ru/docs/guides/restapi/#sig diff --git a/docs/session.md b/docs/session.md new file mode 100644 index 0000000..9215aa0 --- /dev/null +++ b/docs/session.md @@ -0,0 +1,212 @@ +# Session + +The session makes **GET** requests when you call instance of `APIMethod` +class that are returned as attributes of an `API` class instance. + +## Request + +By default, the session (`CodeSession`, `PasswordSession`, `RefreshSession`) +tries to infer which signature generation circuit to use: + +* if `uid` and `private_key` are not empty strings - **client-server** signature generation circuit is used +* else if `secret_key` is not an empty string - **server-server** signature generation circuit is used +* else exception is raised + +You can explicitly set a signature generation circuit for signing requests +by passing to `API` one of the sessions below. + +### Client-Server signature generation circuit + +Let's consider the following example of API request with client-server signature: + +```python +from aiomailru import TokenSession, API + +session = TokenSession( + app_id=423004, + private_key='7815696ecbf1c96e6894b779456d330e', + secret_key='', + access_token='be6ef89965d58e56dec21acb9b62bdaa', + uid='1324730981306483817', +) +api = API(session) + +friends = await api.friends.get() +``` + +It is equivalent to **GET** request: + +```shell +https://appsmail.ru/platform/api + ?method=friends.get + &app_id=423004 + &session_key=be6ef89965d58e56dec21acb9b62bdaa + &sig=5073f15c6d5b6ab2fde23ac43332b002 +``` + +The following steps were taken: + +1. request parameters were sorted and concatenated - `app_id=423004method=friends.getsession_key=be6ef89965d58e56dec21acb9b62bdaa` +2. `uid`, sorted request parameters, `private_key` were concatenated - `1324730981306483817app_id=423004method=friends.getsession_key=be6ef89965d58e56dec21acb9b62bdaa7815696ecbf1c96e6894b779456d330e` +3. signature `5073f15c6d5b6ab2fde23ac43332b002` calculated as MD5 of the previous string +4. signature appended to **GET** request parameters + +For more details, see https://api.mail.ru/docs/guides/restapi/#client. + +#### ClientSession + +`ClientSession` is a subclass of `TokenSession`. + +```python +from aiomailru import ClientSession, API + +session = ClientSession(app_id, 'private key', 'access token', uid) +api = API(session) +... +``` + +#### CodeClientSession + +`CodeClientSession` is a subclass of `CodeSession`. + +```python +from aiomailru import CodeClientSession, API + +async with CodeClientSession(app_id, 'private key', code, redirect_uri) as session: + api = API(session) + ... +``` + +#### PasswordClientSession + +`PasswordClientSession` is a subclass of `PasswordSession`. + +```python +from aiomailru import PasswordClientSession, API + +async with PasswordClientSession(app_id, 'private key', email, passwd, scope) as session: + api = API(session) + ... +``` + +#### RefreshClientSession + +`RefreshClientSession` is a subclass of `RefreshSession`. + +```python +from aiomailru import RefreshClientSession, API + +async with RefreshClientSession(app_id, 'private key', refresh_token) as session: + api = API(session) + ... +``` + +### Server-Server signature generation circuit + +Let's consider the following example of API request with server-server signature: + +```python +from aiomailru import TokenSession, API + +session = TokenSession( + app_id=423004, + private_key='', + secret_key='3dad9cbf9baaa0360c0f2ba372d25716', + access_token='be6ef89965d58e56dec21acb9b62bdaa', + uid='', +) +api = API(session) + +friends = await api.friends.get() +``` + +It is equivalent to **GET** request: + +```shell +https://appsmail.ru/platform/api + ?method=friends.get + &app_id=423004 + &session_key=be6ef89965d58e56dec21acb9b62bdaa + &sig=4a05af66f80da18b308fa7e536912bae +``` + +The following steps were taken: + +1. parameter `secure` = `1` appended to parameters +2. request parameters were sorted and concatenated - `app_id=423004method=friends.getsecure=1session_key=be6ef89965d58e56dec21acb9b62bdaa` +3. sorted request parameters and `secret_key` were concatenated - `1324730981306483817app_id=423004method=friends.getsession_key=be6ef89965d58e56dec21acb9b62bdaa3dad9cbf9baaa0360c0f2ba372d25716` +4. signature `4a05af66f80da18b308fa7e536912bae` calculated as MD5 of the previous string +5. signature appended to **GET** request parameters + +For more details, see https://api.mail.ru/docs/guides/restapi/#server. + +#### ServerSession + +`ServerSession` is a subclass of `TokenSession`. + +```python +from aiomailru import ServerSession, API + +session = ServerSession(app_id, 'secret key', 'access token') +api = API(session) +... +``` + +#### CodeServerSession + +`CodeServerSession` is a subclass of `CodeSession`. + +```python +from aiomailru import CodeServerSession, API + +async with CodeServerSession(app_id, 'secret key', code, redirect_uri) as session: + api = API(session) + ... +``` + +#### PasswordServerSession + +`PasswordServerSession` is a subclass of `PasswordSession`. + +```python +from aiomailru import PasswordServerSession, API + +async with PasswordServerSession(app_id, 'secret key', email, password, scope) as session: + api = API(session) + ... +``` + +#### RefreshServerSession + +`RefreshServerSession` is a subclass of `RefreshSession`. + +```python +from aiomailru import RefreshServerSession, API + +async with RefreshServerSession(app_id, 'secret key', refresh_token) as session: + api = API(session) + ... +``` + +## Response + +By default, a session after executing request returns response's body +as `dict` if executing was successful, otherwise it raises exception. + +You can pass `pass_error` parameter to `TokenSession` +for returning original response (including errors). + +## Error + +In case of an error, by default, exception is raised. +You can pass `pass_error` parameter to `TokenSession` +for returning original error's body as `dict`: + +```python +{ + "error": { + "error_code": 202, + "error_msg": "Access to this object is denied" + } +} +``` diff --git a/docs/source/authorization.rst b/docs/source/authorization.rst index ece4caf..f5fc569 100644 --- a/docs/source/authorization.rst +++ b/docs/source/authorization.rst @@ -7,7 +7,6 @@ After authorization the session will have the following attributes: * :code:`session_key` aka :code:`access_token` * :code:`refresh_token` * :code:`expires_in` -* :code:`token_type` if Implicit Grant used * :code:`uid` Authorization Code Grant @@ -30,25 +29,6 @@ About OAuth 2.0 Authorization Code Grant: https://oauth.net/2/grant-types/author For more details, see https://api.mail.ru/docs/guides/oauth/sites/ and https://api.mail.ru/docs/guides/oauth/mobile-web/ -Implicit Grant --------------- - -.. code-block:: python - - from aiomailru import ImplicitSession, API - - app_id = 123456 - private_key = 'abcde' - secret_key = '' - - async with ImplicitSession(app_id, private_key, secret_key, email, passwd, scope) as session: - api = API(session) - ... - -About OAuth 2.0 Implicit Grant: https://oauth.net/2/grant-types/implicit/ - -For more details, see https://api.mail.ru/docs/guides/oauth/standalone/ - Password Grant -------------- @@ -60,7 +40,7 @@ Password Grant private_key = 'abcde' secret_key = '' - async with PasswordSession(app_id, private_key, secret_key, email, passwd, scope) as session: + async with PasswordSession(app_id, private_key, secret_key, email, password, scope) as session: api = API(session) ... diff --git a/docs/source/conf.py b/docs/source/conf.py index 853fb2a..06493e5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,11 +18,11 @@ # -- Project information ----------------------------------------------------- project = 'aiomailru' -copyright = '2020, Konstantin Togoi' +copyright = '2019-2024, Konstantin Togoi' author = 'Konstantin Togoi' # The full version, including alpha/beta/rc tags -release = '0.1.1.post1' +release = '1.0.0rc1' # -- General configuration --------------------------------------------------- diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index d22e3da..6a3ecdf 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -14,7 +14,7 @@ You can install from the source code like .. code-block:: shell - $ git clone https://github.com/KonstantinTogoi/aiomailru.git + $ git clone https://github.com/konstantintogoi/aiomailru.git $ cd aiomailru $ python setup.py install @@ -29,12 +29,3 @@ Application After signing up visit Mail.Ru API `documentation page `_ and create a new application: https://api.mail.ru/apps/my/add. - -Save **client_id** (aka **app_id**), **private_key** and **secret_key** -for user authorization and executing API requests. - -.. code-block:: python - - app_id = 'your_client_id' - private_key = 'your_private_key' - secret_key = 'your_secret_key' diff --git a/docs/source/rest_api.rst b/docs/source/rest_api.rst index 8cdbb9f..00332f6 100644 --- a/docs/source/rest_api.rst +++ b/docs/source/rest_api.rst @@ -29,533 +29,3 @@ Under the hood each API request is enriched with parameters to generate signatur and with the following parameter after generating signature: * :code:`sig`, see https://api.mail.ru/docs/guides/restapi/#sig - -Objects -------- - -.. |br| raw:: html - -
- -Some objects are returned in several methods. - -User -~~~~ - -.. list-table:: - :widths: 15 85 - :header-rows: 1 - - * - **field** - - **description** - * - **uid** |br| :code:`string` - - User ID. - * - **first_name** |br| :code:`string` - - First name. - * - **last_name** |br| :code:`string` - - Last name. - * - **nick** |br| :code:`string` - - Nickname. - * - **status_text** |br| :code:`string` - - User status. - * - **email** |br| :code:`string` - - E-mail address. - * - **sex** |br| :code:`integer, [0,1]` - - User sex. Possible values: |br| - *0* - male |br| - *1* - female - * - **show_age** |br| :code:`integer, [0,1]` - - Information whether the user allows to show the age. - * - **birthday** |br| :code:`string` - - User's date of birth. Returned as DD.MM.YYYY. - * - **has_my** |br| :code:`integer, [0,1]` - - Information whether the user has profile. - * - **has_pic** |br| :code:`integer, [0,1]` - - Information whether the user has profile photo. - * - **pic** |br| :code:`string` - - URL of user's photo. - * - **pic_small** |br| :code:`string` - - URL of user's photo with at most 45 pixels on the longest side. - * - **pic_big** |br| :code:`string` - - URL of user's photo with at most 600 pixels on the longest side. - * - **pic_22** |br| :code:`string` - - URL of square photo of the user photo with 22 pixels in width. - * - **pic_32** |br| :code:`string` - - URL of square photo of the user photo with 32 pixels in width. - * - **pic_40** |br| :code:`string` - - URL of square photo of the user photo with 40 pixels in width. - * - **pic_50** |br| :code:`string` - - URL of square photo of the user photo with 50 pixels in width. - * - **pic_128** |br| :code:`string` - - URL of square photo of the user photo with 128 pixels in width. - * - **pic_180** |br| :code:`string` - - URL of square photo of the user photo with 180 pixels in width. - * - **pic_190** |br| :code:`string` - - URL of square photo of the user photo with 190 pixels in width. - * - **link** |br| :code:`string` - - Returns a website address of a user profile. - * - **referer_type** |br| :code:`string` - - Referer type. Possible values: |br| - *stream.install* |br| - *stream.publish* |br| - *invitation* |br| - *catalog* |br| - *suggests* |br| - *left menu suggest* |br| - *new apps* |br| - *guestbook* |br| - *agent* - * - **referer_id** |br| :code:`string` - - Identifies where a user came from; |br| see https://api.mail.ru/docs/guides/ref/. - * - **is_online** |br| :code:`integer, [0,1]` - - Information whether the user is online. - * - **is_friend** |br| :code:`integer, [0,1]` - - Information whether the user is a friend of current user. - * - **friends_count** |br| :code:`integer` - - Number of friends. - * - **follower** |br| :code:`integer, [0,1]` - - Information whether the user is a follower of current user. - * - **following** |br| :code:`integer, [0,1]` - - Information whether current user is a follower of the user. - * - **subscribe** |br| :code:`integer, [0,1]` - - Information whether current user is a subscriber of the user. - * - **subscribers_count** |br| :code:`integer` - - Number of subscribers. - * - **video_count** |br| :code:`integer` - - Number of videos. - * - **is_verified** |br| :code:`integer, [0,1]` - - Information whether the user is verified. - * - **vip** |br| :code:`integer, [0,1]` - - Information whether the user is vip. - * - **app_installed** |br| :code:`integer, [0,1]` - - Information whether the user has installed the current app. - * - **last_visit** |br| :code:`integer` - - Date (in Unixtime) of the last user's visit. - * - **cover** |br| :code:`object` - - Information about profile's cover; see :ref:`Cover`. - * - **group_info** |br| :code:`object` - - Object with following fields: |br| - **category_id** :code:`integer` |br| - **short_description** :code:`string` |br| - **full_description** :code:`string` |br| - **interests** :code:`string` |br| - **posts_cnt** :code:`integer` |br| - **category_name** :code:`string` |br| - **rules** :code:`string` - * - **location** |br| :code:`object` - - Object with following fields: |br| - **country** :code:`object`: {**id** :code:`integer`, **name** :code:`string`} |br| - **city** :code:`object`: {**id** :code:`integer`, **name** :code:`string`} |br| - **region** :code:`object`: {**id** :code:`integer`, **name** :code:`string`} - -Event -~~~~~ - -Object describes an event and contains following fields: - -.. list-table:: - :widths: 15 85 - :header-rows: 1 - - * - **field** - - **description** - * - **thread_id** |br| :code:`string` - - Comment thread ID in the following format: |br| :code:``. - * - **authors** |br| :code:`array` - - Information about authors; see :ref:`User`. - * - **type_name** |br| :code:`string` - - Event type name. - * - **click_url** |br| :code:`string` |br| Returns only if current |br| event is likeable. - - Event URL. - * - **likes_count** |br| :code:`integer` |br| Returns only if current |br| event is likeable. - - Number of "likes". - * - **attachments** |br| :code:`array` - - Information about attachments to the event |br| (link, image, video, audio, user, ...) if any; |br| see :ref:`Attachments`. - * - **time** |br| :code:`integer` - - Date (in Unixtime) of the event. - * - **huid** |br| :code:`string` - - Event ID in the following format: |br| :code:``. - * - **generator** |br| :code:`object` - - Object with the following fields: |br| - **icon** :code:`string` - URL of app icon. |br| - **url** :code:`string` - App url. |br| - **app_id** :code:`integer` - App ID. |br| - **type** :code:`string` - App type. |br| - **title** :code:`string` - App title. - * - **user_text** |br| :code:`string` - - User text. - * - **is_liked_by_me** |br| :code:`integer, [0,1]` - - Shows if current user has liked the event. - * - **subtype** |br| :code:`string` - - "event" - * - **is_commentable** |br| :code:`integer, [0,1]` - - Shows if the event is commentable. - * - **type** |br| :code:`string` - - Event type; see :ref:`Event types`. - * - **is_likeable** |br| :code:`integer, [0,1]` - - Shows if the event is likeable. - * - **id** |br| :code:`string` - - Event ID. - * - **text_media** |br| :code:`array` |br| Returns only if event's |br| type name is *micropost*. - - Information about text; see :ref:`Attachments`. - * - **comments_count** |br| :code:`integer` |br| Returns only if current |br| event is commentable. - - Number of comments. - * - **action_links** |br| :code:`array` - - Each object contains following fields: |br| - **text** :code:`string` |br| - **href** :code:`string` - -Event types -^^^^^^^^^^^ - -* 1-1 Photo -* 1-2 Video -* 1-3 Photo mark -* 1-4 Video mark -* 1-6 TYPE_PHOTO_WAS_SELECTED
 -* 1-7 Music
 -* 1-8 Photo comment -* 1-9 TYPE_PHOTO_SUBSCRIPTION
 -* 1-10 Video comment -* 1-11 TYPE_PHOTO_WAS_MODERATED -* 1-12 TYPE_VIDEO_WAS_MODERATED -* 1-13 TYPE_VIDEO_TRANSLATION
 -* 1-14 Private photo comment
 -* 1-15 Private video comment -* 1-16 Music comment -* 1-17 TYPE_PHOTO_NEW_COMMENT
 -* 1-18 TYPE_VIDEO_NEW_COMMENT
 -* 3-1 Blog post -* 3-2 Blog post comment -* 3-3 Join community -* 3-4 Community -* 3-5 TYPE_USER_COMMUNITY_LEAVE -* 3-6 TYPE_BLOG_COMMUNITY_POST
 -* 3-7 TYPE_USER_GUESTBOOK
 -* 3-8 TYPE_BLOG_CHALLENGE_ACCEPT
 -* 3-9 TYPE_BLOG_CHALLENGE_THROW
 -* * 3-10 TYPE_BLOG_SUBSCRIPTION
 -* 3-12 Blog post mark -* 3-13 Community post mark -* 3-23 Post in micro blog -* 3-25 Private post in micro blog -* 4-1 TYPE_QUESTION -* 4-2 TYPE_QUESTION_ANSWER -* 4-6 TYPE_QUESTION_ANSWER_PRIVATE
 -* 5-1 TYPE_USER_FRIEND -* 5-2 TYPE_USER_ANKETA -* 5-4 TYPE_USER_CLASSMATES -* 5-5 TYPE_USER_CAREER -* 5-7 TYPE_USER_AVATAR -* 5-9 TYPE_USER_PARTNER
 -* 5-10 TYPE_GIFT_SENT
 -* 5-11 TYPE_GIFT_RECEIVED
 -* 5-12 TYPE_USER_MILITARY -* 5-13 TYPE_USER_PARTNER_APPROVED -* 5-15 TYPE_USER_ITEM -* 5-16 App install -* 5-17 App event -* 5-18 Community post -* 5-19 Post in community guestbook -* 5-20 Join community -* 5-21 Community video -* 5-22 Community photo -* 5-24 App event -* 5-24 TYPE_APP_INFO -* 5-26 Link share -* 5-27 Event like -* 5-29 Video share -* 5-30 Comment to link share -* 5-31 Comment to video share -* 5-32 Micropost comment - -Like -~~~~ - -Object wraps an event that a user liked and contains following fields: - -.. list-table:: - :widths: 15 85 - :header-rows: 1 - - * - **field** - - **description** - * - **time** |br| :code:`integer` - - Date (in Unixtime) of the "like". - * - **author** |br| :code:`object` - - Information about the user; see :ref:`User`. - * - **huid** |br| :code:`string` - - Like ID in the following format: |br| :code:``. - * - **subevent** |br| :code:`object` - - Information about the event; see :ref:`Event`. - * - **subtype** |br| :code:`string` - - "like". - * - **is_commentable** |br| :code:`integer, [0,1]` - - 0. - * - **id** |br| :code:`string` - - Like ID. - * - **is_likeable** |br| :code:`integer, [0,1]` - - 0. - -Comment -~~~~~~~ - -Object wraps an event that a user commented and contains following fields: - -.. list-table:: - :widths: 15 85 - :header-rows: 1 - - * - **field** - - **description** - * - **time** |br| :code:`integer` - - Date (in Unixtime) of the comment. - * - **huid** |br| :code:`string` - - Comment ID in the following format: |br| :code:``. - * - **subevent** |br| :code:`object` - - Information about the event; see :ref:`Event`. - * - **subtype** |br| :code:`string` - - "comment". - * - **comment** |br| :code:`object` - - Object with following fields: |br| - **text** :code:`string` - Text. |br| - **time** :code:`integer` - Date (in Unixtime) of the comment. |br| - **is_deleted** :code:`integer [0,1]` - Shows if the comment deleted. |br| - **id** :code:`string` - Comment ID. |br| - **author** :code:`object` - Information about the user; see :ref:`User`. |br| - **text_media** :code:`object` - Object: {**object** :code:`string` and **content** :code:`string`}. - * - **is_commentable** |br| :code:`integer, [0,1]` - - 0. - * - **id** |br| :code:`string` - - Comment ID. - * - **is_likeable** |br| :code:`integer, [0,1]` - - 0. - -Attachments -~~~~~~~~~~~ - -Information about event's media attachments is returned -in field **attachments** and contains an :code:`array` of objects. -Each object contains field **object** with type name that -defines all other fields. - -text -^^^^ - -contains following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **object** |br| :code:`string, ["text"]` - * - **content** |br| :code:`string` - -tag -^^^ - -contains one additional field **content** with an object with following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **is_blacklist** |br| :code:`integer, [0,1]` - * - **tag** |br| :code:`string` - -link -^^^^ - -contains one additional field content with an object with following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **type-id** |br| :code:`string, ["text"]` - * - **contents** |br| :code:`string` - -or contains following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **object** |br| :code:`string, ["link"]` - * - **text** |br| :code:`string` - * - **url** |br| :code:`string` - -avatar -^^^^^^ - -contains one additional field **new** with an object with following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **thread_id** |br| :code:`string` - * - **width** |br| :code:`integer` - * - **click_url** |br| :code:`string` - * - **album_id** |br| :code:`string` - * - **src** |br| :code:`string` - * - **height** |br| :code:`integer` - * - **desc** |br| :code:`string` - * - **src_hires** |br| :code:`string` - * - **id** |br| :code:`string` - * - **owner_id** |br| :code:`string` - -image -^^^^^ - -contains following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **likes_count** |br| :code:`integer` - * - **thread_id** |br| :code:`string` - * - **width** |br| :code:`string` - * - **object** |br| :code:`string, ["image"]` - * - **click_url** |br| :code:`string` - * - **album_id** |br| :code:`string` - * - **src** |br| :code:`string` - * - **resized_src** |br| :code:`string` - * - **height** |br| :code:`string` - * - **src_filed** |br| :code:`string` - * - **src_hires** |br| :code:`string` - * - **id** |br| :code:`string` - * - **owner_id** |br| :code:`string` - * - **comments_count** |br| :code:`integer` - -All fields but **object** and **src** may not be returned. - -music -^^^^^ - -contains following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **is_add** |br| :code:`integer` - * - **click_url** |br| :code:`string` - * - **object** |br| :code:`string, ["music"]` - * - **name** |br| :code:`string` - * - **author** |br| :code:`string` - * - **duration** |br| :code:`integer` - * - **file_url** |br| :code:`string` - * - **uploader** |br| :code:`string` - * - **mid** |br| :code:`string` - -video -^^^^^ - -contains following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **width** |br| :code:`integer` - * - **object** |br| :code:`string, ["video"]` - * - **album_id** |br| :code:`string` - * - **view_count** |br| :code:`integer` - * - **desc** |br| :code:`string` - * - **comments_count** |br| :code:`integer` - * - **likes_count** |br| :code:`integer` - * - **thread_id** |br| :code:`string` - * - **image_filed** |br| :code:`string` - * - **click_url** |br| :code:`string` - * - **src** |br| :code:`string` - * - **duration** |br| :code:`integer` - * - **height** |br| :code:`integer` - * - **is_liked_by_me** |br| :code:`integer` - * - **external_id** |br| :code:`string` - * - **owner_id** |br| :code:`string` - * - **title** |br| :code:`string` - -app -^^^ - -contains one additional field **content** with an object with following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **PublishStatus** |br| :code:`object` |br| Object with following fields: |br| - **My** :code:`string` |br| - **Mobile** :code:`string` - * - **ID** |br| :code:`string` - * - **InstallationsSpaced** |br| :code:`string` - * - **ShortName** |br| :code:`string` - * - **Genre** |br| :code:`array` |br| Each object contains following fields: |br| - **name** :code:`string` |br| - **id** :code:`string` |br| - **admin_genre** :code:`integer, [0,1]` - * - **Votes** |br| :code:`object` |br| Object with following fields: |br| - **VoteSum** :code:`string` |br| - **VotesCount** :code:`string` |br| - **VotesStarsWidth** :code:`string` |br| - **Votes2IntRounded** :code:`string` |br| - **Votes2DigitRounded** :code:`string` - * - **Installations** |br| :code:`integer` - * - **ShortDescription** |br| :code:`string` - * - **Name** |br| :code:`string` - * - **Description** |br| :code:`string` - * - **Pictures** |br| :code:`object` - -group -^^^^^ - -contains one additional field **content** with an object; see :ref:`User`. - -gift -^^^^ - -contains one additional field **content** with an object with following fields: - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **is_private** |br| :code:`integer, [0,1]` - * - **click_url** |br| :code:`string` - * - **is_anonymous** |br| :code:`integer, [0,1]` - * - **time** |br| :code:`integer` - * - **is_read** |br| :code:`integer, [0,1]` - * - **to** |br| :code:`object` |br| see :ref:`User`. - * - **gift** |br| :code:`object` - * - **from** |br| :code:`object` |br| see :ref:`User`. - * - **text** |br| :code:`string` - * - **rus_time** |br| :code:`string` - * - **long_id** |br| :code:`string` - -Other -~~~~~ - -Objects that are not classified. - -Cover -^^^^^ - -Object contains information about profile's cover. - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **cover_position** |br| :code:`string` - * - **width** |br| :code:`string` - * - **size** |br| :code:`string` - * - **aid** |br| :code:`string` - * - **pid** |br| :code:`string` - * - **thread_id** |br| :code:`string` - * - **owner** |br| :code:`string` - * - **target_album** |br| :code:`object` |br| Information about target album; |br| see :ref:`Target Album`. - * - **click_url** |br| :code:`string` - * - **src** |br| :code:`string` - * - **height** |br| :code:`string` - * - **cover_width** |br| :code:`string` - * - **created** |br| :code:`string` - * - **comment** |br| :code:`string` - * - **src_small** |br| :code:`string` - * - **cover_height** |br| :code:`string` - * - **title** |br| :code:`string` - -Target Album -^^^^^^^^^^^^ - -Object contains information about cover's target album. - -.. list-table:: - :widths: 100 - :header-rows: 1 - - * - **field** - * - **link** |br| :code:`string` - * - **owner** |br| :code:`string` - * - **sort_order** |br| :code:`string` - * - **sort_by** |br| :code:`string` - * - **description** |br| :code:`string` - * - **privacy_desc** |br| :code:`string` - * - **size** |br| :code:`integer` - * - **aid** |br| :code:`string` - * - **created** |br| :code:`integer` - * - **cover_pid** |br| :code:`string` - * - **cover_url** |br| :code:`string` - * - **is_commentable** |br| :code:`integer, [0,1]` - * - **title** |br| :code:`string` - * - **updated** |br| :code:`integer` - * - **privacy** |br| :code:`integer` - * - **can_read_comment** |br| :code:`integer, [0,1]` diff --git a/docs/source/scrapers.rst b/docs/source/scrapers.rst deleted file mode 100644 index 50cf3de..0000000 --- a/docs/source/scrapers.rst +++ /dev/null @@ -1,98 +0,0 @@ -Scrapers -======== - -The following scrapers are available: - -* :code:`groups.get` -* :code:`groups.getInfo` -* :code:`groups.join` -* :code:`stream.getByAuthor`, works only with a group's id - -.. code-block:: python - - from aiomailru.scrapers import APIScraper - - api = APIScraper(session) - groups = await api.groups.get(scrape=True) # current user's groups - -Scrapers have the following requirements: - -* Cookies -* Pyppeteer -* Browserless - -Cookies -------- - -If :code:`session` is instance of :code:`TokenSession` you must set cookies -that were given by :code:`ImplicitSession`: - -.. code-block:: python - - session = ServerSession(app_id, secret_key, access_token, cookies=cookies) - -Pyppeteer ---------- - -Scrapers require an instance of Chrome. - -You can start a new Chrome process: - -.. code-block:: python - - from aiomailru.scrapers import APIScraper - from pyppeteer import launch - - browser = await launch() - api = APIScraper(session, browser=browser) - - print(browser.wsEndpoint) # your browser's endpoint - -or connect to the existing Chrome: - -.. code-block:: python - - from aiomailru.scrapers import APIScraper - from pyppeteer import connect - - browser_conn = {'browserWSEndpoint': 'your_endpoint'} - browser = await connect(browser_conn) - api = APIScraper(session, browser=browser) - -Export environment variable - -.. code-block:: shell - - $ export PYPPETEER_BROWSER_ENDPOINT='your_endpoint' - -to automatically connect to Chrome: - -.. code-block:: python - - from aiomailru.scrapers import APIScraper - api = APIScraper(session) # connects to PYPPETEER_BROWSER_ENDPOINT - -Browserless ------------ - -You can replace :code:`pyppeteer.launch` with :code:`pyppeteer.connect`. -See https://www.browserless.io - -Start headless chrome using - -.. code-block:: shell - - $ docker-compose up -d chrome - -Export environment variable - -.. code-block:: shell - - $ export PYPPETEER_BROWSER_ENDPOINT=ws://localhost:3000 - -to automatically connect to Browserless container: - -.. code-block:: python - - from aiomailru.scrapers import APIScraper - api = APIScraper(session) # connects to ws://localhost:3000 diff --git a/docs/source/session.rst b/docs/source/session.rst index a0220f0..971f60b 100644 --- a/docs/source/session.rst +++ b/docs/source/session.rst @@ -8,7 +8,7 @@ Request ------- By default, the session -(:code:`CodeSession`, :code:`ImplicitSession`, :code:`PasswordSession`, :code:`RefreshSession`) +(:code:`CodeSession`, :code:`PasswordSession`, :code:`RefreshSession`) tries to infer which signature generation circuit to use: * if :code:`uid` and :code:`private_key` are not empty strings - **client-server** signature generation circuit is used @@ -83,19 +83,6 @@ CodeClientSession api = API(session) ... -ImplicitClientSession -^^^^^^^^^^^^^^^^^^^^^ - -:code:`ImplicitClientSession` is a subclass of :code:`ImplicitSession`. - -.. code-block:: python - - from aiomailru import ImplicitClientSession, API - - async with ImplicitClientSession(app_id, 'private key', email, passwd, scope) as session: - api = API(session) - ... - PasswordClientSession ^^^^^^^^^^^^^^^^^^^^^ @@ -188,19 +175,6 @@ CodeServerSession api = API(session) ... -ImplicitServerSession -^^^^^^^^^^^^^^^^^^^^^ - -:code:`ImplicitServerSession` is a subclass of :code:`ImplicitSession`. - -.. code-block:: python - - from aiomailru import ImplicitServerSession, API - - async with ImplicitServerSession(app_id, 'secret key', email, passwd, scope) as session: - api = API(session) - ... - PasswordServerSession ^^^^^^^^^^^^^^^^^^^^^ @@ -210,7 +184,7 @@ PasswordServerSession from aiomailru import PasswordServerSession, API - async with PasswordServerSession(app_id, 'secret key', email, passwd, scope) as session: + async with PasswordServerSession(app_id, 'secret key', email, password, scope) as session: api = API(session) ... diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..018decc --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,31 @@ +site_name: aiomailru +site_url: https://konstantintogoi.github.io/aiomailru +repo_url: https://github.com/konstantintogoi/aiomailru +repo_name: aiomailru +nav: + - 'Home': index.md + - 'Getting Started': getting_started.md + - 'Authorization': authorization.md + - 'Session': session.md + - 'REST API': rest_api.md +theme: + name: material + palette: + + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d033340 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[tool.ruff] +ignore = [] +fixable = ["I", "W"] +select = [ + "A", # flake8-builtins + "B", # flake8-bugbear + "D", # pydocstyle + "E", # pycodestyle errors + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "W", # pycodestyle warnings + "C4", # flake8-comprehensions + "C90", # mccabe + "COM", # flake8-commas +] +extend-select = [ + "D", # pydocstyle +] +extend-ignore = [ + # Google docstring conventions + "D203", # No blank lines before class docstring + "D213", # Multi-line docstrings should start at the first line + "D406", # Allow colons after "Args", "Returns" + "D407", # Allow "Args", "Returns" +] + +exclude = [ + "__pypackages__", + "__pycache__", + ".mypy_cache", + ".ruff_cache", + ".git", +] + +line-length = 80 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Assume Python 3.7. +target-version = "py37" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2d13c79..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aiohttp>=3.0.0 -pyppeteer<=0.0.25 -yarl \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index bdedc5e..6b7b41c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,4 @@ test = pytest [tool:pytest] addopts = --verbose -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning \ No newline at end of file +asyncio_mode=auto diff --git a/setup.py b/setup.py index 7989e8e..8b6f5bc 100644 --- a/setup.py +++ b/setup.py @@ -1,25 +1,24 @@ +"""aiomailru setup.""" from setuptools import find_packages, setup - setup( name='aiomailru', - version='0.1.1.post1', + version='1.0.0rc1', author='Konstantin Togoi', author_email='konstantin.togoi@gmail.com', - url='https://github.com/KonstantinTogoi/aiomailru', - project_urls={'Documentation': 'https://aiomailru.readthedocs.io'}, + url='https://github.com/konstantintogoi/aiomailru', + project_urls={'Documentation': 'https://konstantintogoi.github.io/aiomailru'}, download_url='https://pypi.org/project/aiomailru/', description='Python Mail.Ru API wrapper', long_description=open('README.rst').read(), license='BSD', packages=find_packages(), platforms=['Any'], - python_requires='>=3.5', - install_requires=['aiohttp>=3.0.0', 'yarl'], + python_requires='>=3.7', + install_requires=['httpx<=1.0.0'], setup_requires=['pytest-runner'], - tests_require=['pytest-asyncio', 'pytest-dotenv', 'pytest-localserver'], - extras_require={'scrapers': ['pyppeteer<=0.0.25']}, - keywords=['mail.ru rest api scrapers asyncio'], + tests_require=['pytest-asyncio', 'pytest-localserver'], + keywords=['mail.ru api'], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', @@ -28,13 +27,12 @@ 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3 :: Only', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Python Modules', - ] + ], ) diff --git a/tests/conftest.py b/tests/conftest.py index 3b8bf67..8e57a41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,66 +1,39 @@ +"""Conftest.""" import json -from os.path import dirname, join +from asyncio import AbstractEventLoop, get_event_loop_policy +from typing import Generator import pytest -from aiomailru.sessions import PublicSession - -data_path = join(dirname(__file__), 'data') - - -@pytest.fixture -def error(): - return {'error': {'error_code': -1, 'error_msg': 'test error msg'}} - - -@pytest.fixture -def dummy(): - return {} +@pytest.fixture(scope='session') +def event_loop() -> Generator[AbstractEventLoop, None, None]: + """Event loop.""" + loop = get_event_loop_policy().new_event_loop() + yield loop + loop.close() @pytest.fixture -def data(): - return {'key': 'value'} - - -@pytest.yield_fixture -async def error_server(httpserver, error): +async def error_server(httpserver): + """Return error server.""" httpserver.serve_content(**{ 'code': 401, - 'headers': {'Content-Type': PublicSession.CONTENT_TYPE}, - 'content': json.dumps(error), + 'headers': {'Content-Type': 'text/javascript; charset=utf-8'}, + 'content': json.dumps({'error': { + 'error_code': -1, + 'error_msg': 'test error msg', + }}), }) return httpserver -@pytest.yield_fixture -async def dummy_server(httpserver, dummy): - httpserver.serve_content(**{ - 'code': 401, - 'headers': {'Content-Type': PublicSession.CONTENT_TYPE}, - 'content': json.dumps(dummy), - }) - return httpserver - - -@pytest.yield_fixture -async def data_server(httpserver, data): +@pytest.fixture +async def data_server(httpserver): + """Return data server.""" httpserver.serve_content(**{ - 'code': 401, - 'headers': {'Content-Type': PublicSession.CONTENT_TYPE}, - 'content': json.dumps(data), + 'code': 200, + 'headers': {'Content-Type': 'text/javascript; charset=utf-8'}, + 'content': json.dumps({'key': 'value'}), }) return httpserver - - -@pytest.fixture -def auth_dialog(): - with open(join(data_path, 'dialogs', 'auth_dialog.html')) as f: - return f.read() - - -@pytest.fixture -def access_dialog(): - with open(join(data_path, 'dialogs', 'access_dialog.html')) as f: - return f.read() diff --git a/tests/data/dialogs/access_denied.html b/tests/data/dialogs/access_denied.html deleted file mode 100644 index 9fe43f8..0000000 --- a/tests/data/dialogs/access_denied.html +++ /dev/null @@ -1,184 +0,0 @@ - - - - -Mail.Ru — Запрос доступа - - - - - - - -
-
- -
-
-
-

Авторизация запрещена

-

Приложение находится в разработке, поэтому авторизация не доступна

-
-
- -
-
-
- - - - - - - - diff --git a/tests/data/dialogs/access_dialog.html b/tests/data/dialogs/access_dialog.html deleted file mode 100644 index 94820b6..0000000 --- a/tests/data/dialogs/access_dialog.html +++ /dev/null @@ -1,219 +0,0 @@ - - - - -Mail.Ru — Запрос доступа - - - - - - - -
-
- - -
- -
-
- - - - - - - - -
-

Необходим доступ к вашим данным

-
-

App name просит вас разрешить следующие действия:

-
    -
  • Получать информацию о вашей анкете, списке друзей, фотографиях и музыке
  • -
  • Создавать альбомы и размещать там изображения
  • -
  • Отправлять личные сообщения другим пользователям
  • -
-
-
-
- - Не согласен, закрыть - -
-
- - - - - - - - - - - - diff --git a/tests/data/dialogs/auth_dialog.html b/tests/data/dialogs/auth_dialog.html deleted file mode 100644 index b4b5d92..0000000 --- a/tests/data/dialogs/auth_dialog.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - Mail.Ru — Запрос доступа - - - - - - -
-
- - -
-
-

Необходим доступ к вашим данным

-
-

App name просит вас разрешить следующие действия:

-
    -
  • Получать информацию о вашей анкете, списке друзей, фотографиях и музыке
  • -
  • Создавать альбомы и размещать там изображения
  • -
  • Отправлять личные сообщения другим пользователям
  • -
-
- -
-
- - - - \ No newline at end of file diff --git a/tests/test_scrapers.py b/tests/test_scrapers.py deleted file mode 100644 index 5799085..0000000 --- a/tests/test_scrapers.py +++ /dev/null @@ -1,97 +0,0 @@ -import atexit -import os - -import pytest - -from aiomailru.sessions import ImplicitSession, TokenSession - - -EMAIL = os.environ.get('MAILRU_EMAIL') -PASSWD = os.environ.get('MAILRU_PASSWD') -CLIENT_ID = os.environ.get('MAILRU_CLIENT_ID') -PRIVATE_KEY = os.environ.get('MAILRU_PRIVATE_KEY') -SECRET_KEY = os.environ.get('MAILRU_SECRET_KEY') -SCOPE = os.environ.get('MAILRU_SCOPE') - -BROWSER_ENDPOINT = os.environ.get('PYPPETEER_BROWSER_ENDPOINT') - -skip_scrapers = False -try: - from aiomailru.scrapers import APIScraper -except ModuleNotFoundError: - reasons = ['pyppeteer not found'] -else: - reasons = [] - if EMAIL is None: - reasons.append('MAILRU_EMAIL (user e-mail) not set') - if PASSWD is None: - reasons.append('MAILRU_PASSWD (password) not set') - if CLIENT_ID is None: - reasons.append('MAILRU_CLIENT_ID (app id) not set') - if PRIVATE_KEY is None: - reasons.append('MAILRU_PRIVATE_KEY not set') - if SECRET_KEY is None: - reasons.append('MAILRU_SECRET_KEY not set') - if SCOPE is None: - reasons.append('MAILRU_SCOPE (permissions) not set') - if BROWSER_ENDPOINT is None: - reasons.append('PYPPETEER_BROWSER_ENDPOINT (Chrome endpoint) not set') - - -if reasons: - skip_scrapers = True - atexit.register(lambda: print('\n'.join(reasons))) - atexit.register(lambda: print('Scrapers tests were skipped')) - - -GROUP_ID = os.environ.get( - 'MAILRU_GROUP_ID', - '5396991818946538245' # My@Mail.Ru official community -) - - -@pytest.mark.skipif(skip_scrapers, reason=';'.join(reasons)) -class TestScrapers: - - @pytest.fixture - def app(self): - return CLIENT_ID, PRIVATE_KEY, SECRET_KEY - - @pytest.fixture - def cred(self): - return EMAIL, PASSWD, SCOPE - - @pytest.fixture - async def session(self, app, cred): - async with ImplicitSession(*app, *cred) as session: - token = session.session_key - cookies = session.cookies - return TokenSession(*app, token, 0, cookies=cookies) - - @pytest.mark.asyncio - async def test_groups_get(self, session: TokenSession): - async with session: - api = APIScraper(session) - _ = await api.groups.get(scrape=True) - await api.browser.disconnect() - - @pytest.mark.asyncio - async def test_groups_get_info(self, session: TokenSession): - async with session: - api = APIScraper(session) - _ = await api.groups.getInfo(uids=GROUP_ID, scrape=True) - await api.browser.disconnect() - - @pytest.mark.asyncio - async def test_groups_join(self, session: TokenSession): - async with session: - api = APIScraper(session) - _ = await api.groups.join(group_id=GROUP_ID, scrape=True) - await api.browser.disconnect() - - @pytest.mark.asyncio - async def test_stream_get_by_author(self, session: TokenSession): - async with session: - api = APIScraper(session) - _ = await api.stream.getByAuthor(uid=GROUP_ID, scrape=True) - await api.browser.disconnect() diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 6cf814c..305aa45 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,70 +1,27 @@ -import json -from urllib.parse import unquote - +"""Sessions tests.""" import pytest +from httpx import HTTPStatusError -from aiomailru.exceptions import ( - Error, OAuthError, APIError, EmptyResponseError -) -from aiomailru.utils import SignatureCircuit -from aiomailru.sessions import ( - PublicSession, - TokenSession, - ImplicitSession, -) +from aiomailru.session import PublicSession, TokenSession class TestPublicSession: - """Tests of PublicSession class.""" - - @pytest.mark.asyncio - async def test_error_request(self, error_server, error): - async with PublicSession() as session: - session.PUBLIC_URL = error_server.url - - session.pass_error = True - response = await session.public_request() - assert response == error - - @pytest.mark.asyncio - async def test_error_request_with_raising(self, error_server): - async with PublicSession() as session: - session.PUBLIC_URL = error_server.url - - session.pass_error = False - with pytest.raises(APIError): - await session.public_request() - - @pytest.mark.asyncio - async def test_dummy_request(self, dummy_server, dummy): - async with PublicSession() as session: - session.PUBLIC_URL = dummy_server.url + """Tests for PublicSession class.""" - session.pass_error = True - response = await session.public_request() - assert response == dummy + async def test_failed_request(self, error_server): + """Test failed request.""" + session = PublicSession() + session.client.base_url = error_server.url - @pytest.mark.asyncio - async def test_dummy_request_with_raising(self, dummy_server): - async with PublicSession() as session: - session.PUBLIC_URL = dummy_server.url + with pytest.raises(HTTPStatusError): + await session.request({}) - session.pass_error = False - with pytest.raises(EmptyResponseError): - await session.public_request() + async def test_regulat_request(self, data_server): + """Test regular request.""" + session = PublicSession() + session.client.base_url = data_server.url - @pytest.mark.asyncio - async def test_data_request(self, data_server, data): - async with PublicSession() as session: - session.PUBLIC_URL = data_server.url - - session.pass_error = True - response = await session.public_request() - assert response == data - - session.pass_error = False - response = await session.public_request() - assert response == data + assert await session.request({}) == {'key': 'value'} class TestTokenSession: @@ -72,201 +29,37 @@ class TestTokenSession: @pytest.fixture def app(self): + """Return app info.""" return {'app_id': 123, 'private_key': '', 'secret_key': ''} @pytest.fixture def token(self): + """Return token info.""" return {'access_token': '', 'uid': 0} - @pytest.mark.asyncio - async def test_sig_circuit(self, app, token): - async with TokenSession(**app, **token) as session: - assert session.sig_circuit is SignatureCircuit.UNDEFINED - - session.secret_key = 'secret key' - assert session.sig_circuit is SignatureCircuit.SERVER_SERVER - - session.uid = 456 - session.private_key = 'private key' - assert session.sig_circuit is SignatureCircuit.CLIENT_SERVER - - @pytest.mark.asyncio - async def test_required_params(self, app, token): - async with TokenSession(**app, **token) as session: - assert 'app_id' in session.required_params - assert 'session_key' in session.required_params - assert 'secure' not in session.required_params - session.uid = 456 - session.private_key = '' - session.secret_key = 'secret key' - assert 'secure' in session.required_params - - @pytest.mark.asyncio - async def test_params_to_str(self, app, token): - async with TokenSession(**app, **token) as session: - params = {'"a"': 1, '"b"': 2, '"c"': 3} - - session.uid = 789 - session.private_key = 'private key' - query = session.params_to_str(params) - assert query == '789"a"=1"b"=2"c"=3private key' - - session.uid = 0 - session.private_key = '' - session.secret_key = 'secret key' - query = session.params_to_str(params) - assert query == '"a"=1"b"=2"c"=3secret key' - - session.secret_key = '' - with pytest.raises(Error): - _ = session.params_to_str(params) - - @pytest.mark.asyncio - async def test_error_request(self, app, token, error_server, error): - async with TokenSession(**app, **token) as session: - session.API_URL = error_server.url - session.secret_key = 'secret key' - - session.pass_error = True - response = await session.request(params={'key': 'value'}) - assert response == error - - @pytest.mark.asyncio - async def test_error_request_with_raising(self, app, token, error_server): - async with TokenSession(**app, **token) as session: - session.API_URL = error_server.url - session.secret_key = 'secret key' - - session.pass_error = False - with pytest.raises(APIError): - await session.request(params={'key': 'value'}) - - @pytest.mark.asyncio - async def test_dummy_request(self, app, token, dummy_server, dummy): - async with TokenSession(**app, **token) as session: - session.API_URL = dummy_server.url - session.secret_key = 'secret key' - - session.pass_error = True - response = await session.request(params={'key': 'value'}) - assert response == dummy - - @pytest.mark.asyncio - async def test_dummy_request_with_raising(self, app, token, dummy_server): - async with TokenSession(**app, **token) as session: - session.API_URL = dummy_server.url - session.secret_key = 'secret key' - - session.pass_error = False - with pytest.raises(EmptyResponseError): - await session.request(params={'key': 'value'}) - - @pytest.mark.asyncio - async def test_data_request(self, app, token, data_server, data): - async with TokenSession(**app, **token) as session: - session.API_URL = data_server.url - session.secret_key = 'secret key' - - session.pass_error = True - response = await session.request(params={'key': 'value'}) - assert response == data - - session.pass_error = False - response = await session.request(params={'key': 'value'}) - assert response == data - - -class TestImplicitSession: - """Tests of ImplicitSession class.""" - - @pytest.fixture - def app(self): - return {'app_id': 123, 'private_key': '', 'secret_key': ''} - - @pytest.fixture - def cred(self): - return { - 'email': 'email@example.ru', - 'passwd': 'password', - 'scope': 'permission1 permission2 permission3', - } - - @pytest.mark.asyncio - async def test_get_auth_dialog(self, app, cred, httpserver, auth_dialog): - # success - httpserver.serve_content(**{ - 'code': 200, - 'headers': {'Content-Type': 'text/html'}, - 'content': auth_dialog, - }) - session = ImplicitSession(**app, **cred) - session.OAUTH_URL = httpserver.url - url, html = await session._get_auth_dialog() - - assert url.query['client_id'] == str(session.app_id) - assert url.query['redirect_uri'] == unquote(session.REDIRECT_URI) - assert url.query['response_type'] == session.params['response_type'] - assert url.query['scope'] == session.scope - assert html == auth_dialog - - # fail - httpserver.serve_content(**{ - 'code': 400, - 'headers': {'Content-Type': 'text/json'}, - 'content': json.dumps({'error': '', 'error_description': ''}) - }) - with pytest.raises(OAuthError): - _ = await session._get_auth_dialog() - - await session.close() - - @pytest.mark.asyncio - async def test_post_auth_dialog(self, app, cred, httpserver, - auth_dialog, access_dialog): - # success - httpserver.serve_content(**{'code': 200, 'content': access_dialog}) - session = ImplicitSession(**app, **cred) - - auth_dialog = auth_dialog.replace( - 'https://auth.mail.ru/cgi-bin/auth', httpserver.url, + async def test_error_request_with_raising(self, error_server): + """Test error request that raises an error.""" + session = TokenSession( + app_id='123', + session_key='session key', + secret='secret key', + secure='1', + uid='', ) - url, html = await session._post_auth_dialog(auth_dialog) - assert html == access_dialog - - # fail - httpserver.serve_content(**{'code': 400, 'content': ''}) - with pytest.raises(OAuthError): - _ = await session._post_auth_dialog(auth_dialog) - - await session.close() - - @pytest.mark.asyncio - async def test_post_access_dialog(self, app, cred, httpserver, access_dialog): - # success - httpserver.serve_content(**{'code': 200, 'content': 'blank page'}) - session = ImplicitSession(**app, **cred) - - access_dialog = access_dialog.replace( - 'https://connect.mail.ru/oauth/authorize', httpserver.url + session.client.base_url = error_server.url + + with pytest.raises(HTTPStatusError): + await session.request(params={'key': 'value'}) + + async def test_data_request(self, data_server): + """Test regular request.""" + session = TokenSession( + app_id='123', + session_key='session key', + secret='secret key', + secure='1', + uid='', ) - url, html = await session._post_access_dialog(access_dialog) - assert html == 'blank page' - - # fail - httpserver.serve_content(**{'code': 400, 'content': ''}) - with pytest.raises(OAuthError): - _ = await session._post_access_dialog(access_dialog) - - await session.close() - - @pytest.mark.asyncio - async def test_get_access_token(self, app, cred, httpserver): - # fail - httpserver.serve_content(**{'code': 400, 'content': ''}) - session = ImplicitSession(**app, **cred) - session.OAUTH_URL = httpserver.url - - with pytest.raises(OAuthError): - _ = await session._get_access_token() + session.client.base_url = data_server.url - await session.close() + assert await session.request(params={'k': 'v'}) == {'key': 'value'}