├── .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 | 
15 | [](https://badge.fury.io/py/logux-django)
16 | 
17 | 
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 |
--------------------------------------------------------------------------------