├── .editorconfig ├── .flake8 ├── .github └── workflows │ └── lint_and_test.yml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst ├── logux.rst ├── make.bat └── modules.rst ├── logux ├── __init__.py ├── apps.py ├── core.py ├── dispatchers.py ├── exceptions.py ├── settings.py ├── throttling.py ├── urls.py ├── utils.py └── views.py ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── setup.py └── tests ├── lbt ├── README.md └── package.json ├── manage.py ├── rest.http ├── test_app ├── __init__.py ├── admin.py ├── apps.py ├── logux_actions.py ├── logux_subscriptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── wipe_db.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20200908_0759.py │ └── __init__.py ├── models.py └── tests │ ├── __init__.py │ ├── helpers.py │ ├── test_auth.py │ ├── test_errors.py │ └── test_meta.py └── test_project ├── __init__.py ├── settings.py ├── test_settings.py ├── urls.py └── wsgi.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 120 10 | tab_width = 4 11 | ij_continuation_indent_size = 8 12 | ij_formatter_off_tag = @formatter:off 13 | ij_formatter_on_tag = @formatter:on 14 | ij_formatter_tags_enabled = false 15 | ij_smart_tabs = false 16 | ij_wrap_on_typing = false 17 | 18 | [Makefile] 19 | indent_style = tab 20 | 21 | [.editorconfig] 22 | ij_editorconfig_align_group_field_declarations = false 23 | ij_editorconfig_space_after_colon = false 24 | ij_editorconfig_space_after_comma = true 25 | ij_editorconfig_space_before_colon = false 26 | ij_editorconfig_space_before_comma = false 27 | ij_editorconfig_spaces_around_assignment_operators = true 28 | 29 | [{*.bash, *.sh, *.zsh}] 30 | indent_size = 2 31 | tab_width = 2 32 | ij_shell_binary_ops_start_line = false 33 | ij_shell_keep_column_alignment_padding = false 34 | ij_shell_minify_program = false 35 | ij_shell_redirect_followed_by_space = false 36 | ij_shell_switch_cases_indented = false 37 | 38 | [{*.har, *.jsb2, *.jsb3, *.json, *.yml}] 39 | indent_size = 2 40 | ij_json_keep_blank_lines_in_code = 0 41 | ij_json_keep_indents_on_empty_lines = false 42 | ij_json_keep_line_breaks = true 43 | ij_json_space_after_colon = true 44 | ij_json_space_after_comma = true 45 | ij_json_space_before_colon = true 46 | ij_json_space_before_comma = false 47 | ij_json_spaces_within_braces = false 48 | ij_json_spaces_within_brackets = false 49 | ij_json_wrap_long_lines = false 50 | 51 | [{*.py, *.pyw}] 52 | ij_python_align_collections_and_comprehensions = true 53 | ij_python_align_multiline_imports = true 54 | ij_python_align_multiline_parameters = true 55 | ij_python_align_multiline_parameters_in_calls = true 56 | ij_python_blank_line_at_file_end = true 57 | ij_python_blank_lines_after_imports = 1 58 | ij_python_blank_lines_after_local_imports = 0 59 | ij_python_blank_lines_around_class = 1 60 | ij_python_blank_lines_around_method = 1 61 | ij_python_blank_lines_around_top_level_classes_functions = 2 62 | ij_python_blank_lines_before_first_method = 0 63 | ij_python_dict_alignment = 0 64 | ij_python_dict_new_line_after_left_brace = false 65 | ij_python_dict_new_line_before_right_brace = false 66 | ij_python_dict_wrapping = 1 67 | ij_python_from_import_new_line_after_left_parenthesis = false 68 | ij_python_from_import_new_line_before_right_parenthesis = false 69 | ij_python_from_import_parentheses_force_if_multiline = false 70 | ij_python_from_import_trailing_comma_if_multiline = false 71 | ij_python_from_import_wrapping = 1 72 | ij_python_hang_closing_brackets = false 73 | ij_python_keep_blank_lines_in_code = 1 74 | ij_python_keep_blank_lines_in_declarations = 1 75 | ij_python_keep_indents_on_empty_lines = false 76 | ij_python_keep_line_breaks = true 77 | ij_python_new_line_after_colon = false 78 | ij_python_new_line_after_colon_multi_clause = true 79 | ij_python_optimize_imports_always_split_from_imports = false 80 | ij_python_optimize_imports_case_insensitive_order = false 81 | ij_python_optimize_imports_join_from_imports_with_same_source = false 82 | ij_python_optimize_imports_sort_by_type_first = true 83 | ij_python_optimize_imports_sort_imports = true 84 | ij_python_optimize_imports_sort_names_in_from_imports = false 85 | ij_python_space_after_comma = true 86 | ij_python_space_after_number_sign = true 87 | ij_python_space_after_py_colon = true 88 | ij_python_space_before_backslash = true 89 | ij_python_space_before_comma = false 90 | ij_python_space_before_for_semicolon = false 91 | ij_python_space_before_lbracket = false 92 | ij_python_space_before_method_call_parentheses = false 93 | ij_python_space_before_method_parentheses = false 94 | ij_python_space_before_number_sign = true 95 | ij_python_space_before_py_colon = false 96 | ij_python_space_within_empty_method_call_parentheses = false 97 | ij_python_space_within_empty_method_parentheses = false 98 | ij_python_spaces_around_additive_operators = true 99 | ij_python_spaces_around_assignment_operators = true 100 | ij_python_spaces_around_bitwise_operators = true 101 | ij_python_spaces_around_eq_in_keyword_argument = false 102 | ij_python_spaces_around_eq_in_named_parameter = false 103 | ij_python_spaces_around_equality_operators = true 104 | ij_python_spaces_around_multiplicative_operators = true 105 | ij_python_spaces_around_power_operator = true 106 | ij_python_spaces_around_relational_operators = true 107 | ij_python_spaces_around_shift_operators = true 108 | ij_python_spaces_within_braces = false 109 | ij_python_spaces_within_brackets = false 110 | ij_python_spaces_within_method_call_parentheses = false 111 | ij_python_spaces_within_method_parentheses = false 112 | ij_python_use_continuation_indent_for_arguments = false 113 | ij_python_use_continuation_indent_for_collection_and_comprehensions = false 114 | ij_python_wrap_long_lines = false 115 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 127 3 | max-complexity = 11 4 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint and Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ 3.7, 3.8, 3.9 ] 19 | django-version: [ 2.2, 3.0, 3.1 ] 20 | fail-fast: false 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies for Django-${{ matrix.django-version }} 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install poetry 32 | poetry config virtualenvs.create false 33 | poetry install 34 | python -m pip install Django==${{ matrix.django-version }} 35 | make install 36 | - name: Lint with flake8, pylint and mypy 37 | run: | 38 | make lint 39 | - name: Test 40 | run: | 41 | make ci_test 42 | - uses: actions/setup-node@v1 43 | with: 44 | node-version: '14' 45 | - name: Install Node and logux-backend tests deps 46 | run: | 47 | make lbt_deps 48 | make integration_test_ci 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # sqlite3 132 | *.sqlite3 133 | 134 | # PIDs 135 | *.PID 136 | *.pid 137 | 138 | /tests/lbt/* 139 | !/tests/lbt/package.json 140 | !/tests/lbt/README.md 141 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = bad-continuation,duplicate-code,too-few-public-methods,fixme,C0103,missing-module-docstring,missing-class-docstring,missing-function-docstring,unnecessary-pass,unsubscriptable-object 3 | max-line-length = 127 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # (2020-09-08) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * add .is_older method for Meta (like in the Node API). Tests. ([bb66689](https://github.com/logux/django/commit/bb66689e0577051de800476d00219baa8c0b3a6a)) 7 | * add .is_older method for Meta (like in the Node API). Tests. ([9eb0c71](https://github.com/logux/django/commit/9eb0c7181c04e218c4a59be1336b51bd6ea9a373)) 8 | * add `raise from` for all reraised exceptions ([b325166](https://github.com/logux/django/commit/b3251662ab02c048590ea5592b97260c21e05a5b)) 9 | * add test app init migration ([a3c9e25](https://github.com/logux/django/commit/a3c9e256cd12b1805b7ae1ff735b1450a4515d20)) 10 | * apply_commands types ([e6ff52d](https://github.com/logux/django/commit/e6ff52dd60ecc2f644ef8009006f17180c10773b)) 11 | * call resend with named args, close [#42](https://github.com/logux/django/issues/42) ([24e0de0](https://github.com/logux/django/commit/24e0de00bf0e6105899381c2c0a61fbc87ca731c)) 12 | * change "stack" field to "details", tests. ([0ed4804](https://github.com/logux/django/commit/0ed480456c16ccfb41474b53eb12cf03923476ed)) 13 | * channel_pattern type ([480651e](https://github.com/logux/django/commit/480651e9fe86c89cf7fd8d42d8edbf4acfbeb1d9)) 14 | * checks version ([cf7efac](https://github.com/logux/django/commit/cf7efac7d1114386289ce0283fc0331982bbd4ab)) 15 | * dispatch proto4 support, test unknownAction ([09bb4da](https://github.com/logux/django/commit/09bb4dacf959887f5b904ef718de7174ce9f0263)) 16 | * django security fixes ([c1148bb](https://github.com/logux/django/commit/c1148bba8bcc158076d41f17b1f94412dfcb7651)) 17 | * forbidden answer for sub action ([b01138d](https://github.com/logux/django/commit/b01138d49025126e9f8f412ccc1cf15765e881d1)) 18 | * github actions typos ([0f66cc2](https://github.com/logux/django/commit/0f66cc211b0d80a3eb9abca3ec727f73c3914a63)) 19 | * missing docstring ([4e93947](https://github.com/logux/django/commit/4e939477bed0ccf66a45a23b855cbe3672cc99a7)) 20 | * mypy as django-stubs dep ([b88f85f](https://github.com/logux/django/commit/b88f85ff8226bc29464107e4ac25215ccdd8b3fd)) 21 | * pylint satisfaction ([c28c160](https://github.com/logux/django/commit/c28c16018aa2568013c48b982fb0fcef4c91b607)) 22 | * remove headers arg from Actions callbacks. close [#43](https://github.com/logux/django/issues/43) ([d529198](https://github.com/logux/django/commit/d5291981beea9578ad0bb62a86b07c14424e784d)) 23 | * reset test app migration ([0c28303](https://github.com/logux/django/commit/0c283037fb8167550191023e9456debda5873eca)) 24 | * test refactoring, cleanup ([a216c90](https://github.com/logux/django/commit/a216c90e1ae2769c24d99b64165359845d692fb4)) 25 | * tests types ([f7cf769](https://github.com/logux/django/commit/f7cf769c5dadc84af00f89ea0d7e1740ad39fcb1)) 26 | * token from cookie, cleanup. answer types extracting. rest tests. ([e7e2bfa](https://github.com/logux/django/commit/e7e2bfa9ed27dde684f805566bf77159f864d2c8)) 27 | * unsupported subprotocol format ([0004bec](https://github.com/logux/django/commit/0004beccbd9c948a362698fc18fd5ea78241b780)) 28 | * UserChannel action (wrong username property) ([686bf1c](https://github.com/logux/django/commit/686bf1c1c8a9295850fd37b55a9689850f2b2f31)) 29 | * wrong logic for throttle ([1aad7f9](https://github.com/logux/django/commit/1aad7f93c990b9a8bdeb58da39bbfb2520344061)) 30 | 31 | 32 | ### Features 33 | 34 | * ActionCommand support for proto4 ([11a8340](https://github.com/logux/django/commit/11a83408a41ed0c082f4b9ceb227102f339f18d7)) 35 | * add 'headers' for action callbacks ([5fcbe6e](https://github.com/logux/django/commit/5fcbe6edec779a21f0af86a36e30b08337e118c9)) 36 | * add semantic_version pkg for subprotocols processing ([64f0f75](https://github.com/logux/django/commit/64f0f75853c7debe0ab32a0b1eb2c8e839059215)) 37 | * base implementation of bruteforce protection (throttle class) ([006bbc7](https://github.com/logux/django/commit/006bbc7b69c01af59d2fab94b739639f9214a82a)) 38 | * bruteforce protection, logux internal settings update ([cbc63fb](https://github.com/logux/django/commit/cbc63fb419e702229df53f9f9479c0f7e08c9f98)) 39 | * django >=2.2, <4 support ([a295163](https://github.com/logux/django/commit/a295163b6d5f1cc1a1e341633618879eecdffc7f)) 40 | * load multi type return support, resolved [#35](https://github.com/logux/django/issues/35) ([45ffeec](https://github.com/logux/django/commit/45ffeec72a4c000f70c57b9ec17274c6721de1ed)) 41 | * logux_add support for proto4 ([d2c26c1](https://github.com/logux/django/commit/d2c26c1f2bef259fa950d08f3464b6694bf21126)) 42 | * make cmd for running "logux backend tests" ([71142e5](https://github.com/logux/django/commit/71142e5a583d89bfb571a99041244f70e1f9b36b)) 43 | * new auth_func signature. ([c08fdb2](https://github.com/logux/django/commit/c08fdb24d1064073339847c059c09d607cacd5cb)) 44 | * new settings format, auth cmd, tests, readme update ([ae6cd66](https://github.com/logux/django/commit/ae6cd665a72900995fbae643bd93c41851b8abf3)) 45 | * proto4 auth support. ([f9c2067](https://github.com/logux/django/commit/f9c2067868b053b1c203afd5ed0b96fa2f37d7d5)) 46 | * require_http_methods for logux url ([b8e3cfb](https://github.com/logux/django/commit/b8e3cfb0feb36eadb68cddbadea9a11442e1d76d)) 47 | * return actions from channel. resolved [#35](https://github.com/logux/django/issues/35) ([098afaa](https://github.com/logux/django/commit/098afaad2cbc7613f941e0c5f999bc4927011111)) 48 | * SubCommand support for proto4, errors. Mypy. ([4542c73](https://github.com/logux/django/commit/4542c732da3443a3140873ea69c11d9a72e87e84)) 49 | * subprotocols support, wrongSubprotocol answer ([99acab8](https://github.com/logux/django/commit/99acab8284e0f6a30ffa170d30bb2cfb16d0a335)) 50 | 51 | 52 | 53 | # (2020-08-05) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * add test app init migration ([a3c9e25](https://github.com/logux/django/commit/a3c9e256cd12b1805b7ae1ff735b1450a4515d20)) 59 | * apply_commands types ([e6ff52d](https://github.com/logux/django/commit/e6ff52dd60ecc2f644ef8009006f17180c10773b)) 60 | * call resend with named args, close [#42](https://github.com/logux/django/issues/42) ([24e0de0](https://github.com/logux/django/commit/24e0de00bf0e6105899381c2c0a61fbc87ca731c)) 61 | * change "stack" field to "details", tests. ([0ed4804](https://github.com/logux/django/commit/0ed480456c16ccfb41474b53eb12cf03923476ed)) 62 | * channel_pattern type ([480651e](https://github.com/logux/django/commit/480651e9fe86c89cf7fd8d42d8edbf4acfbeb1d9)) 63 | * checks version ([cf7efac](https://github.com/logux/django/commit/cf7efac7d1114386289ce0283fc0331982bbd4ab)) 64 | * dispatch proto4 support, test unknownAction ([09bb4da](https://github.com/logux/django/commit/09bb4dacf959887f5b904ef718de7174ce9f0263)) 65 | * django security fixes ([c1148bb](https://github.com/logux/django/commit/c1148bba8bcc158076d41f17b1f94412dfcb7651)) 66 | * forbidden answer for sub action ([b01138d](https://github.com/logux/django/commit/b01138d49025126e9f8f412ccc1cf15765e881d1)) 67 | * missing docstring ([4e93947](https://github.com/logux/django/commit/4e939477bed0ccf66a45a23b855cbe3672cc99a7)) 68 | * pylint satisfaction ([c28c160](https://github.com/logux/django/commit/c28c16018aa2568013c48b982fb0fcef4c91b607)) 69 | * remove headers arg from Actions callbacks. close [#43](https://github.com/logux/django/issues/43) ([d529198](https://github.com/logux/django/commit/d5291981beea9578ad0bb62a86b07c14424e784d)) 70 | * reset test app migration ([0c28303](https://github.com/logux/django/commit/0c283037fb8167550191023e9456debda5873eca)) 71 | * test refactoring, cleanup ([a216c90](https://github.com/logux/django/commit/a216c90e1ae2769c24d99b64165359845d692fb4)) 72 | * tests types ([f7cf769](https://github.com/logux/django/commit/f7cf769c5dadc84af00f89ea0d7e1740ad39fcb1)) 73 | * token from cookie, cleanup. answer types extracting. rest tests. ([e7e2bfa](https://github.com/logux/django/commit/e7e2bfa9ed27dde684f805566bf77159f864d2c8)) 74 | * unsupported subprotocol format ([0004bec](https://github.com/logux/django/commit/0004beccbd9c948a362698fc18fd5ea78241b780)) 75 | 76 | 77 | ### Features 78 | 79 | * ActionCommand support for proto4 ([11a8340](https://github.com/logux/django/commit/11a83408a41ed0c082f4b9ceb227102f339f18d7)) 80 | * add 'headers' for action callbacks ([5fcbe6e](https://github.com/logux/django/commit/5fcbe6edec779a21f0af86a36e30b08337e118c9)) 81 | * add semantic_version pkg for subprotocols processing ([64f0f75](https://github.com/logux/django/commit/64f0f75853c7debe0ab32a0b1eb2c8e839059215)) 82 | * load multi type return support, resolved [#35](https://github.com/logux/django/issues/35) ([45ffeec](https://github.com/logux/django/commit/45ffeec72a4c000f70c57b9ec17274c6721de1ed)) 83 | * logux_add support for proto4 ([d2c26c1](https://github.com/logux/django/commit/d2c26c1f2bef259fa950d08f3464b6694bf21126)) 84 | * make cmd for running "logux backend tests" ([71142e5](https://github.com/logux/django/commit/71142e5a583d89bfb571a99041244f70e1f9b36b)) 85 | * new auth_func signature. ([c08fdb2](https://github.com/logux/django/commit/c08fdb24d1064073339847c059c09d607cacd5cb)) 86 | * new settings format, auth cmd, tests, readme update ([ae6cd66](https://github.com/logux/django/commit/ae6cd665a72900995fbae643bd93c41851b8abf3)) 87 | * proto4 auth support. ([f9c2067](https://github.com/logux/django/commit/f9c2067868b053b1c203afd5ed0b96fa2f37d7d5)) 88 | * require_http_methods for logux url ([b8e3cfb](https://github.com/logux/django/commit/b8e3cfb0feb36eadb68cddbadea9a11442e1d76d)) 89 | * return actions from channel. resolved [#35](https://github.com/logux/django/issues/35) ([098afaa](https://github.com/logux/django/commit/098afaad2cbc7613f941e0c5f999bc4927011111)) 90 | * SubCommand support for proto4, errors. Mypy. ([4542c73](https://github.com/logux/django/commit/4542c732da3443a3140873ea69c11d9a72e87e84)) 91 | * subprotocols support, wrongSubprotocol answer ([99acab8](https://github.com/logux/django/commit/99acab8284e0f6a30ffa170d30bb2cfb16d0a335)) 92 | 93 | 94 | 95 | # (2020-06-06) 96 | 97 | 98 | ### Bug Fixes 99 | 100 | * django security fixes ([c1148bb](https://github.com/logux/django/commit/c1148bba8bcc158076d41f17b1f94412dfcb7651)) 101 | * pylint satisfaction ([c28c160](https://github.com/logux/django/commit/c28c16018aa2568013c48b982fb0fcef4c91b607)) 102 | * tests types ([f7cf769](https://github.com/logux/django/commit/f7cf769c5dadc84af00f89ea0d7e1740ad39fcb1)) 103 | 104 | 105 | 106 | # (2020-05-06) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * pylint satisfaction ([c28c160](https://github.com/logux/django/commit/c28c16018aa2568013c48b982fb0fcef4c91b607)) 112 | * tests types ([f7cf769](https://github.com/logux/django/commit/f7cf769c5dadc84af00f89ea0d7e1740ad39fcb1)) 113 | 114 | 115 | ## [0.1.1](https://github.com/logux/django/compare/0.1.0...0.1.1) (2020-04-18) 116 | 117 | ### Features 118 | 119 | * LoguxRequest and LoguxResponse are LoguxValue now 120 | * Type annotation refactoring 121 | * Add `lint` (mypy, flake8) command for `make` 122 | * Reduce requirements for `Django` and `requests` 123 | * Add EditConfig 124 | * Add SemVer 125 | * Add custom `LoguxProxyException` 126 | 127 | 128 | 129 | This project adheres to [Semantic Versioning](http://semver.org/). 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Logux 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | .PHONY: install deps run test ci_test build release clean release_test release_production lint lbt docs setup 3 | 4 | ## Init 5 | 6 | install: ## Install this pkg in editable (develop) mode 7 | poetry run dephell project register --from=pyproject . 8 | 9 | deps: ## Install dev dependencies (global) 10 | poetry install 11 | 12 | ## Code quality 13 | 14 | lint: ## Lint and static-check code 15 | flake8 logux 16 | pylint logux 17 | mypy logux 18 | 19 | lbt: ## Run logux-backend integration tests 20 | cd tests/lbt && npx @logux/backend-test http://localhost:8000/logux/ 21 | 22 | lbt_deps: ## Install @logux/backend-test from NPM 23 | cd tests/lbt && npm install 24 | 25 | integration_test: ## Up Django backend and run backend-test 26 | poetry run tests/manage.py migrate && poetry run tests/manage.py wipe_db 27 | poetry run tests/manage.py runserver --settings=tests.test_project.test_settings & echo $$! > django.PID 28 | sleep 3 29 | cd tests/lbt && npx @logux/backend-test http://localhost:8000/logux/ || echo "FAIL" > ../test_result.tmp 30 | 31 | if [ -a test_result.tmp ]; then \ 32 | kill -TERM $$(cat django.PID); \ 33 | rm -f test_result.tmp django.PID && exit 1; \ 34 | fi; 35 | 36 | kill -TERM $$(cat django.PID) 37 | rm -f test_result.tmp django.PID 38 | 39 | integration_test_ci: ## Up Django backend and run backend-test 40 | export PYTHONPATH=$PYTHONPATH:$(pwd) && python tests/manage.py migrate && python tests/manage.py wipe_db 41 | export PYTHONPATH=$PYTHONPATH:$(pwd) && python tests/manage.py runserver --settings=tests.test_project.test_settings & echo $$! > django.PID 42 | sleep 3 43 | cd tests/lbt && npx @logux/backend-test http://localhost:8000/logux/ || echo "FAIL" > ../test_result.tmp 44 | 45 | if [ -a test_result.tmp ]; then \ 46 | kill -TERM $$(cat django.PID); \ 47 | rm -f test_result.tmp django.PID && exit 1; \ 48 | fi; 49 | 50 | kill -TERM $$(cat django.PID) 51 | rm -f test_result.tmp django.PID 52 | 53 | test: ## Run tests (venv) 54 | poetry run tests/manage.py test test_app 55 | 56 | ci_test: ## Run tests inside CI ENV 57 | export PYTHONPATH=$PYTHONPATH:$(pwd) && python tests/manage.py test test_app 58 | 59 | ## Run 60 | 61 | run: ## Run local dev server (venv) 62 | poetry run tests/manage.py runserver 63 | 64 | build: clean test lint setup ## Build package 65 | poetry build 66 | 67 | changelog: ## Generate changelog 68 | conventional-changelog -p angular -i CHANGELOG.md -s 69 | 70 | docs: ## Run auto-docs build 71 | cd docs && poetry run make clean && poetry run make xml 72 | 73 | ## Release 74 | 75 | release_test: build ## Release package on test PyPI server 76 | poetry config repositories.test https://test.pypi.org/legacy/ 77 | poetry publish -r test 78 | 79 | release_production: build ## Release package on PyPI server 80 | poetry publish 81 | 82 | setup: ## Convert pyproject to setup.py 83 | dephell deps convert 84 | 85 | clean: ## Remove cache 86 | rm -rf ./dist ./build ./logux_django.egg-info ./README.rst 87 | 88 | ## Help 89 | 90 | help: ## Show help message 91 | @IFS=$$'\n' ; \ 92 | help_lines=(`fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##/:/'`); \ 93 | printf "%s\n\n" "Usage: make [task]"; \ 94 | printf "%-20s %s\n" "task" "help" ; \ 95 | printf "%-20s %s\n" "------" "----" ; \ 96 | for help_line in $${help_lines[@]}; do \ 97 | IFS=$$':' ; \ 98 | help_split=($$help_line) ; \ 99 | help_command=`echo $${help_split[0]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ 100 | help_info=`echo $${help_split[2]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ 101 | printf '\033[36m'; \ 102 | printf "%-20s %s" $$help_command ; \ 103 | printf '\033[0m'; \ 104 | printf "%s\n" $$help_info; \ 105 | done 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logux Django 2 | 3 | 5 | 6 | Django [Logux](https://logux.org/) integration engine. 7 | 8 | * **[Guide, recipes, and API](https://logux.org/)** 9 | * **[Issues](https://github.com/logux/logux/issues)** 10 | and **[roadmap](https://github.com/orgs/logux/projects/1)** 11 | * **[Projects](https://logux.org/guide/architecture/parts/)** 12 | inside Logux ecosystem 13 | 14 | ![Logux Proto](https://img.shields.io/badge/logux%20protocol-4-brightgreen) 15 | [![PyPI version](https://badge.fury.io/py/logux-django.svg)](https://badge.fury.io/py/logux-django) 16 | ![Travis CI](https://travis-ci.org/logux/django.svg?branch=master) 17 | ![Lint and Test](https://github.com/logux/django/workflows/Lint%20and%20Test/badge.svg) 18 | 19 | ## Installation 20 | 21 | Install from PyPI 22 | ```shell script 23 | pip install logux-django 24 | ``` 25 | 26 | Install dev version from current master. 27 | ```shell script 28 | pip install -e git://github.com/logux/django.git#egg=logux_django 29 | ``` 30 | 31 | Add `path(r'logux/', include('logux.urls')),` into your `urls.py` 32 | 33 | Sets Logux settings in your `settings.py`: 34 | ```python 35 | # Logux settings: https://logux.org/guide/starting/proxy-server/ 36 | LOGUX_CONFIG = { 37 | 'URL': 'http://localhost:31337/', 38 | 'CONTROL_SECRET': 'parole', 39 | 'AUTH_FUNC': auth_func, # auth_func(user_id: str, token: str, cookie: dict, headers: dict) -> bool 40 | 'SUBPROTOCOL': '1.0.0', 41 | 'SUPPORTS': '^1.0.0' 42 | } 43 | ``` 44 | 45 | _Storing passwords or secrets in `settings.py` is bad practice. Use ENV._ 46 | 47 | For urls and settings examples, please checkout `test_app` 48 | [settings](https://github.com/logux/django/blob/master/tests/test_project/settings.py) 49 | 50 | Keep in mind: the path in your `urls.py` (`logux/`) and the `LOGUX_CONTROL_SECRET` from the settings should be passed 51 | into [Logux Server](https://logux.org/guide/starting/proxy-server/#creating-the-project) by ENV as 52 | `LOGUX_BACKEND` and `LOGUX_CONTROL_SECRET` respectively. 53 | 54 | For example: 55 | ```shell script 56 | LOGUX_BACKEND=http://localhost:8000/logux/ 57 | LOGUX_CONTROL_SECRET=secret 58 | ``` 59 | 60 | ## Usage 61 | 62 | ### Actions 63 | 64 | For `action` handling add `logux_actions.py` file in your app, add `ActionCommand` inheritors and implement all his 65 | abstract methods. 66 | 67 | Actions classes requirements: 68 | 69 | * Set `action_type: str` 70 | * Implement all `ActionCommand` abstracts methods 71 | * Implement `resend` and `process` methods if you need (optional) 72 | * import `logux` dispatcher: `from logux.dispatchers import logux` 73 | * Register all your action handlers: `logux.actions.register(YourAction)` 74 | 75 | For example – User rename action handler: 76 | ```python 77 | import json 78 | from typing import Optional, List 79 | 80 | from logux.core import ActionCommand, Meta, Action 81 | from logux.dispatchers import logux 82 | from logux.exceptions import LoguxProxyException 83 | from tests.test_app.models import User 84 | 85 | class RenameUserAction(ActionCommand): 86 | """ During the subscription to users/USER_ID channel sends { type: "users/name", payload: { userId, name } } 87 | action with the latest user’s name. """ 88 | action_type = 'users/name' 89 | 90 | def resend(self, action: Action, meta: Optional[Meta]) -> List[str]: 91 | return [f"users/{action['payload']['userId']}"] 92 | 93 | def access(self, action: Action, meta: Meta) -> bool: 94 | if 'error' in self.headers: 95 | raise LoguxProxyException(self.headers['error']) 96 | return action['payload']['userId'] == meta.user_id 97 | 98 | def process(self, action: Action, meta: Meta) -> None: 99 | user = User.objects.get(pk=action['payload']['userId']) 100 | first_name_meta = json.loads(user.first_name_meta) 101 | 102 | if not first_name_meta or Meta(first_name_meta).is_older(meta): 103 | user.first_name = action['payload']['name'] 104 | user.first_name_meta = meta.get_json() 105 | user.save() 106 | 107 | 108 | logux.actions.register(RenameUserAction) 109 | 110 | ``` 111 | 112 | ### Channels (Subscription) 113 | 114 | For `subsription` handling add `logux_subsriptions.py` file in your app, and `ChannelCommand` inheritors 115 | and implement all his abstract methods. 116 | 117 | Subscription classes requirements: 118 | 119 | * Set `channel_pattern: str` – this is a regexp like Django's url's patters in `urls.py` 120 | * Implement all `ChannelCommand` abstracts methods 121 | * import `logux` dispatcher: `from logux.dispatchers import logux` 122 | * Register all your subscription handlers: `logux.channels.register(YourChannelCommand)` 123 | 124 | For example: 125 | ```python 126 | from typing import Optional 127 | 128 | from logux.core import ChannelCommand, Action, Meta 129 | from logux.dispatchers import logux 130 | from logux.exceptions import LoguxProxyException 131 | from tests.test_app.models import User 132 | 133 | 134 | class UserChannel(ChannelCommand): 135 | 136 | channel_pattern = r'^users/(?P\w+)$' 137 | 138 | def access(self, action: Action, meta: Meta) -> bool: 139 | return self.params['user_id'] == meta.user_id 140 | 141 | def load(self, action: Action, meta: Meta) -> Action: 142 | if 'error' in self.headers: 143 | raise LoguxProxyException(self.headers['error']) 144 | 145 | user, created = User.objects.get_or_create(id=self.params['user_id']) 146 | if created: 147 | user.first_name = 'Name' 148 | 149 | return { 150 | 'type': 'users/name', 151 | 'payload': {'userId': str(user.id), 'name': user.first_name} 152 | } 153 | 154 | 155 | logux.channels.register(UserChannel) 156 | 157 | ``` 158 | 159 | For more examples, please checkout `test app` (tests/test_app) 160 | 161 | ### Utils 162 | 163 | #### logux.core.logux_add 164 | `logux_add(action: Action, raw_meta: Optional[Dict] = None) -> None` is low level API function to send any actions and meta into Logux server. 165 | 166 | If `raw_meta` is `None` just empty Dict will be passed to Logux server. 167 | 168 | Keep in mind, in the current version `logux_add` is sync. 169 | 170 | For more information: https://logux.org/node-api/#log-add 171 | 172 | ## Development 173 | 174 | We use [Poetry](https://python-poetry.org/) and [dephell](https://github.com/dephell/dephell) for dealing with deps. 175 | 176 | Create dev environment, setup logux in develop mode, run local test server 177 | ```shell script 178 | make deps 179 | make install 180 | make run 181 | ``` 182 | 183 | Type checking and linting: 184 | ```shell script 185 | make lint 186 | ``` 187 | 188 | Test: 189 | ```shell script 190 | make test 191 | ``` 192 | 193 | Integration tests (up server and run [backend-test](https://github.com/logux/backend-test)). 194 | 195 | Install [backend-test](https://github.com/logux/backend-test) deps: 196 | ```shell script 197 | make lbt_deps 198 | ``` 199 | 200 | Run integration tests 201 | ```shell script 202 | make integration_test 203 | ``` 204 | 205 | ## License 206 | 207 | The package is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 208 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 django 16 | 17 | from logux import VERSION 18 | 19 | sys.path.insert(0, os.path.abspath('../logux')) 20 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_project.settings' 21 | django.setup() 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = 'logux-django' 26 | copyright = '2020, Vadim Iskuchekov @egregors' 27 | author = 'Vadim Iskuchekov @egregors' 28 | 29 | # The full version, including alpha/beta/rc tags 30 | release = VERSION 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'sphinx.ext.autodoc' 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 49 | 50 | 51 | # -- Options for HTML output ------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for 54 | # a list of builtin themes. 55 | # 56 | html_theme = 'alabaster' 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 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. logux-django documentation master file, created by 2 | sphinx-quickstart on Thu Apr 23 13:50:34 2020. 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 logux-django's documentation! 7 | ======================================== 8 | 9 | Version: |release| 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /docs/logux.rst: -------------------------------------------------------------------------------- 1 | logux package 2 | ============= 3 | 4 | Submodules 5 | ---------- 6 | 7 | logux.core module 8 | ----------------- 9 | 10 | .. automodule:: logux.core 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | logux.dispatchers module 16 | ------------------------ 17 | 18 | .. automodule:: logux.dispatchers 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | logux.exceptions module 24 | ----------------------- 25 | 26 | .. automodule:: logux.exceptions 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | logux.settings module 32 | --------------------- 33 | 34 | .. automodule:: logux.settings 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | logux.urls module 40 | ----------------- 41 | 42 | .. automodule:: logux.urls 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | logux.utils module 48 | ------------------ 49 | 50 | .. automodule:: logux.utils 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | logux.views module 56 | ------------------ 57 | 58 | .. automodule:: logux.views 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | 64 | Module contents 65 | --------------- 66 | 67 | .. automodule:: logux 68 | :members: 69 | :undoc-members: 70 | :show-inheritance: 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | django 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | logux 8 | -------------------------------------------------------------------------------- /logux/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Logux integration engine: https://logux.org 3 | """ 4 | __title__ = 'Django Logux integration engine' 5 | __author__ = 'Vadim Iskuchekov @egregors' 6 | __license__ = 'MIT License' 7 | 8 | # Synonyms 9 | AUTHOR = __author__ 10 | 11 | # Logux protocol version: https://logux.org/protocols/ws/spec/ 12 | LOGUX_PROTOCOL_VERSION = 4 13 | 14 | default_app_config = 'logux.apps.LoguxConfig' 15 | -------------------------------------------------------------------------------- /logux/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from logux import settings 4 | from logux.utils import autodiscover 5 | 6 | 7 | class LoguxConfig(AppConfig): 8 | """ Logux app conf """ 9 | name = 'logux' 10 | verbose_name = 'Logux' 11 | 12 | def ready(self): 13 | # check if all required settings is defined 14 | settings.get_config() 15 | # import all logux_actions.py and logux_subscriptions.py from consumer modules 16 | autodiscover() 17 | -------------------------------------------------------------------------------- /logux/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core of Logux Django 3 | """ 4 | from __future__ import annotations 5 | 6 | import json 7 | import logging 8 | import re 9 | from abc import abstractmethod, ABC 10 | from copy import deepcopy 11 | from datetime import datetime 12 | from typing import List, Callable, Optional, Dict, Any, Union 13 | 14 | import requests 15 | from semantic_version import Version, NpmSpec 16 | 17 | from logux import LOGUX_PROTOCOL_VERSION 18 | from logux import settings 19 | from logux.exceptions import LoguxProxyException, LoguxBadAuthException, LoguxWrongLoadResultsException 20 | 21 | # Logux requests \ response data format 22 | LoguxValue = List[Dict[str, Any]] 23 | 24 | Action = Dict[str, Any] 25 | logger = logging.getLogger(__name__) 26 | 27 | LOGUX_SUBSCRIBE = 'logux/subscribe' 28 | LOGUX_UNDO = 'logux/undo' 29 | 30 | 31 | def protocol_version_is_supported(version: int) -> bool: 32 | """ Check possibility of support protocol version. 33 | :param version: the proto version from request 34 | 35 | :return: True if version is supported 36 | """ 37 | return version == LOGUX_PROTOCOL_VERSION 38 | 39 | 40 | class Meta: # pylint: disable=too-many-instance-attributes 41 | """ Logux meta: https://logux.org/guide/concepts/meta/ 42 | TODO: add docs about comp: 43 | https://github.com/logux/django/issues/12#issuecomment-612394901 44 | """ 45 | 46 | def __init__(self, raw_meta: Dict[str, str]): 47 | # Take raw meta and parse all required to properties 48 | self._raw_meta = raw_meta 49 | # Keep in mind, if self._raw_meta will change all properties do not be reassignment, 50 | # so, do not change self._raw_meta during Meta instance lifecycle 51 | 52 | # Let's say Meta is 53 | # { 54 | # "id": "1564508138460 380:R7BNGAP5:px3-J3oc 0", 55 | # "time": 1 56 | # } 57 | 58 | # ["380", "R7BNGAP5", "px3-J3oc"] 59 | self._uid: List[str] = self._get_uid() 60 | 61 | # "1564508138460 380:R7BNGAP5:px3-J3oc 0" 62 | self.id: str = self._raw_meta['id'] 63 | # 0 64 | self.counter: int = self._get_counter() 65 | # "380:R7BNGAP5:px3-J3oc" 66 | self.node: str = self._get_node() 67 | # fromtimestamp(1564508138460) 68 | self.time_from_id = self._get_time_from_id() 69 | # "380" 70 | self.user_id: str = self._get_user_id() 71 | # "380:R7BNGAP5" 72 | self.client_id: str = self._get_client_id() 73 | # Optional("px3-J3oc"). Note: in Docs node_id is "tab ID" 74 | self.node_id: Optional[str] = self._get_node_id() 75 | # fromtimestamp(1) 76 | self.time: datetime = self._get_time() 77 | 78 | self.subprotocol: str = self._get_subprotocol() 79 | 80 | def __getitem__(self, item): 81 | return self._raw_meta[item] 82 | 83 | def __eq__(self, o) -> bool: 84 | return self.time == o.time and self.id == o.id 85 | 86 | def __ne__(self, o) -> bool: 87 | return not self.__eq__(o) 88 | 89 | def __lt__(self, other: Meta) -> bool: 90 | # self < other 91 | return False if other is None or self == other else not self.is_older(other) 92 | 93 | def __gt__(self, other: Meta) -> bool: 94 | # self > other 95 | return False if other is None or self == other else self.is_older(other) 96 | 97 | def __str__(self): 98 | return self.get_json() 99 | 100 | def is_older(self, other: Meta): 101 | # pylint: disable=no-else-return,too-many-return-statements 102 | """ To be less confusing, in the method will be implementation 103 | from the Node API. Ord arophetoca will work throug this method. 104 | For more info, check this out: 105 | https://github.com/logux/core/blob/master/is-first-older/index.js 106 | """ 107 | if self.get_raw_meta() and other is None: 108 | return False 109 | if other.get_raw_meta() and self is None: 110 | return True 111 | 112 | # real time 113 | if self.time > other.time: 114 | return False 115 | if self.time < other.time: 116 | return True 117 | 118 | # node 119 | if self.node > other.node: 120 | return False 121 | if self.node < other.node: 122 | return True 123 | 124 | # counter 125 | if self.counter > other.counter: 126 | return False 127 | if self.counter < other.counter: 128 | return True 129 | 130 | # node time 131 | if self.time_from_id > other.time_from_id: 132 | return False 133 | if self.time_from_id < other.time_from_id: 134 | return True 135 | 136 | return False 137 | 138 | # Helpers 139 | def _get_uid(self): 140 | try: 141 | uid = self._raw_meta['id'].split(' ')[1].split(':') 142 | except IndexError as err: 143 | raise ValueError(f'wrong meta id format: {self._raw_meta["id"]}') from err 144 | return uid 145 | 146 | def _get_user_id(self) -> str: 147 | """ Get user id from mata.id. 148 | For example, if meta.id is '1560954012838 38:Y7bysd:O0ETfc 0', 149 | then user_id is '38' 150 | """ 151 | return self._uid[0] 152 | 153 | def _get_client_id(self) -> str: 154 | """ Get client id from mata.id. 155 | For example, if meta.id is '1560954012838 38:Y7bysd:O0ETfc 0', 156 | then client_id is '38:Y7bysd' 157 | """ 158 | return ':'.join(self._uid[:2]) 159 | 160 | def _get_node(self) -> str: 161 | """ Get node from meta.id. 162 | For example, if meta.id is '1560954012838 38:Y7bysd:O0ETfc 0', 163 | then node is '38:Y7bysd:O0ETfc' 164 | """ 165 | return ':'.join(self._uid) 166 | 167 | def _get_counter(self) -> int: 168 | """ Get counter from meta.id. 169 | For example, if meta.id is '1560954012838 38:Y7bysd:O0ETfc 0', 170 | then counter is 0 171 | """ 172 | return int(self.id.split(' ')[-1]) 173 | 174 | def _get_node_id(self) -> Optional[str]: 175 | """ Get node id from mata.id if exist. 176 | For example, if meta.id is '1560954012838 38:Y7bysd:O0ETfc 0', 177 | then client_id is 'O0ETfc' 178 | 179 | If UID does not contain node_id None will be returned 180 | """ 181 | return self._uid[-1] if len(self._uid) == 3 else None 182 | 183 | def _get_time(self) -> datetime: 184 | """ Get time from mata in Python datetime type. 185 | For example, if meta is {'id': "1560954012838 38:Y7bysd 0", 'time': 1560954012838}, 186 | then time is 'datetime.datetime(2019, 6, 20, 0, 20, 12, 838000)' 187 | """ 188 | return datetime.fromtimestamp(int(self._raw_meta['time']) / 1e3) 189 | 190 | def _get_time_from_id(self) -> datetime: 191 | """ Get time from `id` of meta in Python datetime type. 192 | For example, if meta is {'id': "1560954012838 38:Y7bysd 0", 'time': 1560954012838}, 193 | then time from id is 'datetime.datetime(2019, 6, 20, 0, 20, 12, 838000)', that means 194 | datetime from meta.id[0] 195 | """ 196 | return datetime.fromtimestamp(int(self.id.split(' ')[0]) / 1e3) 197 | 198 | def _get_subprotocol(self): 199 | return self._raw_meta.get('subprotocol') 200 | 201 | def get_raw_meta(self) -> Dict: 202 | """ Get the copy of raw meta dict """ 203 | return deepcopy(self._raw_meta) 204 | 205 | def get_json(self) -> str: 206 | """ Get raw meta and convert it to JSON """ 207 | return json.dumps(self._raw_meta) 208 | 209 | 210 | def logux_add(action: Action, raw_meta: Optional[Dict] = None) -> None: 211 | """ `logux_add` is low level API function to send any actions and meta into Logux server. 212 | If `raw_meta` is None just empty dict will be passed to Logux server. Logux server 213 | will set `id` and `time` on this side. 214 | 215 | Keep in mind, in the current version `logux_add` is sync. 216 | 217 | For more information: https://logux.org/node-api/#log-add 218 | 219 | :param action: action dict 220 | :param raw_meta: meta dict (not Meta instance) 221 | 222 | :raises: base LoguxProxyException() if Logux Proxy returns non 200 response code 223 | 224 | :return: None 225 | """ 226 | command = { 227 | 'version': LOGUX_PROTOCOL_VERSION, 228 | 'secret': settings.get_config()['CONTROL_SECRET'], 229 | 'commands': [ 230 | { 231 | 'command': 'action', 232 | 'action': action, 233 | 'meta': raw_meta or {} 234 | } 235 | ] 236 | } 237 | 238 | logger.debug('logux_add action %s with meta %s to Logux', action, raw_meta or {}) 239 | 240 | r = requests.post(url=settings.get_config()['URL'], json=command) 241 | logger.debug('Logux answer is %s: %s', r.status_code, r.text) 242 | 243 | if r.status_code != 200: 244 | logger.error('`logux_add` to Logux is failed! err: %s: %s', r.status_code, r.text) 245 | raise LoguxProxyException(f'Non 200 response from Logux Proxy (logux_add): {r.status_code}: {r.text}') 246 | 247 | 248 | class Command(ABC): 249 | """ Logux Command abstract class. 250 | All type of Logux Commands should be inheritance from this one. 251 | 252 | Required only one method `apply()` witch executing command and return LoguxValue 253 | with an answer or error a message. 254 | """ 255 | 256 | class ANSWER: 257 | """ Possible value of Logux commands answers """ 258 | AUTHENTICATED = 'authenticated' 259 | DENIED = 'denied' 260 | RESEND = 'resend' 261 | APPROVED = 'approved' 262 | PROCESSED = 'processed' 263 | ACTION = 'action' 264 | FORBIDDEN = 'forbidden' 265 | ERROR = 'error' 266 | UNKNOWN_ACTION = 'unknownAction' 267 | UNKNOWN_CHANNEL = 'unknownChannel' 268 | WRONG_SUBPROTOCOL = 'wrongSubprotocol' 269 | 270 | @abstractmethod 271 | def apply(self) -> LoguxValue: 272 | """ This method consistently apply all Action methods inside of try/catch and construct List of 273 | LoguxValue's with action methods results or error messages. 274 | 275 | :return: list of results of applying all actions methods 276 | """ 277 | raise NotImplementedError() 278 | 279 | 280 | class AuthCommand(Command): 281 | """ Logux Auth Command provide way to check is the User authenticated. 282 | 283 | Auth command should look like Object: 284 | { 285 | command: "auth", 286 | authId: string, 287 | userId: string, 288 | token?: string, 289 | cookie: { 290 | [name]: string 291 | }, 292 | headers: { 293 | [name]: string 294 | } 295 | } 296 | """ 297 | 298 | auth_id: str 299 | user_id: str 300 | token: Optional[str] 301 | subprotocol: Version 302 | cookie: Dict 303 | headers: Dict 304 | 305 | def __init__(self, 306 | cmd_body: Dict[str, Any], 307 | logux_auth: Callable[[str, Optional[str], Dict, Dict], bool]): 308 | """ Construct Auth cmd from raw logux command. 309 | 310 | :param cmd_body: raw logux command, like 311 | { 312 | "command": "auth", 313 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 314 | "userId": "38", 315 | "token": "parole", // optional 316 | "cookie": {...}, 317 | "headers": {...}, 318 | "subprotocol": "1.0.0" 319 | } 320 | :type cmd_body: Dict[str, Any] 321 | :param logux_auth: function to prove user is authenticated, 322 | type hint: `logux_auth(user_id: str, token: str, cookie: dict, headers: dict) -> bool`. 323 | `logux_auth` function will be taken from settings.get_config()['AUTH_FUNC'] (should be provided by consumer) 324 | :type logux_auth: Callable[[str, Optional[str], Dict, Dict], bool]) 325 | """ 326 | try: 327 | self.auth_id = cmd_body['authId'] 328 | self.user_id = cmd_body['userId'] 329 | except KeyError as err: 330 | logger.warning('AUTH command does not contain "authId" or "userId" keys') 331 | raise LoguxBadAuthException('Missing "authId" or "userId" keys in AUTH command') from err 332 | 333 | self.token = cmd_body.get('token', None) 334 | 335 | try: 336 | self.subprotocol = Version(cmd_body['subprotocol']) 337 | except ValueError as err: 338 | logger.warning('wrong subprotocol format for AUTH command: %s', err) 339 | raise LoguxBadAuthException('Wrong subprotocol format for AUTH command: %s' % err) from err 340 | 341 | self.cookie = cmd_body.get('cookie', {}) 342 | self.headers = cmd_body.get('headers', {}) 343 | 344 | self.logux_auth = logux_auth 345 | 346 | def apply(self) -> LoguxValue: 347 | """ Applying auth 348 | 349 | :returns: `authenticated` or `denied` action dependently if user is authenticated. 350 | """ 351 | # TODO: Init NpmSpec(settings.get_config()['SUPPORTS']) int __init__ instead of creating new Object 352 | supported_subprotocol = NpmSpec(settings.get_config()['SUPPORTS']) 353 | if self.subprotocol not in supported_subprotocol: 354 | logger.warning("unsupported subprotocol version: %s expected: %s", self.subprotocol, supported_subprotocol) 355 | return [{ 356 | "answer": self.ANSWER.WRONG_SUBPROTOCOL, 357 | "authId": self.auth_id, 358 | "supported": str(supported_subprotocol) 359 | }] 360 | 361 | try: 362 | is_authenticated: bool = self.logux_auth(self.user_id, self.token, self.cookie, self.headers) 363 | 364 | # TODO: extract errors returns 365 | except KeyError as err: 366 | logger.warning("can't apply AUTH func because of missing key: %s", err) 367 | return [{ 368 | "answer": self.ANSWER.ERROR, 369 | "authId": self.auth_id, 370 | "details": "missing auth token: %s" % err 371 | }] 372 | except LoguxBadAuthException as err: 373 | logger.warning("AUTH err: %s", err) 374 | return [{ 375 | "answer": self.ANSWER.ERROR, 376 | "authId": self.auth_id, 377 | "details": str(err) 378 | }] 379 | 380 | return [{ 381 | 'answer': self.ANSWER.AUTHENTICATED if is_authenticated else self.ANSWER.DENIED, 382 | 'subprotocol': settings.get_config()['SUBPROTOCOL'], 383 | 'authId': self.auth_id 384 | }] 385 | 386 | 387 | class ActionCommand(Command): 388 | """ Logux Action Command provide way to handle actions from Logux Proxy. 389 | 390 | Action command should look like Object: 391 | { 392 | command: "action", 393 | action: Action, 394 | meta: Meta, 395 | headers: { 396 | [name]: string 397 | } 398 | } 399 | """ 400 | # `action_type` is a required property, if the property does not define 401 | # DefaultActionDispatcher will raise ValueError('`action_type` attribute is required for all Actions') Exception 402 | action_type: str 403 | 404 | def __init__(self, cmd_body: Dict[str, Any]): 405 | """ Construct Action cmd from raw logux command. 406 | 407 | :param cmd_body: raw logux cmd, like: 408 | { 409 | "command": "action", 410 | "action": { 411 | "type": "user/rename", 412 | "user": 38, 413 | "name": "New" 414 | }, 415 | "meta": { 416 | "id": "1560954012838 38:Y7bysd:O0ETfc 0", 417 | "time": 1560954012838, 418 | "subprotocol": "1.0.0" 419 | }, 420 | headers: { 421 | "key": "value" 422 | } 423 | } 424 | 425 | :type cmd_body: List[Action] 426 | """ 427 | self._action: Action = cmd_body['action'] 428 | self._meta: Meta = Meta(cmd_body['meta']) 429 | # TODO: add headers to access, resend, process funcs signatures 430 | self._headers = cmd_body.get('headers', {}) 431 | 432 | @property 433 | def action(self): 434 | """ Get copy of Action. Do not change internal action state from outside. """ 435 | return deepcopy(self._action) 436 | 437 | @property 438 | def meta(self): 439 | """ Get copy of Meta. Do not change internal meta state from outside. """ 440 | return deepcopy(self._meta) 441 | 442 | @property 443 | def headers(self): 444 | """ Get copy of headers. Do not change internal headers state from outside. """ 445 | return deepcopy(self._headers) 446 | 447 | def send_back(self, action: Action, raw_meta: Optional[Dict] = None) -> None: 448 | """ Sand action with meta back to Logux. Will add `clients` from original action to the meta. 449 | For more information: https://logux.org/guide/concepts/action/#adding-actions-on-the-server 450 | 451 | :param action: any logux action 452 | :type action: Action 453 | :param raw_meta: optional additional mata 454 | :type raw_meta: Optional[Dict] 455 | """ 456 | raw_meta = {} if raw_meta is None else raw_meta 457 | logux_add(action, {'clients': [self.meta.client_id], **raw_meta}) 458 | 459 | def undo(self, reason: Optional[str] = 'error', extra: Optional[Dict] = None): 460 | """ Logux undo action. https://logux.org/guide/concepts/action/#loguxundo 461 | 462 | :param reason: describes the reason for reverting 463 | :type reason: str 464 | :param extra: optional additional data 465 | :type extra: Dict 466 | """ 467 | undo_action = { 468 | 'type': LOGUX_UNDO, 469 | 'id': self.meta.id, 470 | 'reason': reason, 471 | **extra # type: ignore 472 | } 473 | 474 | raw_meta = self.meta.get_raw_meta() 475 | undo_raw_meta = { 476 | 'status': 'processed', 477 | 478 | 'users': raw_meta.get('users'), 479 | 'nodes': raw_meta.get('nodes'), 480 | 'clients': raw_meta.get('clients', []) + [self.meta.client_id], 481 | 'reasons': raw_meta.get('reasons'), 482 | 'channels': raw_meta.get('channels') 483 | } 484 | 485 | # reduce None keys 486 | undo_meta = {k: v for (k, v) in undo_raw_meta.items() if v is not None} 487 | 488 | logux_add(undo_action, undo_meta) 489 | 490 | def _try_access(self) -> Dict[str, Any]: 491 | try: 492 | access_result = { 493 | 'answer': self.ANSWER.APPROVED if self.access( 494 | action=self._action, meta=self._meta) else self.ANSWER.FORBIDDEN, 495 | 'id': self._meta.id 496 | } 497 | except Exception as access_err: # pylint: disable=broad-except 498 | access_result = { 499 | 'answer': self.ANSWER.ERROR, 500 | 'id': self._meta.id, 501 | 'details': f'{access_err}' 502 | } 503 | return access_result 504 | 505 | # Required and optional action methods (these methods should be implemented by consumer) 506 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 507 | def _finally(self, action: Action, meta: Meta) -> LoguxValue: # pylint: disable=unused-argument,no-self-use 508 | """ Callback which will be run on the end of action/subscription processing or on an error """ 509 | return [] 510 | 511 | @abstractmethod 512 | def access(self, action: Action, meta: Meta) -> bool: 513 | """ `access` is required method and should contain code for checking user permissions. 514 | 515 | :param action: logux action 516 | :type action: Action 517 | :param meta: logux meta 518 | :type meta: Meta 519 | 520 | :returns: does current user have permission for apply this action? 521 | """ 522 | raise NotImplementedError() 523 | 524 | def resend(self, action: Action, # pylint: disable=unused-argument,no-self-use 525 | meta: Optional[Meta]) -> List[str]: # pylint: disable=unused-argument 526 | """ `resend` should return recipients for this action. 527 | It should look like: 528 | {'channels': ['users/38']} 529 | and may content fields: channels, users, nodes, clients. 530 | 531 | For more information: https://logux.org/node-api/#resend 532 | 533 | :param action: logux action 534 | :type action: Action 535 | :param meta: logux meta 536 | :type meta: Meta 537 | 538 | :returns: dict with recipients 539 | """ 540 | return [] 541 | 542 | def process(self, action: Action, meta: Meta) -> None: 543 | """ `process` should contain consumer business code. If it raised exception, 544 | self.apply will return error action automatically. If `process` return error action 545 | Logux server will eval `undo` by this side. 546 | 547 | :param action: logux action 548 | :type action: Action 549 | :param meta: logux meta 550 | :type meta: Meta 551 | """ 552 | pass 553 | 554 | def apply(self) -> LoguxValue: 555 | """ Apply all the commands and collect results in the one Logux Value, like: 556 | [ 557 | { 558 | "answer": "resend", 559 | "id": "1560954012838 38:Y7bysd:O0ETfc 0", 560 | "channels": ["users/38"] 561 | }, 562 | { 563 | "answer": "resend", 564 | "id": "1560954012900 38:Y7bysd:O0ETfc 1", 565 | "channels": ["users/21"] 566 | }, 567 | { 568 | "answer": "approved", 569 | "id": "1560954012838 38:Y7bysd:O0ETfc 0" 570 | }, 571 | { 572 | "answer": "denied", 573 | "id": "1560954012900 38:Y7bysd:O0ETfc 1" 574 | }, 575 | { 576 | "answer": "processed", 577 | "id": "1560954012838 38:Y7bysd:O0ETfc 0" 578 | } 579 | ] 580 | """ 581 | applying_result = [] 582 | 583 | # resend 584 | resend_result = { 585 | 'answer': self.ANSWER.RESEND, 586 | 'id': self.meta.id, 587 | 'channels': self.resend(action=self._action, meta=self._meta) 588 | } 589 | applying_result.append(resend_result) 590 | 591 | # access 592 | access_result = self._try_access() 593 | applying_result.append(access_result) 594 | 595 | # process 596 | if access_result['answer'] == self.ANSWER.APPROVED: 597 | try: 598 | self.process(action=self._action, meta=self._meta) 599 | process_result = { 600 | 'answer': self.ANSWER.PROCESSED, 601 | 'id': self._meta.id 602 | } 603 | except Exception as process_err: # pylint: disable=broad-except 604 | process_result = { 605 | 'answer': self.ANSWER.ERROR, 606 | 'id': self._meta.id, 607 | 'details': f'{process_err}' 608 | } 609 | 610 | applying_result.append(process_result) 611 | 612 | # finally 613 | try: 614 | self._finally(self._action, self._meta) 615 | finally_result = {} 616 | except Exception as finally_err: # pylint: disable=broad-except 617 | finally_result = { 618 | 'answer': self.ANSWER.ERROR, 619 | 'id': self._meta.id, 620 | 'details': f'{finally_err}' 621 | } 622 | 623 | applying_result.append(finally_result) 624 | 625 | return [r for r in applying_result if len(r.items()) != 0] 626 | 627 | 628 | class ChannelCommand(ActionCommand): 629 | """ Logux Subscribe Action Command provide way to handle subscription actions from Logux Proxy. 630 | 631 | For more information: https://logux.org/protocols/backend/examples/#subscription 632 | 633 | Subscription actions should look like: 634 | [ 635 | "action", 636 | { type: 'logux/subscribe', channel: '38/name' }, 637 | { id: "1560954012858 38:Y7bysd:O0ETfc 0", time: 1560954012858 } 638 | ] 639 | """ 640 | # `channel_pattern` is required property, if property does not define 641 | # DefaultSubscriptionsDispatcher will raise 642 | # ValueError('`channel_pattern` attribute is required for `logux/subscription` Actions') Exception 643 | action_type = LOGUX_SUBSCRIBE 644 | # regexp, like in urls.py 645 | # TODO: https://github.com/logux/django/issues/38 646 | channel_pattern: Optional[str] 647 | 648 | def __init__(self, cmd_body: Dict[str, Any]): 649 | super().__init__(cmd_body) 650 | self.channel = self._action['channel'] 651 | self.params = self._parse_params() 652 | 653 | def _parse_params(self) -> Dict: 654 | return re.match(self.channel_pattern, self.channel).groupdict() if self.channel_pattern else {} # type: ignore 655 | 656 | def _normalize(self, actions: Union[Action, List[Action], List[List[Action]]]) -> List[Dict]: 657 | """ self.load could return Action or [Action] or [[Action, raw_meta],]. 658 | This function will normalize load results to list of Dict to put it to bulk response body. 659 | """ 660 | if not actions: 661 | logger.warning('nothing to normalize') 662 | return [{}] 663 | 664 | if isinstance(actions, dict): 665 | # [...] -> Action case 666 | return [{ 667 | 'answer': self.ANSWER.ACTION, 668 | 'id': self._meta.id, 669 | 'action': actions, 670 | 'meta': {'clients': [self.meta.client_id]} 671 | }] 672 | 673 | normalized = [] 674 | if isinstance(actions, list): 675 | for action in actions: 676 | if isinstance(action, dict): 677 | # [...] -> [Action] case 678 | normalized.append({ 679 | 'answer': self.ANSWER.ACTION, 680 | 'id': self._meta.id, 681 | 'action': action, 682 | 'meta': {'clients': [self.meta.client_id]} 683 | }) 684 | if isinstance(action, list): 685 | # [...] -> [[Action, raw_meta],] case 686 | try: 687 | _action, _meta = action 688 | assert isinstance(_action, dict) 689 | assert isinstance(_meta, dict) 690 | except (ValueError, AssertionError) as err: 691 | raise LoguxWrongLoadResultsException("'load' method returns invalid data. It should be " 692 | "Action or [Action] or [[Action, raw_meta],] where" 693 | "Action and rew_meta is Dict[str, Any]") from err 694 | normalized.append({ 695 | 'answer': self.ANSWER.ACTION, 696 | 'id': self._meta.id, 697 | 'action': _action, 698 | 'meta': {'clients': [self.meta.client_id], **_meta} 699 | }) 700 | 701 | return normalized 702 | 703 | @classmethod 704 | def is_match(cls, channel: str) -> bool: 705 | """ Check if the Dispatcher contains channel handler """ 706 | return re.match(cls.channel_pattern, channel) is not None # type: ignore 707 | 708 | # Required and optional action methods (these methods should be implemented by consumer) 709 | @abstractmethod 710 | def load(self, action: Action, meta: Meta) -> Union[Action, List[Action], List[List[Action]]]: 711 | """ `load` should contain consumer code for applying subscription. 712 | Generally this method is almost the same as `process`. If it raised exception, 713 | self.apply will return error action automatically. If `load` return error action 714 | Logux server will eval `undo` by this side. 715 | 716 | :param action: logux action 717 | :type action: Action 718 | :param meta: logux meta 719 | :type meta: Meta 720 | 721 | :returns: Logux action or list of actions or list of list with action and meta: 722 | Action | Action[] | [Action, raw_meta][] where Action and rew_meta is Dict[str, Any] 723 | """ 724 | pass 725 | 726 | def apply(self) -> LoguxValue: 727 | applying_result = [] 728 | 729 | # access 730 | access_result = self._try_access() 731 | applying_result.append(access_result) 732 | 733 | # load 734 | if access_result['answer'] == self.ANSWER.APPROVED: 735 | try: 736 | normalized_actions: List[Action] = self._normalize( 737 | self.load(action=self._action, meta=self._meta)) 738 | applying_result.extend(normalized_actions) 739 | except Exception as load_err: # pylint: disable=broad-except 740 | applying_result.append({ 741 | 'answer': self.ANSWER.ERROR, 742 | 'id': self._meta.id, 743 | 'details': f'{load_err}' 744 | }) 745 | 746 | # processed 747 | applying_result.append({ 748 | 'answer': self.ANSWER.PROCESSED, 749 | 'id': self._meta.id 750 | }) 751 | 752 | return applying_result 753 | 754 | 755 | class UnknownAction(ActionCommand): 756 | """ Action for generation `unknownAction` error. 757 | Will be used and evaluated if actions dispatcher 758 | got unexpected action type. """ 759 | 760 | def access(self, action: Action, meta: Optional[Meta]) -> bool: 761 | return False 762 | 763 | def apply(self) -> LoguxValue: 764 | return [ 765 | { 766 | 'answer': self.ANSWER.UNKNOWN_ACTION, 767 | 'id': self._meta.id 768 | } 769 | ] 770 | 771 | 772 | class UnknownSubscription(ChannelCommand): 773 | """ Action for generation `unknownChannel` error. 774 | Will be used and evaluated if actions dispatcher 775 | got unexpected action type. """ 776 | 777 | channel_pattern = None 778 | 779 | def load(self, action: Action, meta: Meta) -> Action: 780 | return {} 781 | 782 | def access(self, action: Action, meta: Optional[Meta]) -> bool: 783 | return False 784 | 785 | def apply(self) -> LoguxValue: 786 | return [ 787 | { 788 | 'answer': self.ANSWER.UNKNOWN_CHANNEL, 789 | 'id': self._meta.id 790 | } 791 | ] 792 | -------------------------------------------------------------------------------- /logux/dispatchers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from typing import Dict, Union, Type 4 | 5 | from logux.core import ActionCommand, ChannelCommand, UnknownAction, UnknownSubscription 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class BaseActionDispatcher(ABC): 11 | """ Meta class for all Dispatchers """ 12 | 13 | @abstractmethod 14 | def register(self, action: ActionCommand): 15 | """ Add Action handler for Dispatcher """ 16 | raise NotImplementedError() 17 | 18 | 19 | class DefaultActionDispatcher(BaseActionDispatcher): 20 | """ Default logux Dispatcher for Actions """ 21 | _actions: Dict[str, Type[ActionCommand]] = {} 22 | 23 | def __str__(self): 24 | return ', '.join(self._actions) 25 | 26 | def __getitem__(self, action_type: str): 27 | return self._actions[action_type] 28 | 29 | def _action_is_valid(self, action: Type[ActionCommand]) -> bool: 30 | if not action.action_type: 31 | raise ValueError('`action_type` attribute is required for all Actions') 32 | 33 | if self.has_action(action.action_type): 34 | raise ValueError(f'`{action.action_type}` action type already registered') 35 | 36 | if getattr(action.access, '__isabstractmethod__', False): 37 | raise ValueError('`access` method is required') 38 | 39 | return True 40 | 41 | def has_action(self, action_type: str) -> bool: 42 | """ Check if Dispatcher has handler for particular action type """ 43 | return action_type in self._actions 44 | 45 | def register(self, action: Type[ActionCommand]): # type: ignore # noqa 46 | if self._action_is_valid(action): 47 | logger.info('registering action `%s`', action.action_type) 48 | self._actions[action.action_type] = action 49 | 50 | 51 | class DefaultChannelDispatcher(BaseActionDispatcher): 52 | """ Default logux Dispatcher for Channels """ 53 | _subs: Dict[str, Type[ChannelCommand]] = {} 54 | 55 | def __str__(self): 56 | return ', '.join(self._subs) 57 | 58 | def __getitem__(self, item: str) -> Union[Type[ChannelCommand], Type[UnknownAction]]: 59 | for sub in self._subs.values(): 60 | if sub.is_match(channel=item): 61 | return sub 62 | 63 | logger.warning("can't match channel name: %s", item) 64 | 65 | return UnknownSubscription 66 | 67 | def has_subscription(self, channel_pattern: str) -> bool: 68 | """ Check if Dispatcher has handler for a particular channel subscription type """ 69 | return channel_pattern in self._subs 70 | 71 | def _sub_is_valid(self, sub) -> bool: 72 | if not sub.channel_pattern: 73 | raise ValueError('`action_type` attribute is required for all Actions') 74 | 75 | if self.has_subscription(sub.channel_pattern): 76 | raise ValueError(f'subscription for channel `{sub.channel_pattern}` already registered') 77 | 78 | if getattr(sub.access, '__isabstractmethod__', False): 79 | raise ValueError('`access` method is required') 80 | 81 | if getattr(sub.load, '__isabstractmethod__', False): 82 | raise ValueError('`load` method is required') 83 | 84 | return True 85 | 86 | def register(self, action: Type[ChannelCommand]): # type: ignore # noqa 87 | if self._sub_is_valid(action) and action.channel_pattern is not None: 88 | logger.info('registering subscription for `%s`', action.channel_pattern) 89 | self._subs[action.channel_pattern] = action 90 | 91 | 92 | class DefaultDispatcher: 93 | """ Shortcut for actions and channels Dispatchers """ 94 | 95 | def __init__(self): 96 | self.actions = DefaultActionDispatcher() 97 | self.channels = DefaultChannelDispatcher() 98 | 99 | 100 | logux = DefaultDispatcher() 101 | 102 | __all__ = ['logux'] 103 | -------------------------------------------------------------------------------- /logux/exceptions.py: -------------------------------------------------------------------------------- 1 | class LoguxProxyException(Exception): 2 | """ Communication errors during acting with Logux Proxy server. """ 3 | pass 4 | 5 | 6 | class LoguxBadAuthException(Exception): 7 | """ Wrong AUTH command format or missing keys. """ 8 | pass 9 | 10 | 11 | class LoguxWrongLoadResultsException(Exception): 12 | """ `load` method of ChannelCommand returns invalid data. """ 13 | pass 14 | 15 | 16 | class LoguxProxyToManyWrongAuthException(Exception): 17 | """ Kind of bruteforce protection. Rises when server got to many wrong auth secrets. """ 18 | pass 19 | -------------------------------------------------------------------------------- /logux/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | 5 | CONFIG_DEFAULTS = { 6 | 'URL': 'http://localhost:31337', 7 | 'CONTROL_SECRET': None, 8 | 'AUTH_FUNC': None, 9 | 'SUBPROTOCOL': '1.0.0', 10 | 'SUPPORTS': '^1.0.0' 11 | } 12 | 13 | DEBUG = getattr(settings, 'DEBUG', True) 14 | 15 | if DEBUG: 16 | logging.basicConfig(level=logging.DEBUG, 17 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 18 | 19 | # Internal throttling settings, for bruteforce protection 20 | # https://github.com/logux/django/issues/6 21 | THROTTLE = { 22 | 'NUM_REQUESTS': 3, 23 | 'DURATION': 1 24 | } 25 | 26 | 27 | # TODO: find a way how to cache it for prod and do not cache it for tests 28 | # @lru_cache() 29 | def get_config(): 30 | """ Get default configs and marge it with a user's config """ 31 | user_config = getattr(settings, "LOGUX_CONFIG", {}) 32 | config = CONFIG_DEFAULTS.copy() 33 | config.update(user_config) 34 | 35 | if config['CONTROL_SECRET'] is None: 36 | raise ValueError("can't get CONTROL_SECRET") 37 | 38 | if config['AUTH_FUNC'] is None: 39 | raise ValueError('AUTH_FUNC is required! Set auth function in your settings.py') 40 | 41 | return config 42 | -------------------------------------------------------------------------------- /logux/throttling.py: -------------------------------------------------------------------------------- 1 | # This Throttle class was inspired by DRF: https://www.django-rest-framework.org/ 2 | 3 | import time 4 | from typing import List, Optional 5 | 6 | from django.core.cache import cache as default_cache 7 | from django.http import HttpRequest 8 | 9 | from logux import settings 10 | 11 | 12 | class Throttle: 13 | """ Rate throttling of requests. """ 14 | cache = default_cache 15 | key = None 16 | 17 | history: List[float] = [] 18 | 19 | def __init__(self): 20 | self.num_requests = settings.THROTTLE['NUM_REQUESTS'] 21 | self.duration = settings.THROTTLE['DURATION'] 22 | 23 | def allow_request(self, request: HttpRequest) -> bool: 24 | """ Returns True if request allows passing inside, otherwise False """ 25 | 26 | self.key = self.get_ident(request) 27 | if self.key is None: 28 | return True 29 | 30 | self.history = self.cache.get(self.key, []) 31 | 32 | now = time.time() 33 | 34 | # Drop any requests from the history which have now passed the 35 | # throttle duration 36 | while self.history and self.history[-1] <= now - self.duration: 37 | self.history.pop() 38 | if len(self.history) >= self.num_requests: 39 | return False 40 | 41 | return True 42 | 43 | def remember_bad_auth(self, when: float) -> None: 44 | """ Put identity + request timestamp into the cache """ 45 | 46 | self.history.insert(0, when) 47 | self.cache.set(self.key, self.history, self.duration) 48 | 49 | @staticmethod 50 | def get_ident(request: HttpRequest) -> Optional[str]: 51 | """ Get identity (IP address) of client from request """ 52 | xff = request.META.get('HTTP_X_FORWARDED_FOR') 53 | remote_addr = request.META.get('REMOTE_ADDR') 54 | return ''.join(xff.split()) if xff else remote_addr 55 | -------------------------------------------------------------------------------- /logux/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from logux.views import dispatch 4 | 5 | urlpatterns = [ 6 | path(r'', dispatch, name='logux-dispatch'), 7 | ] 8 | -------------------------------------------------------------------------------- /logux/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import autodiscover_modules 2 | 3 | _autodiscovered = False 4 | 5 | 6 | def autodiscover(): 7 | """ 8 | Auto-discover INSTALLED_APPS logux_actions.py modules and fail silently 9 | when not present. This forces an import on them to register any logux bits 10 | they may want. 11 | """ 12 | global _autodiscovered # pylint: disable=global-statement 13 | 14 | if _autodiscovered: 15 | return 16 | 17 | autodiscover_modules('logux_actions', 'logux_subscriptions') 18 | _autodiscovered = True 19 | -------------------------------------------------------------------------------- /logux/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from itertools import chain 5 | from typing import List, Tuple, Union 6 | 7 | from django.http import JsonResponse, HttpRequest, HttpResponse 8 | from django.views.decorators.csrf import csrf_exempt 9 | from django.views.decorators.http import require_http_methods 10 | 11 | from logux import settings 12 | from logux.core import AuthCommand, LoguxValue, UnknownAction, Command, LOGUX_SUBSCRIBE, \ 13 | protocol_version_is_supported 14 | from logux.dispatchers import logux 15 | from logux.exceptions import LoguxProxyException 16 | from logux.throttling import Throttle 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | # TODO: move it to settings 21 | # drop request if more then 3 successes auth per sec 22 | LOGUX_AUTH_RATE = 3 23 | 24 | default_throttle = Throttle() 25 | 26 | 27 | class LoguxRequest: 28 | """ LoguxValue is class for deserialized request from Logux Server proxy 29 | 30 | The constructor should extract common fields like `version` and `secret` and parse list of commands. 31 | 32 | By default, command parser will provide only AuthCommand implementation (with logux_auth function 33 | injection). Other Action should by parsed by consumer dispatcher. 34 | 35 | TODO: if the proxy secret is wrong send 403 36 | TODO: if the brute force check is fail send 429 37 | """ 38 | 39 | class CommandType: 40 | """ All possible Logux command types. 41 | https://logux.org/protocols/backend/spec/#requests """ 42 | AUTH = 'auth' 43 | ACTION = 'action' 44 | 45 | choices = [AUTH, ACTION] 46 | 47 | def __init__(self, request: HttpRequest) -> None: 48 | """ Construct the Command and check protocol version support. 49 | 50 | :param request: request with command from Logux Proxy 51 | :raises: base Exception if request protocol version is not supported by backend 52 | """ 53 | self.request = request 54 | self.throttle = default_throttle 55 | 56 | try: 57 | self._get_body(request) 58 | except (TypeError, ValueError) as err: 59 | logger.warning('Wrong body: %s', err) 60 | raise LoguxProxyException('Wrong body') from err 61 | 62 | if not protocol_version_is_supported(self.version): 63 | logger.warning('Unsupported protocol version: %s', self.version) 64 | raise LoguxProxyException('Back-end protocol version is not supported') 65 | 66 | self.commands: List[Command] = self._parse_commands() 67 | 68 | def _get_body(self, request: HttpRequest) -> None: 69 | _body = json.loads(request.body.decode('utf-8')) 70 | 71 | self.version: int = int(_body['version']) 72 | self.secret: str = _body['secret'] 73 | self.raw_commands = _body['commands'] 74 | 75 | def _parse_commands(self) -> List[Command]: 76 | commands: List[Command] = [] 77 | 78 | for cmd in self.raw_commands: 79 | cmd_type = cmd['command'] 80 | 81 | if cmd_type == self.CommandType.AUTH: 82 | logger.debug('got auth cmd: %s', cmd) 83 | commands.append(AuthCommand(cmd, settings.get_config()['AUTH_FUNC'])) 84 | 85 | elif cmd_type == self.CommandType.ACTION: 86 | logger.debug('got action: %s', cmd) 87 | action_type = cmd['action']['type'] 88 | 89 | # subscribe actions 90 | if action_type == LOGUX_SUBSCRIBE: 91 | channel = cmd['action']["channel"] 92 | logger.debug('got subscription for channel: %s', channel) 93 | commands.append(logux.channels[channel](cmd)) 94 | continue 95 | 96 | # custom actions 97 | if not logux.actions.has_action(action_type): 98 | logger.warning('unknown action: %s', action_type) 99 | commands.append(UnknownAction(cmd)) 100 | continue 101 | 102 | commands.append(logux.actions[action_type](cmd)) 103 | 104 | else: 105 | logger.warning('wrong command type: %s', cmd) 106 | err_msg = f'wrong command type: {cmd_type}, expected {self.CommandType.choices}' 107 | logger.warning(err_msg) 108 | logger.warning('command with wrong type will be ignored') 109 | 110 | return commands 111 | 112 | def _is_server_authenticated(self) -> bool: 113 | """ Check Logux proxy server secret """ 114 | return self.secret == settings.get_config()['CONTROL_SECRET'] 115 | 116 | def apply_commands(self) -> Tuple[int, Union[str, LoguxValue]]: 117 | """ Apply all actions commands one by one 118 | 119 | :return: HTTP code and List of command applying results or error message 120 | """ 121 | 122 | if not self.throttle.allow_request(self.request): 123 | err_msg = 'Too many wrong secret attempts' 124 | logger.warning(err_msg) 125 | return 429, err_msg 126 | 127 | if not self._is_server_authenticated(): 128 | self.throttle.remember_bad_auth(when=time.time()) 129 | err_msg = 'Wrong secret' 130 | logger.warning(err_msg) 131 | return 403, err_msg 132 | 133 | if len(self.commands) == 0: 134 | return 200, [ 135 | { 136 | 'answer': Command.ANSWER.ERROR, 137 | 'details': 'command list is empty' 138 | } 139 | ] 140 | 141 | return 200, list(filter(None, chain.from_iterable([cmd.apply() for cmd in self.commands]))) 142 | 143 | 144 | @csrf_exempt 145 | @require_http_methods(["POST"]) 146 | def dispatch(request: HttpRequest) -> HttpResponse: 147 | """ Entry point for all requests from Logux Proxy 148 | 149 | :param request: HTTP request from Logux Proxy server. 150 | 151 | :return: JSON response with results of commands applying 152 | """ 153 | try: 154 | status, commands_results = LoguxRequest(request).apply_commands() 155 | except LoguxProxyException as err: 156 | status, commands_results = (400, str(err)) 157 | 158 | if status != 200: 159 | r = HttpResponse(commands_results) 160 | r.status_code = status 161 | return r 162 | 163 | for cmd_res in commands_results: 164 | logger.debug(cmd_res) 165 | 166 | r = JsonResponse(commands_results, safe=False) 167 | r.status_code = status 168 | return r 169 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | ignore_errors = False 4 | ignore_missing_imports = True 5 | plugins = 6 | mypy_django_plugin.main 7 | 8 | [mypy.plugins.django-stubs] 9 | django_settings_module = "tests.test_project.test_settings" 10 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiohttp" 3 | version = "2.3.10" 4 | description = "Async http client/server framework (asyncio)" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.4.2" 8 | 9 | [package.dependencies] 10 | async-timeout = ">=1.2.0" 11 | chardet = "*" 12 | idna-ssl = ">=1.0.0" 13 | multidict = ">=4.0.0" 14 | yarl = ">=1.0.0" 15 | 16 | [[package]] 17 | name = "alabaster" 18 | version = "0.7.12" 19 | description = "A configurable sidebar-enabled Sphinx theme" 20 | category = "dev" 21 | optional = false 22 | python-versions = "*" 23 | 24 | [[package]] 25 | name = "appdirs" 26 | version = "1.4.4" 27 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 28 | category = "dev" 29 | optional = false 30 | python-versions = "*" 31 | 32 | [[package]] 33 | name = "asgiref" 34 | version = "3.3.1" 35 | description = "ASGI specs, helper code, and adapters" 36 | category = "main" 37 | optional = false 38 | python-versions = ">=3.5" 39 | 40 | [package.extras] 41 | tests = ["pytest", "pytest-asyncio"] 42 | 43 | [[package]] 44 | name = "astroid" 45 | version = "2.4.2" 46 | description = "An abstract syntax tree for Python with inference support." 47 | category = "dev" 48 | optional = false 49 | python-versions = ">=3.5" 50 | 51 | [package.dependencies] 52 | lazy-object-proxy = ">=1.4.0,<1.5.0" 53 | six = ">=1.12,<2.0" 54 | typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} 55 | wrapt = ">=1.11,<2.0" 56 | 57 | [[package]] 58 | name = "async-timeout" 59 | version = "3.0.1" 60 | description = "Timeout context manager for asyncio programs" 61 | category = "dev" 62 | optional = false 63 | python-versions = ">=3.5.3" 64 | 65 | [[package]] 66 | name = "attrs" 67 | version = "20.3.0" 68 | description = "Classes Without Boilerplate" 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 72 | 73 | [package.extras] 74 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 75 | docs = ["furo", "sphinx", "zope.interface"] 76 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 77 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 78 | 79 | [[package]] 80 | name = "babel" 81 | version = "2.9.0" 82 | description = "Internationalization utilities" 83 | category = "dev" 84 | optional = false 85 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 86 | 87 | [package.dependencies] 88 | pytz = ">=2015.7" 89 | 90 | [[package]] 91 | name = "black" 92 | version = "20.8b1" 93 | description = "The uncompromising code formatter." 94 | category = "dev" 95 | optional = false 96 | python-versions = ">=3.6" 97 | 98 | [package.dependencies] 99 | appdirs = "*" 100 | click = ">=7.1.2" 101 | mypy-extensions = ">=0.4.3" 102 | pathspec = ">=0.6,<1" 103 | regex = ">=2020.1.8" 104 | toml = ">=0.10.1" 105 | typed-ast = ">=1.4.0" 106 | typing-extensions = ">=3.7.4" 107 | 108 | [package.extras] 109 | colorama = ["colorama (>=0.4.3)"] 110 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 111 | 112 | [[package]] 113 | name = "cerberus" 114 | version = "1.3.2" 115 | description = "Lightweight, extensible schema and data validation tool for Python dictionaries." 116 | category = "dev" 117 | optional = false 118 | python-versions = ">=2.7" 119 | 120 | [[package]] 121 | name = "certifi" 122 | version = "2020.12.5" 123 | description = "Python package for providing Mozilla's CA Bundle." 124 | category = "main" 125 | optional = false 126 | python-versions = "*" 127 | 128 | [[package]] 129 | name = "chardet" 130 | version = "4.0.0" 131 | description = "Universal encoding detector for Python 2 and 3" 132 | category = "main" 133 | optional = false 134 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 135 | 136 | [[package]] 137 | name = "click" 138 | version = "7.1.2" 139 | description = "Composable command line interface toolkit" 140 | category = "dev" 141 | optional = false 142 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 143 | 144 | [[package]] 145 | name = "colorama" 146 | version = "0.4.4" 147 | description = "Cross-platform colored terminal text." 148 | category = "dev" 149 | optional = false 150 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 151 | 152 | [[package]] 153 | name = "coverage" 154 | version = "5.3.1" 155 | description = "Code coverage measurement for Python" 156 | category = "dev" 157 | optional = false 158 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 159 | 160 | [package.extras] 161 | toml = ["toml"] 162 | 163 | [[package]] 164 | name = "dephell" 165 | version = "0.8.3" 166 | description = "Dependency resolution for Python" 167 | category = "dev" 168 | optional = false 169 | python-versions = ">=3.6" 170 | 171 | [package.dependencies] 172 | aiohttp = "*" 173 | attrs = ">=19.2.0" 174 | cerberus = ">=1.3" 175 | certifi = "*" 176 | dephell-archive = ">=0.1.5" 177 | dephell-argparse = ">=0.1.1" 178 | dephell-changelogs = "*" 179 | dephell-discover = ">=0.2.6" 180 | dephell-licenses = ">=0.1.6" 181 | dephell-links = ">=0.1.4" 182 | dephell-markers = ">=1.0.0" 183 | dephell-pythons = ">=0.1.11" 184 | dephell-setuptools = ">=0.2.1" 185 | dephell-shells = ">=0.1.3" 186 | dephell-specifier = ">=0.1.7" 187 | dephell-venvs = ">=0.1.16" 188 | dephell-versioning = "*" 189 | jinja2 = "*" 190 | m2r = "*" 191 | packaging = "*" 192 | requests = "*" 193 | "ruamel.yaml" = "*" 194 | tomlkit = "*" 195 | yaspin = "*" 196 | 197 | [package.extras] 198 | full = ["aiofiles", "appdirs", "autopep8", "bowler", "colorama", "docker", "dockerpty", "fissix", "graphviz", "html5lib", "pygments", "python-gnupg", "tabulate", "yapf"] 199 | tests = ["aioresponses", "pytest", "requests-mock"] 200 | dev = ["aioresponses", "alabaster", "flake8-isort", "isort", "pygments-github-lexers", "pytest", "recommonmark", "requests-mock", "sphinx"] 201 | docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"] 202 | 203 | [[package]] 204 | name = "dephell-archive" 205 | version = "0.1.7" 206 | description = "pathlib for archives" 207 | category = "dev" 208 | optional = false 209 | python-versions = ">=3.6" 210 | 211 | [package.dependencies] 212 | attrs = "*" 213 | 214 | [[package]] 215 | name = "dephell-argparse" 216 | version = "0.1.3" 217 | description = "Argparse on steroids: groups, commands, colors." 218 | category = "dev" 219 | optional = false 220 | python-versions = ">=3.5" 221 | 222 | [[package]] 223 | name = "dephell-changelogs" 224 | version = "0.0.1" 225 | description = "Find changelog for github repository, local dir, parse changelog" 226 | category = "dev" 227 | optional = false 228 | python-versions = ">=3.5" 229 | 230 | [package.dependencies] 231 | requests = "*" 232 | 233 | [package.extras] 234 | dev = ["pytest", "pytest-xdist"] 235 | 236 | [[package]] 237 | name = "dephell-discover" 238 | version = "0.2.10" 239 | description = "Find project modules and data files (packages and package_data for setup.py)." 240 | category = "dev" 241 | optional = false 242 | python-versions = ">=3.5" 243 | 244 | [package.dependencies] 245 | attrs = "*" 246 | 247 | [[package]] 248 | name = "dephell-licenses" 249 | version = "0.1.7" 250 | description = "Get info about OSS licenses" 251 | category = "dev" 252 | optional = false 253 | python-versions = ">=3.5" 254 | 255 | [package.dependencies] 256 | attrs = "*" 257 | requests = "*" 258 | 259 | [[package]] 260 | name = "dephell-links" 261 | version = "0.1.5" 262 | description = "Parse dependency links" 263 | category = "dev" 264 | optional = false 265 | python-versions = ">=3.5" 266 | 267 | [package.dependencies] 268 | attrs = "*" 269 | 270 | [[package]] 271 | name = "dephell-markers" 272 | version = "1.0.3" 273 | description = "Work with environment markers (PEP-496)" 274 | category = "dev" 275 | optional = false 276 | python-versions = ">=3.5" 277 | 278 | [package.dependencies] 279 | attrs = "*" 280 | dephell-specifier = "*" 281 | packaging = "*" 282 | 283 | [[package]] 284 | name = "dephell-pythons" 285 | version = "0.1.15" 286 | description = "Work with python versions" 287 | category = "dev" 288 | optional = false 289 | python-versions = ">=3.6" 290 | 291 | [package.dependencies] 292 | attrs = "*" 293 | dephell-specifier = "*" 294 | packaging = "*" 295 | 296 | [[package]] 297 | name = "dephell-setuptools" 298 | version = "0.2.4" 299 | description = "Read metainfo from setup.py" 300 | category = "dev" 301 | optional = false 302 | python-versions = ">=3.5" 303 | 304 | [package.extras] 305 | dev = ["mypy", "pkginfo", "pytest"] 306 | 307 | [[package]] 308 | name = "dephell-shells" 309 | version = "0.1.5" 310 | description = "activate virtual environment for current shell" 311 | category = "dev" 312 | optional = false 313 | python-versions = ">=3.6" 314 | 315 | [package.dependencies] 316 | attrs = "*" 317 | pexpect = "*" 318 | shellingham = "*" 319 | 320 | [[package]] 321 | name = "dephell-specifier" 322 | version = "0.2.2" 323 | description = "Work with version specifiers." 324 | category = "dev" 325 | optional = false 326 | python-versions = ">=3.6" 327 | 328 | [package.dependencies] 329 | packaging = ">=17.1" 330 | 331 | [[package]] 332 | name = "dephell-venvs" 333 | version = "0.1.18" 334 | description = "Manage virtual environments" 335 | category = "dev" 336 | optional = false 337 | python-versions = ">=3.5" 338 | 339 | [package.dependencies] 340 | attrs = "*" 341 | dephell-pythons = "*" 342 | requests = "*" 343 | 344 | [[package]] 345 | name = "dephell-versioning" 346 | version = "0.1.2" 347 | description = "Library for bumping project version like a pro" 348 | category = "dev" 349 | optional = false 350 | python-versions = ">=3.6" 351 | 352 | [package.dependencies] 353 | packaging = "*" 354 | 355 | [[package]] 356 | name = "django" 357 | version = "3.1.4" 358 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 359 | category = "main" 360 | optional = false 361 | python-versions = ">=3.6" 362 | 363 | [package.dependencies] 364 | asgiref = ">=3.2.10,<4" 365 | pytz = "*" 366 | sqlparse = ">=0.2.2" 367 | 368 | [package.extras] 369 | argon2 = ["argon2-cffi (>=16.1.0)"] 370 | bcrypt = ["bcrypt"] 371 | 372 | [[package]] 373 | name = "django-stubs" 374 | version = "1.7.0" 375 | description = "Mypy stubs for Django" 376 | category = "dev" 377 | optional = false 378 | python-versions = ">=3.6" 379 | 380 | [package.dependencies] 381 | django = "*" 382 | mypy = ">=0.790" 383 | typing-extensions = "*" 384 | 385 | [[package]] 386 | name = "docutils" 387 | version = "0.16" 388 | description = "Docutils -- Python Documentation Utilities" 389 | category = "dev" 390 | optional = false 391 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 392 | 393 | [[package]] 394 | name = "flake8" 395 | version = "3.8.4" 396 | description = "the modular source code checker: pep8 pyflakes and co" 397 | category = "dev" 398 | optional = false 399 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 400 | 401 | [package.dependencies] 402 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 403 | mccabe = ">=0.6.0,<0.7.0" 404 | pycodestyle = ">=2.6.0a1,<2.7.0" 405 | pyflakes = ">=2.2.0,<2.3.0" 406 | 407 | [[package]] 408 | name = "idna" 409 | version = "2.10" 410 | description = "Internationalized Domain Names in Applications (IDNA)" 411 | category = "main" 412 | optional = false 413 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 414 | 415 | [[package]] 416 | name = "idna-ssl" 417 | version = "1.1.0" 418 | description = "Patch ssl.match_hostname for Unicode(idna) domains support" 419 | category = "dev" 420 | optional = false 421 | python-versions = "*" 422 | 423 | [package.dependencies] 424 | idna = ">=2.0" 425 | 426 | [[package]] 427 | name = "imagesize" 428 | version = "1.2.0" 429 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 430 | category = "dev" 431 | optional = false 432 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 433 | 434 | [[package]] 435 | name = "importlib-metadata" 436 | version = "3.3.0" 437 | description = "Read metadata from Python packages" 438 | category = "dev" 439 | optional = false 440 | python-versions = ">=3.6" 441 | 442 | [package.dependencies] 443 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 444 | zipp = ">=0.5" 445 | 446 | [package.extras] 447 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 448 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 449 | 450 | [[package]] 451 | name = "isort" 452 | version = "5.6.4" 453 | description = "A Python utility / library to sort Python imports." 454 | category = "dev" 455 | optional = false 456 | python-versions = ">=3.6,<4.0" 457 | 458 | [package.extras] 459 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 460 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 461 | colors = ["colorama (>=0.4.3,<0.5.0)"] 462 | 463 | [[package]] 464 | name = "jinja2" 465 | version = "2.11.2" 466 | description = "A very fast and expressive template engine." 467 | category = "dev" 468 | optional = false 469 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 470 | 471 | [package.dependencies] 472 | MarkupSafe = ">=0.23" 473 | 474 | [package.extras] 475 | i18n = ["Babel (>=0.8)"] 476 | 477 | [[package]] 478 | name = "lazy-object-proxy" 479 | version = "1.4.3" 480 | description = "A fast and thorough lazy object proxy." 481 | category = "dev" 482 | optional = false 483 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 484 | 485 | [[package]] 486 | name = "m2r" 487 | version = "0.2.1" 488 | description = "Markdown and reStructuredText in a single file." 489 | category = "dev" 490 | optional = false 491 | python-versions = "*" 492 | 493 | [package.dependencies] 494 | docutils = "*" 495 | mistune = "*" 496 | 497 | [[package]] 498 | name = "markupsafe" 499 | version = "1.1.1" 500 | description = "Safely add untrusted strings to HTML/XML markup." 501 | category = "dev" 502 | optional = false 503 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 504 | 505 | [[package]] 506 | name = "mccabe" 507 | version = "0.6.1" 508 | description = "McCabe checker, plugin for flake8" 509 | category = "dev" 510 | optional = false 511 | python-versions = "*" 512 | 513 | [[package]] 514 | name = "mistune" 515 | version = "0.8.4" 516 | description = "The fastest markdown parser in pure Python" 517 | category = "dev" 518 | optional = false 519 | python-versions = "*" 520 | 521 | [[package]] 522 | name = "multidict" 523 | version = "5.1.0" 524 | description = "multidict implementation" 525 | category = "dev" 526 | optional = false 527 | python-versions = ">=3.6" 528 | 529 | [[package]] 530 | name = "mypy" 531 | version = "0.790" 532 | description = "Optional static typing for Python" 533 | category = "dev" 534 | optional = false 535 | python-versions = ">=3.5" 536 | 537 | [package.dependencies] 538 | mypy-extensions = ">=0.4.3,<0.5.0" 539 | typed-ast = ">=1.4.0,<1.5.0" 540 | typing-extensions = ">=3.7.4" 541 | 542 | [package.extras] 543 | dmypy = ["psutil (>=4.0)"] 544 | 545 | [[package]] 546 | name = "mypy-extensions" 547 | version = "0.4.3" 548 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 549 | category = "dev" 550 | optional = false 551 | python-versions = "*" 552 | 553 | [[package]] 554 | name = "packaging" 555 | version = "20.8" 556 | description = "Core utilities for Python packages" 557 | category = "dev" 558 | optional = false 559 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 560 | 561 | [package.dependencies] 562 | pyparsing = ">=2.0.2" 563 | 564 | [[package]] 565 | name = "pathspec" 566 | version = "0.8.1" 567 | description = "Utility library for gitignore style pattern matching of file paths." 568 | category = "dev" 569 | optional = false 570 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 571 | 572 | [[package]] 573 | name = "pexpect" 574 | version = "4.8.0" 575 | description = "Pexpect allows easy control of interactive console applications." 576 | category = "dev" 577 | optional = false 578 | python-versions = "*" 579 | 580 | [package.dependencies] 581 | ptyprocess = ">=0.5" 582 | 583 | [[package]] 584 | name = "ptyprocess" 585 | version = "0.6.0" 586 | description = "Run a subprocess in a pseudo terminal" 587 | category = "dev" 588 | optional = false 589 | python-versions = "*" 590 | 591 | [[package]] 592 | name = "pycodestyle" 593 | version = "2.6.0" 594 | description = "Python style guide checker" 595 | category = "dev" 596 | optional = false 597 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 598 | 599 | [[package]] 600 | name = "pyflakes" 601 | version = "2.2.0" 602 | description = "passive checker of Python programs" 603 | category = "dev" 604 | optional = false 605 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 606 | 607 | [[package]] 608 | name = "pygments" 609 | version = "2.7.3" 610 | description = "Pygments is a syntax highlighting package written in Python." 611 | category = "dev" 612 | optional = false 613 | python-versions = ">=3.5" 614 | 615 | [[package]] 616 | name = "pylint" 617 | version = "2.6.0" 618 | description = "python code static checker" 619 | category = "dev" 620 | optional = false 621 | python-versions = ">=3.5.*" 622 | 623 | [package.dependencies] 624 | astroid = ">=2.4.0,<=2.5" 625 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 626 | isort = ">=4.2.5,<6" 627 | mccabe = ">=0.6,<0.7" 628 | toml = ">=0.7.1" 629 | 630 | [[package]] 631 | name = "pyparsing" 632 | version = "2.4.7" 633 | description = "Python parsing module" 634 | category = "dev" 635 | optional = false 636 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 637 | 638 | [[package]] 639 | name = "pytz" 640 | version = "2020.4" 641 | description = "World timezone definitions, modern and historical" 642 | category = "main" 643 | optional = false 644 | python-versions = "*" 645 | 646 | [[package]] 647 | name = "regex" 648 | version = "2020.11.13" 649 | description = "Alternative regular expression module, to replace re." 650 | category = "dev" 651 | optional = false 652 | python-versions = "*" 653 | 654 | [[package]] 655 | name = "requests" 656 | version = "2.25.1" 657 | description = "Python HTTP for Humans." 658 | category = "main" 659 | optional = false 660 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 661 | 662 | [package.dependencies] 663 | certifi = ">=2017.4.17" 664 | chardet = ">=3.0.2,<5" 665 | idna = ">=2.5,<3" 666 | urllib3 = ">=1.21.1,<1.27" 667 | 668 | [package.extras] 669 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] 670 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 671 | 672 | [[package]] 673 | name = "ruamel.yaml" 674 | version = "0.16.12" 675 | description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" 676 | category = "dev" 677 | optional = false 678 | python-versions = "*" 679 | 680 | [package.dependencies] 681 | "ruamel.yaml.clib" = {version = ">=0.1.2", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.9\""} 682 | 683 | [package.extras] 684 | docs = ["ryd"] 685 | jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] 686 | 687 | [[package]] 688 | name = "ruamel.yaml.clib" 689 | version = "0.2.2" 690 | description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" 691 | category = "dev" 692 | optional = false 693 | python-versions = "*" 694 | 695 | [[package]] 696 | name = "semantic-version" 697 | version = "2.8.5" 698 | description = "A library implementing the 'SemVer' scheme." 699 | category = "main" 700 | optional = false 701 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 702 | 703 | [[package]] 704 | name = "shellingham" 705 | version = "1.3.2" 706 | description = "Tool to Detect Surrounding Shell" 707 | category = "dev" 708 | optional = false 709 | python-versions = "!=3.0,!=3.1,!=3.2,!=3.3,>=2.6" 710 | 711 | [[package]] 712 | name = "six" 713 | version = "1.15.0" 714 | description = "Python 2 and 3 compatibility utilities" 715 | category = "dev" 716 | optional = false 717 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 718 | 719 | [[package]] 720 | name = "snowballstemmer" 721 | version = "2.0.0" 722 | description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." 723 | category = "dev" 724 | optional = false 725 | python-versions = "*" 726 | 727 | [[package]] 728 | name = "sphinx" 729 | version = "3.4.0" 730 | description = "Python documentation generator" 731 | category = "dev" 732 | optional = false 733 | python-versions = ">=3.5" 734 | 735 | [package.dependencies] 736 | alabaster = ">=0.7,<0.8" 737 | babel = ">=1.3" 738 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 739 | docutils = ">=0.12" 740 | imagesize = "*" 741 | Jinja2 = ">=2.3" 742 | packaging = "*" 743 | Pygments = ">=2.0" 744 | requests = ">=2.5.0" 745 | snowballstemmer = ">=1.1" 746 | sphinxcontrib-applehelp = "*" 747 | sphinxcontrib-devhelp = "*" 748 | sphinxcontrib-htmlhelp = "*" 749 | sphinxcontrib-jsmath = "*" 750 | sphinxcontrib-qthelp = "*" 751 | sphinxcontrib-serializinghtml = "*" 752 | 753 | [package.extras] 754 | docs = ["sphinxcontrib-websupport"] 755 | lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.790)", "docutils-stubs"] 756 | test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] 757 | 758 | [[package]] 759 | name = "sphinxcontrib-applehelp" 760 | version = "1.0.2" 761 | description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" 762 | category = "dev" 763 | optional = false 764 | python-versions = ">=3.5" 765 | 766 | [package.extras] 767 | lint = ["flake8", "mypy", "docutils-stubs"] 768 | test = ["pytest"] 769 | 770 | [[package]] 771 | name = "sphinxcontrib-devhelp" 772 | version = "1.0.2" 773 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 774 | category = "dev" 775 | optional = false 776 | python-versions = ">=3.5" 777 | 778 | [package.extras] 779 | lint = ["flake8", "mypy", "docutils-stubs"] 780 | test = ["pytest"] 781 | 782 | [[package]] 783 | name = "sphinxcontrib-htmlhelp" 784 | version = "1.0.3" 785 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 786 | category = "dev" 787 | optional = false 788 | python-versions = ">=3.5" 789 | 790 | [package.extras] 791 | lint = ["flake8", "mypy", "docutils-stubs"] 792 | test = ["pytest", "html5lib"] 793 | 794 | [[package]] 795 | name = "sphinxcontrib-jsmath" 796 | version = "1.0.1" 797 | description = "A sphinx extension which renders display math in HTML via JavaScript" 798 | category = "dev" 799 | optional = false 800 | python-versions = ">=3.5" 801 | 802 | [package.extras] 803 | test = ["pytest", "flake8", "mypy"] 804 | 805 | [[package]] 806 | name = "sphinxcontrib-qthelp" 807 | version = "1.0.3" 808 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 809 | category = "dev" 810 | optional = false 811 | python-versions = ">=3.5" 812 | 813 | [package.extras] 814 | lint = ["flake8", "mypy", "docutils-stubs"] 815 | test = ["pytest"] 816 | 817 | [[package]] 818 | name = "sphinxcontrib-serializinghtml" 819 | version = "1.1.4" 820 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 821 | category = "dev" 822 | optional = false 823 | python-versions = ">=3.5" 824 | 825 | [package.extras] 826 | lint = ["flake8", "mypy", "docutils-stubs"] 827 | test = ["pytest"] 828 | 829 | [[package]] 830 | name = "sqlparse" 831 | version = "0.4.1" 832 | description = "A non-validating SQL parser." 833 | category = "main" 834 | optional = false 835 | python-versions = ">=3.5" 836 | 837 | [[package]] 838 | name = "toml" 839 | version = "0.10.2" 840 | description = "Python Library for Tom's Obvious, Minimal Language" 841 | category = "dev" 842 | optional = false 843 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 844 | 845 | [[package]] 846 | name = "tomlkit" 847 | version = "0.7.0" 848 | description = "Style preserving TOML library" 849 | category = "dev" 850 | optional = false 851 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 852 | 853 | [[package]] 854 | name = "typed-ast" 855 | version = "1.4.1" 856 | description = "a fork of Python 2 and 3 ast modules with type comment support" 857 | category = "dev" 858 | optional = false 859 | python-versions = "*" 860 | 861 | [[package]] 862 | name = "typing-extensions" 863 | version = "3.7.4.3" 864 | description = "Backported and Experimental Type Hints for Python 3.5+" 865 | category = "dev" 866 | optional = false 867 | python-versions = "*" 868 | 869 | [[package]] 870 | name = "urllib3" 871 | version = "1.26.2" 872 | description = "HTTP library with thread-safe connection pooling, file post, and more." 873 | category = "main" 874 | optional = false 875 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 876 | 877 | [package.extras] 878 | brotli = ["brotlipy (>=0.6.0)"] 879 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 880 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 881 | 882 | [[package]] 883 | name = "wrapt" 884 | version = "1.12.1" 885 | description = "Module for decorators, wrappers and monkey patching." 886 | category = "dev" 887 | optional = false 888 | python-versions = "*" 889 | 890 | [[package]] 891 | name = "yarl" 892 | version = "1.6.3" 893 | description = "Yet another URL library" 894 | category = "dev" 895 | optional = false 896 | python-versions = ">=3.6" 897 | 898 | [package.dependencies] 899 | idna = ">=2.0" 900 | multidict = ">=4.0" 901 | typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} 902 | 903 | [[package]] 904 | name = "yaspin" 905 | version = "1.2.0" 906 | description = "Yet Another Terminal Spinner" 907 | category = "dev" 908 | optional = false 909 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 910 | 911 | [[package]] 912 | name = "zipp" 913 | version = "3.4.0" 914 | description = "Backport of pathlib-compatible object wrapper for zip files" 915 | category = "dev" 916 | optional = false 917 | python-versions = ">=3.6" 918 | 919 | [package.extras] 920 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 921 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 922 | 923 | [metadata] 924 | lock-version = "1.1" 925 | python-versions = "<4,^3.7" 926 | content-hash = "262599c483a0a241a5a54f7d3c045a71efec6812e6f1349131540b7d5d636ca9" 927 | 928 | [metadata.files] 929 | aiohttp = [ 930 | {file = "aiohttp-2.3.10-cp34-cp34m-macosx_10_10_x86_64.whl", hash = "sha256:834f687b806fbf49cb135b5a208b5c27338e19c219d6e09e9049936e01e8bea8"}, 931 | {file = "aiohttp-2.3.10-cp34-cp34m-macosx_10_11_x86_64.whl", hash = "sha256:6b8c5a00432b8a5a083792006e8fdfb558b8b10019ce254200855264d3a25895"}, 932 | {file = "aiohttp-2.3.10-cp34-cp34m-macosx_10_12_x86_64.whl", hash = "sha256:7b407c22b0ab473ffe0a7d3231f2084a8ae80fdb64a31842b88d57d6b7bdab7c"}, 933 | {file = "aiohttp-2.3.10-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:14821eb8613bfab9118be3c55afc87bf4cef97689896fa0874c6877b117afbeb"}, 934 | {file = "aiohttp-2.3.10-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:8f32a4e157bad9c60ebc38c3bb93fcc907a020b017ddf8f7ab1580390e21940e"}, 935 | {file = "aiohttp-2.3.10-cp34-cp34m-win32.whl", hash = "sha256:82a9068d9cb15eb2d99ecf39f8d56b4ed9f931a77a3622a0de747465fd2a7b96"}, 936 | {file = "aiohttp-2.3.10-cp34-cp34m-win_amd64.whl", hash = "sha256:7ac6378ae364d8e5e5260c7224ea4a1965cb6f4719f15d0552349d0b0cc93953"}, 937 | {file = "aiohttp-2.3.10-cp35-cp35m-macosx_10_10_x86_64.whl", hash = "sha256:5a952d4af7de5f78dfb3206dbc352717890b37d447f0bbd4b5969b3c8bb713af"}, 938 | {file = "aiohttp-2.3.10-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:b25c7720c495048ed658086a29925ab485ac7ececf1b346f2b459e5431d85016"}, 939 | {file = "aiohttp-2.3.10-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:528b0b811b6260a79222b055664b82093d01f35fe5c82521d8659cb2b28b8044"}, 940 | {file = "aiohttp-2.3.10-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:46ace48789865a89992419205024ae451d577876f9919fbb0f22f71189822dea"}, 941 | {file = "aiohttp-2.3.10-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:5436ca0ed752bb05a399fc07dc86dc23c756db523a3b7d5da46a457eacf4c4b5"}, 942 | {file = "aiohttp-2.3.10-cp35-cp35m-win32.whl", hash = "sha256:f5e7d41d924a1d5274060c467539ee0c4f3bab318c1671ad65abd91f6b637baf"}, 943 | {file = "aiohttp-2.3.10-cp35-cp35m-win_amd64.whl", hash = "sha256:a8c12f3184c7cad8f66cae6c945d2c97e598b0cb7afd655a5b9471475e67f30e"}, 944 | {file = "aiohttp-2.3.10-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:756fc336a29c551b02252685f01bc87116bc9b04bbd02c1a6b8a96b3c6ad713b"}, 945 | {file = "aiohttp-2.3.10-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:cf790e61c2af0278f39dcedad9a22532bf81fb029c2cd73b1ceba7bea062c908"}, 946 | {file = "aiohttp-2.3.10-cp36-cp36m-macosx_10_12_x86_64.whl", hash = "sha256:44c9cf24e63576244c13265ef0786b56d6751f5fb722225ecc021d6ecf91b4d2"}, 947 | {file = "aiohttp-2.3.10-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:ef1a36a16e72b6689ce0a6c7fc6bd88837d8fef4590b16bd72817644ae1f414d"}, 948 | {file = "aiohttp-2.3.10-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3a4cdb9ca87c099d8ba5eb91cb8f000b60c21f8c1b50c75e04e8777e903bd278"}, 949 | {file = "aiohttp-2.3.10-cp36-cp36m-win32.whl", hash = "sha256:f72bb19cece43483171264584bbaaf8b97717d9c0f244d1ef4a51df1cdb34085"}, 950 | {file = "aiohttp-2.3.10-cp36-cp36m-win_amd64.whl", hash = "sha256:c77e29243a79e376a1b51d71a13df4a61bc54fd4d9597ce3790b8d82ec6eb44d"}, 951 | {file = "aiohttp-2.3.10.tar.gz", hash = "sha256:8adda6583ba438a4c70693374e10b60168663ffa6564c5c75d3c7a9055290964"}, 952 | ] 953 | alabaster = [ 954 | {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, 955 | {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, 956 | ] 957 | appdirs = [ 958 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 959 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 960 | ] 961 | asgiref = [ 962 | {file = "asgiref-3.3.1-py3-none-any.whl", hash = "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17"}, 963 | {file = "asgiref-3.3.1.tar.gz", hash = "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"}, 964 | ] 965 | astroid = [ 966 | {file = "astroid-2.4.2-py3-none-any.whl", hash = "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"}, 967 | {file = "astroid-2.4.2.tar.gz", hash = "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703"}, 968 | ] 969 | async-timeout = [ 970 | {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, 971 | {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, 972 | ] 973 | attrs = [ 974 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 975 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 976 | ] 977 | babel = [ 978 | {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, 979 | {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, 980 | ] 981 | black = [ 982 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 983 | ] 984 | cerberus = [ 985 | {file = "Cerberus-1.3.2.tar.gz", hash = "sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"}, 986 | ] 987 | certifi = [ 988 | {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, 989 | {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, 990 | ] 991 | chardet = [ 992 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, 993 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, 994 | ] 995 | click = [ 996 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 997 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 998 | ] 999 | colorama = [ 1000 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 1001 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 1002 | ] 1003 | coverage = [ 1004 | {file = "coverage-5.3.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d"}, 1005 | {file = "coverage-5.3.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7"}, 1006 | {file = "coverage-5.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528"}, 1007 | {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044"}, 1008 | {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b"}, 1009 | {file = "coverage-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297"}, 1010 | {file = "coverage-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb"}, 1011 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899"}, 1012 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36"}, 1013 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500"}, 1014 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7"}, 1015 | {file = "coverage-5.3.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f"}, 1016 | {file = "coverage-5.3.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b"}, 1017 | {file = "coverage-5.3.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec"}, 1018 | {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714"}, 1019 | {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b"}, 1020 | {file = "coverage-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7"}, 1021 | {file = "coverage-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72"}, 1022 | {file = "coverage-5.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b"}, 1023 | {file = "coverage-5.3.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4"}, 1024 | {file = "coverage-5.3.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105"}, 1025 | {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448"}, 1026 | {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277"}, 1027 | {file = "coverage-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f"}, 1028 | {file = "coverage-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c"}, 1029 | {file = "coverage-5.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd"}, 1030 | {file = "coverage-5.3.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4"}, 1031 | {file = "coverage-5.3.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff"}, 1032 | {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8"}, 1033 | {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e"}, 1034 | {file = "coverage-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2"}, 1035 | {file = "coverage-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879"}, 1036 | {file = "coverage-5.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b"}, 1037 | {file = "coverage-5.3.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497"}, 1038 | {file = "coverage-5.3.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059"}, 1039 | {file = "coverage-5.3.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631"}, 1040 | {file = "coverage-5.3.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830"}, 1041 | {file = "coverage-5.3.1-cp38-cp38-win32.whl", hash = "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"}, 1042 | {file = "coverage-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606"}, 1043 | {file = "coverage-5.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f"}, 1044 | {file = "coverage-5.3.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1"}, 1045 | {file = "coverage-5.3.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8"}, 1046 | {file = "coverage-5.3.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4"}, 1047 | {file = "coverage-5.3.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d"}, 1048 | {file = "coverage-5.3.1-cp39-cp39-win32.whl", hash = "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98"}, 1049 | {file = "coverage-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1"}, 1050 | {file = "coverage-5.3.1-pp36-none-any.whl", hash = "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3"}, 1051 | {file = "coverage-5.3.1-pp37-none-any.whl", hash = "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c"}, 1052 | {file = "coverage-5.3.1.tar.gz", hash = "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b"}, 1053 | ] 1054 | dephell = [ 1055 | {file = "dephell-0.8.3-py3-none-any.whl", hash = "sha256:3ca3661e2a353b5c67c77034b69b379e360d4c70ce562e8161db32d39064be5a"}, 1056 | {file = "dephell-0.8.3.tar.gz", hash = "sha256:a9fcc528a0c6f9f5d721292bdf846e5338e4dca7cd6fef1551fbe71564dfe61e"}, 1057 | ] 1058 | dephell-archive = [ 1059 | {file = "dephell-archive-0.1.7.tar.gz", hash = "sha256:bb263492a7d430f9e04cef9a0237b7752cc797ab364bf35e70196af09c73ea37"}, 1060 | {file = "dephell_archive-0.1.7-py3-none-any.whl", hash = "sha256:64a688dd8acb780f7d56cdae4622fa01d1e5910fd65788974b3f70fa9a1e517a"}, 1061 | ] 1062 | dephell-argparse = [ 1063 | {file = "dephell_argparse-0.1.3-py3-none-any.whl", hash = "sha256:e37a52c511b53e9d6107b606088664754b4b4d9e734578b333e68c46e4ab45b7"}, 1064 | {file = "dephell_argparse-0.1.3.tar.gz", hash = "sha256:2ab9b2441f808bb11c338c4849d22ded898cde8325946800ac9e39d2b138735d"}, 1065 | ] 1066 | dephell-changelogs = [ 1067 | {file = "dephell_changelogs-0.0.1-py3-none-any.whl", hash = "sha256:963d31346790a3aacc3409bbc7cb0b44cdc0e29c167eec196fb49a131c3035b8"}, 1068 | {file = "dephell_changelogs-0.0.1.tar.gz", hash = "sha256:e639a3d08d389e22fbac0cc64181dbe93c4b4ba9f0134e273e6dd3e26ae70b21"}, 1069 | ] 1070 | dephell-discover = [ 1071 | {file = "dephell_discover-0.2.10-py3-none-any.whl", hash = "sha256:abf190e9707d4a88f14e91be1f80e996e195b20b5400da2362e98cf19e59a1e4"}, 1072 | {file = "dephell_discover-0.2.10.tar.gz", hash = "sha256:a2ad414e5e0fe16c82c537d6a3198afd9818c0c010760eccb23e2d60e5b66df6"}, 1073 | ] 1074 | dephell-licenses = [ 1075 | {file = "dephell-licenses-0.1.7.tar.gz", hash = "sha256:f175cec822a32bda5b56442f48dae39efbb5c3851275ecd41cfd7e849ddd2ea6"}, 1076 | {file = "dephell_licenses-0.1.7-py3-none-any.whl", hash = "sha256:b0b6c93779c4a8d9a82710ef2d5d0fab72e013f335962dc7363831af48570db5"}, 1077 | ] 1078 | dephell-links = [ 1079 | {file = "dephell_links-0.1.5-py3-none-any.whl", hash = "sha256:a86a08fb42da63d903ae3fee9f9e2491be602321204c0df5b53e33cb19ac4dec"}, 1080 | {file = "dephell_links-0.1.5.tar.gz", hash = "sha256:28d694142e2827a59d2c301e7185afb52fb8acdb950b1da38308d69e43418eaa"}, 1081 | ] 1082 | dephell-markers = [ 1083 | {file = "dephell_markers-1.0.3-py3-none-any.whl", hash = "sha256:54ad6807b087d6c9171efc2d94eda3a9e3cad7ea2ca4b27186789d455a6c730a"}, 1084 | {file = "dephell_markers-1.0.3.tar.gz", hash = "sha256:525e17914e705acf8652dd8681fccdec912432a747d8def4720f49416817f2d4"}, 1085 | ] 1086 | dephell-pythons = [ 1087 | {file = "dephell_pythons-0.1.15-py3-none-any.whl", hash = "sha256:03132d083d0369683b87d03767dc0f0f88b8d92d5cf19cfdb36d8845b70ecdb2"}, 1088 | {file = "dephell_pythons-0.1.15.tar.gz", hash = "sha256:804c29afa2147322aa23e791f591d0204fd1e9983afa7d91e1d1452fc7be1c5c"}, 1089 | ] 1090 | dephell-setuptools = [ 1091 | {file = "dephell_setuptools-0.2.4-py3-none-any.whl", hash = "sha256:275f9bec4b276614939ac9efa732a0ae6aef06ae63e3b62371d0f15a19299208"}, 1092 | {file = "dephell_setuptools-0.2.4.tar.gz", hash = "sha256:663629e1ebf7b20bf7e372ee2a2e7ebf1a15aeb3bc6d46ad32e1bcb21044ca29"}, 1093 | ] 1094 | dephell-shells = [ 1095 | {file = "dephell_shells-0.1.5-py3-none-any.whl", hash = "sha256:3bdb8aba72640c51259dc5cb0ee40c4cd948cb644e5ceedd7e725766575a5225"}, 1096 | {file = "dephell_shells-0.1.5.tar.gz", hash = "sha256:77150b732db135d436f41c2c6f12694e6058a8609214117ee80f6c40234ac2d5"}, 1097 | ] 1098 | dephell-specifier = [ 1099 | {file = "dephell_specifier-0.2.2-py3-none-any.whl", hash = "sha256:021ad2ab3f3f130b5ac5cefa554c12f0e2dbb35d5d52ad9474a1f2c8b420f7c2"}, 1100 | {file = "dephell_specifier-0.2.2.tar.gz", hash = "sha256:b5ec6409a1916980c4861da2cb7538246555bff4b95bef2c952c56bd19eb2de6"}, 1101 | ] 1102 | dephell-venvs = [ 1103 | {file = "dephell_venvs-0.1.18-py3-none-any.whl", hash = "sha256:bd3ad440702aa9a9dc21bbab9633537fa395296d40451280d40046d9e3372e6d"}, 1104 | {file = "dephell_venvs-0.1.18.tar.gz", hash = "sha256:c7307291b754edba325ab27edeb05d85ee4dd2f1487c48872a1ebfc372bf7a2e"}, 1105 | ] 1106 | dephell-versioning = [ 1107 | {file = "dephell_versioning-0.1.2-py3-none-any.whl", hash = "sha256:28f611bd3ec1644e3d6972f901b9aa67a1fe2ed3fe57566f82afd9c43f5a335a"}, 1108 | {file = "dephell_versioning-0.1.2.tar.gz", hash = "sha256:9ba7636704af7bd64af5a64ab8efb482c8b0bf4868699722f5e2647763edf8e5"}, 1109 | ] 1110 | django = [ 1111 | {file = "Django-3.1.4-py3-none-any.whl", hash = "sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2"}, 1112 | {file = "Django-3.1.4.tar.gz", hash = "sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"}, 1113 | ] 1114 | django-stubs = [ 1115 | {file = "django-stubs-1.7.0.tar.gz", hash = "sha256:ddd190aca5b9adb4d30760d5c64f67cb3658703f5f42c3bb0c2c71ff4d752c39"}, 1116 | {file = "django_stubs-1.7.0-py3-none-any.whl", hash = "sha256:30a7d99c694acf79c5d93d69a5a8e4b54d2a8c11dd672aa869006789e2189fa6"}, 1117 | ] 1118 | docutils = [ 1119 | {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, 1120 | {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, 1121 | ] 1122 | flake8 = [ 1123 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 1124 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 1125 | ] 1126 | idna = [ 1127 | {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, 1128 | {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, 1129 | ] 1130 | idna-ssl = [ 1131 | {file = "idna-ssl-1.1.0.tar.gz", hash = "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"}, 1132 | ] 1133 | imagesize = [ 1134 | {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, 1135 | {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, 1136 | ] 1137 | importlib-metadata = [ 1138 | {file = "importlib_metadata-3.3.0-py3-none-any.whl", hash = "sha256:bf792d480abbd5eda85794e4afb09dd538393f7d6e6ffef6e9f03d2014cf9450"}, 1139 | {file = "importlib_metadata-3.3.0.tar.gz", hash = "sha256:5c5a2720817414a6c41f0a49993908068243ae02c1635a228126519b509c8aed"}, 1140 | ] 1141 | isort = [ 1142 | {file = "isort-5.6.4-py3-none-any.whl", hash = "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7"}, 1143 | {file = "isort-5.6.4.tar.gz", hash = "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58"}, 1144 | ] 1145 | jinja2 = [ 1146 | {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, 1147 | {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, 1148 | ] 1149 | lazy-object-proxy = [ 1150 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, 1151 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, 1152 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, 1153 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, 1154 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, 1155 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, 1156 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, 1157 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, 1158 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, 1159 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, 1160 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, 1161 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, 1162 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, 1163 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, 1164 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, 1165 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, 1166 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, 1167 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, 1168 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, 1169 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, 1170 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, 1171 | ] 1172 | m2r = [ 1173 | {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, 1174 | ] 1175 | markupsafe = [ 1176 | {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, 1177 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, 1178 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, 1179 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, 1180 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, 1181 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, 1182 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, 1183 | {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, 1184 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, 1185 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, 1186 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, 1187 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, 1188 | {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, 1189 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, 1190 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, 1191 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, 1192 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, 1193 | {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, 1194 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, 1195 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, 1196 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, 1197 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, 1198 | {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, 1199 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, 1200 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, 1201 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, 1202 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, 1203 | {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, 1204 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, 1205 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, 1206 | {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, 1207 | {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, 1208 | {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, 1209 | ] 1210 | mccabe = [ 1211 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 1212 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 1213 | ] 1214 | mistune = [ 1215 | {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, 1216 | {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, 1217 | ] 1218 | multidict = [ 1219 | {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, 1220 | {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, 1221 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, 1222 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, 1223 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, 1224 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, 1225 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, 1226 | {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, 1227 | {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, 1228 | {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, 1229 | {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, 1230 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, 1231 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, 1232 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, 1233 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, 1234 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, 1235 | {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, 1236 | {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, 1237 | {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, 1238 | {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, 1239 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, 1240 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, 1241 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, 1242 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, 1243 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, 1244 | {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, 1245 | {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, 1246 | {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, 1247 | {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, 1248 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, 1249 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, 1250 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, 1251 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, 1252 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, 1253 | {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, 1254 | {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, 1255 | {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, 1256 | ] 1257 | mypy = [ 1258 | {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, 1259 | {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, 1260 | {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"}, 1261 | {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"}, 1262 | {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"}, 1263 | {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"}, 1264 | {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"}, 1265 | {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"}, 1266 | {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"}, 1267 | {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"}, 1268 | {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"}, 1269 | {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"}, 1270 | {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"}, 1271 | {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"}, 1272 | ] 1273 | mypy-extensions = [ 1274 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 1275 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 1276 | ] 1277 | packaging = [ 1278 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, 1279 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, 1280 | ] 1281 | pathspec = [ 1282 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 1283 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 1284 | ] 1285 | pexpect = [ 1286 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 1287 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 1288 | ] 1289 | ptyprocess = [ 1290 | {file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"}, 1291 | {file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"}, 1292 | ] 1293 | pycodestyle = [ 1294 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 1295 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 1296 | ] 1297 | pyflakes = [ 1298 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 1299 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 1300 | ] 1301 | pygments = [ 1302 | {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, 1303 | {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, 1304 | ] 1305 | pylint = [ 1306 | {file = "pylint-2.6.0-py3-none-any.whl", hash = "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f"}, 1307 | {file = "pylint-2.6.0.tar.gz", hash = "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210"}, 1308 | ] 1309 | pyparsing = [ 1310 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 1311 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 1312 | ] 1313 | pytz = [ 1314 | {file = "pytz-2020.4-py2.py3-none-any.whl", hash = "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"}, 1315 | {file = "pytz-2020.4.tar.gz", hash = "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268"}, 1316 | ] 1317 | regex = [ 1318 | {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, 1319 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, 1320 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, 1321 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, 1322 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, 1323 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, 1324 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, 1325 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, 1326 | {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, 1327 | {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, 1328 | {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, 1329 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, 1330 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, 1331 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, 1332 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, 1333 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, 1334 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, 1335 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, 1336 | {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, 1337 | {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, 1338 | {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, 1339 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, 1340 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, 1341 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, 1342 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, 1343 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, 1344 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, 1345 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, 1346 | {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, 1347 | {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, 1348 | {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, 1349 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, 1350 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, 1351 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, 1352 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, 1353 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, 1354 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, 1355 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, 1356 | {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, 1357 | {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, 1358 | {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, 1359 | ] 1360 | requests = [ 1361 | {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, 1362 | {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, 1363 | ] 1364 | "ruamel.yaml" = [ 1365 | {file = "ruamel.yaml-0.16.12-py2.py3-none-any.whl", hash = "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5"}, 1366 | {file = "ruamel.yaml-0.16.12.tar.gz", hash = "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e"}, 1367 | ] 1368 | "ruamel.yaml.clib" = [ 1369 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc"}, 1370 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1"}, 1371 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win32.whl", hash = "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7"}, 1372 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win_amd64.whl", hash = "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"}, 1373 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2"}, 1374 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026"}, 1375 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b"}, 1376 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1236df55e0f73cd138c0eca074ee086136c3f16a97c2ac719032c050f7e0622f"}, 1377 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win32.whl", hash = "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f"}, 1378 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62"}, 1379 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c"}, 1380 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988"}, 1381 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:2fd336a5c6415c82e2deb40d08c222087febe0aebe520f4d21910629018ab0f3"}, 1382 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win32.whl", hash = "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2"}, 1383 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91"}, 1384 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6"}, 1385 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e"}, 1386 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:75f0ee6839532e52a3a53f80ce64925ed4aed697dd3fa890c4c918f3304bd4f4"}, 1387 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win32.whl", hash = "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6"}, 1388 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5"}, 1389 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0"}, 1390 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99"}, 1391 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8be05be57dc5c7b4a0b24edcaa2f7275866d9c907725226cdde46da09367d923"}, 1392 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win32.whl", hash = "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1"}, 1393 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b"}, 1394 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a"}, 1395 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5"}, 1396 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1f8c0a4577c0e6c99d208de5c4d3fd8aceed9574bb154d7a2b21c16bb924154c"}, 1397 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-win32.whl", hash = "sha256:46d6d20815064e8bb023ea8628cfb7402c0f0e83de2c2227a88097e239a7dffd"}, 1398 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:6c0a5dc52fc74eb87c67374a4e554d4761fd42a4d01390b7e868b30d21f4b8bb"}, 1399 | {file = "ruamel.yaml.clib-0.2.2.tar.gz", hash = "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7"}, 1400 | ] 1401 | semantic-version = [ 1402 | {file = "semantic_version-2.8.5-py2.py3-none-any.whl", hash = "sha256:45e4b32ee9d6d70ba5f440ec8cc5221074c7f4b0e8918bdab748cc37912440a9"}, 1403 | {file = "semantic_version-2.8.5.tar.gz", hash = "sha256:d2cb2de0558762934679b9a104e82eca7af448c9f4974d1f3eeccff651df8a54"}, 1404 | ] 1405 | shellingham = [ 1406 | {file = "shellingham-1.3.2-py2.py3-none-any.whl", hash = "sha256:7f6206ae169dc1a03af8a138681b3f962ae61cc93ade84d0585cca3aaf770044"}, 1407 | {file = "shellingham-1.3.2.tar.gz", hash = "sha256:576c1982bea0ba82fb46c36feb951319d7f42214a82634233f58b40d858a751e"}, 1408 | ] 1409 | six = [ 1410 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 1411 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 1412 | ] 1413 | snowballstemmer = [ 1414 | {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, 1415 | {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, 1416 | ] 1417 | sphinx = [ 1418 | {file = "Sphinx-3.4.0-py3-none-any.whl", hash = "sha256:77c801947eb86457822e01eadd5c2e2de020db0201f1f9fc98b0927980b6d212"}, 1419 | {file = "Sphinx-3.4.0.tar.gz", hash = "sha256:4dcde313801f23ea4789ac31e5405e240cb758b5d375804807f2f3cc3c396bfa"}, 1420 | ] 1421 | sphinxcontrib-applehelp = [ 1422 | {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, 1423 | {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, 1424 | ] 1425 | sphinxcontrib-devhelp = [ 1426 | {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, 1427 | {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, 1428 | ] 1429 | sphinxcontrib-htmlhelp = [ 1430 | {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, 1431 | {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, 1432 | ] 1433 | sphinxcontrib-jsmath = [ 1434 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 1435 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 1436 | ] 1437 | sphinxcontrib-qthelp = [ 1438 | {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, 1439 | {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, 1440 | ] 1441 | sphinxcontrib-serializinghtml = [ 1442 | {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, 1443 | {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, 1444 | ] 1445 | sqlparse = [ 1446 | {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, 1447 | {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, 1448 | ] 1449 | toml = [ 1450 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1451 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1452 | ] 1453 | tomlkit = [ 1454 | {file = "tomlkit-0.7.0-py2.py3-none-any.whl", hash = "sha256:6babbd33b17d5c9691896b0e68159215a9387ebfa938aa3ac42f4a4beeb2b831"}, 1455 | {file = "tomlkit-0.7.0.tar.gz", hash = "sha256:ac57f29693fab3e309ea789252fcce3061e19110085aa31af5446ca749325618"}, 1456 | ] 1457 | typed-ast = [ 1458 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 1459 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 1460 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 1461 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 1462 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 1463 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 1464 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 1465 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, 1466 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 1467 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 1468 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 1469 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 1470 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 1471 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, 1472 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 1473 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 1474 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 1475 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 1476 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 1477 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, 1478 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 1479 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 1480 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 1481 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, 1482 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, 1483 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, 1484 | {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, 1485 | {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, 1486 | {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, 1487 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 1488 | ] 1489 | typing-extensions = [ 1490 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 1491 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 1492 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 1493 | ] 1494 | urllib3 = [ 1495 | {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, 1496 | {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, 1497 | ] 1498 | wrapt = [ 1499 | {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, 1500 | ] 1501 | yarl = [ 1502 | {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, 1503 | {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, 1504 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, 1505 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, 1506 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, 1507 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, 1508 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, 1509 | {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, 1510 | {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, 1511 | {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, 1512 | {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, 1513 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, 1514 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, 1515 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, 1516 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, 1517 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, 1518 | {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, 1519 | {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, 1520 | {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, 1521 | {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, 1522 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, 1523 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, 1524 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, 1525 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, 1526 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, 1527 | {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, 1528 | {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, 1529 | {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, 1530 | {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, 1531 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, 1532 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, 1533 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, 1534 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, 1535 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, 1536 | {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, 1537 | {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, 1538 | {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, 1539 | ] 1540 | yaspin = [ 1541 | {file = "yaspin-1.2.0-py2.py3-none-any.whl", hash = "sha256:2742b0648ce0d56d5c6fd950453f474c712cb88f161e54b40e987ba53a19b845"}, 1542 | {file = "yaspin-1.2.0.tar.gz", hash = "sha256:72e9cdbc0e797ef886c373fef2bcd6526a704a470696f9d78d0bb27951fe659a"}, 1543 | ] 1544 | zipp = [ 1545 | {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, 1546 | {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, 1547 | ] 1548 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | version = "2.0" 3 | 4 | name = "logux_django" 5 | homepage = "https://github.com/logux/django/" 6 | description = "Django Logux integration engine https://logux.org/" 7 | 8 | license = "MIT" 9 | 10 | packages = [ 11 | { include = "logux/**/*.py" }, 12 | ] 13 | 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Environment :: Web Environment", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3.7", 22 | ] 23 | 24 | authors = [ 25 | "Vadim Iskuchekov ", 26 | ] 27 | 28 | readme = "README.md" 29 | 30 | [tool.poetry.dependencies] 31 | python = "<4,^3.7" 32 | Django = "<4,>=2.2" 33 | requests = "<3,>=2.22.0" 34 | semantic-version = "^2.8.5" 35 | 36 | [tool.poetry.dev-dependencies] 37 | black = "^20.8b1" 38 | coverage = "^5.3.1" 39 | flake8 = "^3.8.4" 40 | mccabe = "^0.6.1" 41 | django-stubs = "^1.7.0" 42 | pylint = "^2.6.0" 43 | Sphinx = "^3.4.0" 44 | dephell = "^0.8.3" 45 | 46 | [build-system] 47 | requires = [ 48 | "poetry-core>=1.0.0", 49 | "dephell>=0.8.3" 50 | ] 51 | build-backend = "poetry.core.masonry.api" 52 | 53 | [tool.dephell.main] 54 | from = {format = "poetry", path = "pyproject.toml"} 55 | to = {format = "setuppy", path = "setup.py"} 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | 4 | # DO NOT EDIT THIS FILE! 5 | # This file has been autogenerated by dephell <3 6 | # https://github.com/dephell/dephell 7 | 8 | try: 9 | from setuptools import setup 10 | except ImportError: 11 | from distutils.core import setup 12 | 13 | 14 | import os.path 15 | 16 | readme = '' 17 | here = os.path.abspath(os.path.dirname(__file__)) 18 | readme_path = os.path.join(here, 'README.rst') 19 | if os.path.exists(readme_path): 20 | with open(readme_path, 'rb') as stream: 21 | readme = stream.read().decode('utf8') 22 | 23 | 24 | setup( 25 | long_description=readme, 26 | name='logux_django', 27 | version='2.0', 28 | description='Django Logux integration engine https://logux.org/', 29 | python_requires='<4,==3.*,>=3.7.0', 30 | project_urls={"homepage": "https://github.com/logux/django/"}, 31 | author='Vadim Iskuchekov', 32 | author_email='egregors@pm.me', 33 | license='MIT', 34 | classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3.7'], 35 | packages=['logux'], 36 | package_dir={"": "."}, 37 | package_data={}, 38 | install_requires=['django<4,>=2.2', 'requests<3,>=2.22.0', 'semantic-version==2.*,>=2.8.5'], 39 | extras_require={"dev": ["black==20.*,>=20.8.0.b1", "coverage==5.*,>=5.3.1", "dephell==0.*,>=0.8.3", "django-stubs==1.*,>=1.7.0", "flake8==3.*,>=3.8.4", "mccabe==0.*,>=0.6.1", "pylint==2.*,>=2.6.0", "sphinx==3.*,>=3.4.0"]}, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/lbt/README.md: -------------------------------------------------------------------------------- 1 | # lbt - logux backend-test 2 | 3 | Integration tests for Logux servers: https://github.com/logux/backend-test 4 | -------------------------------------------------------------------------------- /tests/lbt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "@logux/backend-test": "^4.0.11" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /tests/rest.http: -------------------------------------------------------------------------------- 1 | # 2 | # List of test requests from the Logux Proxy server to the Django backend. 3 | # Most of them taked from doc's: https://logux.org/protocols/backend/examples/ 4 | # 5 | # All of them are for the version 2 6 | 7 | # ### 8 | # Proxy Server auth: https://logux.org/protocols/backend/spec/#requests 9 | # 10 | 11 | # Proxy Server auth with correct secret. 12 | POST http://localhost:8000/logux/ 13 | Content-Type: application/json 14 | 15 | { 16 | "version": 4, 17 | "secret": "secret", 18 | "commands": [] 19 | } 20 | 21 | ### 22 | 23 | # Proxy Server auth with wrong secret. 24 | POST http://localhost:8000/logux/ 25 | Content-Type: application/json 26 | 27 | { 28 | "version": 4, 29 | "secret": "wrong secret", 30 | "commands": [] 31 | } 32 | 33 | ### 34 | 35 | # ### 36 | # Wrong command. Not Auth and not Action 37 | # 38 | 39 | # bad command type -> error (tests/test_project/settings.py:122) 40 | POST http://localhost:8000/logux/ 41 | Content-Type: application/json 42 | 43 | { 44 | "version": 4, 45 | "secret": "secret", 46 | "commands": [ 47 | [ 48 | "sup guys!", 49 | "foo", 50 | "bar", 51 | "gf4Ygi6grYZYDH5Z2BsoR" 52 | ] 53 | ] 54 | } 55 | 56 | ### 57 | 58 | # ### 59 | # "Auth" command: https://logux.org/protocols/backend/spec/#requests 60 | # 61 | 62 | # "auth" command -> authenticated (tests/test_project/settings.py:122) for token as cmd kay 63 | POST http://localhost:8000/logux/ 64 | Content-Type: application/json 65 | 66 | { 67 | "version": 4, 68 | "secret": "parole", 69 | "commands": [ 70 | { 71 | "command": "auth", 72 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 73 | "userId": "38", 74 | "token": "good-token", 75 | "subprotocol": "1.0.0" 76 | } 77 | ] 78 | } 79 | 80 | ### 81 | 82 | # "auth" command -> authenticated (tests/test_project/settings.py:122) for token in cookies 83 | POST http://localhost:8000/logux/ 84 | Content-Type: application/json 85 | 86 | { 87 | "version": 4, 88 | "secret": "secret", 89 | "commands": [ 90 | { 91 | "command": "auth", 92 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 93 | "userId": "38", 94 | "cookie": { 95 | "token": "good-token" 96 | } 97 | } 98 | ] 99 | } 100 | 101 | ### 102 | 103 | # "auth" command -> error (tests/test_project/settings.py:122) missing token 104 | POST http://localhost:8000/logux/ 105 | Content-Type: application/json 106 | 107 | { 108 | "version": 4, 109 | "secret": "secret", 110 | "commands": [ 111 | { 112 | "command": "auth", 113 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 114 | "userId": "38" 115 | } 116 | ] 117 | } 118 | 119 | ### 120 | 121 | # "auth" command -> denied (tests/test_project/settings.py:122) 122 | POST http://localhost:8000/logux/ 123 | Content-Type: application/json 124 | 125 | { 126 | "version": 4, 127 | "secret": "secret", 128 | "commands": [ 129 | { 130 | "command": "auth", 131 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 132 | "userId": "38", 133 | "token": "bad-token" 134 | } 135 | ] 136 | } 137 | 138 | ### 139 | 140 | # ### 141 | # "Action" command: https://logux.org/protocols/backend/spec/#requests 142 | # 143 | 144 | # "action" command -> authenticated + user rename (tests/test_project/settings.py:122) 145 | POST http://localhost:8000/logux/ 146 | Content-Type: application/json 147 | 148 | { 149 | "version": 3, 150 | "secret": "secret", 151 | "commands": [ 152 | [ 153 | "action", 154 | { 155 | "type": "user/rename", 156 | "user": 38, 157 | "name": "Ivan" 158 | }, 159 | { 160 | "id": "1560954012838 38:Y7bysd:O0ETfc 0", 161 | "time": 1560954012838 162 | } 163 | ], 164 | [ 165 | "action", 166 | { 167 | "type": "user/rename", 168 | "user": 21, 169 | "name": "Egor" 170 | }, 171 | { 172 | "id": "1560954012900 38:Y7bysd:O0ETfc 1", 173 | "time": 1560954012900 174 | } 175 | ] 176 | ] 177 | } 178 | 179 | ### 180 | 181 | # "action" command -> unknownAction 182 | POST http://localhost:8000/logux/ 183 | Content-Type: application/json 184 | 185 | { 186 | "version": 3, 187 | "secret": "secret", 188 | "commands": [ 189 | [ 190 | "action", 191 | { 192 | "type": "user/unknown", 193 | "user": 38, 194 | "name": "New" 195 | }, 196 | { 197 | "id": "1560954012838 38:Y7bysd:O0ETfc 0", 198 | "time": 1560954012838 199 | } 200 | ] 201 | ] 202 | } 203 | 204 | ### 205 | 206 | ### 207 | 208 | # "action" command -> channels 209 | POST http://localhost:8000/logux/ 210 | Content-Type: application/json 211 | 212 | { 213 | "version": 3, 214 | "secret": "secret", 215 | "commands": [ 216 | [ 217 | "action", 218 | { 219 | "type": "logux/subscribe", 220 | "channel": "user/38" 221 | }, 222 | { 223 | "id": "1560954012858 38:Y7bysd:O0ETfc 0", 224 | "time": 1560954012858 225 | } 226 | ] 227 | ] 228 | } 229 | 230 | ### 231 | 232 | # "action" command -> channels but with inexistent user (produce UNDO action) 233 | POST http://localhost:8000/logux/ 234 | Content-Type: application/json 235 | 236 | { 237 | "version": 3, 238 | "secret": "secret", 239 | "commands": [ 240 | [ 241 | "action", 242 | { 243 | "type": "logux/subscribe", 244 | "channel": "user/39" 245 | }, 246 | { 247 | "id": "1560954012858 39:Y7bysd:O0ETfc 0", 248 | "time": 1560954012858 249 | } 250 | ] 251 | ] 252 | } 253 | 254 | ### 255 | 256 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/django/c0b98d62f759986450a7b476a27139b13feb4277/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from tests.test_app.models import Cat 4 | 5 | 6 | class CatAdmin(admin.ModelAdmin): 7 | list_display = ( 8 | 'id', 9 | 'name', 10 | 'age', 11 | ) 12 | search_fields = ('name',) 13 | 14 | 15 | admin.site.register(Cat, CatAdmin) 16 | -------------------------------------------------------------------------------- /tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = 'test_app' 6 | verbose_name = 'Logux test app' 7 | -------------------------------------------------------------------------------- /tests/test_app/logux_actions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional, List 3 | 4 | from logux.core import ActionCommand, Meta, Action 5 | from logux.dispatchers import logux 6 | from logux.exceptions import LoguxProxyException 7 | from tests.test_app.models import User 8 | 9 | 10 | class RenameUserAction(ActionCommand): 11 | """ During the subscription to users/USER_ID channel sends { type: "users/name", payload: { userId, name } } 12 | action with the latest user’s name. """ 13 | action_type = 'users/name' 14 | 15 | def resend(self, action: Action, meta: Optional[Meta]) -> List[str]: 16 | return [f"users/{action['payload']['userId']}"] 17 | 18 | def access(self, action: Action, meta: Meta) -> bool: 19 | if 'error' in self.headers: 20 | raise LoguxProxyException(self.headers['error']) 21 | return action['payload']['userId'] == meta.user_id 22 | 23 | def process(self, action: Action, meta: Meta) -> None: 24 | user = User.objects.get(pk=action['payload']['userId']) 25 | first_name_meta = json.loads(user.first_name_meta) 26 | 27 | if not first_name_meta or Meta(first_name_meta).is_older(meta): 28 | user.first_name = action['payload']['name'] 29 | user.first_name_meta = meta.get_json() 30 | user.save() 31 | 32 | 33 | class CleanUserAction(ActionCommand): 34 | """ On users/clean action set all names to "" and sends users/name action with new name to all clients """ 35 | action_type = 'users/clean' 36 | 37 | def access(self, action: Action, meta: Meta) -> bool: 38 | if 'error' in self.headers: 39 | raise LoguxProxyException(self.headers['error']) 40 | return True 41 | 42 | def process(self, action: Action, meta: Meta) -> None: 43 | for u in User.objects.all(): 44 | u.first_name = '' 45 | u.save() 46 | self.send_back({ 47 | 'type': 'users/name', 48 | 'payload': 49 | { 50 | 'userId': str(u.id), 51 | 'name': str(u.first_name) 52 | } 53 | }) 54 | 55 | 56 | logux.actions.register(RenameUserAction) 57 | logux.actions.register(CleanUserAction) 58 | -------------------------------------------------------------------------------- /tests/test_app/logux_subscriptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from logux.core import ChannelCommand, Action, Meta 4 | from logux.dispatchers import logux 5 | from logux.exceptions import LoguxProxyException 6 | from tests.test_app.models import User 7 | 8 | 9 | class UserChannel(ChannelCommand): 10 | """ TODO: add docstring """ 11 | channel_pattern = r'^users/(?P\w+)$' 12 | 13 | def access(self, action: Action, meta: Meta) -> bool: 14 | return self.params['user_id'] == meta.user_id 15 | 16 | def load(self, action: Action, meta: Meta) -> Action: 17 | if 'error' in self.headers: 18 | raise LoguxProxyException(self.headers['error']) 19 | 20 | user, created = User.objects.get_or_create(id=self.params['user_id']) 21 | if created: 22 | user.first_name = 'Name' 23 | 24 | return { 25 | 'type': 'users/name', 26 | 'payload': {'userId': str(user.id), 'name': user.first_name} 27 | } 28 | 29 | 30 | logux.channels.register(UserChannel) 31 | -------------------------------------------------------------------------------- /tests/test_app/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/django/c0b98d62f759986450a7b476a27139b13feb4277/tests/test_app/management/__init__.py -------------------------------------------------------------------------------- /tests/test_app/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/django/c0b98d62f759986450a7b476a27139b13feb4277/tests/test_app/management/commands/__init__.py -------------------------------------------------------------------------------- /tests/test_app/management/commands/wipe_db.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from tests.test_app.models import User 4 | 5 | 6 | class Command(BaseCommand): 7 | """ Clean up db before tests """ 8 | 9 | def handle(self, *args, **options): 10 | User.objects.all().delete() 11 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.14 on 2020-07-23 03:04 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0011_update_proxy_permissions'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Cat', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=64, verbose_name='name')), 23 | ('age', models.IntegerField(verbose_name='age')), 24 | ], 25 | options={ 26 | 'verbose_name': 'cat', 27 | 'verbose_name_plural': 'cats', 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name='User', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('password', models.CharField(max_length=128, verbose_name='password')), 35 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 36 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 37 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 38 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 39 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 40 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 41 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 42 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 43 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 44 | ('first_name_meta', models.TextField(default='{}')), 45 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 46 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 47 | ], 48 | options={ 49 | 'verbose_name': 'user', 50 | 'verbose_name_plural': 'users', 51 | }, 52 | managers=[ 53 | ('objects', django.contrib.auth.models.UserManager()), 54 | ], 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0002_auto_20200908_0759.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-09-08 07:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('test_app', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='first_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/django/c0b98d62f759986450a7b476a27139b13feb4277/tests/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | class Cat(models.Model): 6 | name = models.CharField(verbose_name='name', max_length=64) 7 | age = models.IntegerField(verbose_name='age') 8 | 9 | class Meta: 10 | verbose_name = 'cat' 11 | verbose_name_plural = 'cats' 12 | 13 | def __str__(self): 14 | return f'Cat {self.name} {self.age} years old' 15 | 16 | 17 | class User(AbstractUser): 18 | first_name_meta = models.TextField(default='{}') 19 | 20 | class Meta: 21 | verbose_name = 'user' 22 | verbose_name_plural = 'users' 23 | -------------------------------------------------------------------------------- /tests/test_app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | TEST_CONTROL_SECRET = 'parole' 2 | -------------------------------------------------------------------------------- /tests/test_app/tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any 3 | 4 | from django.http import JsonResponse 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | 8 | PROTO_VER = 4 9 | 10 | 11 | class LoguxTestCase(TestCase): 12 | """ TestCase helper. Easy way to make Logux protocol requests """ 13 | 14 | def logux_request(self, data: Dict[str, Any]) -> JsonResponse: 15 | """ Logux request shortcut """ 16 | return json.loads(self.client.post( 17 | path=reverse('logux-dispatch'), 18 | data=data, 19 | content_type='application/json' 20 | ).content.decode('utf-8')) 21 | -------------------------------------------------------------------------------- /tests/test_app/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import JsonResponse 3 | from django.test import override_settings 4 | 5 | from logux.core import AuthCommand 6 | from tests.test_app.tests import TEST_CONTROL_SECRET 7 | from tests.test_app.tests.helpers import LoguxTestCase, PROTO_VER 8 | 9 | 10 | class LoguxAuthTestCase(LoguxTestCase): 11 | """ Auth command """ 12 | 13 | good_user_id = '42' 14 | good_token = f'{good_user_id}:good' 15 | 16 | def test_success_auth(self) -> None: 17 | """ Try to auth with: 18 | 19 | * token in body 20 | * token in cookie 21 | """ 22 | # token in body 23 | r: JsonResponse = self.logux_request({ 24 | "version": PROTO_VER, 25 | "secret": TEST_CONTROL_SECRET, 26 | "commands": [ 27 | { 28 | "command": "auth", 29 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 30 | "userId": "42", 31 | "subprotocol": "1.0.0", 32 | "token": self.good_token, 33 | } 34 | ] 35 | }) 36 | 37 | self.assertEqual(r[0]["answer"], AuthCommand.ANSWER.AUTHENTICATED) 38 | self.assertEqual(r[0]["authId"], 'gf4Ygi6grYZYDH5Z2BsoR') 39 | 40 | def test_denied_auth(self) -> None: 41 | """ Check denied auth. 42 | 43 | * bad token 44 | * bad token in cookie 45 | * missing token 46 | """ 47 | # bad token 48 | r: JsonResponse = self.logux_request({ 49 | "version": PROTO_VER, 50 | "secret": TEST_CONTROL_SECRET, 51 | "commands": [ 52 | { 53 | "command": "auth", 54 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 55 | "userId": "42", 56 | "subprotocol": "1.0.0", 57 | "token": "blablabla", 58 | } 59 | ] 60 | }) 61 | self.assertEqual(r[0]["answer"], AuthCommand.ANSWER.DENIED) 62 | self.assertEqual(r[0]["authId"], 'gf4Ygi6grYZYDH5Z2BsoR') 63 | 64 | 65 | @override_settings(LOGUX_CONFIG={ 66 | **settings.LOGUX_CONFIG, 67 | 'AUTH_FUNC': lambda user_id, token, cookie, headers: cookie['AuthPassword'] == 'good-token' 68 | }) 69 | class LoguxAuthWithCookieTestCase(LoguxTestCase): 70 | """ Auth command with token in the cookies """ 71 | good_token = 'good-token' 72 | good_user_id = '42' 73 | 74 | def test_success_auth_by_cookie_custom_key_name(self) -> None: 75 | """ Try to auth by token from the cookie with custom lookup key name. """ 76 | r: JsonResponse = self.logux_request( 77 | { 78 | "version": PROTO_VER, 79 | "secret": TEST_CONTROL_SECRET, 80 | "commands": [ 81 | { 82 | "command": "auth", 83 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 84 | "userId": "42", 85 | "subprotocol": "1.0.0", 86 | "cookie": { 87 | "AuthPassword": self.good_token, 88 | } 89 | } 90 | ] 91 | } 92 | ) 93 | self.assertEqual(r[0]["answer"], AuthCommand.ANSWER.AUTHENTICATED) 94 | self.assertEqual(r[0]["authId"], 'gf4Ygi6grYZYDH5Z2BsoR') 95 | 96 | def test_fail_auth_by_cookie_custom_key_name(self) -> None: 97 | """ Try to auth by token from the cookie with wrong lookup key name. """ 98 | r: JsonResponse = self.logux_request( 99 | { 100 | "version": PROTO_VER, 101 | "secret": TEST_CONTROL_SECRET, 102 | "commands": [ 103 | { 104 | "command": "auth", 105 | "authId": "gf4Ygi6grYZYDH5Z2BsoR", 106 | "userId": "42", 107 | "subprotocol": "1.0.0", 108 | "cookie": { 109 | "token": self.good_token, 110 | } 111 | } 112 | ] 113 | } 114 | ) 115 | 116 | self.assertEqual(r[0]["answer"], AuthCommand.ANSWER.ERROR) 117 | self.assertEqual(r[0]["authId"], 'gf4Ygi6grYZYDH5Z2BsoR') 118 | self.assertEqual(r[0]["details"], "missing auth token: 'AuthPassword'") 119 | -------------------------------------------------------------------------------- /tests/test_app/tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | 3 | from tests.test_app.tests import TEST_CONTROL_SECRET 4 | from tests.test_app.tests.helpers import LoguxTestCase, PROTO_VER 5 | 6 | 7 | class LoguxServerErrorsTestCase(LoguxTestCase): 8 | """ Check error formats """ 9 | 10 | def test_unknown_action(self): 11 | """ unknownAction """ 12 | r: JsonResponse = self.logux_request({ 13 | "version": PROTO_VER, 14 | "secret": TEST_CONTROL_SECRET, 15 | "commands": [ 16 | { 17 | "command": "action", 18 | "action": { 19 | "type": "user/unknown", 20 | "user": 38, 21 | "name": "New" 22 | }, 23 | "meta": { 24 | "id": "1560954012838 38:Y7bysd:O0ETfc 0", 25 | "time": 1560954012838 26 | } 27 | } 28 | ] 29 | }) 30 | self.assertEqual(r[0]['answer'], 'unknownAction', ) 31 | self.assertEqual(r[0]['id'], '1560954012838 38:Y7bysd:O0ETfc 0') 32 | -------------------------------------------------------------------------------- /tests/test_app/tests/test_meta.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from logux.core import Meta 4 | 5 | 6 | class MetaTestCase(TestCase): 7 | """ Tests for meta arithmetic. """ 8 | 9 | def test_entries_by_time(self): 10 | a = Meta({'id': '2 a 0', 'time': 2}) 11 | b = Meta({'id': '1 a 0', 'time': 1}) 12 | 13 | self.assertFalse(a.is_older(b)) 14 | self.assertTrue(b.is_older(a)) 15 | 16 | self.assertFalse(a > b) 17 | self.assertTrue(a < b) 18 | 19 | def test_entries_by_real_time(self): 20 | a = Meta({'id': '1 a 0', 'time': 2}) 21 | b = Meta({'id': '1 a 0', 'time': 1}) 22 | 23 | self.assertFalse(a.is_older(b)) 24 | self.assertTrue(b.is_older(a)) 25 | 26 | self.assertFalse(a > b) 27 | self.assertTrue(a < b) 28 | 29 | def test_entries_by_other_ID_parts(self): 30 | a = Meta({'id': '1 a 9', 'time': 1}) 31 | b = Meta({'id': '1 a 10', 'time': 1}) 32 | 33 | self.assertTrue(a.is_older(b)) 34 | self.assertFalse(b.is_older(a)) 35 | 36 | self.assertTrue(a > b) 37 | self.assertFalse(a < b) 38 | 39 | def test_entries_by_other_ID_parts_with_priority(self): 40 | a = Meta({'id': '1 b 1', 'time': 1}) 41 | b = Meta({'id': '1 a 1', 'time': 1}) 42 | 43 | self.assertFalse(a.is_older(b)) 44 | self.assertTrue(b.is_older(a)) 45 | 46 | self.assertFalse(a > b) 47 | self.assertTrue(a < b) 48 | 49 | def test_entries_with_same_time(self): 50 | a = Meta({'id': '2 a 0', 'time': 1}) 51 | b = Meta({'id': '1 a 0', 'time': 1}) 52 | 53 | self.assertFalse(a.is_older(b)) 54 | self.assertTrue(b.is_older(a)) 55 | 56 | self.assertFalse(a > b) 57 | self.assertTrue(a < b) 58 | 59 | def test_returns_false_for_same_entry(self): 60 | a = Meta({'id': '1 b 1', 'time': 1}) 61 | 62 | self.assertFalse(a.is_older(a)) 63 | 64 | self.assertFalse(a > a) 65 | self.assertFalse(a < a) 66 | 67 | def test_orders_entries_with_different_node_ID_length(self): 68 | a = Meta({'id': '1 11 1', 'time': 1}) 69 | b = Meta({'id': '1 1 2', 'time': 1}) 70 | 71 | self.assertFalse(a.is_older(b)) 72 | self.assertTrue(b.is_older(a)) 73 | 74 | self.assertFalse(a > b) 75 | self.assertTrue(a < b) 76 | 77 | def test_works_with_undefined_in_one_meta(self): 78 | a = Meta({'id': '1 a 0', 'time': 1}) 79 | 80 | self.assertFalse(a.is_older(None)) 81 | -------------------------------------------------------------------------------- /tests/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/logux/django/c0b98d62f759986450a7b476a27139b13feb4277/tests/test_project/__init__.py -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.12. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | from logux.exceptions import LoguxBadAuthException 17 | 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = 'v^6xug(15)bgfh0nns-vo5mk-p_l^is!bg9a=trs6w&)5!5=-s' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'logux', 42 | 43 | 'tests.test_app', 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'test_project.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'test_project.wsgi.application' 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 83 | } 84 | } 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 119 | 120 | STATIC_URL = '/static/' 121 | 122 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model 123 | AUTH_USER_MODEL = 'test_app.User' 124 | 125 | 126 | # Logux settings: https://logux.org/guide/starting/proxy-server/ 127 | def auth_func(user_id: str, token: str, cookie: dict, headers: dict) -> bool: 128 | """ Custom AUTH functions to pass https://github.com/logux/backend-test """ 129 | err = headers.pop('error', None) 130 | if err: 131 | raise LoguxBadAuthException(err) 132 | 133 | good_token = f'{user_id}:good' 134 | if token: 135 | return token == good_token 136 | 137 | return cookie['token'] == good_token 138 | 139 | 140 | # TODO: add to Doc: do not use passwords in the settings, use ENV instead 141 | LOGUX_CONFIG = { 142 | 'URL': 'http://localhost:31337/', 143 | 'CONTROL_SECRET': 'parole', 144 | 'AUTH_FUNC': auth_func if DEBUG else None, 145 | 'SUBPROTOCOL': '1.0.0', 146 | 'SUPPORTS': '^1.0.0' 147 | } 148 | -------------------------------------------------------------------------------- /tests/test_project/test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.12. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | # noqa 14 | # noinspection PyUnresolvedReferences 15 | from .settings import * 16 | 17 | DEBUG = False 18 | ALLOWED_HOSTS = ["*"] 19 | LOGGING = { 20 | 'version': 1, 21 | 'disable_existing_loggers': False, 22 | 'handlers': { 23 | 'console': { 24 | 'class': 'logging.StreamHandler', 25 | }, 26 | }, 27 | 'root': { 28 | 'handlers': ['console'], 29 | 'level': 'WARNING', 30 | }, 31 | 'loggers': { 32 | 'django': { 33 | 'handlers': ['console'], 34 | 'level': 'ERROR', 35 | 'propagate': False, 36 | }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | 22 | # logux URLs 23 | path(r'logux/', include('logux.urls')), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------