├── release.sh ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── docs ├── source │ ├── modules.rst │ └── activesoup.rst ├── netlify.toml ├── support.rst ├── _templates │ └── layout.html ├── install.rst ├── index.rst ├── Makefile ├── make.bat ├── conf.py ├── requirements.txt └── gettingstarted.rst ├── src ├── activesoup │ ├── __init__.py │ ├── response.py │ ├── driver.py │ └── html.py └── conftest.py ├── tests ├── test_files │ ├── simple_page.html │ ├── page_with_comments.html │ ├── page_with_form.html │ ├── page_with_nested_objects.html │ ├── page_with_article_list.html │ ├── page_with_checkboxes_and_radios.html │ └── page_with_form_no_method.html ├── test_json_decode.py ├── test_csv.py ├── test_gets.py ├── test_xpath_finds.py ├── test_forms.py └── conftest.py ├── .bumpversion.cfg ├── .travis.yml ├── .pre-commit-config.yaml ├── make-docs.sh ├── LICENSE ├── pyproject.toml ├── CONTRIBUTING.md ├── .gitignore ├── README.rst └── poetry.lock /release.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -euo pipefail 3 | poetry run bump2version "$@" 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jelford] 4 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | activesoup 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | activesoup 8 | -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "_build/html" 3 | command = "make html" 4 | 5 | [build.environment] 6 | RUBY_VERSION = "2.7.2" 7 | PYTHON_VERSION = "3.8" 8 | -------------------------------------------------------------------------------- /src/activesoup/__init__.py: -------------------------------------------------------------------------------- 1 | from .response import Response 2 | from .driver import Driver 3 | 4 | __all__ = ["Response", "Driver"] 5 | 6 | __version__ = "0.3.1" 7 | -------------------------------------------------------------------------------- /tests/test_files/simple_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | page-title 8 | 9 | 10 | 11 |

text-in-body

12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/test_json_decode.py: -------------------------------------------------------------------------------- 1 | from activesoup import driver 2 | 3 | 4 | def test_json_response_decoded_as_json_object(localwebserver): 5 | d = driver.Driver() 6 | resp = d.get(f"http://localhost:{localwebserver.port}/json?foo=bar") 7 | assert resp["foo"] == "bar" 8 | -------------------------------------------------------------------------------- /docs/support.rst: -------------------------------------------------------------------------------- 1 | Support 2 | ------- 3 | 4 | The best way to get support is to file an issue on the main `GitHub project`_. This 5 | is a part-time hobby project, so please be patient as it might take a few days for 6 | someone to get to your issue. 7 | 8 | .. _GitHub project: https://github.com/jelford/activesoup/issues 9 | 10 | -------------------------------------------------------------------------------- /tests/test_files/page_with_comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | compatibility shim 6 | 7 | 8 |

some body text

9 | 10 | 11 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:src/activesoup/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends '!layout.html' %} 2 | 3 | {% block footer %} 4 | 5 | 7 | 10 | {% endblock %} -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: 5 | - pip install tox flake8 6 | script: 7 | - flake8 src tests 8 | - tox 9 | 10 | before_deploy: "pip install wheel --upgrade" 11 | deploy: 12 | provider: pypi 13 | user: "jelford" 14 | password: ${PYPI_PASSWORD} 15 | distributions: "sdist bdist_wheel" 16 | on: 17 | tags: true 18 | branch: master 19 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | At the command line: 5 | 6 | .. code-block:: 7 | 8 | pip install activesoup 9 | 10 | 11 | Or, if you are using a tool like `poetry`_ or `pipenv`_: 12 | 13 | .. code-block:: 14 | 15 | poetry add activesoup 16 | pipenv install activesoup 17 | 18 | .. _poetry: https://python-poetry.org/ 19 | .. _pipenv: https://pipenv.pypa.io/en/latest/ 20 | 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 20.8b1 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: master 14 | hooks: 15 | - id: mypy 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 5.7.0 18 | hooks: 19 | - id: isort 20 | -------------------------------------------------------------------------------- /make-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\t\n' 4 | 5 | PROJECTROOT=$(git rev-parse --show-toplevel) 6 | DOCSROOT="${PROJECTROOT}/docs" 7 | 8 | poetry export --dev --without-hashes --format requirements.txt > docs/requirements.txt 9 | 10 | cd $DOCSROOT 11 | 12 | make clean 13 | rm -rf "${DOCSROOT}/source" 14 | 15 | export SPHINX_APIDOC_OPTIONS="members,no-undoc-members,show-inheritance" 16 | sphinx-apidoc -o "${DOCSROOT}/source" --ext-intersphinx --module-first "${PROJECTROOT}/src/activesoup" 17 | 18 | make html 19 | -------------------------------------------------------------------------------- /tests/test_csv.py: -------------------------------------------------------------------------------- 1 | from activesoup import Driver 2 | 3 | 4 | def test_can_download_csv(tmp_path, requests_mock): 5 | d = Driver() 6 | output_path = tmp_path / "output.csv" 7 | 8 | requests_mock.get( 9 | "http://remote.test/csv", 10 | headers={"Content-Type": "text/csv"}, 11 | text="Col1,Col2\nVal1,Val2", 12 | ) 13 | 14 | page = d.get("http://remote.test/csv") 15 | page.save(output_path) 16 | 17 | assert output_path.exists() 18 | assert output_path.read_text() == "Col1,Col2\nVal1,Val2" 19 | -------------------------------------------------------------------------------- /tests/test_files/page_with_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/test_files/page_with_nested_objects.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | Something in the content-body 13 | 14 |

Something nested

15 |
16 | 17 |

Something nested 2

18 |
19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. activesoup documentation master file, created by 2 | sphinx-quickstart on Wed May 26 15:58:00 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to activesoup's documentation! 7 | ====================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | :caption: Contents: 12 | 13 | install 14 | support 15 | gettingstarted 16 | API Reference 17 | 18 | .. include:: ../README.rst 19 | 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/test_files/page_with_article_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | compatibility shim 6 | 7 | 8 |

some body text

9 |
10 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/source/activesoup.rst: -------------------------------------------------------------------------------- 1 | activesoup package 2 | ================== 3 | 4 | .. automodule:: activesoup 5 | :members: 6 | :no-undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | activesoup.driver module 13 | ------------------------ 14 | 15 | .. automodule:: activesoup.driver 16 | :members: 17 | :no-undoc-members: 18 | :show-inheritance: 19 | 20 | activesoup.html module 21 | ---------------------- 22 | 23 | .. automodule:: activesoup.html 24 | :members: 25 | :no-undoc-members: 26 | :show-inheritance: 27 | 28 | activesoup.response module 29 | -------------------------- 30 | 31 | .. automodule:: activesoup.response 32 | :members: 33 | :no-undoc-members: 34 | :show-inheritance: 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 James Elford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_files/page_with_checkboxes_and_radios.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | Label 1 13 | Label 2 14 | Label 3 15 | 16 | Label 1 17 | Label 2 18 | 19 | Label 1 20 | Label 2 21 | 22 | Label 1 23 | Label 2 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/test_gets.py: -------------------------------------------------------------------------------- 1 | from activesoup import driver 2 | 3 | 4 | def test_can_get_something(localwebserver): 5 | d = driver.Driver() 6 | d.get(f"http://localhost:{localwebserver.port}/foo") 7 | 8 | 9 | def test_can_decodes_html_into_bs_like_api_document(localwebserver): 10 | d = driver.Driver() 11 | page = d.get(f"http://localhost:{localwebserver.port}/html/simple_page.html") 12 | page_text = page.body.p.text() 13 | assert page_text == "text-in-body" 14 | 15 | 16 | def test_can_decode_page_with_html_comments(localwebserver): 17 | d = driver.Driver() 18 | page = d.get(f"http://localhost:{localwebserver.port}/html/page_with_comments.html") 19 | text = page.body.p.text() 20 | 21 | assert "some body text" in text 22 | 23 | 24 | def test_sets_headers_on_request(requests_mock): 25 | d = driver.Driver() 26 | 27 | requests_mock.get( 28 | "http://remote.test", 29 | request_headers={"X-Test-Header": "Value"}, 30 | headers={"Content-Type": "text/html"}, 31 | text="test-response", 32 | ) 33 | 34 | page = d.get("http://remote.test", headers={"X-Test-Header": "Value"}) 35 | text = page.body.text() 36 | 37 | assert "test-response" == text 38 | -------------------------------------------------------------------------------- /tests/test_files/page_with_form_no_method.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 |
21 |
22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/test_xpath_finds.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from activesoup import driver 4 | 5 | 6 | @pytest.fixture 7 | def nested_objects_page(localwebserver): 8 | d = driver.Driver() 9 | yield d.get( 10 | f"http://localhost:{localwebserver.port}/html/page_with_nested_objects.html" 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def articles_list_page(localwebserver): 16 | d = driver.Driver() 17 | yield d.get( 18 | f"http://localhost:{localwebserver.port}/html/page_with_article_list.html" 19 | ) 20 | 21 | 22 | def test_can_get_something(nested_objects_page): 23 | content_body = nested_objects_page.find('.//div[@class="content-body"]') 24 | 25 | assert content_body is not None 26 | assert content_body["class"] == "content-body" 27 | assert "Something in the content-body" in content_body.text() 28 | 29 | 30 | def test_can_find_by_id(articles_list_page): 31 | articles_list = articles_list_page.find(id="articles") 32 | 33 | assert articles_list is not None 34 | 35 | article_links = articles_list.find_all('li[@class="article"]/a') 36 | hrefs = [a["href"] for a in article_links] 37 | assert len(article_links) == 3 38 | assert hrefs == [ 39 | "https://example.com/article1", 40 | "https://example.com/article2", 41 | "https://example.com/article3", 42 | ] 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry_core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "activesoup" 7 | version = "0.3.1" 8 | description = "A pure-python headless browser" 9 | license = "MIT" 10 | authors = ["James Elford "] 11 | readme = "README.rst" 12 | homepage = "https://github.com/jelford/activesoup" 13 | repository = "https://github.com/jelford/activesoup" 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha" 16 | ] 17 | packages = [ 18 | { include = "activesoup", from = "src" } 19 | ] 20 | include = [ 21 | "README.rst" 22 | ] 23 | 24 | [tool.poetry.dependencies] 25 | requests = "^2.9.0" 26 | html5lib = ">=0.9" 27 | python = "^3.6.7,<4.0.0" 28 | typing_extensions = "^3.10" 29 | 30 | [tool.poetry.dev-dependencies] 31 | pytest = "^=6.2.1" 32 | Flask = "^1.1.2" 33 | pre-commit = "^2.9.3" 34 | mypy = "^0.960" 35 | black = "^22.3" 36 | typed-ast = "^1.5" 37 | isort = "^5.10.0" 38 | bump2version = "^1.0.1" 39 | requests-mock = "^1.9.3" 40 | Sphinx = "^5.0" 41 | sphinx-rtd-theme = "^0.5.2" 42 | types-requests = "^2.27" 43 | 44 | [tool.pytest.ini_options] 45 | minversion = "6.0" 46 | addopts = "--doctest-modules" 47 | 48 | [tool.mypy] 49 | strict_optional = true 50 | 51 | [[tool.mypy.overrides]] 52 | module = [ 53 | "html5lib", 54 | ] 55 | ignore_missing_imports = true 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions of any kind are very welcome. These could be: 4 | - Questions 5 | - Bug reports 6 | - Feature suggestions 7 | - Pull requests 8 | - Just stopping by to let me know you're using the project 9 | 10 | ## What to expect from me 11 | 12 | `activesoup` is a hobby project, without a surrounding community. 13 | It's something I knocked up in my spare time to help me automate interacting with a website. 14 | That means that any code triage, review, or answers have to be provided by me - in 15 | my spare time (I don't use this at work). 16 | 17 | Because `activesoup` doesn't have too many users, the workload associated with that is 18 | _low_ and I don't mind doing it (I'm happy to!) - but the flip side is that I'll get to your request 19 | when I have time (probably: at the weekend, if I need to look at code). 20 | 21 | I won't assume you've meticulously read all the code, or got a great 22 | understanding from the (practically non-existent) documentation. 23 | Feel free to jump in and ask your question / submit your PR - I'm just happy 24 | you're finding this thing useful, and grateful if you want to improve it. 25 | 26 | ## When will you see your changes? 27 | 28 | `activesoup` gets released whenever I add a version tag (which is manually). I'll generally 29 | try to do that straight after merging any changes. 30 | 31 | ## What I expect from you 32 | 33 | `activesoup` is a hobby project. When I look at a PR, I'll assume you 34 | will have tested your changes, and - where you've changed behaviour - 35 | added or updated the unit tests. Travis will run the tests before 36 | I merge, but test coverage isn't terribly complete, and to some extent 37 | I'm relying on you to make sure things don't break. 38 | -------------------------------------------------------------------------------- /src/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test context for doctests 3 | """ 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def add_html_parsing(doctest_namespace): 10 | import requests 11 | import activesoup.html 12 | 13 | def html_page(raw_html): 14 | response_from_server = requests.Response() 15 | response_from_server._content = raw_html.encode("utf-8") 16 | return activesoup.html.resolve( 17 | driver=None, response=response_from_server 18 | ) # typing: ignore 19 | 20 | doctest_namespace["html_page"] = html_page 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def add_json_parsing(doctest_namespace): 25 | import requests 26 | import activesoup.response 27 | 28 | def json_page(raw_json): 29 | response_from_server = requests.Response() 30 | response_from_server._content = raw_json.encode("utf-8") 31 | return activesoup.response.JsonResponse(raw_response=response_from_server) 32 | 33 | doctest_namespace["json_page"] = json_page 34 | 35 | 36 | @pytest.fixture(autouse=True) 37 | def fake_github(requests_mock): 38 | for form in ( 39 | "https://github.com/jelford/activesoup/", 40 | "https://github.com/jelford/activesoup", 41 | ): 42 | requests_mock.get( 43 | form, 44 | text="

Fake activesoup repo

", 45 | headers={"Content-Type": "text/html"}, 46 | ) 47 | 48 | requests_mock.get( 49 | "https://github.com/jelford/activesoup/issues/new", 50 | text="
", 51 | headers={"Content-Type": "text/html"}, 52 | ) 53 | requests_mock.post( 54 | "https://github.com/jelford/activesoup/issues/new", 55 | status_code=302, 56 | headers={"Location": "https://github.com/jelford/activesoup/issues/1"}, 57 | ) 58 | requests_mock.get( 59 | "https://github.com/jelford/activesoup/issues/1", text="Dummy page" 60 | ) 61 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from activesoup import driver 2 | 3 | 4 | def test_form_submission_includes_form_fields_which_arent_specified(localwebserver): 5 | d = driver.Driver() 6 | page = d.get(f"http://localhost:{localwebserver.port}/html/page_with_form.html") 7 | result = page.form.submit({"visible_field": "my-value"}) 8 | 9 | assert result._raw_response.json() == { 10 | "visible_field": "my-value", 11 | "visible-field-with-value": "preset-value", 12 | "some-hidden-field": "5", 13 | } 14 | 15 | 16 | def test_unspecified_fields_can_be_suppressed_on_form_submission(localwebserver): 17 | d = driver.Driver() 18 | page = d.get(f"http://localhost:{localwebserver.port}/html/page_with_form.html") 19 | result = page.form.submit({"visible_field": "my-value"}, suppress_unspecified=True) 20 | 21 | assert result._raw_response.json() == {"visible_field": "my-value"} 22 | 23 | 24 | def test_can_submit_form_without_explicit_method(localwebserver): 25 | d = driver.Driver() 26 | page = d.get( 27 | f"http://localhost:{localwebserver.port}/html/page_with_form_no_method.html" 28 | ) 29 | 30 | result = page.find('.//form[@id="no-method"]').submit( 31 | {"fieldname": "value"}, suppress_unspecified=True 32 | ) 33 | 34 | assert result._raw_response.json() == {"fieldname": "value"} 35 | 36 | 37 | def test_can_submit_form_without_explicit_action(localwebserver): 38 | d = driver.Driver() 39 | page = d.get( 40 | f"http://localhost:{localwebserver.port}/form/page_with_form_no_method.html" 41 | ) 42 | result = page.find('.//form[@id="no-action"]').submit( 43 | {"fieldname": "value"}, suppress_unspecified=True 44 | ) 45 | 46 | assert result._raw_response.json() == {"fieldname": "value"} 47 | 48 | 49 | def test_submits_checked_checkboxes_and_selected_radios(localwebserver): 50 | d = driver.Driver() 51 | page = d.get( 52 | f"http://localhost:{localwebserver.port}/form/page_with_checkboxes_and_radios.html" 53 | ) 54 | 55 | result = page.find(".//form").submit(data={}) 56 | 57 | assert result._raw_response.json() == { 58 | "checkbox-field-1": ["label-1", "label-2", "label-3"], 59 | "radio-field-1": "label-1", 60 | } 61 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import subprocess 16 | 17 | top_level_path = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], encoding="utf-8").strip() 18 | sys.path.insert(0, os.path.abspath(os.path.join(top_level_path, 'src'))) 19 | import activesoup # lint: ignore 20 | 21 | import sphinx_rtd_theme 22 | 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = "activesoup" 27 | copyright = "2021, James Elford (and contributors)" 28 | author = "James Elford (and contributors)" 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.intersphinx", 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ["_templates"] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 48 | 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | 52 | # The theme to use for HTML and HTML Help pages. See the documentation for 53 | # a list of builtin themes. 54 | # 55 | html_theme = "sphinx_rtd_theme" 56 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named "default.css" will overwrite the builtin "default.css". 61 | html_static_path = ["_static"] 62 | 63 | intersphinx_mapping = { 64 | "python": ("https://docs.python.org/3", None), 65 | "requests": ("https://docs.python-requests.org/en/master/", None), 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Project-specific ### 2 | 3 | .mypy_cache 4 | .pytest_cache 5 | 6 | # IDE etc # 7 | .vscode 8 | *.iml 9 | .idea 10 | tags 11 | *.swp 12 | 13 | # Created by https://www.gitignore.io/api/python,virtualenv,intellij 14 | 15 | ### Python ### 16 | # Byte-compiled / optimized / DLL files 17 | __pycache__/ 18 | *.py[cod] 19 | *$py.class 20 | 21 | # C extensions 22 | *.so 23 | 24 | # Distribution / packaging 25 | .Python 26 | env/ 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *,cover 61 | .hypothesis/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # IPython Notebook 85 | .ipynb_checkpoints 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # dotenv 94 | .env 95 | 96 | # virtualenv 97 | venv/ 98 | ENV/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | 107 | ### VirtualEnv ### 108 | # Virtualenv 109 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 110 | .Python 111 | [Bb]in 112 | [Ii]nclude 113 | [Ll]ib 114 | [Ll]ib64 115 | [Ll]ocal 116 | [Ss]cripts 117 | pyvenv.cfg 118 | .venv 119 | pip-selfcheck.json 120 | 121 | 122 | ### Intellij ### 123 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 124 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 125 | 126 | # User-specific stuff: 127 | .idea/workspace.xml 128 | .idea/tasks.xml 129 | .idea/dictionaries 130 | .idea/vcs.xml 131 | .idea/jsLibraryMappings.xml 132 | 133 | # Sensitive or high-churn files: 134 | .idea/dataSources.ids 135 | .idea/dataSources.xml 136 | .idea/dataSources.local.xml 137 | .idea/sqlDataSources.xml 138 | .idea/dynamic.xml 139 | .idea/uiDesigner.xml 140 | 141 | # Gradle: 142 | .idea/gradle.xml 143 | .idea/libraries 144 | 145 | # Mongo Explorer plugin: 146 | .idea/mongoSettings.xml 147 | 148 | ## File-based project format: 149 | *.iws 150 | 151 | ## Plugin-specific files: 152 | 153 | # IntelliJ 154 | /out/ 155 | 156 | # mpeltonen/sbt-idea plugin 157 | .idea_modules/ 158 | 159 | # JIRA plugin 160 | atlassian-ide-plugin.xml 161 | 162 | # Crashlytics plugin (for Android Studio and IntelliJ) 163 | com_crashlytics_export_strings.xml 164 | crashlytics.properties 165 | crashlytics-build.properties 166 | fabric.properties 167 | 168 | ### Intellij Patch ### 169 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 170 | 171 | # *.iml 172 | # modules.xml 173 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | activesoup 2 | ========== 3 | 4 | .. image:: https://github.com/jelford/activesoup/workflows/Build/badge.svg 5 | :target: https://github.com/jelford/activesoup/actions?query=workflow%3Abuild 6 | 7 | .. image:: https://img.shields.io/pypi/v/activesoup.svg?maxAge=3600 8 | :target: https://pypi.python.org/pypi?:action=display&name=activesoup 9 | 10 | A simple library for interacting with the web from python 11 | 12 | Description 13 | ----------- 14 | 15 | ``activesoup`` combines familiar python web capabilities for convenient 16 | headless "browsing" functionality: 17 | 18 | * Modern HTTP support with `requests `__ - 19 | connection pooling, sessions, ... 20 | * Convenient access to the web page with an interface inspired by 21 | `beautifulsoup `__ - 22 | convenient HTML navigation. 23 | * Robust HTML parsing with 24 | `html5lib `__ - parse the web 25 | like browsers do. 26 | 27 | Full documentation can be found at https://activesoup.dev. 28 | 29 | Use cases 30 | --------- 31 | 32 | ``activesoup`` aims to provide just enough functionality for basic web automation 33 | / crawler tasks. Consider using ``activesoup`` when: 34 | 35 | * You've already checked out `requests-html `__ 36 | * You need to actively interact with some web-page from Python (e.g. submitting 37 | forms, downloading files) 38 | * You don't control the site you need to interact with (if you do, just make an 39 | API). 40 | * You don't need javascript support (you'll need 41 | `selenium `__ or 42 | `phantomjs `__). 43 | 44 | Usage examples 45 | -------------- 46 | 47 | In the example below, we'll load a page with a simple form, enumerate 48 | the fields, and make a submission: 49 | 50 | .. code-block:: python 51 | 52 | >>> import activesoup 53 | 54 | >>> # Start a session 55 | >>> d = activesoup.Driver() 56 | 57 | >>> page = d.get("https://httpbin.org/forms/post") 58 | 59 | >>> # conveniently access elements, inspired by BeautifulSoup 60 | >>> form = page.form 61 | 62 | >>> # get the power of raw xpath search too 63 | >>> form.find('.//input[@name="size"]') 64 | BoundTag 65 | >>> # any element, searching by attribute 66 | >>> form.find('.//*', name="size") 67 | BoundTag 68 | >>> # or just search by attribute 69 | >>> form.find(name="size") 70 | BoundTag 71 | 72 | >>> # inspect element attributes 73 | >>> print([i['name'] for i in form.find_all('input')]) 74 | ['custname', 'custtel', 'custemail', 'size', 'size', 'size', 'topping', 'topping', 'topping', 'topping', 'delivery'] 75 | 76 | >>> # work actively with objects on the page 77 | >>> r = form.submit({"custname": "john", "size": "small"}) 78 | 79 | >>> # responses parsed and ready based on content type 80 | >>> r.keys() 81 | dict_keys(['args', 'data', 'files', 'form', 'headers', 'json', 'origin', 'url']) 82 | >>> r['form'] 83 | {'custname': 'john', 'size': 'small', 'topping': 'mushroom'} 84 | 85 | >>> # access the underlying requests.Session too 86 | >>> d.session 87 | 88 | 89 | >>> # log in with cookie support 90 | >>> d.get('https://httpbin.org/cookies/set/foo/bar') 91 | >>> d.session.cookies['foo'] 92 | 'bar' 93 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | jobs: 4 | build: 5 | strategy: 6 | matrix: 7 | pythonversion: ["3.10", "3.11.0-beta.3"] 8 | runs-on: "ubuntu-latest" 9 | name: Package lint and build 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: ${{ matrix.pythonversion }} 15 | - uses: actions/cache@v2 16 | name: Pip Cache 17 | with: 18 | path: ~/.cache/pip 19 | key: "${{ runner.os }}-build-pip-${{ matrix.pythonversion }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('ci/ci-requirements.txt') }}" 20 | - name: Python Poetry Action 21 | uses: abatilo/actions-poetry@v2.1.3 22 | - uses: actions/cache@v2 23 | name: Poetry Cache 24 | with: 25 | path: ~/.cache/pypoetry 26 | key: "${{ runner.os }}-build-poetry-${{ matrix.pythonversion }}-${{ hashFiles('poetry.lock') }}" 27 | - run: poetry install 28 | name: Install Dependencies (poetry) 29 | - run: poetry run black --check src tests 30 | name: Formatting (black) 31 | - run: poetry run mypy src 32 | name: Type Checking (mypy) 33 | - run: poetry build 34 | name: Package (wheel / sdist) 35 | - name: Upload built packages 36 | uses: actions/upload-artifact@v2 37 | with: 38 | name: dist-artefacts 39 | path: dist/ 40 | 41 | test: 42 | needs: build 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | python-version: ["3.6.7", "3.7", "3.8", "3.9", "3.10", "3.11.0-beta.3"] 47 | pkg-type: ["whl", "tar.gz"] 48 | name: "Test (Python: ${{ matrix.python-version }}, Dist: ${{ matrix.pkg-type}})" 49 | steps: 50 | - uses: actions/checkout@v2 51 | - uses: actions/setup-python@v2 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | - uses: actions/cache@v2 55 | name: Pip Cache 56 | with: 57 | path: ~/.cache/pip 58 | key: "${{ runner.os }}-build-pip-${{ matrix.pythonversion }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('ci/ci-requirements.txt') }}" 59 | - name: Python Poetry Action 60 | uses: abatilo/actions-poetry@v2.1.3 61 | - uses: actions/cache@v2 62 | name: Poetry Cache 63 | with: 64 | path: ~/.cache/pypoetry 65 | key: "${{ runner.os }}-build-poetry-${{ matrix.pythonversion }}-${{ hashFiles('poetry.lock') }}" 66 | - run: poetry install --no-root 67 | name: Install dependencies (poetry) 68 | - uses: actions/download-artifact@v2 69 | name: Fetch built packages 70 | with: 71 | name: dist-artefacts 72 | path: dist/ 73 | - run: poetry run pip install dist/activesoup-*.${{ matrix.pkg-type }} 74 | name: Install library (pip) 75 | - run: poetry run pytest 76 | name: Test (pytest) 77 | 78 | publish: 79 | needs: test 80 | runs-on: ubuntu-latest 81 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 82 | steps: 83 | - uses: actions/cache@v2 84 | name: "Deployment pip cache" 85 | with: 86 | path: ~/.cache/pip 87 | key: "${{ runner.os }}-build-pip-deploy" 88 | 89 | - uses: actions/download-artifact@v2 90 | name: Fetch built packages 91 | with: 92 | name: dist-artefacts 93 | path: dist/ 94 | - uses: pypa/gh-action-pypi-publish@master 95 | name: Publish package 96 | with: 97 | user: __token__ 98 | password: ${{ secrets.PYPI_API_TOKEN }} 99 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import multiprocessing 3 | from os import path 4 | from typing import Dict, Any 5 | 6 | import pytest 7 | 8 | _local_directory = path.dirname(path.abspath(__file__)) 9 | _test_files_directory = path.join(_local_directory, "test_files") 10 | 11 | 12 | def render(req_path): 13 | path_to_file = path.realpath(path.join(_test_files_directory, req_path)) 14 | if not path_to_file.startswith(_test_files_directory): 15 | raise RuntimeError("Path outside of _test_files_directory") 16 | 17 | with open(path_to_file, "r") as f: 18 | return f.read() 19 | 20 | 21 | def _render_multidict(multidict) -> Dict[str, Any]: 22 | data = dict(multidict) 23 | for fname, fvalues in multidict.lists(): 24 | if len(fvalues) > 1: 25 | data[fname] = fvalues 26 | 27 | return data 28 | 29 | 30 | class LocalWebServer: 31 | def __enter__(self): 32 | return self 33 | 34 | def __exit__(self, *args, **kwargs): 35 | self.stop() 36 | 37 | def __init__(self, port): 38 | self.port = port 39 | 40 | def _start_local(self, parent_pipe, host, port): 41 | import json 42 | 43 | import flask 44 | 45 | self._parent_pipe = parent_pipe 46 | self._local_web_server = flask.Flask(__name__) 47 | 48 | @self._local_web_server.route("/html/") 49 | def page(name): 50 | return render(name) 51 | 52 | @self._local_web_server.route("/form/", methods=["GET", "POST"]) 53 | def form(name): 54 | req = flask.request 55 | if req.method == "POST": 56 | data = _render_multidict(req.form) 57 | return (json.dumps(data), 200, {"Content-Type": "application/json"}) 58 | else: 59 | return render(name) 60 | 61 | @self._local_web_server.route("/status") 62 | def status(): 63 | return "" 64 | 65 | @self._local_web_server.route("/json") 66 | def json_request(): 67 | req = flask.request 68 | return ( 69 | json.dumps(_render_multidict(req.args)), 70 | 200, 71 | {"Content-Type": "application/json"}, 72 | ) 73 | 74 | @self._local_web_server.route("/csv") 75 | def csv_document(): 76 | return ("Col1,Col2\nVal1,Val2", 200, {"Content-Type": "text/csv"}) 77 | 78 | self._local_web_server.run(host=host, port=port) 79 | 80 | def _await_remote_server_up(self, timeout): 81 | import time 82 | 83 | import requests 84 | 85 | for _ in range(timeout): 86 | try: 87 | if not self._serverthread.is_alive(): 88 | pytest.fail("Stub HTTP Server terminated unexpectedly") 89 | 90 | if requests.get( 91 | "http://localhost:{port}/status".format(port=self.port) 92 | ).status_code in range(200, 300): 93 | return 94 | except Exception as e: 95 | print(e) 96 | pass 97 | time.sleep(1) 98 | 99 | pytest.fail( 100 | "Timed out waiting {timeout} seconds for local web server to start".format( 101 | timeout=timeout 102 | ) 103 | ) 104 | 105 | def start_remote(self, timeout=10): 106 | self._remote_pipe = multiprocessing.Pipe() 107 | 108 | self._serverthread = multiprocessing.Process( 109 | target=self._start_local, 110 | kwargs={ 111 | "parent_pipe": self._remote_pipe, 112 | "host": "127.0.0.1", 113 | "port": self.port, 114 | }, 115 | ) 116 | 117 | self._serverthread.start() 118 | atexit.register(self.stop) 119 | self._await_remote_server_up(timeout) 120 | 121 | def stop(self): 122 | self._serverthread.terminate() 123 | self._serverthread.join() 124 | 125 | def serve_forever(self): 126 | self.start_remote() 127 | try: 128 | self._serverthread.join() 129 | except KeyboardInterrupt: 130 | pass 131 | finally: 132 | self.stop() 133 | 134 | 135 | @pytest.fixture(scope="session") 136 | def localwebserver(request): 137 | lws = LocalWebServer(port=60123) 138 | request.addfinalizer(lws.stop) 139 | lws.start_remote() 140 | return lws 141 | 142 | 143 | if __name__ == "__main__": 144 | with LocalWebServer(port=60123) as lws: 145 | lws.serve_forever() 146 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12; python_version >= "3.6" 2 | atomicwrites==1.4.0; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.4.0" 3 | attrs==21.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 4 | babel==2.10.1; python_version >= "3.6" 5 | black==22.3.0; python_full_version >= "3.6.2" 6 | bump2version==1.0.1; python_version >= "3.5" 7 | certifi==2022.5.18.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6" 8 | cfgv==3.3.1; python_full_version >= "3.6.1" 9 | charset-normalizer==2.0.12; python_full_version >= "3.6.0" and python_version >= "3.6" 10 | click==8.0.4; python_full_version >= "3.6.2" and python_version >= "3.6" 11 | colorama==0.4.4; sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.6.2" and platform_system == "Windows" and (python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.5.0") 12 | dataclasses==0.8; python_version >= "3.6" and python_version < "3.7" and python_full_version >= "3.6.2" and (python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.7" or python_full_version >= "3.5.0" and python_version >= "3.6" and python_version < "3.7") 13 | distlib==0.3.4; python_full_version >= "3.6.1" 14 | docutils==0.16; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 15 | filelock==3.4.1; python_version >= "3.6" and python_full_version >= "3.6.1" 16 | flask==1.1.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") 17 | html5lib==1.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") 18 | identify==2.4.4; python_full_version >= "3.6.1" 19 | idna==3.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6" 20 | imagesize==1.3.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" 21 | importlib-metadata==4.8.3; python_version < "3.8" and python_version >= "3.6" and python_full_version >= "3.6.2" 22 | importlib-resources==5.2.3; python_version < "3.7" and python_full_version >= "3.6.1" and python_version >= "3.6" 23 | iniconfig==1.1.1; python_version >= "3.6" 24 | isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0" 25 | itsdangerous==2.0.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 26 | jinja2==3.0.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 27 | markupsafe==2.0.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 28 | mypy-extensions==0.4.3; python_version >= "3.6" and python_full_version >= "3.6.2" 29 | mypy==0.960; python_version >= "3.6" 30 | nodeenv==1.6.0; python_full_version >= "3.6.1" 31 | packaging==21.3; python_version >= "3.6" 32 | pathspec==0.9.0; python_full_version >= "3.6.2" 33 | platformdirs==2.4.0; python_version >= "3.6" and python_full_version >= "3.6.2" 34 | pluggy==1.0.0; python_version >= "3.6" 35 | pre-commit==2.17.0; python_full_version >= "3.6.1" 36 | py==1.11.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 37 | pygments==2.12.0; python_version >= "3.6" 38 | pyparsing==3.0.7; python_version >= "3.6" 39 | pytest==7.0.1; python_version >= "3.6" 40 | pytz==2022.1; python_version >= "3.6" 41 | pyyaml==6.0; python_version >= "3.6" and python_full_version >= "3.6.1" 42 | requests-mock==1.9.3 43 | requests==2.27.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0") 44 | six==1.16.0; python_full_version >= "3.6.1" 45 | snowballstemmer==2.2.0; python_version >= "3.6" 46 | sphinx-rtd-theme==0.5.2 47 | sphinx==5.0.1; python_version >= "3.6" 48 | sphinxcontrib-applehelp==1.0.2; python_version >= "3.6" 49 | sphinxcontrib-devhelp==1.0.2; python_version >= "3.6" 50 | sphinxcontrib-htmlhelp==2.0.0; python_version >= "3.6" 51 | sphinxcontrib-jsmath==1.0.1; python_version >= "3.6" 52 | sphinxcontrib-qthelp==1.0.3; python_version >= "3.6" 53 | sphinxcontrib-serializinghtml==1.1.5; python_version >= "3.6" 54 | toml==0.10.2; python_full_version >= "3.6.1" 55 | tomli==1.2.3; python_version < "3.11" and python_version >= "3.6" and python_full_version >= "3.6.2" 56 | typed-ast==1.5.4; python_version >= "3.6" 57 | types-requests==2.27.30 58 | types-urllib3==1.26.15 59 | typing-extensions==3.10.0.2 60 | urllib3==1.26.9; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.6" 61 | virtualenv==20.14.1; python_full_version >= "3.6.1" 62 | webencodings==0.5.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" 63 | werkzeug==2.0.3; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" 64 | zipp==3.6.0; python_version < "3.7" and python_version >= "3.6" and python_full_version >= "3.6.1" 65 | -------------------------------------------------------------------------------- /src/activesoup/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | The module contains the various types of response object, used to access after 3 | navigating to a page with :py:meth:`activesoup.Driver.get`. All responses 4 | are instances of :py:class:`activesoup.response.Response`. When ``activesoup`` 5 | recognises the type of data, the response is specialized for convenient access. 6 | This detection is driven by the ``Content-Type`` header in the server's response 7 | (so, if a web server labels a CSV file as HTML, ``activesoup`` will just assume 8 | it's ``HTML`` and try to parse it as such) 9 | 10 | The following specialisations are applied: 11 | 12 | ``text/html`` 13 | :py:class:`activesoup.html.BoundTag`. The HTML page is parsed, and a handle 14 | to the top-level ```` element is provided. 15 | 16 | ``text/csv`` 17 | :py:class:`activesoup.response.CsvResponse` 18 | 19 | ``application/json`` 20 | :py:class:`activesoup.response.JsonResponse`. The JSON data is parsed into 21 | python objects via ``json.loads``, and made available via dictionary-like 22 | access. 23 | 24 | """ 25 | 26 | import requests 27 | 28 | from typing import Union, Dict, Any, List, Optional 29 | from pathlib import Path 30 | from typing import Union, IO 31 | 32 | 33 | class UnknownResponseType(RuntimeError): 34 | pass 35 | 36 | 37 | class Response: 38 | """The result of a page load by :py:class:`activesoup.Driver`. 39 | 40 | :param requests.Response raw_response: The raw data returned from the server. 41 | :param str content_type: The datatype used for interpretting this response object. 42 | 43 | This top-level class contains attributes common to all responses. Child 44 | classes contain response-type-specific helpers. Check the :py:attr:`content_type` 45 | of this object to determine what data you have (and therefore which 46 | methods are available). 47 | 48 | Generally, fields of a ``Response`` can be accessed directly through the 49 | ``Driver``: 50 | 51 | >>> import activesoup 52 | >>> d = activesoup.Driver() 53 | >>> page = d.get("https://github.com/jelford/activesoup") 54 | >>> d.content_type 55 | 'text/html' 56 | >>> links = d.find_all("a") # ... etc 57 | """ 58 | 59 | def __init__(self, raw_response: requests.Response, content_type: Optional[str]): 60 | self._raw_response = raw_response 61 | self._content_type = content_type 62 | 63 | @property 64 | def url(self) -> str: 65 | """Which URL was requested that resulted in this response? 66 | 67 | :rtype: str""" 68 | return self._raw_response.url 69 | 70 | @property 71 | def status_code(self): 72 | """Status code from the HTTP response 73 | 74 | e.g. 200 75 | 76 | :rtype: int""" 77 | return self._raw_response.status_code 78 | 79 | @property 80 | def response(self): 81 | """The raw :py:class:`requests.Response` object returned by the server. 82 | 83 | You can use this object to inspect information not directly available 84 | through the ``activesoup`` API. 85 | 86 | :rtype: requests.Response""" 87 | return self._raw_response 88 | 89 | @property 90 | def content_type(self): 91 | """The type of content contained in this response 92 | 93 | e.g. application/csv 94 | 95 | :rtype: str""" 96 | 97 | return self._content_type 98 | 99 | def __getattr__(self, attr): 100 | raise UnknownResponseType( 101 | f"Wasn't sure how to parse this response (type: {self._content_type}), can't look up attribute \"{attr}\"" 102 | ) 103 | 104 | def __getitem__(self, lookup: str): 105 | raise UnknownResponseType( 106 | f"Wasn't sure how to parse this response (type: {self._content_type}), can't look up item \"{lookup}\"" 107 | ) 108 | 109 | 110 | class JsonResponse(Response): 111 | """A response object representing a ``JSON`` page 112 | 113 | :param requests.Response raw_response: The raw data returned from the server. 114 | 115 | ``JSON`` data returned by the page will be parsed into a Python object: 116 | 117 | >>> raw_content = '{"key": "value"}' 118 | >>> resp = json_page(raw_content) 119 | >>> resp["key"] 120 | 'value' 121 | 122 | 123 | """ 124 | 125 | def __init__(self, raw_response: requests.Response) -> None: 126 | """ """ 127 | super().__init__(raw_response, "application/json") 128 | self.json = raw_response.json() 129 | 130 | def __getitem__( 131 | self, name: Union[str, int] 132 | ) -> Union[str, int, Dict[str, Any], List[Any]]: 133 | """Look up an item in the parsed JSON object. 134 | 135 | ``__getitem__`` allows you to treat this object like a JSON array or 136 | object directly, without any further unwrapping. 137 | """ 138 | return self.json[name] 139 | 140 | def __getattr__(self, attr: str) -> Any: 141 | return getattr(self.json, attr) 142 | 143 | def __repr__(self) -> str: 144 | return "JsonResponse" 145 | 146 | def __str__(self) -> str: 147 | return "<[json]>" 148 | 149 | 150 | class CsvResponse(Response): 151 | """A response object representing a ``CSV`` page 152 | 153 | :param requests.Response raw_response: The raw data returned from the server. 154 | 155 | """ 156 | 157 | def __init__(self, raw_response): 158 | super().__init__(raw_response, "text/csv") 159 | self.content = raw_response.content 160 | 161 | def save(self, to: Union[Path, str, IO]): 162 | """Saves the current page to ``to`` 163 | 164 | :param to: Where to save the file. ``to`` may be a path (in which case 165 | that path will be opened in binary mode, and truncated if it 166 | already exists) or a file-like object (in which case that object 167 | will be written to directly)""" 168 | 169 | if isinstance(to, type("")) or isinstance(to, Path): 170 | with open(to, "wb") as f: 171 | self._write_to_file(f) 172 | else: 173 | self._write_to_file(to) 174 | 175 | def _write_to_file(self, file_object): 176 | file_object.write(self.content) 177 | 178 | def __repr__(self) -> str: 179 | return "CsvResponse" 180 | 181 | def __str__(self) -> str: 182 | return "<[csv]>" 183 | -------------------------------------------------------------------------------- /src/activesoup/driver.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Dict, Optional, Callable 3 | 4 | from urllib.parse import urljoin 5 | 6 | import requests 7 | 8 | import activesoup 9 | import activesoup.html 10 | from activesoup.response import CsvResponse, JsonResponse 11 | 12 | 13 | class DriverError(RuntimeError): 14 | """Errors that occur as part of operating the driver 15 | 16 | These errors reflect logic errors (such as accessing the ``last_response`` 17 | before navigating) or that the ``Driver`` is unable to carry out the 18 | action that was requested (e.g. the server returned a bad redirect)""" 19 | 20 | pass 21 | 22 | 23 | _Resolver = Callable[[requests.Response], activesoup.Response] 24 | 25 | 26 | class ContentResolver: 27 | def __init__(self): 28 | self._resolvers: Dict[str, _Resolver] = {} 29 | 30 | def register(self, content_type: str, resolver: _Resolver) -> None: 31 | self._resolvers[content_type] = resolver 32 | 33 | def resolve(self, response: requests.Response) -> activesoup.Response: 34 | content_type = response.headers.get("Content-Type", None) 35 | if content_type is not None: 36 | for k, factory in self._resolvers.items(): 37 | if content_type.startswith(k): 38 | return factory(response) 39 | 40 | return activesoup.Response(response, content_type) 41 | 42 | 43 | class Driver: 44 | """:py:class:`Driver` is the main entrypoint into ``activesoup``. 45 | 46 | The ``Driver`` provides navigation functions, and keeps track of the 47 | current page. Note that this class is re-exposed via ``activesoup.Driver``. 48 | 49 | >>> d = Driver() 50 | >>> page = d.get("https://github.com/jelford/activesoup") 51 | >>> assert d.url == "https://github.com/jelford/activesoup" 52 | 53 | - Navigation updates the current page 54 | - Any methods which are not defined directly on ``Driver`` are 55 | forwarded on to the most recent ``Response`` object 56 | 57 | A single :py:class:`requests.Session` is held open for the 58 | lifetime of the ``Driver`` - the ``Session`` will accumulate 59 | cookies and open connections. ``Driver`` may be used as a 60 | context manager to automatically close all open connections 61 | when finished: 62 | 63 | .. code-block:: 64 | 65 | with Driver() as d: 66 | d.get("https://github.com/jelford/activesoup") 67 | 68 | See :ref:`getting-started` for a full demo of usage. 69 | 70 | :param kwargs: optional keyword arguments may be passed, which will be set 71 | as attributes of the :py:class:`requests.Session` which will be used 72 | for the lifetime of this ``Driver``: 73 | 74 | >>> d = Driver(headers={"User-Agent": "activesoup script"}) 75 | >>> d.session.headers["User-Agent"] 76 | 'activesoup script' 77 | """ 78 | 79 | def __init__(self, **kwargs) -> None: 80 | self.session = requests.Session() 81 | for k, v in kwargs.items(): 82 | setattr(self.session, k, v) 83 | self._last_response: Optional[activesoup.Response] = None 84 | self._raw_response: Optional[requests.Response] = None 85 | self.content_resolver = ContentResolver() 86 | self.content_resolver.register( 87 | "text/html", functools.partial(activesoup.html.resolve, self) 88 | ) 89 | self.content_resolver.register("text/csv", CsvResponse) 90 | self.content_resolver.register("application/json", JsonResponse) 91 | 92 | def __enter__(self) -> "Driver": 93 | return self 94 | 95 | def __exit__(self, exc_type, exc_val, exc_tb) -> None: 96 | self.session.close() 97 | 98 | def _resolve_url(self, possibly_relative_url) -> str: 99 | """Converts a relative URL into an absolute one if possible. 100 | 101 | If there is no current page (because the ``Driver`` has not yet received 102 | a call to :py:meth:`Driver.get`), then the URL is returned unchanged 103 | 104 | >>> d = Driver() 105 | >>> d._resolve_url("./something") 106 | './something' 107 | >>> _ = d.get("https://github.com/jelford/activesoup/") 108 | >>> d._resolve_url("./something") 109 | 'https://github.com/jelford/activesoup/something' 110 | 111 | :param str possibly_relative_url: A URL which is assumed to be relative 112 | the current page 113 | :returns: A URL that has been resolved relative to the current page, if 114 | there is one. 115 | """ 116 | current_url_str = self.url 117 | if not current_url_str: 118 | return possibly_relative_url 119 | 120 | return urljoin(current_url_str, possibly_relative_url) 121 | 122 | def get(self, url, **kwargs) -> "Driver": 123 | """Move the Driver to a new page. 124 | 125 | This is the primary means of navigating the ``Driver`` to the page of interest. 126 | 127 | :param str url: the new URL for the Driver to navigate to (e.g. `https://www.example.com`) 128 | :param kwargs: additional keyword arguments are passed in to the 129 | constructor of the :py:class:`requests.Request` used to fetch the 130 | page. 131 | :returns: the ``Driver`` object itself 132 | :rtype: Driver 133 | 134 | """ 135 | return self._do(requests.Request(method="GET", url=url, **kwargs)) 136 | 137 | def _do(self, request: requests.Request) -> "Driver": 138 | request.url = self._resolve_url(request.url) 139 | prepped = self.session.prepare_request(request) 140 | return self._handle_response(self.session.send(prepped)) 141 | 142 | def _handle_response(self, response: requests.Response) -> "Driver": 143 | if response.status_code in range(300, 304): 144 | redirected_to = response.headers.get("Location", None) 145 | if not redirected_to: 146 | raise DriverError("Found a redirect, but no onward location given") 147 | return self.get(redirected_to) 148 | 149 | self._last_response = self.content_resolver.resolve(response) 150 | self._raw_response = response 151 | 152 | return self 153 | 154 | @property 155 | def url(self) -> Optional[str]: 156 | """The URL of the current page 157 | 158 | :returns: ``None`` if no page has been loaded, otherwise the URL of the most recently 159 | loaded page. 160 | :rtype: str 161 | """ 162 | return self._last_response.url if self._last_response is not None else None 163 | 164 | @property 165 | def last_response(self) -> Optional[activesoup.Response]: 166 | """Get the response object that was the result of the most recent page load 167 | 168 | :returns: ``None`` if no page has been loaded, otherwise the parsed result of the most 169 | recent page load 170 | :rtype: activesoup.Response 171 | """ 172 | return self._last_response 173 | 174 | def __getattr__(self, item) -> Any: 175 | if not self._last_response: 176 | raise DriverError("Not on a page") 177 | 178 | return getattr(self._last_response, item) 179 | 180 | def __getitem__(self, item) -> Any: 181 | if not self._last_response: 182 | raise DriverError("Not on a page") 183 | 184 | return self._last_response[item] 185 | 186 | def __str__(self) -> str: 187 | last_resp_str = str(self._last_response) if self._last_response else "unbound" 188 | return f"" 189 | -------------------------------------------------------------------------------- /docs/gettingstarted.rst: -------------------------------------------------------------------------------- 1 | .. _getting-started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | What are we going to do? 7 | ------------------------ 8 | 9 | For this section, we'll use a form on `httpbin`_ as an example. You can 10 | start a local copy of ``httpbin`` with: 11 | 12 | .. code-block:: 13 | 14 | docker run -p 8080:80 kennethreitz/httpbin 15 | 16 | .. _httpbin: https://httpbin.org 17 | 18 | If you don't have docker, you can follow along all the same - just 19 | swap ``http://localhost:8080`` for ``https://httpbin.org``. 20 | 21 | Once that's started, open up a browser to http://localhost:8080/forms/post. You'll 22 | see a basic HTML form with a few fields relating to a pizza order. Go ahead and fill 23 | some values in, then hit the ``Submit order`` button at the bottom of the screen. 24 | From there, you should see a JSON document returned, with some details about your 25 | order. The JSON document look some thing like this: 26 | 27 | .. code-block:: 28 | 29 | { 30 | "args": {}, 31 | "data": "", 32 | "files": {}, 33 | "form": { 34 | "comments": "Pizza is delicious", 35 | "custemail": "pizza-lover@example.com", 36 | "custname": "John Doe", 37 | "custtel": "111-PIZZA", 38 | "delivery": "12:45", 39 | "size": "large", 40 | "topping": [ 41 | "bacon", 42 | "cheese" 43 | ] 44 | }, 45 | "headers": { 46 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 47 | "Accept-Encoding": "gzip, deflate, br", 48 | ... 49 | }, 50 | "json": null, 51 | "origin": "84.67.72.8", 52 | "url": "https://httpbin.org/post" 53 | } 54 | 55 | What we're going to do in the rest of this Getting Started guide is just the same thing, in code. We'll: 56 | 57 | 1. Create a :py:class:`activesoup.Driver` object, which will be like our browser 58 | 2. Navigate the ``Driver`` to the form 59 | 3. Inspect the page to see what fields are available 60 | 4. Submit the form with a pizza order 61 | 62 | Fetching a page 63 | --------------- 64 | 65 | The starting point for working with ``activesoup`` is the :py:class:`activesoup.Driver` 66 | class. You can instantiate a ``Driver`` object as follows: 67 | 68 | .. code-block:: 69 | 70 | import activesoup 71 | d = activesoup.Driver() 72 | 73 | Now we're ready to fetch a page: 74 | 75 | .. code-block:: 76 | 77 | page = d.get("http://localhost:8080/forms/post") 78 | 79 | We can see all the inputs available on the page using :py:meth:`find_all `: 80 | 81 | .. code-block:: 82 | 83 | inputs = page.find_all('input') # 1 84 | for i in inputs: 85 | print(i['name']) # 2 86 | 87 | Try it now! You should see output like the following: 88 | 89 | .. code-block:: 90 | 91 | custname 92 | custtel 93 | custemail 94 | size 95 | size 96 | size 97 | topping 98 | topping 99 | topping 100 | topping 101 | delivery 102 | 103 | What happened here? 104 | 105 | 1. The ``page`` returned by ``d.get(...)`` represents the ``Driver`` after it has transitioned to the given page. 106 | In our case, the ``Driver`` is now on a normal HTML webpage. When a ``Driver`` is on an HTML webpage, we can 107 | query it for elements on the page using the :py:meth:`find_all ` method. 108 | ``find_all`` takes the name of the HTML tag and returns all instances of that tag that it can find. We'll see 109 | later that ``find_all`` can be used to search only parts of the page, and can have filters applied to narrow down 110 | the results further. 111 | 112 | 2. Having found our inputs, we can access their attributes using Python's dictionary-lookup syntax. In the case 113 | of form inputs, they should all have a name, so that's what we print out. 114 | 115 | Extracting data from the page 116 | ----------------------------- 117 | 118 | You might have noticed in the previous section that some form elements are repeated. Take a look at the original 119 | HTML (right-click and "Inspect" in your browser), and you'll see what's going on: the ``size`` and 120 | ``topping`` elements do have several corresponding ```` elements. Here's the section for ``size``: 121 | 122 | .. code-block:: 123 | 124 |
125 | Pizza Size 126 |

127 |

128 |

129 |
130 | 131 | In this section we'll see: 132 | 133 | - How you can enumerate the different options for ``size`` with ``activesoup`` 134 | - How you can get the raw HTML you see above 135 | 136 | Enumerating the sizes 137 | ^^^^^^^^^^^^^^^^^^^^^ 138 | 139 | How can we see those options with ``activesoup``? Notice the ``value`` attribute. When you select one of these 140 | options and hit "Submit order" in your browser, it sends only the selected value over to the website. It knows 141 | they go together, because they have the same name. So, let's enumerate all the possible values for inputs with 142 | the name "size": 143 | 144 | 145 | .. code-block:: 146 | 147 | pizza_size_inputs = page.find_all('input[@name="size"]') # 1 148 | 149 | for s in pizza_size_inputs: 150 | print(s['value']) # small, medium, large # 2 151 | 152 | 1. We're using a more advanced form of :py:meth:`find_all ` here. 153 | ``find_all`` is implemented using Python's built-in :py:mod:`xml.etree.ElementTree`: 154 | 155 | - Any HTML page is parsed as an :py:class:`xml.etree.ElementTree.Element` 156 | - ``find_all`` is a shortcut to the ``Element``'s :py:meth:`xml.etree.ElementTree.Element.findall` method, 157 | searching against all children of the current element (in this case, the whole page). Any filter syntax that 158 | would work with ``Element.findall`` will work here. 159 | 160 | 2. ``s['value']`` is doing exactly the same thing as ``i['name']`` in the previous section: it looks up the ``value`` 161 | attribute of the HTML element. 162 | 163 | - Now we know that `page` is implemeted by passing requests through to an :py:class:`xml.etree.ElementTree.Element`, 164 | we can guess that ``s['value']`` is implemented in a similar way to ``find_all``: it's just a shortcut to :py:meth:`xml.etree.ElementTree.Element.attrs``. 165 | 166 | We've covered an important aspect of how ``activesoup`` works here: the basic idea is to provide a convenient 167 | way to access existing (and well-known) ways of doing things. When we work with HTML pages, ``activesoup`` is 168 | just providing a thin wrapper around Python's built-in ``Element``. 169 | 170 | Showing the whole ``
`` 171 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 172 | 173 | Armed with the knowledge that our ``page`` is a ``ElementTree.Element``, we can guess that ``ElementTree``'s powerful 174 | query API is available to us. We'd be guessing right! We can use the :py:meth:`find ` 175 | method to perform advanced queries. First, let's see what we're looking for: 176 | 177 | .. code-block:: 178 | 179 | print(", ".join((f'"{l.text()}"' for l in page.find_all("fieldset/legend")))) 180 | 181 | # Note surrounding spaces 182 | # " Pizza Size ", " Pizza Toppings " 183 | 184 | .. code-block:: 185 | 186 | sizes_fieldset = page.find('.//fieldset[legend=" Pizza Size "]') # 1 187 | html = sizes_fieldset.html() # 2 188 | print(html.decode()) # 3 189 | 190 | #
191 | # Pizza Size 192 | #

193 | #

194 | #

195 | #
196 | 197 | Here, we've extracted the HTML snippet we found by inspecting the element in the browser. 198 | 199 | 1. ``find`` accepts an `XPath query `_ 200 | ``ElementTree``'s XPath support is a little limited, but still very useful - you can find all the details on the 201 | official documentation page. 202 | 2. We can extract the raw HTML from any element by querying its :py:meth:`.html() ` method. 203 | A couple of points to note: 204 | 205 | - Since the top-level page is an element too, we could have used the same method to get the raw HTML of the whole page too. 206 | - The string here is generated from the *parsed* HTML. ``activesoup`` interprets pages in the same way as the browser would, 207 | and that might mean making some changes to the structure of the document, if the original HTML contained errors. We will see 208 | later that it's still possible to get the original data that was received over the network. 209 | 210 | 3. Finally, we need to decode the data into textual form. This may change (to become automatic) in future releases. 211 | 212 | 213 | Submitting a form 214 | ----------------- 215 | 216 | Okay, it's about time we submitted our pizza order. In this section we'll: 217 | 218 | #. Use the query methods we saw above to find the form object 219 | #. Use what we learned about the page above to decide what fields to submit 220 | #. See how to submit the form, like a browser would 221 | 222 | Finding the form object 223 | ^^^^^^^^^^^^^^^^^^^^^^^ 224 | 225 | .. code-block:: 226 | 227 | form = page.find('.//form') 228 | 229 | There's only one form on the page, so we can just use `find` to get it directly. Recall that the argument 230 | is passed to :py:meth:`xml.etree.ElementTree.Element.find` and interpreted as an XPath query. Since this 231 | is such a common operation, ``activesoup`` provides a shortcut. The following is equivalent: 232 | 233 | .. code-block:: 234 | 235 | form = page.form 236 | 237 | Preparing our form submission 238 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 239 | 240 | Recall the list of fields from the previous section (this time with the duplicates removed): 241 | 242 | .. code-block:: 243 | 244 | for name in {f["name"] for f in page.find_all("input")}: 245 | print(name) 246 | 247 | # custname 248 | # custtel 249 | # custemail 250 | # size 251 | # topping 252 | # delivery 253 | 254 | With that, we can prepare our list of values: 255 | 256 | .. code-block:: 257 | 258 | order = { 259 | "custname": "Pete Tsarlouvre", 260 | "custtel": "111-pizza-please", 261 | "size": "large", 262 | "topping": ["cheese", "mushroom"], 263 | } 264 | 265 | And submit our order: 266 | 267 | .. code-block:: 268 | 269 | form.submit(order) 270 | 271 | 272 | Reading a JSON response 273 | ----------------------- 274 | 275 | Now that we've submitted our data, let's take a look at the response. Just like a browser, when you submit a form, 276 | your ``activesoup.Driver`` it navigates to the new page. So, we can ask the ``Driver`` for details about the 277 | page it's on now, having submitted our order. 278 | 279 | .. code-block:: 280 | 281 | print(d.url) # We've navigated away from the original page 282 | # http://localhost:8080/post 283 | 284 | print(type(d.last_response)) 285 | # 286 | 287 | print(d.json) 288 | # {'args': {}, ... } 289 | 290 | print(d.json['form']['custname']) 291 | # Pete Tsarlouvre 292 | 293 | When we have a ``json`` response, we can access it with ``d.json``. This is another example of ``activesoup`` being 294 | a thin wrapper on an underlying more well-known technology; in this case, we are accessing the :py:meth:`requests.Response.json` 295 | method, which parses the ``json`` response directly from the server. Again, for convenience, ``activesoup`` provides 296 | a shortcut: 297 | 298 | .. code-block:: 299 | 300 | d['form']['custname'] # .json can be freely ommitted. 301 | 302 | 303 | 304 | -------------------------------------------------------------------------------- /src/activesoup/html.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Callable, Dict, List, Optional, Any, cast 3 | from xml.etree.ElementTree import Element 4 | from xml.etree.ElementTree import tostring as et_str 5 | 6 | import html5lib 7 | import requests 8 | 9 | import activesoup 10 | 11 | 12 | _namespaces = ["http://www.w3.org/1999/xhtml"] 13 | 14 | 15 | def _strip_namespace(etree: Element) -> Element: 16 | if type(etree.tag) != type(_strip_namespace): 17 | # For comments, the tag comes through as a function that, when invoked, returns the element. 18 | for ns in _namespaces: 19 | etree.tag = etree.tag.replace(f"{{{ns}}}", "") 20 | for c in etree: 21 | _strip_namespace(c) 22 | return etree 23 | 24 | 25 | class BoundTag(activesoup.Response): 26 | """A ``BoundTag`` represents a single node in an HTML document. 27 | 28 | When a new HTML page is opened by the :py:class:`activesoup.Driver`, 29 | the page is parsed, and a new ``BoundTag`` is created, which is a 30 | handle to the top-level ```` element. 31 | 32 | ``BoundTag`` provides convenient access to data in the page: 33 | 34 | Via field-style find operation (inspired by BeautifulSoup): 35 | 36 | >>> page = html_page('link-text') 37 | >>> page.a.text() 38 | 'link-text' 39 | 40 | Via dictionary-stype attribute lookup: 41 | 42 | >>> page.a["id"] 43 | 'link' 44 | 45 | A ``BoundTag`` wraps an :py:class:`xml.etree.ElementTree.Element`, 46 | providing shortcuts for common operations. The underlying ``Element`` can 47 | be accessed via :py:meth:`etree `. When child elements are 48 | accessed via those helpers, they are also wrapped in a ``BoundTag`` object. 49 | 50 | Note: a ``BoundTag`` object is created internally by the 51 | :py:class:`activesoup.Driver` - you will generally not need to 52 | construct one directly. 53 | """ 54 | 55 | def __init__( 56 | self, 57 | driver: "activesoup.Driver", 58 | raw_response: requests.Response, 59 | element: Element, 60 | ) -> None: 61 | super().__init__(raw_response, "text/html") 62 | self._driver = driver 63 | self._et = element 64 | 65 | @lru_cache(maxsize=1024) 66 | def __getattr__(self, item: str) -> "BoundTag": 67 | e = self._find(f".//{item}") 68 | if e is not None: 69 | return e 70 | raise AttributeError(f"{type(self)} has no attribute {item}") 71 | 72 | @lru_cache(maxsize=1024) 73 | def __getitem__(self, attr: str) -> str: 74 | return self._et.attrib[attr] 75 | 76 | def find_all(self, element_matcher: str) -> List["BoundTag"]: 77 | """Find all matching elements on the current page 78 | 79 | :param str element_matcher: match expression to be used. 80 | :rtype: List[BoundTag] 81 | 82 | The match expression is made relative (by prefixing with ``.//``) and 83 | then forwarded to :py:meth:`etree's findall ` 84 | on the parsed ``Element``. 85 | 86 | Note that the general power of :py:mod:`xml.etree`'s XPath support is available, so 87 | filter expressions work too: 88 | 89 | >>> page = html_page('first linksecond link') 90 | >>> links = page.find_all('a') 91 | >>> links[0].text() 92 | 'first link' 93 | >>> links[1].text() 94 | 'second link' 95 | 96 | >>> cool_links = page.find_all('a[@class="cool"]') 97 | >>> len(cool_links) 98 | 1 99 | >>> cool_links[0].text() 100 | 'second link' 101 | 102 | ``find_all`` is a shortcut for ``.etree().findall()`` with a relative path: 103 | 104 | .. code-block:: 105 | 106 | # The following are equivalent: 107 | tag.find_all("a") 108 | tag.etree().findall(".//a") 109 | 110 | """ 111 | return [ 112 | _get_bound_tag_factory(element_matcher)(self._driver, self._raw_response, e) 113 | for e in self._et.findall(f".//{element_matcher}") 114 | ] 115 | 116 | @lru_cache(maxsize=1024) 117 | def find(self, xpath: str = None, **kwargs) -> Optional["BoundTag"]: 118 | """Find a single element matching the provided xpath expression 119 | 120 | :param str xpath: xpath expression that will be forwarded to :py:meth:`etree's find ` 121 | :param kwargs: Optional dictionary of attribute values. If present, 122 | ``activesoup`` will append attribute filters to the XPath expression 123 | :rtype: Optional[BoundTag] 124 | 125 | Note that unlike :py:meth:`find_all`, the path is not first made relative. 126 | 127 | >>> page = html_page('') 128 | >>> page.find(".//input", type="checkbox")["name"] 129 | 'second' 130 | 131 | The simplest use-case, of returning the first matching item for a 132 | particular tag, can be done via the field-stype find shortcut: 133 | 134 | >>> first_input = page.input 135 | >>> first_input["name"] 136 | 'first' 137 | 138 | ``find`` is a shortcut for ``.etree().find()``: 139 | 140 | .. code-block:: 141 | 142 | # The following are equivalent except that the returned value is wrapped in a BoundTag 143 | page.find('input', type="checkbox") 144 | page.find('input[@type="checkbox"]') 145 | page.etree().find('input[@type="checkbox"]') 146 | 147 | # The following are equivalent except that the returned value is wrapped in a BoundTag 148 | page.find('.//input') 149 | page.input 150 | """ 151 | return self._find(xpath, **kwargs) 152 | 153 | def text(self) -> Optional[str]: 154 | """Access the text content of an HTML node 155 | 156 | :rtype: Optional[str] 157 | 158 | >>> page = html_page('

Hello world

') 159 | >>> p = page.p 160 | >>> p.text() 161 | 'Hello world' 162 | 163 | ``text`` is a shortcut fro ``.etree().text``: 164 | 165 | .. code-block:: 166 | 167 | # The following are equivalent: 168 | p.text() 169 | p.etree().text 170 | """ 171 | return self._et.text 172 | 173 | def html(self) -> bytes: 174 | """Render this element's HTML as bytes 175 | 176 | :rtype: bytes 177 | 178 | The output is generated from the parsed HTML structure, as interpretted by ``html5lib``. 179 | ``html5lib`` is how ``activesoup`` interprets pages in the same way as the browser would, 180 | and that might mean making some changes to the structure of the document - for example, 181 | if the original HTML contained errors. 182 | """ 183 | return et_str(self._et) 184 | 185 | def attrs(self) -> Dict[str, str]: 186 | return self._et.attrib 187 | 188 | def etree(self) -> Element: 189 | """Access the wrapped :py:class:`etree.Element ` object 190 | 191 | The other methods on this class class are generally shortcuts to 192 | functionality provided by the underlying ``Element`` - with the 193 | difference that where applicable they wrap the results in a new 194 | ``BoundTag``. 195 | 196 | :rtype: Element 197 | """ 198 | return self._et 199 | 200 | def _find(self, xpath: str = None, **kwargs) -> Optional["BoundTag"]: 201 | if xpath is None: 202 | xpath = ".//*" 203 | 204 | if kwargs: 205 | xpath += "".join(f"[@{k}='{v}']" for k, v in kwargs.items()) 206 | 207 | e = self._et.find(xpath) 208 | if e is None: 209 | return None 210 | 211 | bound_tag = _get_bound_tag_factory(e.tag)(self._driver, self._raw_response, e) 212 | return bound_tag 213 | 214 | def __repr__(self) -> str: 215 | return f"BoundTag[{self._et.tag}]" 216 | 217 | def __str__(self) -> str: 218 | return f"{self._et.tag}" 219 | 220 | 221 | class BoundForm(BoundTag): 222 | """A ``BoundForm`` is a specialisation of the ``BoundTag`` class, returned 223 | when the tag is a ``
`` element. 224 | 225 | ``BoundForm`` adds the ability to submit forms to the server. 226 | 227 | >>> d = activesoup.Driver() 228 | >>> page = d.get("https://github.com/jelford/activesoup/issues/new") 229 | >>> f = page.form 230 | >>> page = f.submit({"title": "Misleading examples", "body": "Examples appear to show interactions with GitHub.com but don't reflect GitHub's real page structure"}) 231 | >>> page.url 232 | 'https://github.com/jelford/activesoup/issues/1' 233 | 234 | 235 | """ 236 | 237 | def submit( 238 | self, data: Dict, suppress_unspecified: bool = False 239 | ) -> "activesoup.Driver": 240 | """Submit the form to the server 241 | 242 | :param Dict data: The values that should be provided for the various 243 | fields in the submitted form. Keys should correspond to the form 244 | inputs' ``name`` attribute, and may be simple string values, or 245 | lists (in the case where a form input can take several values) 246 | :param bool suppress_unspecified: If False (the default), then 247 | ``activesoup`` will augment the ``data`` parameter to include the 248 | values of fields that are: 249 | 250 | - not specified in the ``data`` parameter 251 | - present with default values in the form as it was presented to 252 | us. 253 | 254 | The most common use-cases for this is to pick up fields with 255 | ``type="hidden"`` (commonly used for CSRF protection) or fields 256 | with ``type="checkbox"`` (commonly some default values are ticked). 257 | 258 | If the form has an ``action`` attribute specified, then the form will 259 | be submitted to that URL. If the form does not specify a ``method``, 260 | then ``POST`` will be used as a default. 261 | """ 262 | try: 263 | action = self._et.attrib["action"] 264 | except KeyError: 265 | action = cast(str, self._raw_response.request.url) 266 | try: 267 | method = self._et.attrib["method"] 268 | except KeyError: 269 | method = "POST" 270 | 271 | to_submit: Dict[str, Any] = {} 272 | if not suppress_unspecified: 273 | for i in self.find_all("input"): 274 | type = i.attrs().get("type", "text") 275 | 276 | if type in {"checkbox", "radio"}: 277 | should_take_value = i.attrs().get("checked") is not None 278 | else: 279 | should_take_value = True 280 | 281 | if should_take_value: 282 | try: 283 | if type != "checkbox": 284 | to_submit[i["name"]] = i["value"] 285 | else: 286 | value = to_submit.get(i["name"]) 287 | if value is None: 288 | value = [] 289 | 290 | value.append(i["value"]) 291 | to_submit[i["name"]] = value 292 | except KeyError: 293 | pass 294 | 295 | to_submit.update(data) 296 | req = requests.Request(method=method, url=action, data=to_submit) 297 | return self._driver._do(req) 298 | 299 | 300 | _BoundTagFactory = Callable[["activesoup.Driver", requests.Response, Element], BoundTag] 301 | 302 | 303 | def resolve(driver: "activesoup.Driver", response: requests.Response) -> BoundTag: 304 | parsed: Element = html5lib.parse(response.content) 305 | return BoundTag(driver, response, _strip_namespace(parsed)) 306 | 307 | 308 | def _get_bound_tag_factory(tagname: str) -> _BoundTagFactory: 309 | return {"form": BoundForm}.get(tagname, BoundTag) 310 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alabaster" 3 | version = "0.7.12" 4 | description = "A configurable sidebar-enabled Sphinx theme" 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "21.4.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 27 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 30 | 31 | [[package]] 32 | name = "babel" 33 | version = "2.10.1" 34 | description = "Internationalization utilities" 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | pytz = ">=2015.7" 41 | 42 | [[package]] 43 | name = "black" 44 | version = "22.3.0" 45 | description = "The uncompromising code formatter." 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.6.2" 49 | 50 | [package.dependencies] 51 | click = ">=8.0.0" 52 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 53 | mypy-extensions = ">=0.4.3" 54 | pathspec = ">=0.9.0" 55 | platformdirs = ">=2" 56 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 57 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 58 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 59 | 60 | [package.extras] 61 | colorama = ["colorama (>=0.4.3)"] 62 | d = ["aiohttp (>=3.7.4)"] 63 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 64 | uvloop = ["uvloop (>=0.15.2)"] 65 | 66 | [[package]] 67 | name = "bump2version" 68 | version = "1.0.1" 69 | description = "Version-bump your software with a single command!" 70 | category = "dev" 71 | optional = false 72 | python-versions = ">=3.5" 73 | 74 | [[package]] 75 | name = "certifi" 76 | version = "2022.5.18.1" 77 | description = "Python package for providing Mozilla's CA Bundle." 78 | category = "main" 79 | optional = false 80 | python-versions = ">=3.6" 81 | 82 | [[package]] 83 | name = "cfgv" 84 | version = "3.3.1" 85 | description = "Validate configuration and produce human readable error messages." 86 | category = "dev" 87 | optional = false 88 | python-versions = ">=3.6.1" 89 | 90 | [[package]] 91 | name = "charset-normalizer" 92 | version = "2.0.12" 93 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 94 | category = "main" 95 | optional = false 96 | python-versions = ">=3.5.0" 97 | 98 | [package.extras] 99 | unicode_backport = ["unicodedata2"] 100 | 101 | [[package]] 102 | name = "click" 103 | version = "8.0.4" 104 | description = "Composable command line interface toolkit" 105 | category = "dev" 106 | optional = false 107 | python-versions = ">=3.6" 108 | 109 | [package.dependencies] 110 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 111 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 112 | 113 | [[package]] 114 | name = "colorama" 115 | version = "0.4.4" 116 | description = "Cross-platform colored terminal text." 117 | category = "dev" 118 | optional = false 119 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 120 | 121 | [[package]] 122 | name = "dataclasses" 123 | version = "0.8" 124 | description = "A backport of the dataclasses module for Python 3.6" 125 | category = "dev" 126 | optional = false 127 | python-versions = ">=3.6, <3.7" 128 | 129 | [[package]] 130 | name = "distlib" 131 | version = "0.3.4" 132 | description = "Distribution utilities" 133 | category = "dev" 134 | optional = false 135 | python-versions = "*" 136 | 137 | [[package]] 138 | name = "docutils" 139 | version = "0.16" 140 | description = "Docutils -- Python Documentation Utilities" 141 | category = "dev" 142 | optional = false 143 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 144 | 145 | [[package]] 146 | name = "filelock" 147 | version = "3.4.1" 148 | description = "A platform independent file lock." 149 | category = "dev" 150 | optional = false 151 | python-versions = ">=3.6" 152 | 153 | [package.extras] 154 | docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] 155 | testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] 156 | 157 | [[package]] 158 | name = "flask" 159 | version = "1.1.2" 160 | description = "A simple framework for building complex web applications." 161 | category = "dev" 162 | optional = false 163 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 164 | 165 | [package.dependencies] 166 | click = ">=5.1" 167 | itsdangerous = ">=0.24" 168 | Jinja2 = ">=2.10.1" 169 | Werkzeug = ">=0.15" 170 | 171 | [package.extras] 172 | dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] 173 | docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] 174 | dotenv = ["python-dotenv"] 175 | 176 | [[package]] 177 | name = "html5lib" 178 | version = "1.1" 179 | description = "HTML parser based on the WHATWG HTML specification" 180 | category = "main" 181 | optional = false 182 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 183 | 184 | [package.dependencies] 185 | six = ">=1.9" 186 | webencodings = "*" 187 | 188 | [package.extras] 189 | all = ["genshi", "chardet (>=2.2)", "lxml"] 190 | chardet = ["chardet (>=2.2)"] 191 | genshi = ["genshi"] 192 | lxml = ["lxml"] 193 | 194 | [[package]] 195 | name = "identify" 196 | version = "2.4.4" 197 | description = "File identification library for Python" 198 | category = "dev" 199 | optional = false 200 | python-versions = ">=3.6.1" 201 | 202 | [package.extras] 203 | license = ["ukkonen"] 204 | 205 | [[package]] 206 | name = "idna" 207 | version = "3.3" 208 | description = "Internationalized Domain Names in Applications (IDNA)" 209 | category = "main" 210 | optional = false 211 | python-versions = ">=3.5" 212 | 213 | [[package]] 214 | name = "imagesize" 215 | version = "1.3.0" 216 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 217 | category = "dev" 218 | optional = false 219 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 220 | 221 | [[package]] 222 | name = "importlib-metadata" 223 | version = "4.8.3" 224 | description = "Read metadata from Python packages" 225 | category = "dev" 226 | optional = false 227 | python-versions = ">=3.6" 228 | 229 | [package.dependencies] 230 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 231 | zipp = ">=0.5" 232 | 233 | [package.extras] 234 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 235 | perf = ["ipython"] 236 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 237 | 238 | [[package]] 239 | name = "importlib-resources" 240 | version = "5.2.3" 241 | description = "Read resources from Python packages" 242 | category = "dev" 243 | optional = false 244 | python-versions = ">=3.6" 245 | 246 | [package.dependencies] 247 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 248 | 249 | [package.extras] 250 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 251 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] 252 | 253 | [[package]] 254 | name = "iniconfig" 255 | version = "1.1.1" 256 | description = "iniconfig: brain-dead simple config-ini parsing" 257 | category = "dev" 258 | optional = false 259 | python-versions = "*" 260 | 261 | [[package]] 262 | name = "isort" 263 | version = "5.10.1" 264 | description = "A Python utility / library to sort Python imports." 265 | category = "dev" 266 | optional = false 267 | python-versions = ">=3.6.1,<4.0" 268 | 269 | [package.extras] 270 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 271 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 272 | colors = ["colorama (>=0.4.3,<0.5.0)"] 273 | plugins = ["setuptools"] 274 | 275 | [[package]] 276 | name = "itsdangerous" 277 | version = "2.0.1" 278 | description = "Safely pass data to untrusted environments and back." 279 | category = "dev" 280 | optional = false 281 | python-versions = ">=3.6" 282 | 283 | [[package]] 284 | name = "jinja2" 285 | version = "3.0.3" 286 | description = "A very fast and expressive template engine." 287 | category = "dev" 288 | optional = false 289 | python-versions = ">=3.6" 290 | 291 | [package.dependencies] 292 | MarkupSafe = ">=2.0" 293 | 294 | [package.extras] 295 | i18n = ["Babel (>=2.7)"] 296 | 297 | [[package]] 298 | name = "markupsafe" 299 | version = "2.0.1" 300 | description = "Safely add untrusted strings to HTML/XML markup." 301 | category = "dev" 302 | optional = false 303 | python-versions = ">=3.6" 304 | 305 | [[package]] 306 | name = "mypy" 307 | version = "0.960" 308 | description = "Optional static typing for Python" 309 | category = "dev" 310 | optional = false 311 | python-versions = ">=3.6" 312 | 313 | [package.dependencies] 314 | mypy-extensions = ">=0.4.3" 315 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 316 | typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} 317 | typing-extensions = ">=3.10" 318 | 319 | [package.extras] 320 | dmypy = ["psutil (>=4.0)"] 321 | python2 = ["typed-ast (>=1.4.0,<2)"] 322 | reports = ["lxml"] 323 | 324 | [[package]] 325 | name = "mypy-extensions" 326 | version = "0.4.3" 327 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 328 | category = "dev" 329 | optional = false 330 | python-versions = "*" 331 | 332 | [[package]] 333 | name = "nodeenv" 334 | version = "1.6.0" 335 | description = "Node.js virtual environment builder" 336 | category = "dev" 337 | optional = false 338 | python-versions = "*" 339 | 340 | [[package]] 341 | name = "packaging" 342 | version = "21.3" 343 | description = "Core utilities for Python packages" 344 | category = "dev" 345 | optional = false 346 | python-versions = ">=3.6" 347 | 348 | [package.dependencies] 349 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 350 | 351 | [[package]] 352 | name = "pathspec" 353 | version = "0.9.0" 354 | description = "Utility library for gitignore style pattern matching of file paths." 355 | category = "dev" 356 | optional = false 357 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 358 | 359 | [[package]] 360 | name = "platformdirs" 361 | version = "2.4.0" 362 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 363 | category = "dev" 364 | optional = false 365 | python-versions = ">=3.6" 366 | 367 | [package.extras] 368 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 369 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 370 | 371 | [[package]] 372 | name = "pluggy" 373 | version = "1.0.0" 374 | description = "plugin and hook calling mechanisms for python" 375 | category = "dev" 376 | optional = false 377 | python-versions = ">=3.6" 378 | 379 | [package.dependencies] 380 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 381 | 382 | [package.extras] 383 | dev = ["pre-commit", "tox"] 384 | testing = ["pytest", "pytest-benchmark"] 385 | 386 | [[package]] 387 | name = "pre-commit" 388 | version = "2.17.0" 389 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 390 | category = "dev" 391 | optional = false 392 | python-versions = ">=3.6.1" 393 | 394 | [package.dependencies] 395 | cfgv = ">=2.0.0" 396 | identify = ">=1.0.0" 397 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 398 | importlib-resources = {version = "<5.3", markers = "python_version < \"3.7\""} 399 | nodeenv = ">=0.11.1" 400 | pyyaml = ">=5.1" 401 | toml = "*" 402 | virtualenv = ">=20.0.8" 403 | 404 | [[package]] 405 | name = "py" 406 | version = "1.11.0" 407 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 408 | category = "dev" 409 | optional = false 410 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 411 | 412 | [[package]] 413 | name = "pygments" 414 | version = "2.12.0" 415 | description = "Pygments is a syntax highlighting package written in Python." 416 | category = "dev" 417 | optional = false 418 | python-versions = ">=3.6" 419 | 420 | [[package]] 421 | name = "pyparsing" 422 | version = "3.0.7" 423 | description = "Python parsing module" 424 | category = "dev" 425 | optional = false 426 | python-versions = ">=3.6" 427 | 428 | [package.extras] 429 | diagrams = ["jinja2", "railroad-diagrams"] 430 | 431 | [[package]] 432 | name = "pytest" 433 | version = "7.0.1" 434 | description = "pytest: simple powerful testing with Python" 435 | category = "dev" 436 | optional = false 437 | python-versions = ">=3.6" 438 | 439 | [package.dependencies] 440 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 441 | attrs = ">=19.2.0" 442 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 443 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 444 | iniconfig = "*" 445 | packaging = "*" 446 | pluggy = ">=0.12,<2.0" 447 | py = ">=1.8.2" 448 | tomli = ">=1.0.0" 449 | 450 | [package.extras] 451 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 452 | 453 | [[package]] 454 | name = "pytz" 455 | version = "2022.1" 456 | description = "World timezone definitions, modern and historical" 457 | category = "dev" 458 | optional = false 459 | python-versions = "*" 460 | 461 | [[package]] 462 | name = "pyyaml" 463 | version = "6.0" 464 | description = "YAML parser and emitter for Python" 465 | category = "dev" 466 | optional = false 467 | python-versions = ">=3.6" 468 | 469 | [[package]] 470 | name = "requests" 471 | version = "2.27.1" 472 | description = "Python HTTP for Humans." 473 | category = "main" 474 | optional = false 475 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 476 | 477 | [package.dependencies] 478 | certifi = ">=2017.4.17" 479 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 480 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 481 | urllib3 = ">=1.21.1,<1.27" 482 | 483 | [package.extras] 484 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 485 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 486 | 487 | [[package]] 488 | name = "requests-mock" 489 | version = "1.9.3" 490 | description = "Mock out responses from the requests package" 491 | category = "dev" 492 | optional = false 493 | python-versions = "*" 494 | 495 | [package.dependencies] 496 | requests = ">=2.3,<3" 497 | six = "*" 498 | 499 | [package.extras] 500 | fixture = ["fixtures"] 501 | test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.18)", "testtools"] 502 | 503 | [[package]] 504 | name = "six" 505 | version = "1.16.0" 506 | description = "Python 2 and 3 compatibility utilities" 507 | category = "main" 508 | optional = false 509 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 510 | 511 | [[package]] 512 | name = "snowballstemmer" 513 | version = "2.2.0" 514 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 515 | category = "dev" 516 | optional = false 517 | python-versions = "*" 518 | 519 | [[package]] 520 | name = "sphinx" 521 | version = "5.0.1" 522 | description = "Python documentation generator" 523 | category = "dev" 524 | optional = false 525 | python-versions = ">=3.6" 526 | 527 | [package.dependencies] 528 | alabaster = ">=0.7,<0.8" 529 | babel = ">=1.3" 530 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 531 | docutils = ">=0.14,<0.19" 532 | imagesize = "*" 533 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 534 | Jinja2 = ">=2.3" 535 | packaging = "*" 536 | Pygments = ">=2.0" 537 | requests = ">=2.5.0" 538 | snowballstemmer = ">=1.1" 539 | sphinxcontrib-applehelp = "*" 540 | sphinxcontrib-devhelp = "*" 541 | sphinxcontrib-htmlhelp = ">=2.0.0" 542 | sphinxcontrib-jsmath = "*" 543 | sphinxcontrib-qthelp = "*" 544 | sphinxcontrib-serializinghtml = ">=1.1.5" 545 | 546 | [package.extras] 547 | docs = ["sphinxcontrib-websupport"] 548 | lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.950)", "docutils-stubs", "types-typed-ast", "types-requests"] 549 | test = ["pytest (>=4.6)", "html5lib", "cython", "typed-ast"] 550 | 551 | [[package]] 552 | name = "sphinx-rtd-theme" 553 | version = "0.5.2" 554 | description = "Read the Docs theme for Sphinx" 555 | category = "dev" 556 | optional = false 557 | python-versions = "*" 558 | 559 | [package.dependencies] 560 | docutils = "<0.17" 561 | sphinx = "*" 562 | 563 | [package.extras] 564 | dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] 565 | 566 | [[package]] 567 | name = "sphinxcontrib-applehelp" 568 | version = "1.0.2" 569 | description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" 570 | category = "dev" 571 | optional = false 572 | python-versions = ">=3.5" 573 | 574 | [package.extras] 575 | lint = ["flake8", "mypy", "docutils-stubs"] 576 | test = ["pytest"] 577 | 578 | [[package]] 579 | name = "sphinxcontrib-devhelp" 580 | version = "1.0.2" 581 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 582 | category = "dev" 583 | optional = false 584 | python-versions = ">=3.5" 585 | 586 | [package.extras] 587 | lint = ["flake8", "mypy", "docutils-stubs"] 588 | test = ["pytest"] 589 | 590 | [[package]] 591 | name = "sphinxcontrib-htmlhelp" 592 | version = "2.0.0" 593 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 594 | category = "dev" 595 | optional = false 596 | python-versions = ">=3.6" 597 | 598 | [package.extras] 599 | lint = ["flake8", "mypy", "docutils-stubs"] 600 | test = ["pytest", "html5lib"] 601 | 602 | [[package]] 603 | name = "sphinxcontrib-jsmath" 604 | version = "1.0.1" 605 | description = "A sphinx extension which renders display math in HTML via JavaScript" 606 | category = "dev" 607 | optional = false 608 | python-versions = ">=3.5" 609 | 610 | [package.extras] 611 | test = ["pytest", "flake8", "mypy"] 612 | 613 | [[package]] 614 | name = "sphinxcontrib-qthelp" 615 | version = "1.0.3" 616 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 617 | category = "dev" 618 | optional = false 619 | python-versions = ">=3.5" 620 | 621 | [package.extras] 622 | lint = ["flake8", "mypy", "docutils-stubs"] 623 | test = ["pytest"] 624 | 625 | [[package]] 626 | name = "sphinxcontrib-serializinghtml" 627 | version = "1.1.5" 628 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 629 | category = "dev" 630 | optional = false 631 | python-versions = ">=3.5" 632 | 633 | [package.extras] 634 | lint = ["flake8", "mypy", "docutils-stubs"] 635 | test = ["pytest"] 636 | 637 | [[package]] 638 | name = "toml" 639 | version = "0.10.2" 640 | description = "Python Library for Tom's Obvious, Minimal Language" 641 | category = "dev" 642 | optional = false 643 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 644 | 645 | [[package]] 646 | name = "tomli" 647 | version = "1.2.3" 648 | description = "A lil' TOML parser" 649 | category = "dev" 650 | optional = false 651 | python-versions = ">=3.6" 652 | 653 | [[package]] 654 | name = "typed-ast" 655 | version = "1.5.4" 656 | description = "a fork of Python 2 and 3 ast modules with type comment support" 657 | category = "dev" 658 | optional = false 659 | python-versions = ">=3.6" 660 | 661 | [[package]] 662 | name = "types-requests" 663 | version = "2.27.30" 664 | description = "Typing stubs for requests" 665 | category = "dev" 666 | optional = false 667 | python-versions = "*" 668 | 669 | [package.dependencies] 670 | types-urllib3 = "<1.27" 671 | 672 | [[package]] 673 | name = "types-urllib3" 674 | version = "1.26.15" 675 | description = "Typing stubs for urllib3" 676 | category = "dev" 677 | optional = false 678 | python-versions = "*" 679 | 680 | [[package]] 681 | name = "typing-extensions" 682 | version = "3.10.0.2" 683 | description = "Backported and Experimental Type Hints for Python 3.5+" 684 | category = "main" 685 | optional = false 686 | python-versions = "*" 687 | 688 | [[package]] 689 | name = "urllib3" 690 | version = "1.26.9" 691 | description = "HTTP library with thread-safe connection pooling, file post, and more." 692 | category = "main" 693 | optional = false 694 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 695 | 696 | [package.extras] 697 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 698 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 699 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 700 | 701 | [[package]] 702 | name = "virtualenv" 703 | version = "20.14.1" 704 | description = "Virtual Python Environment builder" 705 | category = "dev" 706 | optional = false 707 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 708 | 709 | [package.dependencies] 710 | distlib = ">=0.3.1,<1" 711 | filelock = ">=3.2,<4" 712 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 713 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 714 | platformdirs = ">=2,<3" 715 | six = ">=1.9.0,<2" 716 | 717 | [package.extras] 718 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] 719 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] 720 | 721 | [[package]] 722 | name = "webencodings" 723 | version = "0.5.1" 724 | description = "Character encoding aliases for legacy web content" 725 | category = "main" 726 | optional = false 727 | python-versions = "*" 728 | 729 | [[package]] 730 | name = "werkzeug" 731 | version = "2.0.3" 732 | description = "The comprehensive WSGI web application library." 733 | category = "dev" 734 | optional = false 735 | python-versions = ">=3.6" 736 | 737 | [package.dependencies] 738 | dataclasses = {version = "*", markers = "python_version < \"3.7\""} 739 | 740 | [package.extras] 741 | watchdog = ["watchdog"] 742 | 743 | [[package]] 744 | name = "zipp" 745 | version = "3.6.0" 746 | description = "Backport of pathlib-compatible object wrapper for zip files" 747 | category = "dev" 748 | optional = false 749 | python-versions = ">=3.6" 750 | 751 | [package.extras] 752 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 753 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 754 | 755 | [metadata] 756 | lock-version = "1.1" 757 | python-versions = "^3.6.7,<4.0.0" 758 | content-hash = "268028321bf85079ded3044cefa887a90bf4205d73e10ec7f9e5293cb8b904e0" 759 | 760 | [metadata.files] 761 | alabaster = [ 762 | {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, 763 | {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, 764 | ] 765 | atomicwrites = [ 766 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 767 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 768 | ] 769 | attrs = [ 770 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 771 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 772 | ] 773 | babel = [ 774 | {file = "Babel-2.10.1-py3-none-any.whl", hash = "sha256:3f349e85ad3154559ac4930c3918247d319f21910d5ce4b25d439ed8693b98d2"}, 775 | {file = "Babel-2.10.1.tar.gz", hash = "sha256:98aeaca086133efb3e1e2aad0396987490c8425929ddbcfe0550184fdc54cd13"}, 776 | ] 777 | black = [ 778 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, 779 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, 780 | {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, 781 | {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, 782 | {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, 783 | {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, 784 | {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, 785 | {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, 786 | {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, 787 | {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, 788 | {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, 789 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, 790 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, 791 | {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, 792 | {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, 793 | {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, 794 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, 795 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, 796 | {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, 797 | {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, 798 | {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, 799 | {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, 800 | {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, 801 | ] 802 | bump2version = [ 803 | {file = "bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410"}, 804 | {file = "bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"}, 805 | ] 806 | certifi = [ 807 | {file = "certifi-2022.5.18.1-py3-none-any.whl", hash = "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"}, 808 | {file = "certifi-2022.5.18.1.tar.gz", hash = "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7"}, 809 | ] 810 | cfgv = [ 811 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 812 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 813 | ] 814 | charset-normalizer = [ 815 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 816 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 817 | ] 818 | click = [ 819 | {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, 820 | {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, 821 | ] 822 | colorama = [ 823 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 824 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 825 | ] 826 | dataclasses = [ 827 | {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, 828 | {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, 829 | ] 830 | distlib = [ 831 | {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, 832 | {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, 833 | ] 834 | docutils = [ 835 | {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, 836 | {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, 837 | ] 838 | filelock = [ 839 | {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"}, 840 | {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"}, 841 | ] 842 | flask = [ 843 | {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, 844 | {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, 845 | ] 846 | html5lib = [ 847 | {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, 848 | {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, 849 | ] 850 | identify = [ 851 | {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, 852 | {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, 853 | ] 854 | idna = [ 855 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 856 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 857 | ] 858 | imagesize = [ 859 | {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, 860 | {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, 861 | ] 862 | importlib-metadata = [ 863 | {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, 864 | {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, 865 | ] 866 | importlib-resources = [ 867 | {file = "importlib_resources-5.2.3-py3-none-any.whl", hash = "sha256:ae35ed1cfe8c0d6c1a53ecd168167f01fa93b893d51a62cdf23aea044c67211b"}, 868 | {file = "importlib_resources-5.2.3.tar.gz", hash = "sha256:203d70dda34cfbfbb42324a8d4211196e7d3e858de21a5eb68c6d1cdd99e4e98"}, 869 | ] 870 | iniconfig = [ 871 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 872 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 873 | ] 874 | isort = [ 875 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 876 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 877 | ] 878 | itsdangerous = [ 879 | {file = "itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c"}, 880 | {file = "itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"}, 881 | ] 882 | jinja2 = [ 883 | {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, 884 | {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, 885 | ] 886 | markupsafe = [ 887 | {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, 888 | {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, 889 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, 890 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, 891 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, 892 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, 893 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, 894 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, 895 | {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, 896 | {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, 897 | {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, 898 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, 899 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, 900 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, 901 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, 902 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, 903 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, 904 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, 905 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, 906 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, 907 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, 908 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, 909 | {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, 910 | {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, 911 | {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, 912 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, 913 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, 914 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, 915 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, 916 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, 917 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, 918 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, 919 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, 920 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, 921 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, 922 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, 923 | {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, 924 | {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, 925 | {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, 926 | {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, 927 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, 928 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, 929 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, 930 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, 931 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, 932 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, 933 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, 934 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, 935 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, 936 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, 937 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, 938 | {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, 939 | {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, 940 | {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, 941 | {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, 942 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, 943 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, 944 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, 945 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, 946 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, 947 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, 948 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, 949 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, 950 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, 951 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, 952 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, 953 | {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, 954 | {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, 955 | {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, 956 | ] 957 | mypy = [ 958 | {file = "mypy-0.960-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3a3e525cd76c2c4f90f1449fd034ba21fcca68050ff7c8397bb7dd25dd8b8248"}, 959 | {file = "mypy-0.960-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7a76dc4f91e92db119b1be293892df8379b08fd31795bb44e0ff84256d34c251"}, 960 | {file = "mypy-0.960-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffdad80a92c100d1b0fe3d3cf1a4724136029a29afe8566404c0146747114382"}, 961 | {file = "mypy-0.960-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7d390248ec07fa344b9f365e6ed9d205bd0205e485c555bed37c4235c868e9d5"}, 962 | {file = "mypy-0.960-cp310-cp310-win_amd64.whl", hash = "sha256:925aa84369a07846b7f3b8556ccade1f371aa554f2bd4fb31cb97a24b73b036e"}, 963 | {file = "mypy-0.960-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:239d6b2242d6c7f5822163ee082ef7a28ee02e7ac86c35593ef923796826a385"}, 964 | {file = "mypy-0.960-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f1ba54d440d4feee49d8768ea952137316d454b15301c44403db3f2cb51af024"}, 965 | {file = "mypy-0.960-cp36-cp36m-win_amd64.whl", hash = "sha256:cb7752b24528c118a7403ee955b6a578bfcf5879d5ee91790667c8ea511d2085"}, 966 | {file = "mypy-0.960-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:826a2917c275e2ee05b7c7b736c1e6549a35b7ea5a198ca457f8c2ebea2cbecf"}, 967 | {file = "mypy-0.960-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3eabcbd2525f295da322dff8175258f3fc4c3eb53f6d1929644ef4d99b92e72d"}, 968 | {file = "mypy-0.960-cp37-cp37m-win_amd64.whl", hash = "sha256:f47322796c412271f5aea48381a528a613f33e0a115452d03ae35d673e6064f8"}, 969 | {file = "mypy-0.960-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2c7f8bb9619290836a4e167e2ef1f2cf14d70e0bc36c04441e41487456561409"}, 970 | {file = "mypy-0.960-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbfb873cf2b8d8c3c513367febde932e061a5f73f762896826ba06391d932b2a"}, 971 | {file = "mypy-0.960-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc537885891382e08129d9862553b3d00d4be3eb15b8cae9e2466452f52b0117"}, 972 | {file = "mypy-0.960-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:481f98c6b24383188c928f33dd2f0776690807e12e9989dd0419edd5c74aa53b"}, 973 | {file = "mypy-0.960-cp38-cp38-win_amd64.whl", hash = "sha256:29dc94d9215c3eb80ac3c2ad29d0c22628accfb060348fd23d73abe3ace6c10d"}, 974 | {file = "mypy-0.960-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:33d53a232bb79057f33332dbbb6393e68acbcb776d2f571ba4b1d50a2c8ba873"}, 975 | {file = "mypy-0.960-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d645e9e7f7a5da3ec3bbcc314ebb9bb22c7ce39e70367830eb3c08d0140b9ce"}, 976 | {file = "mypy-0.960-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:85cf2b14d32b61db24ade8ac9ae7691bdfc572a403e3cb8537da936e74713275"}, 977 | {file = "mypy-0.960-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a85a20b43fa69efc0b955eba1db435e2ffecb1ca695fe359768e0503b91ea89f"}, 978 | {file = "mypy-0.960-cp39-cp39-win_amd64.whl", hash = "sha256:0ebfb3f414204b98c06791af37a3a96772203da60636e2897408517fcfeee7a8"}, 979 | {file = "mypy-0.960-py3-none-any.whl", hash = "sha256:bfd4f6536bd384c27c392a8b8f790fd0ed5c0cf2f63fc2fed7bce56751d53026"}, 980 | {file = "mypy-0.960.tar.gz", hash = "sha256:d4fccf04c1acf750babd74252e0f2db6bd2ac3aa8fe960797d9f3ef41cf2bfd4"}, 981 | ] 982 | mypy-extensions = [ 983 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 984 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 985 | ] 986 | nodeenv = [ 987 | {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, 988 | {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, 989 | ] 990 | packaging = [ 991 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 992 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 993 | ] 994 | pathspec = [ 995 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 996 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 997 | ] 998 | platformdirs = [ 999 | {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, 1000 | {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, 1001 | ] 1002 | pluggy = [ 1003 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 1004 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 1005 | ] 1006 | pre-commit = [ 1007 | {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, 1008 | {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, 1009 | ] 1010 | py = [ 1011 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 1012 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 1013 | ] 1014 | pygments = [ 1015 | {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, 1016 | {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, 1017 | ] 1018 | pyparsing = [ 1019 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 1020 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 1021 | ] 1022 | pytest = [ 1023 | {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, 1024 | {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, 1025 | ] 1026 | pytz = [ 1027 | {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, 1028 | {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, 1029 | ] 1030 | pyyaml = [ 1031 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 1032 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 1033 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 1034 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 1035 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 1036 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 1037 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 1038 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 1039 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 1040 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 1041 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 1042 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 1043 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 1044 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 1045 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 1046 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 1047 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 1048 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 1049 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 1050 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 1051 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 1052 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 1053 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 1054 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 1055 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 1056 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 1057 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 1058 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 1059 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 1060 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 1061 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 1062 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 1063 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 1064 | ] 1065 | requests = [ 1066 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 1067 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 1068 | ] 1069 | requests-mock = [ 1070 | {file = "requests-mock-1.9.3.tar.gz", hash = "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"}, 1071 | {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, 1072 | ] 1073 | six = [ 1074 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1075 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1076 | ] 1077 | snowballstemmer = [ 1078 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 1079 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 1080 | ] 1081 | sphinx = [ 1082 | {file = "Sphinx-5.0.1-py3-none-any.whl", hash = "sha256:36aa2a3c2f6d5230be94585bc5d74badd5f9ed8f3388b8eedc1726fe45b1ad30"}, 1083 | {file = "Sphinx-5.0.1.tar.gz", hash = "sha256:f4da1187785a5bc7312cc271b0e867a93946c319d106363e102936a3d9857306"}, 1084 | ] 1085 | sphinx-rtd-theme = [ 1086 | {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, 1087 | {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, 1088 | ] 1089 | sphinxcontrib-applehelp = [ 1090 | {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, 1091 | {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, 1092 | ] 1093 | sphinxcontrib-devhelp = [ 1094 | {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, 1095 | {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, 1096 | ] 1097 | sphinxcontrib-htmlhelp = [ 1098 | {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, 1099 | {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, 1100 | ] 1101 | sphinxcontrib-jsmath = [ 1102 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 1103 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 1104 | ] 1105 | sphinxcontrib-qthelp = [ 1106 | {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, 1107 | {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, 1108 | ] 1109 | sphinxcontrib-serializinghtml = [ 1110 | {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, 1111 | {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, 1112 | ] 1113 | toml = [ 1114 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1115 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1116 | ] 1117 | tomli = [ 1118 | {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, 1119 | {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, 1120 | ] 1121 | typed-ast = [ 1122 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, 1123 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, 1124 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, 1125 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, 1126 | {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, 1127 | {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, 1128 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, 1129 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, 1130 | {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, 1131 | {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, 1132 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, 1133 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, 1134 | {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, 1135 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, 1136 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, 1137 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, 1138 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, 1139 | {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, 1140 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, 1141 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, 1142 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, 1143 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, 1144 | {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, 1145 | {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, 1146 | ] 1147 | types-requests = [ 1148 | {file = "types-requests-2.27.30.tar.gz", hash = "sha256:ca8d7cc549c3d10dbcb3c69c1b53e3ffd1270089c1001a65c1e9e1017eb5e704"}, 1149 | {file = "types_requests-2.27.30-py3-none-any.whl", hash = "sha256:b9b6cd0a6e5d500e56419b79f44ec96f316e9375ff6c8ee566c39d25e9612621"}, 1150 | ] 1151 | types-urllib3 = [ 1152 | {file = "types-urllib3-1.26.15.tar.gz", hash = "sha256:c89283541ef92e344b7f59f83ea9b5a295b16366ceee3f25ecfc5593c79f794e"}, 1153 | {file = "types_urllib3-1.26.15-py3-none-any.whl", hash = "sha256:6011befa13f901fc934f59bb1fd6973be6f3acf4ebfce427593a27e7f492918f"}, 1154 | ] 1155 | typing-extensions = [ 1156 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 1157 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 1158 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 1159 | ] 1160 | urllib3 = [ 1161 | {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, 1162 | {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, 1163 | ] 1164 | virtualenv = [ 1165 | {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, 1166 | {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, 1167 | ] 1168 | webencodings = [ 1169 | {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, 1170 | {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, 1171 | ] 1172 | werkzeug = [ 1173 | {file = "Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8"}, 1174 | {file = "Werkzeug-2.0.3.tar.gz", hash = "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c"}, 1175 | ] 1176 | zipp = [ 1177 | {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, 1178 | {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, 1179 | ] 1180 | --------------------------------------------------------------------------------