├── tests ├── __init__.py ├── requirements.txt ├── settings.py ├── test_compat.py ├── models.py ├── test_utils.py └── test_base.py ├── .gitignore ├── docs ├── changelog.rst ├── usage.rst ├── contributing.rst ├── introduction.rst ├── index.rst ├── Makefile ├── reference.rst └── conf.py ├── django-anon-recording.gif ├── tox.ini ├── Pipfile ├── PULL_REQUEST_TEMPLATE.md ├── .flake8 ├── ISSUE_TEMPLATE.md ├── .readthedocs.yml ├── runtests.py ├── setup.cfg ├── pyproject.toml ├── anon ├── __init__.py ├── compat.py ├── base.py └── utils.py ├── LICENSE ├── icon.svg ├── CHANGELOG.rst ├── .pre-commit-config.yaml ├── .github └── workflows │ └── main.yml ├── setup.py ├── CONTRIBUTING.rst ├── README.rst └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | django-bulk-update 2 | django-chunkator 3 | -------------------------------------------------------------------------------- /django-anon-recording.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tesorio/django-anon/HEAD/django-anon-recording.gif -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :start-after: start-usage 3 | :end-before: end-usage 4 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | :end-before: start-table-of-contents 3 | 4 | 5 | .. include:: ../CONTRIBUTING.rst 6 | :start-after: end-table-of-contents 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_APPS = ["tests"] 2 | 3 | SECRET_KEY = "j^owl8=_)2do0don9sk@5k7obl!vxm!_404wf%yvk9rp3@a83#" 4 | 5 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27-django{111}, py3-django{2,3} 3 | 4 | [testenv] 5 | deps = 6 | py27: mock 7 | django111: Django>=1.11,<2 8 | django2: Django>=2,<3 9 | django3: Django>=3,<4 10 | -r tests/requirements.txt 11 | 12 | commands = ./runtests.py 13 | 14 | setenv = 15 | PYTHONDONTWRITEBYTECODE=1 16 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | sphinx = "*" 8 | sphinx-rtd-theme = "*" 9 | sphinx-autobuild = "*" 10 | tox = "*" 11 | twine = "*" 12 | 13 | [packages] 14 | django-bulk-update = "*" 15 | django-chunkator = "*" 16 | 17 | [requires] 18 | python_version = "3.7" 19 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | .. include:: ../README.rst 5 | :end-before: start-features 6 | 7 | 8 | Features 9 | -------- 10 | 11 | .. include:: ../README.rst 12 | :start-after: start-features-table 13 | :end-before: end-features-table 14 | 15 | 16 | .. include:: ../README.rst 17 | :start-after: start-introduction 18 | :end-before: end-introduction 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :end-before: start-table-of-contents 3 | 4 | 5 | Table of Contents 6 | ================= 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | introduction 12 | usage 13 | reference 14 | contributing 15 | changelog 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## Description 6 | 7 | 10 | 11 | ## Todos 12 | 13 | - [ ] Tests 14 | - [ ] Documentation 15 | - [ ] Changelog 16 | -------------------------------------------------------------------------------- /tests/test_compat.py: -------------------------------------------------------------------------------- 1 | # deps 2 | from django.test import TestCase 3 | 4 | # local 5 | from anon.compat import bulk_update 6 | 7 | from . import models 8 | 9 | 10 | class CompatTestCase(TestCase): 11 | def test_call_bulk_update(self): 12 | obj = models.person_factory() 13 | obj.first_name = "xyz" 14 | 15 | bulk_update([obj], {"first_name"}, models.Person.objects) 16 | obj.refresh_from_db() 17 | self.assertEqual(obj.first_name, "xyz") 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E501: line too long error (this will be handled by Black) 3 | # E402: module level import not at top of file 4 | # W503: line break before binary operator (this is not PEP8-compliant) 5 | # E203: whitespace before ':' (this is not PEP8-compliant) 6 | # 7 | # More info about the comments above: https://github.com/psf/black 8 | ignore = 9 | E501, 10 | E402, 11 | W503, 12 | E203 13 | exclude = 14 | .venv* 15 | show-source = true 16 | statistics = true 17 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] I have verified that that issue exists against the `master` branch of django-anon 4 | - [ ] I have searched for similar issues in both open and closed tickets and cannot find a duplicate 5 | - [ ] I have reduced the issue to the simplest possible case 6 | - [ ] I have included a failing test as a pull request (If you are unable to do so we can still accept the issue) 7 | 8 | ## Steps to reproduce 9 | 10 | ## Expected behavior 11 | 12 | ## Actual behavior 13 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF and ePub 13 | formats: all 14 | 15 | # Optionally set the version of Python and requirements required to build your docs 16 | python: 17 | version: 3.7 18 | install: 19 | - method: pip 20 | path: . 21 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Recommended approach by Django: 3 | # https://docs.djangoproject.com/en/2.2/topics/testing/advanced/#using-the-django-test-runner-to-test-reusable-applications 4 | 5 | # stdlib 6 | import os 7 | import sys 8 | 9 | # deps 10 | import django 11 | 12 | 13 | if __name__ == "__main__": 14 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 15 | 16 | from django.conf import settings 17 | from django.test.utils import get_runner 18 | 19 | django.setup() 20 | 21 | TestRunner = get_runner(settings) 22 | test_runner = TestRunner() 23 | failures = test_runner.run_tests(["tests"]) 24 | sys.exit(bool(failures)) 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file(s) in the wheel. 3 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 4 | license_files = LICENSE 5 | 6 | [bdist_wheel] 7 | # This flag says to generate wheels that support both Python 2 and Python 8 | # 3. If your code will not run unchanged on both Python 2 and 3, you will 9 | # need to generate separate wheels for each Python version that you 10 | # support. Removing this line (or setting universal to 0) will prevent 11 | # bdist_wheel from trying to make a universal wheel. For more see: 12 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#wheels 13 | universal = 1 14 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | livehtml: 15 | sphinx-autobuild -b html "$(ALLSPHINXOPTS)" "$(BUILDDIR)/html" --watch ../ 16 | 17 | .PHONY: help Makefile 18 | 19 | # Catch-all target: route all unknown targets to Sphinx using the new 20 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 21 | %: Makefile 22 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py27'] 3 | exclude = ''' 4 | /( 5 | \.git 6 | | \.venv([23])? 7 | )/ 8 | ''' 9 | 10 | [tool.isort] 11 | known_django = 'django' 12 | known_first_party = ['anon'] 13 | sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER'] 14 | default_section = 'THIRDPARTY' 15 | import_heading_stdlib = 'stdlib' 16 | import_heading_thirdparty = 'deps' 17 | import_heading_firstparty = 'local' 18 | lines_after_imports = 2 19 | atomic = true 20 | combine_star = true 21 | skip = ['.git', '.venv2', '.venv3'] 22 | # These settings makes isort compatible with Black: 23 | # https://github.com/psf/black#how-black-wraps-lines 24 | multi_line_output = 3 25 | include_trailing_comma = true 26 | force_grid_wrap = false 27 | use_parentheses = true 28 | line_length = 88 29 | -------------------------------------------------------------------------------- /anon/__init__.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import sys 3 | 4 | 5 | __version__ = "0.3.2" 6 | 7 | try: 8 | from .base import BaseAnonymizer, lazy_attribute # noqa: F401 9 | from .utils import * # noqa: F401,F403 10 | 11 | except ImportError: 12 | # During setup.py not all dependencies may be installed, which may cause some 13 | # imports to fail. We still need to be able to import __init__ to check __version__, 14 | # as this is considered a good practice (having __version__ inside __init__) 15 | # 16 | # That's the cost of having shorthands like: 17 | # 18 | # >>> import anon 19 | # >>> anon.BaseAnonymizer 20 | # 21 | # versus having to import from base: 22 | # 23 | # >>> from anon.base import BaseAnonymizer 24 | # >>> BaseAnonymizer 25 | # 26 | if not sys.argv[0].endswith("setup.py"): 27 | raise 28 | -------------------------------------------------------------------------------- /anon/compat.py: -------------------------------------------------------------------------------- 1 | # deps 2 | from django_bulk_update.helper import bulk_update as ext_bulk_update 3 | 4 | 5 | def bulk_update(objects, fields, manager, **bulk_update_kwargs): 6 | """Updates the list of objects using django queryset's 7 | inbuilt ``.bulk_update()`` method if present, else 8 | django_bulk_update's ``bulk_update()`` will be used 9 | 10 | :param objects: list of objects that needs to be bulk updated 11 | :type objects: list[object] 12 | :param manager: instance of django model manager 13 | :type manager: models.Manager 14 | :param bulk_update_kwargs: keyword arguments passed to the ``bulk_update()`` 15 | :return: None 16 | :rtype: None 17 | """ 18 | try: 19 | manager.bulk_update(objects, fields, **bulk_update_kwargs) 20 | except AttributeError: 21 | ext_bulk_update(objects, update_fields=fields, **bulk_update_kwargs) 22 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | # deps 2 | from django.db import models 3 | 4 | 5 | class PersonAnotherQuerySet(models.QuerySet): 6 | pass 7 | 8 | 9 | class Person(models.Model): 10 | first_name = models.CharField(max_length=255) 11 | last_name = models.CharField(max_length=255) 12 | 13 | line1 = models.CharField(max_length=255) 14 | line2 = models.CharField(max_length=255) 15 | line3 = models.CharField(max_length=255) 16 | 17 | raw_data = models.TextField() 18 | 19 | another_manager = models.Manager.from_queryset(PersonAnotherQuerySet) 20 | 21 | 22 | def person_factory(**kwargs): 23 | kwargs.setdefault("first_name", "A") 24 | kwargs.setdefault("last_name", "B") 25 | kwargs.setdefault("line1", "X") 26 | kwargs.setdefault("line2", "Y") 27 | kwargs.setdefault("line3", "Z") 28 | kwargs.setdefault("raw_data", '{"access_token": "XYZ"}') 29 | 30 | return Person.objects.create(**kwargs) 31 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | anon.BaseAnonymizer 5 | ------------------- 6 | 7 | .. autoclass:: anon.BaseAnonymizer 8 | :members: 9 | 10 | 11 | anon.lazy_attribute 12 | ------------------- 13 | 14 | .. autofunction:: anon.lazy_attribute 15 | 16 | 17 | anon.fake_word 18 | -------------- 19 | 20 | .. autofunction:: anon.fake_word 21 | 22 | 23 | anon.fake_text 24 | -------------- 25 | 26 | .. autofunction:: anon.fake_text 27 | 28 | 29 | anon.fake_small_text 30 | -------------------- 31 | 32 | .. autofunction:: anon.fake_small_text 33 | 34 | 35 | anon.fake_name 36 | -------------- 37 | 38 | .. autofunction:: anon.fake_name 39 | 40 | 41 | anon.fake_username 42 | ------------------ 43 | 44 | .. autofunction:: anon.fake_username 45 | 46 | 47 | anon.fake_email 48 | --------------- 49 | 50 | .. autofunction:: anon.fake_email 51 | 52 | 53 | anon.fake_url 54 | ------------- 55 | 56 | .. autofunction:: anon.fake_url 57 | 58 | 59 | anon.fake_phone_number 60 | ---------------------- 61 | 62 | .. autofunction:: anon.fake_phone_number 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tesorio 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 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | **django-anon**'s release numbering works as follows: 5 | 6 | * Versions are numbered in the form **A.B** or **A.B.C.** 7 | * **A.B** is the feature release version number. Each version will be mostly backwards compatible with the previous release. Exceptions to this rule will be listed in the release notes. 8 | * **C** is the patch release version number, which is incremented for bugfix and security releases. These releases will be 100% backwards-compatible with the previous patch release. 9 | 10 | 11 | Releases 12 | -------- 13 | 14 | .. contents:: 15 | :local: 16 | 17 | 18 | master 19 | ~~~~~~ 20 | 21 | * Dropped support for Django 1.8 22 | * ... 23 | 24 | 25 | 0.3.2 26 | ~~~~~ 27 | 28 | * Fixed an infinite loop condition in ``fake_username`` when using the default empty separator 29 | 30 | 31 | 0.3.1 32 | ~~~~~ 33 | 34 | * Fixed bug that happens with newer versions of Django (> 2.2) #63 35 | 36 | 37 | 0.3 38 | ~~~ 39 | 40 | * Updated bulk_update method to use Django's built-in method if available 41 | * Changed default ``max_size`` for ``fake_email`` to ``40`` 42 | * Fixed error in ``fake_text`` when ``max_size`` is too short 43 | 44 | 45 | 0.2 46 | ~~~ 47 | 48 | * Added test for Django 3 using Python 3.7 in tox.ini 49 | * Improved performance of fake_text 50 | * Improved performance of BaseAnonymizer.patch_object 51 | * Fix bug with get_queryset not being treated as reserved name 52 | * Improved performance of fake_username 53 | * Removed rand_range argument from fake_username (backwards incompatible) 54 | * Changed select_chunk_size and update_batch_size to saner defaults 55 | 56 | 57 | 0.1 58 | ~~~ 59 | 60 | * Initial release 61 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import re 3 | 4 | # deps 5 | from django.test import TestCase 6 | 7 | # local 8 | from anon import utils 9 | 10 | 11 | class UtilsTestCase(TestCase): 12 | def test_fake_word(self): 13 | text = utils.fake_word(min_size=6) 14 | self.assertGreaterEqual(len(text), 6) 15 | 16 | def test_fake_text(self): 17 | text = utils.fake_text(40) 18 | self.assertLessEqual(len(text), 40) 19 | 20 | def test_fake_text_separator(self): 21 | text = utils.fake_text(40, separator="...") 22 | self.assertIn("...", text) 23 | 24 | def test_fake_name(self): 25 | text = utils.fake_name(15) 26 | self.assertLessEqual(len(text), 15) 27 | 28 | def test_fake_username(self): 29 | text = utils.fake_username(45, separator="_") 30 | self.assertIn("_", text) 31 | self.assertLessEqual(len(text), 45) 32 | 33 | def test_fake_email(self): 34 | text = utils.fake_email(20) 35 | self.assertLessEqual(len(text), 20) 36 | 37 | def test_fake_url(self): 38 | text = utils.fake_url(30, scheme="https://", suffix=".com.br") 39 | self.assertLessEqual(len(text), 30) 40 | self.assertTrue(text.startswith("https://")) 41 | self.assertIn(".com.br", text) 42 | 43 | def test_fake_phone_number(self): 44 | text = utils.fake_phone_number(format="(99) 9999-9999") 45 | self.assertTrue(bool(re.match(r"^\(\d{2}\) \d{4}-\d{4}$", text))) 46 | 47 | def test_trim_text_empty_separator(self): 48 | text = utils._trim_text(text="example", separator="", max_size=5) 49 | self.assertLessEqual(len(text), 5) 50 | 51 | def test_fake_text_short_length_trimming(self): 52 | text = utils.fake_text(4) 53 | self.assertLessEqual(len(text), 4) 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/pre-commit/pre-commit-hooks 2 | sha: v0.9.2 3 | hooks: 4 | - id: check-added-large-files 5 | args: ['--maxkb=500'] 6 | - id: check-byte-order-marker 7 | - id: check-case-conflict 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: debug-statements 11 | language_version: python2.7 12 | - id: detect-private-key 13 | 14 | - repo: https://github.com/psf/black 15 | sha: 19.10b0 16 | hooks: 17 | - id: black 18 | language_version: python3 19 | 20 | # We run black w/ --fast here but CI runs it with --safe 21 | entry: black --fast --check 22 | 23 | # Anytime the exclude rules below change, we also need to update the 24 | # pyproject.toml file to reflect the changes. This is because Black will not 25 | # respect this rules when running through pre-commit hook, because the pre-commit 26 | # hook passes filenames to black, which causes black to ignore any other 27 | # checks/exclusions. 28 | # 29 | # More info here: https://github.com/psf/black/issues/438 30 | exclude: \.git 31 | 32 | - repo: https://gitlab.com/pycqa/flake8 33 | rev: 3.7.8 34 | hooks: 35 | - id: flake8 36 | language_version: python2 37 | 38 | - repo: https://github.com/timothycrosley/isort 39 | rev: 4.3.21 40 | hooks: 41 | - id: isort 42 | entry: isort --check-only 43 | language_version: python3 44 | additional_dependencies: ["toml"] 45 | 46 | # Anytime the exclude rules below change, we also need to update the .isort.cfg 47 | # file to reflect the changes. This is because isort will not respect this rules 48 | # when running through pre-commit hook, because the pre-commit hook passes 49 | # filenames to isort, which causes isort to ignore any other checks/exclusions. 50 | # 51 | exclude: \.git 52 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | # Run every sunday 10 | - cron: '0 0 * * 0' 11 | 12 | jobs: 13 | 14 | ######## 15 | # Test # 16 | ######## 17 | 18 | test: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | python-version: 23 | - py27-django111 24 | - py3-django2 25 | - py3-django3 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: actions/setup-python@v2 30 | with: 31 | python-version: "3.7" 32 | 33 | - run: pip install tox 34 | - run: tox -e ${{ matrix.python-version }} 35 | 36 | ############ 37 | # Coverage # 38 | ############ 39 | 40 | coverage: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/setup-python@v1 44 | - uses: actions/checkout@v1 45 | 46 | - run: pip install coverage 47 | 48 | - run: python setup.py install 49 | - run: coverage run runtests.py 50 | - run: coverage xml 51 | 52 | - name: Coverage monitor 53 | uses: 5monkeys/cobertura-action@master 54 | with: 55 | repo_token: ${{ secrets.GITHUB_TOKEN }} 56 | path: coverage.xml 57 | minimum_coverage: 50 58 | 59 | 60 | ######### 61 | # Black # 62 | ######### 63 | 64 | black: 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v2 68 | - uses: actions/setup-python@v2 69 | with: 70 | python-version: "3.7" 71 | 72 | - run: python -m pip install black==19.10b0 73 | - run: black --safe --check --diff . 74 | 75 | 76 | ########## 77 | # Flake8 # 78 | ########## 79 | 80 | flake8: 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v2 84 | - uses: actions/setup-python@v2 85 | with: 86 | python-version: "2.7" 87 | 88 | - run: python -m pip install flake8==3.3.0 89 | - run: flake8 --config=.flake8 . 90 | 91 | 92 | ########## 93 | # isort # 94 | ########## 95 | 96 | isort: 97 | runs-on: ubuntu-latest 98 | steps: 99 | - uses: actions/checkout@v2 100 | - uses: actions/setup-python@v2 101 | with: 102 | python-version: "3.7" 103 | 104 | - run: python -m pip install isort[pyproject]==4.3.21 105 | - run: isort -rc --check-only . 106 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import os 3 | 4 | # deps 5 | from setuptools import Command, find_packages, setup 6 | 7 | 8 | # Dynamically calculate the version based on anon.VERSION 9 | VERSION = __import__("anon").__version__ 10 | 11 | 12 | with open("README.rst") as readme_file: 13 | 14 | def remove_banner(readme): 15 | # Since PyPI does not support raw directives, we remove them from the README 16 | # 17 | # raw directives are only used to make README fancier on GitHub and do not 18 | # contain relevant information to be displayed in PyPI, as they are not tied 19 | # to the current version, but to the current development status 20 | out = [] 21 | lines = iter(readme.splitlines(True)) 22 | for line in lines: 23 | if line.startswith(".. BANNERSTART"): 24 | for line in lines: 25 | if line.strip() == ".. BANNEREND": 26 | break 27 | else: 28 | out.append(line) 29 | return "".join(out) 30 | 31 | README = remove_banner(readme_file.read()) 32 | 33 | 34 | class PublishCommand(Command): 35 | description = "Publish to PyPI" 36 | user_options = [] 37 | 38 | def run(self): 39 | # Build & Upload 40 | # https://packaging.python.org/tutorials/packaging-projects/#generating-distribution-archives 41 | # https://packaging.python.org/tutorials/packaging-projects/#uploading-the-distribution-archives 42 | os.system("python setup.py sdist bdist_wheel") 43 | os.system("twine upload dist/*") 44 | 45 | def initialize_options(self): 46 | pass 47 | 48 | def finalize_options(self): 49 | pass 50 | 51 | 52 | class CreateTagCommand(Command): 53 | description = "Create release tag" 54 | user_options = [] 55 | 56 | def run(self): 57 | os.system("git tag -a %s -m 'v%s'" % (VERSION, VERSION)) 58 | os.system("git push --tags") 59 | 60 | def initialize_options(self): 61 | pass 62 | 63 | def finalize_options(self): 64 | pass 65 | 66 | 67 | setup( 68 | name="django-anon", 69 | version=VERSION, 70 | packages=find_packages(), 71 | install_requires=["django-bulk-update", "django-chunkator<2"], 72 | cmdclass={"publish": PublishCommand, "tag": CreateTagCommand}, 73 | # metadata for upload to PyPI 74 | description="Anonymize production data so it can be safely used in not-so-safe environments", 75 | long_description=README, 76 | long_description_content_type="text/x-rst", 77 | author="Tesorio", 78 | author_email="hello@tesorio.com", 79 | url="https://github.com/Tesorio/django-anon", 80 | license="MIT", 81 | platforms=["any"], 82 | classifiers=[ 83 | "Development Status :: 5 - Production/Stable", 84 | "Intended Audience :: Developers", 85 | "License :: OSI Approved :: MIT License", 86 | "Operating System :: OS Independent", 87 | "Framework :: Django", 88 | "Programming Language :: Python", 89 | "Programming Language :: Python :: 2", 90 | "Programming Language :: Python :: 3", 91 | ], 92 | ) 93 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | # deps 2 | from django.test import TestCase 3 | 4 | # local 5 | import anon 6 | 7 | from . import models 8 | 9 | 10 | class BaseAnonymizer(anon.BaseAnonymizer): 11 | class Meta: 12 | pass 13 | 14 | def __init__(self): 15 | """ 16 | The reason we set declarations manually in ``__init__`` is because we want to 17 | patch objects individually for unit testing purposes, without using the 18 | ``run()`` method. In general cases, declarations will be set by ``run()`` 19 | """ 20 | self._declarations = self.get_declarations() 21 | 22 | 23 | class BaseTestCase(TestCase): 24 | def test_get_meta(self): 25 | class Anon(BaseAnonymizer): 26 | class Meta: 27 | pass 28 | 29 | anonymizer = Anon() 30 | self.assertIsInstance(anonymizer._meta, Anon.Meta) 31 | 32 | def test_get_manager(self): 33 | class Anon(BaseAnonymizer): 34 | class Meta: 35 | model = models.Person 36 | manager = models.PersonAnotherQuerySet 37 | 38 | anonymizer = Anon() 39 | self.assertEqual(anonymizer.get_manager(), models.PersonAnotherQuerySet) 40 | 41 | def test_get_queryset(self): 42 | sample_obj = models.person_factory() 43 | 44 | class Anon(BaseAnonymizer): 45 | class Meta: 46 | model = models.Person 47 | 48 | anonymizer = Anon() 49 | result = anonymizer.get_queryset() 50 | self.assertSequenceEqual(result, [sample_obj]) 51 | 52 | def test_patch_object(self): 53 | fake_first_name = lambda: "foo" # noqa: E731 54 | 55 | class Anon(BaseAnonymizer): 56 | first_name = fake_first_name 57 | last_name = fake_first_name 58 | raw_data = "{1: 2}" 59 | 60 | obj = models.person_factory(last_name="") # empty data should be kept empty 61 | 62 | anonymizer = Anon() 63 | anonymizer.patch_object(obj) 64 | self.assertEqual(obj.first_name, "foo") 65 | self.assertEqual(obj.last_name, "") 66 | self.assertEqual(obj.raw_data, "{1: 2}") 67 | 68 | def test_run(self): 69 | class Anon(BaseAnonymizer): 70 | class Meta: 71 | model = models.Person 72 | update_batch_size = 42 73 | 74 | first_name = "xyz" 75 | 76 | obj = models.person_factory() 77 | 78 | anonymizer = Anon() 79 | anonymizer.run() 80 | 81 | obj.refresh_from_db() 82 | self.assertEqual(obj.first_name, "xyz") 83 | 84 | def test_run_without_fields(self): 85 | class Anon(BaseAnonymizer): 86 | def clean(self, obj): 87 | obj.first_name = "xyz" 88 | obj.save() 89 | 90 | class Meta: 91 | model = models.Person 92 | 93 | obj = models.person_factory() 94 | 95 | anonymizer = Anon() 96 | anonymizer.run() 97 | 98 | obj.refresh_from_db() 99 | self.assertEqual(obj.first_name, "xyz") 100 | 101 | def test_lazy_attribute(self): 102 | fake_first_name = anon.lazy_attribute(lambda o: o.last_name) 103 | 104 | class Anon(BaseAnonymizer): 105 | first_name = fake_first_name 106 | 107 | obj = models.person_factory() 108 | 109 | anonymizer = Anon() 110 | anonymizer.patch_object(obj) 111 | self.assertEqual(obj.first_name, obj.last_name) 112 | 113 | def test_lazy_attribute_decorator(self): 114 | class Anon(BaseAnonymizer): 115 | @anon.lazy_attribute 116 | def first_name(self): 117 | return "xyz" 118 | 119 | obj = models.person_factory() 120 | 121 | anonymizer = Anon() 122 | anonymizer.patch_object(obj) 123 | self.assertEqual(obj.first_name, "xyz") 124 | 125 | def test_raw_attributes(self): 126 | class Anon(BaseAnonymizer): 127 | raw_data = "{}" 128 | 129 | obj = models.person_factory() 130 | 131 | anonymizer = Anon() 132 | anonymizer.patch_object(obj) 133 | self.assertEqual(obj.raw_data, "{}") 134 | 135 | def test_clean(self): 136 | class Anon(BaseAnonymizer): 137 | line1 = "" 138 | line2 = "" 139 | line3 = "" 140 | 141 | def clean(self, obj): 142 | obj.line1 = "foo" 143 | obj.line2 = "bar" 144 | 145 | obj = models.person_factory() 146 | 147 | anonymizer = Anon() 148 | anonymizer.patch_object(obj) 149 | self.assertEqual(obj.line1, "foo") 150 | self.assertEqual(obj.line2, "bar") 151 | self.assertEqual(obj.line3, "") 152 | 153 | def test_get_declarations(self): 154 | # Ensure the order is preserved 155 | class Anon(BaseAnonymizer): 156 | a = anon.lazy_attribute(lambda o: 4) 157 | c = anon.lazy_attribute(lambda o: 6) 158 | b = anon.lazy_attribute(lambda o: 5) 159 | 160 | def get_queryset(self): 161 | return [] 162 | 163 | anonymizer = Anon() 164 | self.assertEqual(list(anonymizer.get_declarations().keys()), ["a", "c", "b"]) 165 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | 12 | # stdlib 13 | import os 14 | import sys 15 | 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # 21 | sys.path.insert(0, os.path.dirname(os.path.abspath("."))) 22 | 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = u"django-anon" 27 | copyright = u"2019, Tesorio" 28 | author = u"Tesorio" 29 | 30 | # The short X.Y version 31 | version = u"" 32 | # The full version, including alpha/beta/rc tags 33 | release = u"" 34 | 35 | 36 | # -- General configuration --------------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | # 40 | # needs_sphinx = '1.0' 41 | 42 | # Add any Sphinx extension module names here, as strings. They can be 43 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 44 | # ones. 45 | extensions = [ 46 | "sphinx.ext.autodoc", 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ["_templates"] 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ".rst" 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path. 71 | exclude_patterns = [u"_build", "Thumbs.db", ".DS_Store"] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = None 75 | 76 | 77 | # -- Options for HTML output ------------------------------------------------- 78 | 79 | # The theme to use for HTML and HTML Help pages. See the documentation for 80 | # a list of builtin themes. 81 | # 82 | html_theme = "sphinx_rtd_theme" 83 | 84 | # Theme options are theme-specific and customize the look and feel of a theme 85 | # further. For a list of options available for each theme, see the 86 | # documentation. 87 | # 88 | # html_theme_options = {} 89 | 90 | # Add any paths that contain custom static files (such as style sheets) here, 91 | # relative to this directory. They are copied after the builtin static files, 92 | # so a file named "default.css" will overwrite the builtin "default.css". 93 | html_static_path = ["_static"] 94 | 95 | # Custom sidebar templates, must be a dictionary that maps document names 96 | # to template names. 97 | # 98 | # The default sidebars (for documents that don't match any pattern) are 99 | # defined by theme itself. Builtin themes are using these templates by 100 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 101 | # 'searchbox.html']``. 102 | # 103 | # html_sidebars = {} 104 | 105 | 106 | # -- Options for HTMLHelp output --------------------------------------------- 107 | 108 | # Output file base name for HTML help builder. 109 | htmlhelp_basename = "django-anondoc" 110 | 111 | 112 | # -- Options for LaTeX output ------------------------------------------------ 113 | 114 | latex_elements = { 115 | # The paper size ('letterpaper' or 'a4paper'). 116 | # 117 | # 'papersize': 'letterpaper', 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, "django-anon.tex", u"django-anon Documentation", u"Tesorio", "manual"), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [(master_doc, "django-anon", u"django-anon Documentation", [author], 1)] 142 | 143 | 144 | # -- Options for Texinfo output ---------------------------------------------- 145 | 146 | # Grouping the document tree into Texinfo files. List of tuples 147 | # (source start file, target name, title, author, 148 | # dir menu entry, description, category) 149 | texinfo_documents = [ 150 | ( 151 | master_doc, 152 | "django-anon", 153 | u"django-anon Documentation", 154 | author, 155 | "django-anon", 156 | "One line description of project.", 157 | "Miscellaneous", 158 | ), 159 | ] 160 | 161 | 162 | # -- Options for Epub output ------------------------------------------------- 163 | 164 | # Bibliographic Dublin Core info. 165 | epub_title = project 166 | 167 | # The unique identifier of the text. This can be a ISBN number 168 | # or the project homepage. 169 | # 170 | # epub_identifier = '' 171 | 172 | # A unique identification for the text. 173 | # 174 | # epub_uid = '' 175 | 176 | # A list of files that should not be packed into the epub file. 177 | epub_exclude_files = ["search.html"] 178 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | As an open source project, **django-anon** welcomes contributions of many forms. 5 | 6 | Examples of contributions include: 7 | 8 | * Code patches 9 | * Documentation improvements 10 | * Bug reports and code reviews 11 | 12 | .. start-table-of-contents 13 | 14 | Table of Contents 15 | ----------------- 16 | 17 | .. contents:: 18 | :local: 19 | 20 | .. end-table-of-contents 21 | 22 | 23 | Code of conduct 24 | --------------- 25 | 26 | Please keep the tone polite & professional. First impressions count, so let's try to make everyone feel welcome. 27 | 28 | Be mindful in the language you choose. As an example, in an environment that is heavily male-dominated, posts that start 'Hey guys,' can come across as unintentionally exclusive. It's just as easy, and more inclusive to use gender neutral language in those situations. (e.g. 'Hey folks,') 29 | 30 | The `Django code of conduct `_ gives a fuller set of guidelines for participating in community forums. 31 | 32 | 33 | Issues 34 | ------ 35 | 36 | Some tips on good issue reporting: 37 | 38 | * When describing issues try to phrase your ticket in terms of the behavior you think needs changing rather than the code you think need changing. 39 | * Search the issue list first for related items, and make sure you're running the latest version of **django-anon** before reporting an issue. 40 | * If reporting a bug, then try to include a pull request with a failing test case. This will help us quickly identify if there is a valid issue, and make sure that it gets fixed more quickly if there is one. 41 | * Closing an issue doesn't necessarily mean the end of a discussion. If you believe your issue has been closed incorrectly, explain why and we'll consider if it needs to be reopened. 42 | 43 | 44 | Development 45 | ----------- 46 | 47 | To start developing on **django-anon**, clone the repo: 48 | 49 | .. code:: 50 | 51 | git clone https://github.com/Tesorio/django-anon 52 | 53 | Changes should broadly follow the PEP 8 style conventions, and we recommend you set up your editor to automatically indicate non-conforming styles. 54 | 55 | 56 | Coding Style 57 | ------------ 58 | 59 | `The Black code style `_ is used across the whole codebase. Ideally, you should configure your editor to auto format the code. This means you can use **88 characters per line**, rather than 79 as defined by PEP 8. 60 | 61 | Use `isort` to automate import sorting using the guidelines below: 62 | 63 | * Put imports in these groups: future, stdlib, deps, local 64 | * Sort lines in each group alphabetically by the full module name 65 | * On each line, alphabetize the items with the upper case items grouped before the lowercase items 66 | 67 | Don't be afraid, all specifications for linters are defined in ``pyproject.toml`` and ``.flake8`` 68 | 69 | 70 | Testing 71 | ------- 72 | 73 | To run the tests, clone the repository, and then: 74 | 75 | .. code:: 76 | 77 | # Setup the virtual environment 78 | python3 -m venv env 79 | source env/bin/activate 80 | pip install django 81 | pip install -r tests/requirements.txt 82 | 83 | # Run the tests 84 | ./runtests.py 85 | 86 | 87 | Running against multiple environments 88 | ------------------------------------- 89 | 90 | You can also use the excellent tox testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: 91 | 92 | .. code:: 93 | 94 | tox 95 | 96 | 97 | Using pre-commit hook 98 | --------------------- 99 | 100 | CI will perform some checks during the build, but to save time, most of the checks can be ran locally beforing pushing code. To do this, we use `pre-commit `_ hooks. All you need to do, is to install and configure pre-commit: 101 | 102 | .. code:: bash 103 | 104 | pre-commit install --hook-type pre-push -f 105 | 106 | 107 | Pull requests 108 | ------------- 109 | 110 | It's a good idea to make pull requests early on. A pull request represents the start of a discussion, and doesn't necessarily need to be the final, finished submission. 111 | 112 | It's also always best to make a new branch before starting work on a pull request. This means that you'll be able to later switch back to working on another separate issue without interfering with an ongoing pull requests. 113 | 114 | It's also useful to remember that if you have an outstanding pull request then pushing new commits to your GitHub repo will also automatically update the pull requests. 115 | 116 | GitHub's documentation for working on pull requests is `available here. `_ 117 | 118 | Always run the tests before submitting pull requests, and ideally run tox in order to check that your modifications are compatible on all supported versions of Python and Django. 119 | 120 | Once you've made a pull request take a look at GitHub Checks and make sure the tests are running as you'd expect. 121 | 122 | 123 | Documentation 124 | ------------- 125 | 126 | **django-anon** uses the Sphinx documentation system and is built from the ``.rst`` source files in the ``docs/`` directory. 127 | 128 | To build the documentation locally, install Sphinx: 129 | 130 | .. code:: 131 | 132 | pip install Sphinx 133 | 134 | Then from the ``docs/`` directory, build the HTML: 135 | 136 | .. code:: 137 | 138 | make html 139 | 140 | To get started contributing, you’ll want to read the `reStructuredText reference. `_ 141 | 142 | 143 | Language style 144 | -------------- 145 | 146 | Documentation should be in American English. The tone of the documentation is very important - try to stick to a simple, plain, objective and well-balanced style where possible. 147 | 148 | Some other tips: 149 | 150 | * Keep paragraphs reasonably short. 151 | * Don't use abbreviations such as 'e.g.' but instead use the long form, such as 'For example'. 152 | 153 | 154 | Releasing a new version 155 | ----------------------- 156 | 157 | 1. Bump the version in ``anon/__init__.py`` 158 | 2. Update the ``CHANGELOG.rst`` file, moving items up from master to the new version 159 | 3. Submit a PR and wait for it to get approved/merged 160 | 4. Checkout to the corresponding commit and create a new tag: ``python setup.py tag`` 161 | 5. `Publish the new release `_ in GitHub 162 | 6. Publish the new release in PyPI: ``python setup.py publish`` (Requires access to PyPI) 163 | 164 | 165 | References 166 | ---------- 167 | 168 | * https://github.com/encode/django-rest-framework/blob/master/CONTRIBUTING.md 169 | * https://docs.djangoproject.com/en/dev/internals/contributing/ 170 | -------------------------------------------------------------------------------- /anon/base.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | from collections import OrderedDict 3 | from logging import getLogger 4 | 5 | # deps 6 | from chunkator import chunkator_page 7 | 8 | # local 9 | from anon.compat import bulk_update 10 | 11 | 12 | logger = getLogger(__name__) 13 | 14 | 15 | class OrderedDeclaration(object): 16 | """ Any classes inheriting from this will have an unique global counter 17 | associated with it. This counter is used to determine the order in 18 | which fields were declarated 19 | 20 | Idea taken from: https://stackoverflow.com/a/4460034/639465 21 | Also inspired by https://github.com/FactoryBoy/factory_boy 22 | """ 23 | 24 | global_counter = 0 25 | 26 | def __init__(self): 27 | self._order = self.__class__.global_counter 28 | self.__class__.global_counter += 1 29 | 30 | 31 | class LazyAttribute(OrderedDeclaration): 32 | def __init__(self, lazy_fn): 33 | super(LazyAttribute, self).__init__() 34 | self.lazy_fn = lazy_fn 35 | 36 | def __call__(self, *args, **kwargs): 37 | return self.lazy_fn(*args, **kwargs) 38 | 39 | 40 | def lazy_attribute(lazy_fn): 41 | """ Returns LazyAttribute objects, that basically marks functions that 42 | should take `obj` as first parameter. This is useful when you need 43 | to take in consideration other values of `obj` 44 | 45 | Example: 46 | 47 | >>> full_name = lazy_attribute(o: o.first_name + o.last_name) 48 | 49 | """ 50 | return LazyAttribute(lazy_fn) 51 | 52 | 53 | class BaseAnonymizer(object): 54 | def run(self, select_chunk_size=None, **bulk_update_kwargs): 55 | self._declarations = self.get_declarations() 56 | 57 | queryset = self.get_queryset() 58 | update_fields = list(self._declarations.keys()) 59 | 60 | update_batch_size = bulk_update_kwargs.pop( 61 | "batch_size", self._meta.update_batch_size 62 | ) 63 | 64 | if select_chunk_size is None: 65 | select_chunk_size = self._meta.select_chunk_size 66 | 67 | if update_batch_size > select_chunk_size: 68 | raise ValueError( 69 | "update_batch_size ({}) should not be higher than " 70 | "select_chunk_size ({})".format(update_batch_size, select_chunk_size) 71 | ) 72 | 73 | # info used in log messages 74 | model_name = self._meta.model.__name__ 75 | current_batch = 0 76 | 77 | for page in chunkator_page(queryset, chunk_size=select_chunk_size): 78 | logger.info( 79 | "Updating {}... {}-{}".format( 80 | model_name, current_batch, current_batch + select_chunk_size 81 | ) 82 | ) 83 | current_batch += select_chunk_size 84 | 85 | objs = [] 86 | for obj in page: 87 | self.patch_object(obj) 88 | objs.append(obj) 89 | 90 | if update_fields: 91 | bulk_update( 92 | objs, 93 | update_fields, 94 | self.get_manager(), 95 | **dict(batch_size=update_batch_size, **bulk_update_kwargs) 96 | ) 97 | else: 98 | logger.info( 99 | "Skiping bulk update for {}... No fields to update".format( 100 | model_name 101 | ) 102 | ) 103 | 104 | if current_batch == 0: 105 | logger.info("{} has no records".format(model_name)) 106 | 107 | def get_meta(self): 108 | meta = self.Meta() 109 | if not hasattr(meta, "select_chunk_size"): 110 | # Chunk size to iterate over 111 | meta.select_chunk_size = 5000 112 | if not hasattr(meta, "update_batch_size"): 113 | # Batch size for bulk updates 114 | meta.update_batch_size = 200 115 | return meta 116 | 117 | _meta = property(get_meta) 118 | 119 | def get_manager(self): 120 | meta = self._meta 121 | return getattr(meta, "manager", meta.model.objects) 122 | 123 | _manager = property(get_manager) 124 | 125 | def get_queryset(self): 126 | """ Override this if you want to delimit the objects that should be 127 | affected by anonymization 128 | """ 129 | return self.get_manager().all() 130 | 131 | def patch_object(self, obj): 132 | """ Update object attributes with fake data provided by replacers 133 | """ 134 | # using obj.__dict__ instead of getattr for performance reasons 135 | # see https://stackoverflow.com/a/9791053/639465 136 | fields = [field for field in self._declarations if obj.__dict__[field]] 137 | 138 | for field in fields: 139 | replacer = self._declarations[field] 140 | if isinstance(replacer, LazyAttribute): 141 | # Pass in obj for LazyAttributes 142 | new_value = replacer(obj) 143 | elif callable(replacer): 144 | new_value = replacer() 145 | else: 146 | new_value = replacer 147 | 148 | obj.__dict__[field] = new_value 149 | 150 | self.clean(obj) 151 | 152 | def clean(self, obj): 153 | """ Use this function if you need to update additional data that may 154 | rely on multiple fields, or if you need to update multiple fields 155 | at once 156 | """ 157 | pass 158 | 159 | def get_declarations(self): 160 | """ Returns ordered declarations. Any non-ordered declarations, for 161 | example any types that does not inherit from OrderedDeclaration 162 | will come first, as they are considered "raw" values and should 163 | not be affected by the order of other non-ordered declarations 164 | """ 165 | 166 | def _sort_declaration(declaration): 167 | name, value = declaration 168 | if isinstance(value, OrderedDeclaration): 169 | return value._order 170 | else: 171 | # Any non-ordered declarations come first 172 | return -1 173 | 174 | declarations = self._get_class_attributes().items() 175 | sorted_declarations = sorted(declarations, key=_sort_declaration) 176 | 177 | return OrderedDict(sorted_declarations) 178 | 179 | def _get_class_attributes(self): 180 | """ Return list of class attributes, which also includes methods and 181 | subclasses, ignoring any magic methods and reserved attributes 182 | """ 183 | reserved_names = list(BaseAnonymizer.__dict__.keys()) + ["Meta"] 184 | 185 | return { 186 | name: self.__class__.__dict__[name] 187 | for name, value in self.__class__.__dict__.items() 188 | if not name.startswith("__") and name not in reserved_names 189 | } 190 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. BANNERSTART 2 | .. Since PyPI does not support raw directives, we remove them from the README 3 | .. 4 | .. raw directives are only used to make README fancier on GitHub and do not 5 | .. contain relevant information to be displayed in PyPI, as they are not tied 6 | .. to the current version, but to the current development status 7 | .. raw:: html 8 | 9 |

10 | 11 | 12 | 13 |

14 | 15 |

django-anon

16 |

17 | 18 | Anonymize production data so it can be safely used in not-so-safe environments 19 | 20 |

21 | 22 |

23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |

36 | 37 |

38 | 39 | Install 40 | 41 | | 42 | 43 | Read Documentation 44 | 45 | | 46 | 47 | PyPI 48 | 49 | | 50 | 51 | Contribute 52 | 53 |

54 | .. BANNEREND 55 | 56 | **django-anon** will help you anonymize your production database so it can be 57 | shared among developers, helping to reproduce bugs and make performance improvements 58 | in a production-like environment. 59 | 60 | .. image:: https://raw.githubusercontent.com/Tesorio/django-anon/master/django-anon-recording.gif 61 | 62 | .. start-features 63 | 64 | Features 65 | ======== 66 | 67 | .. start-features-table 68 | 69 | .. csv-table:: 70 | 71 | "🚀", "**Really fast** data anonymization and database operations using bulk updates to operate over huge tables" 72 | "🍰", "**Flexible** to use your own anonymization functions or external libraries like `Faker `_" 73 | "🐩", "**Elegant** solution following consolidated patterns from projects like `Django `_ and `Factory Boy `_" 74 | "🔨", "**Powerful**. It can be used on any projects, not only Django, not only Python. Really!" 75 | 76 | .. end-features-table 77 | .. end-features 78 | .. start-table-of-contents 79 | 80 | Table of Contents 81 | ================= 82 | .. contents:: 83 | :local: 84 | 85 | .. end-table-of-contents 86 | .. start-introduction 87 | 88 | 89 | Installation 90 | ------------ 91 | 92 | .. code:: 93 | 94 | pip install django-anon 95 | 96 | 97 | Supported versions 98 | ------------------ 99 | 100 | * Python (2.7, 3.7) 101 | * Django (1.11, 2.2, 3.0) 102 | 103 | 104 | License 105 | ------- 106 | 107 | `MIT `_ 108 | 109 | .. end-introduction 110 | .. start-usage 111 | 112 | 113 | Usage 114 | ----- 115 | 116 | Use ``anon.BaseAnonymizer`` to define your anonymizer classes: 117 | 118 | .. code-block:: python 119 | 120 | import anon 121 | 122 | from your_app.models import Person 123 | 124 | class PersonAnonymizer(anon.BaseAnonymizer): 125 | email = anon.fake_email 126 | 127 | # You can use static values instead of callables 128 | is_admin = False 129 | 130 | class Meta: 131 | model = Person 132 | 133 | # run anonymizer: be cautious, this will affect your current database! 134 | PersonAnonymizer().run() 135 | 136 | 137 | Built-in functions 138 | ~~~~~~~~~~~~~~~~~~ 139 | 140 | .. code:: python 141 | 142 | import anon 143 | 144 | anon.fake_word(min_size=_min_word_size, max_size=20) 145 | anon.fake_text(max_size=255, max_diff_allowed=5, separator=' ') 146 | anon.fake_small_text(max_size=50) 147 | anon.fake_name(max_size=15) 148 | anon.fake_username(max_size=10, separator='') 149 | anon.fake_email(max_size=40, suffix='@example.com') 150 | anon.fake_url(max_size=50, scheme='http://', suffix='.com') 151 | anon.fake_phone_number(format='999-999-9999') 152 | 153 | 154 | Lazy attributes 155 | ~~~~~~~~~~~~~~~ 156 | 157 | Lazy attributes can be defined as inline lambdas or methods, as shown below, 158 | using the ``anon.lazy_attribute`` function/decorator. 159 | 160 | .. code-block:: python 161 | 162 | import anon 163 | 164 | from your_app.models import Person 165 | 166 | class PersonAnonymizer(anon.BaseAnonymizer): 167 | name = anon.lazy_attribute(lambda o: 'x' * len(o.name)) 168 | 169 | @lazy_attribute 170 | def date_of_birth(self): 171 | # keep year and month 172 | return self.date_of_birth.replace(day=1) 173 | 174 | class Meta: 175 | model = Person 176 | 177 | 178 | The clean method 179 | ~~~~~~~~~~~~~~~~ 180 | 181 | .. code-block:: python 182 | 183 | import anon 184 | 185 | class UserAnonymizer(anon.BaseAnonymizer): 186 | class Meta: 187 | model = User 188 | 189 | def clean(self, obj): 190 | obj.set_password('test') 191 | obj.save() 192 | 193 | 194 | Defining a custom QuerySet 195 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 196 | 197 | A custom QuerySet can be used to select the rows that should be anonymized: 198 | 199 | .. code-block:: python 200 | 201 | import anon 202 | 203 | from your_app.models import Person 204 | 205 | class PersonAnonymizer(anon.BaseAnonymizer): 206 | email = anon.fake_email 207 | 208 | class Meta: 209 | model = Person 210 | 211 | def get_queryset(self): 212 | # keep admins unmodified 213 | return Person.objects.exclude(is_admin=True) 214 | 215 | 216 | High-quality fake data 217 | ~~~~~~~~~~~~~~~~~~~~~~ 218 | 219 | In order to be really fast, **django-anon** uses it's own algorithm to generate fake data. It is 220 | really fast, but the generated data is not pretty. If you need something prettier in terms of data, 221 | we suggest using `Faker `_, which can be used 222 | out-of-the-box as the below: 223 | 224 | .. code-block:: python 225 | 226 | import anon 227 | 228 | from faker import Faker 229 | from your_app.models import Address 230 | 231 | faker = Faker() 232 | 233 | class PersonAnonymizer(anon.BaseAnonymizer): 234 | postalcode = faker.postalcode 235 | 236 | class Meta: 237 | model = Address 238 | 239 | .. end-usage 240 | 241 | Changelog 242 | --------- 243 | 244 | Check out `CHANGELOG.rst `_ for release notes 245 | 246 | Contributing 247 | ------------ 248 | 249 | Check out `CONTRIBUTING.rst `_ for information about getting involved 250 | 251 | ---- 252 | 253 | `Icon `_ made by `Eucalyp `_ from `www.flaticon.com `_ 254 | -------------------------------------------------------------------------------- /anon/utils.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import itertools 3 | import random 4 | 5 | 6 | _WORD_LIST = [ 7 | "a", 8 | "ab", 9 | "accusamus", 10 | "accusantium", 11 | "ad", 12 | "adipisci", 13 | "alias", 14 | "aliquam", 15 | "aliquid", 16 | "amet", 17 | "animi", 18 | "aperiam", 19 | "architecto", 20 | "asperiores", 21 | "aspernatur", 22 | "assumenda", 23 | "at", 24 | "atque", 25 | "aut", 26 | "autem", 27 | "beatae", 28 | "blanditiis", 29 | "commodi", 30 | "consectetur", 31 | "consequatur", 32 | "consequuntur", 33 | "corporis", 34 | "corrupti", 35 | "culpa", 36 | "cum", 37 | "cumque", 38 | "cupiditate", 39 | "debitis", 40 | "delectus", 41 | "deleniti", 42 | "deserunt", 43 | "dicta", 44 | "dignissimos", 45 | "distinctio", 46 | "dolor", 47 | "dolore", 48 | "dolorem", 49 | "doloremque", 50 | "dolores", 51 | "doloribus", 52 | "dolorum", 53 | "ducimus", 54 | "ea", 55 | "eaque", 56 | "earum", 57 | "eius", 58 | "eligendi", 59 | "enim", 60 | "eos", 61 | "error", 62 | "esse", 63 | "est", 64 | "et", 65 | "eum", 66 | "eveniet", 67 | "ex", 68 | "excepturi", 69 | "exercitationem", 70 | "expedita", 71 | "explicabo", 72 | "facere", 73 | "facilis", 74 | "fuga", 75 | "fugiat", 76 | "fugit", 77 | "harum", 78 | "hic", 79 | "id", 80 | "illo", 81 | "illum", 82 | "impedit", 83 | "in", 84 | "incidunt", 85 | "inventore", 86 | "ipsa", 87 | "ipsam", 88 | "ipsum", 89 | "iste", 90 | "itaque", 91 | "iure", 92 | "iusto", 93 | "labore", 94 | "laboriosam", 95 | "laborum", 96 | "laudantium", 97 | "libero", 98 | "magnam", 99 | "magni", 100 | "maiores", 101 | "maxime", 102 | "minima", 103 | "minus", 104 | "modi", 105 | "molestiae", 106 | "molestias", 107 | "mollitia", 108 | "nam", 109 | "natus", 110 | "necessitatibus", 111 | "nemo", 112 | "neque", 113 | "nesciunt", 114 | "nihil", 115 | "nisi", 116 | "nobis", 117 | "non", 118 | "nostrum", 119 | "nulla", 120 | "numquam", 121 | "occaecati", 122 | "odio", 123 | "odit", 124 | "officia", 125 | "officiis", 126 | "omnis", 127 | "optio", 128 | "pariatur", 129 | "perferendis", 130 | "perspiciatis", 131 | "placeat", 132 | "porro", 133 | "possimus", 134 | "praesentium", 135 | "provident", 136 | "quae", 137 | "quaerat", 138 | "quam", 139 | "quas", 140 | "quasi", 141 | "qui", 142 | "quia", 143 | "quibusdam", 144 | "quidem", 145 | "quis", 146 | "quisquam", 147 | "quo", 148 | "quod", 149 | "quos", 150 | "ratione", 151 | "recusandae", 152 | "reiciendis", 153 | "rem", 154 | "repellat", 155 | "repellendus", 156 | "reprehenderit", 157 | "repudiandae", 158 | "rerum", 159 | "saepe", 160 | "sapiente", 161 | "sed", 162 | "sequi", 163 | "similique", 164 | "sint", 165 | "sit", 166 | "soluta", 167 | "sunt", 168 | "suscipit", 169 | "tempora", 170 | "tempore", 171 | "temporibus", 172 | "tenetur", 173 | "totam", 174 | "ullam", 175 | "unde", 176 | "ut", 177 | "vel", 178 | "velit", 179 | "veniam", 180 | "veritatis", 181 | "vero", 182 | "vitae", 183 | "voluptas", 184 | "voluptate", 185 | "voluptatem", 186 | "voluptates", 187 | "voluptatibus", 188 | "voluptatum", 189 | ] 190 | 191 | 192 | try: 193 | xrange 194 | except NameError: 195 | # Python 2/3 proof 196 | xrange = range 197 | 198 | 199 | def _cycle_over_sample_range(start, end, sample_size): 200 | """ 201 | Given a range (start, end), returns a generator that will cycle over a population 202 | sample with size specified by ``sample_size`` 203 | """ 204 | return itertools.cycle(random.sample(xrange(start, end), sample_size)) 205 | 206 | 207 | def _trim_text(text, separator, max_size): 208 | limit = min(text.rindex(separator), max_size) 209 | return text[:limit] 210 | 211 | 212 | # Holds the maximum size of word sample 213 | _max_word_size = max(len(s) for s in _WORD_LIST) 214 | 215 | # Holds a generator that each iteration returns a different word 216 | _word_generator = itertools.cycle(_WORD_LIST) 217 | 218 | # Holds the size of smallest word in _WORD_LIST and is used to define bounds 219 | _min_word_size = len(sorted(_WORD_LIST, key=lambda w: len(w))[0]) 220 | 221 | # Holds a generator that each iteration returns a different number 222 | _number_generator = itertools.cycle("86306894249026785203141") 223 | 224 | # Holds a generator for small integers, same as Django's PositiveSmallIntegerField 225 | _small_int_generator = _cycle_over_sample_range(0, 32767, 1000) 226 | 227 | # Holds a generator for small signed integers, same as Django's SmallIntegerField 228 | _small_signed_int_generator = _cycle_over_sample_range(-32768, 32767, 1000) 229 | 230 | # Holds a generator for integers, same as Django's PositiveIntegerField 231 | _int_generator = _cycle_over_sample_range(0, 2147483647, 10000) 232 | 233 | # Holds a generator for signed integers, same as Django's IntegerField 234 | _signed_int_generator = _cycle_over_sample_range(-2147483648, 2147483647, 100000) 235 | 236 | 237 | def fake_word(min_size=_min_word_size, max_size=20): 238 | """ Return fake word 239 | 240 | :min_size: Minimum number of chars 241 | :max_size: Maximum number of chars 242 | 243 | Example: 244 | 245 | >>> import django_anon as anon 246 | >>> print(anon.fake_word()) 247 | adipisci 248 | 249 | """ 250 | if min_size < _min_word_size: 251 | raise ValueError("no such word with this size < min_size") 252 | 253 | for word in _word_generator: 254 | if min_size <= len(word) <= max_size: 255 | return word 256 | 257 | 258 | def fake_text(max_size=255, max_diff_allowed=5, separator=" "): 259 | """ Return fake text 260 | 261 | :max_size: Maximum number of chars 262 | :max_diff_allowed: Maximum difference (fidelity) allowed, in chars number 263 | :separator: Word separator 264 | 265 | Example: 266 | 267 | >>> print(anon.fake_text()) 268 | alias aliquam aliquid amet animi aperiam architecto asperiores aspernatur assumenda at atque aut autem beatae blanditiis commodi consectetur consequatur consequuntur corporis corrupti culpa cum cumque cupiditate debitis delectus deleniti deserunt dicta 269 | 270 | """ 271 | if max_diff_allowed < 1: 272 | raise ValueError("max_diff_allowed must be > 0") 273 | 274 | num_words = max(1, int(max_size / _max_word_size)) 275 | words = itertools.islice(_word_generator, num_words) 276 | 277 | text = separator.join(words) 278 | try: 279 | if len(text) > max_size: 280 | text = _trim_text(text, separator, max_size) 281 | except ValueError: 282 | text = text[:max_size] 283 | 284 | return text 285 | 286 | 287 | def fake_small_text(max_size=50): 288 | """ Preset for fake_text. 289 | 290 | :max_size: Maximum number of chars 291 | 292 | Example: 293 | 294 | >>> print(anon.fake_small_text()) 295 | Distinctio Dolor Dolore Dolorem Doloremque Dolores 296 | 297 | """ 298 | return fake_text(max_size=max_size).title() 299 | 300 | 301 | def fake_name(max_size=15): 302 | """ Preset for fake_text. Also returns capitalized words. 303 | 304 | :max_size: Maximum number of chars 305 | 306 | Example: 307 | 308 | >>> print(anon.fake_name()) 309 | Doloribus Ea 310 | 311 | """ 312 | return fake_text(max_size=max_size).title() 313 | 314 | 315 | def fake_username(max_size=10, separator=""): 316 | """ Returns fake username 317 | 318 | :max_size: Maximum number of chars 319 | :separator: Word separator 320 | :rand_range: Range to use when generating random number 321 | 322 | Example: 323 | 324 | >>> print(anon.fake_username()) 325 | eius54455 326 | 327 | """ 328 | random_number = str(next(_small_int_generator)) 329 | min_size_allowed = _min_word_size + len(random_number) 330 | 331 | if max_size < min_size_allowed: 332 | raise ValueError("username must be >= {}".format(min_size_allowed)) 333 | else: 334 | max_size -= len(random_number) 335 | 336 | return fake_text(max_size, separator=separator) + random_number 337 | 338 | 339 | def fake_email(max_size=40, suffix="@example.com"): 340 | """ Returns fake email address 341 | 342 | :max_size: Maximum number of chars 343 | :suffix: Suffix to add to email addresses (including @) 344 | 345 | Example: 346 | 347 | >>> print(anon.fake_email()) 348 | enim120238@example.com 349 | 350 | """ 351 | min_size_allowed = _min_word_size + len(suffix) 352 | 353 | if max_size + len(suffix) > 254: 354 | # an email address must not exceed 254 chars 355 | raise ValueError("email address must not exceed 254 chars") 356 | elif max_size < min_size_allowed: 357 | raise ValueError("max_size must be >= {}".format(min_size_allowed)) 358 | else: 359 | max_size -= len(suffix) 360 | 361 | return fake_username(max_size, separator=".") + suffix 362 | 363 | 364 | def fake_url(max_size=50, scheme="http://", suffix=".com"): 365 | """ Returns fake URL 366 | 367 | :max_size: Maximum number of chars 368 | :scheme: URL scheme (http://) 369 | :suffix: Suffix to add to domain (including dot) 370 | 371 | Example: 372 | 373 | >>> print(anon.fake_url()) 374 | http://facilis.fuga.fugiat.fugit.harum.hic.id.com 375 | 376 | """ 377 | min_size_allowed = _min_word_size + len(scheme) + len(suffix) 378 | 379 | if max_size < min_size_allowed: 380 | raise ValueError("max_size must be >= {}".format(min_size_allowed)) 381 | else: 382 | max_size -= len(scheme) + len(suffix) 383 | 384 | domain = fake_text(max_size=max_size, separator=".") + suffix 385 | return scheme + domain 386 | 387 | 388 | def fake_phone_number(format="999-999-9999"): 389 | """ Returns a fake phone number in the desired format 390 | 391 | :format: Format of phone number to generate 392 | 393 | Example: 394 | 395 | >>> print(anon.fake_phone_number()) 396 | 863-068-9424 397 | 398 | """ 399 | number = [] 400 | for char in format: 401 | if char == "9": 402 | n = next(_number_generator) 403 | if not number: 404 | # do not start phone numbers with zero 405 | while n == "0": 406 | n = next(_number_generator) 407 | number.append(n) 408 | else: 409 | number.append(char) 410 | return "".join(number) 411 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "b830d2890d591353241f920dfd497d67cdf7eead00bb74a4bb49ad949592de72" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", 22 | "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" 23 | ], 24 | "version": "==3.3.1" 25 | }, 26 | "django": { 27 | "hashes": [ 28 | "sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f", 29 | "sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7" 30 | ], 31 | "version": "==3.1.6" 32 | }, 33 | "django-bulk-update": { 34 | "hashes": [ 35 | "sha256:49a403392ae05ea872494d74fb3dfa3515f8df5c07cc277c3dc94724c0ee6985", 36 | "sha256:5ab7ce8a65eac26d19143cc189c0f041d5c03b9d1b290ca240dc4f3d6aaeb337" 37 | ], 38 | "index": "pypi", 39 | "version": "==2.2.0" 40 | }, 41 | "django-chunkator": { 42 | "hashes": [ 43 | "sha256:210713faf05d68035d067de9b015868b3d902d5405865401384a3c134f64c43a", 44 | "sha256:57d705966762e6ba6879c2344a1b9288045f64c638fa47cbe00467f0eeeadfaf" 45 | ], 46 | "index": "pypi", 47 | "version": "==2.0.0" 48 | }, 49 | "pytz": { 50 | "hashes": [ 51 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 52 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 53 | ], 54 | "version": "==2021.1" 55 | }, 56 | "sqlparse": { 57 | "hashes": [ 58 | "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", 59 | "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" 60 | ], 61 | "version": "==0.4.1" 62 | } 63 | }, 64 | "develop": { 65 | "alabaster": { 66 | "hashes": [ 67 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 68 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 69 | ], 70 | "version": "==0.7.12" 71 | }, 72 | "appdirs": { 73 | "hashes": [ 74 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 75 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 76 | ], 77 | "version": "==1.4.4" 78 | }, 79 | "argh": { 80 | "hashes": [ 81 | "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", 82 | "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65" 83 | ], 84 | "version": "==0.26.2" 85 | }, 86 | "babel": { 87 | "hashes": [ 88 | "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5", 89 | "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05" 90 | ], 91 | "version": "==2.9.0" 92 | }, 93 | "bleach": { 94 | "hashes": [ 95 | "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125", 96 | "sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433" 97 | ], 98 | "index": "pypi", 99 | "version": "==3.3.0" 100 | }, 101 | "certifi": { 102 | "hashes": [ 103 | "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", 104 | "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" 105 | ], 106 | "version": "==2020.12.5" 107 | }, 108 | "cffi": { 109 | "hashes": [ 110 | "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", 111 | "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", 112 | "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", 113 | "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", 114 | "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", 115 | "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", 116 | "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", 117 | "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", 118 | "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", 119 | "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", 120 | "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", 121 | "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", 122 | "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", 123 | "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", 124 | "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", 125 | "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", 126 | "sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e", 127 | "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", 128 | "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", 129 | "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", 130 | "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", 131 | "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", 132 | "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", 133 | "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", 134 | "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", 135 | "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", 136 | "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", 137 | "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", 138 | "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", 139 | "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", 140 | "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", 141 | "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", 142 | "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", 143 | "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", 144 | "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", 145 | "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", 146 | "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" 147 | ], 148 | "version": "==1.14.4" 149 | }, 150 | "chardet": { 151 | "hashes": [ 152 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 153 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 154 | ], 155 | "version": "==4.0.0" 156 | }, 157 | "colorama": { 158 | "hashes": [ 159 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 160 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 161 | ], 162 | "version": "==0.4.4" 163 | }, 164 | "cryptography": { 165 | "hashes": [ 166 | "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d", 167 | "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7", 168 | "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901", 169 | "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c", 170 | "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244", 171 | "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6", 172 | "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5", 173 | "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e", 174 | "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c", 175 | "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0", 176 | "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812", 177 | "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a", 178 | "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030", 179 | "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302" 180 | ], 181 | "version": "==3.3.1" 182 | }, 183 | "distlib": { 184 | "hashes": [ 185 | "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", 186 | "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" 187 | ], 188 | "version": "==0.3.1" 189 | }, 190 | "docutils": { 191 | "hashes": [ 192 | "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", 193 | "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" 194 | ], 195 | "version": "==0.16" 196 | }, 197 | "filelock": { 198 | "hashes": [ 199 | "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", 200 | "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" 201 | ], 202 | "version": "==3.0.12" 203 | }, 204 | "idna": { 205 | "hashes": [ 206 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 207 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 208 | ], 209 | "version": "==2.10" 210 | }, 211 | "imagesize": { 212 | "hashes": [ 213 | "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", 214 | "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" 215 | ], 216 | "version": "==1.2.0" 217 | }, 218 | "importlib-metadata": { 219 | "hashes": [ 220 | "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", 221 | "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" 222 | ], 223 | "markers": "python_version < '3.8'", 224 | "version": "==1.7.0" 225 | }, 226 | "jeepney": { 227 | "hashes": [ 228 | "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657", 229 | "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae" 230 | ], 231 | "markers": "sys_platform == 'linux'", 232 | "version": "==0.6.0" 233 | }, 234 | "jinja2": { 235 | "hashes": [ 236 | "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", 237 | "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" 238 | ], 239 | "version": "==2.11.3" 240 | }, 241 | "keyring": { 242 | "hashes": [ 243 | "sha256:9acb3e1452edbb7544822b12fd25459078769e560fa51f418b6d00afaa6178df", 244 | "sha256:9f44660a5d4931bdc14c08a1d01ef30b18a7a8147380710d8c9f9531e1f6c3c0" 245 | ], 246 | "version": "==22.0.1" 247 | }, 248 | "livereload": { 249 | "hashes": [ 250 | "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869" 251 | ], 252 | "version": "==2.6.3" 253 | }, 254 | "markupsafe": { 255 | "hashes": [ 256 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 257 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 258 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 259 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 260 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 261 | "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", 262 | "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", 263 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 264 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 265 | "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", 266 | "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", 267 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 268 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 269 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 270 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 271 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 272 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 273 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 274 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 275 | "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", 276 | "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", 277 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 278 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 279 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 280 | "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", 281 | "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", 282 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 283 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 284 | "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", 285 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 286 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 287 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 288 | "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", 289 | "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", 290 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 291 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 292 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 293 | "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", 294 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 295 | "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", 296 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 297 | "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", 298 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 299 | "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", 300 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 301 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 302 | "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", 303 | "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", 304 | "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", 305 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 306 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", 307 | "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" 308 | ], 309 | "version": "==1.1.1" 310 | }, 311 | "packaging": { 312 | "hashes": [ 313 | "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", 314 | "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" 315 | ], 316 | "version": "==20.9" 317 | }, 318 | "pathtools": { 319 | "hashes": [ 320 | "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0" 321 | ], 322 | "version": "==0.1.2" 323 | }, 324 | "pkginfo": { 325 | "hashes": [ 326 | "sha256:029a70cb45c6171c329dfc890cde0879f8c52d6f3922794796e06f577bb03db4", 327 | "sha256:9fdbea6495622e022cc72c2e5e1b735218e4ffb2a2a69cde2694a6c1f16afb75" 328 | ], 329 | "version": "==1.7.0" 330 | }, 331 | "pluggy": { 332 | "hashes": [ 333 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 334 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 335 | ], 336 | "version": "==0.13.1" 337 | }, 338 | "port-for": { 339 | "hashes": [ 340 | "sha256:b16a84bb29c2954db44c29be38b17c659c9c27e33918dec16b90d375cc596f1c" 341 | ], 342 | "version": "==0.3.1" 343 | }, 344 | "py": { 345 | "hashes": [ 346 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 347 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 348 | ], 349 | "version": "==1.10.0" 350 | }, 351 | "pycparser": { 352 | "hashes": [ 353 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 354 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 355 | ], 356 | "version": "==2.20" 357 | }, 358 | "pygments": { 359 | "hashes": [ 360 | "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435", 361 | "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337" 362 | ], 363 | "version": "==2.7.4" 364 | }, 365 | "pyparsing": { 366 | "hashes": [ 367 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 368 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 369 | ], 370 | "version": "==2.4.7" 371 | }, 372 | "pytz": { 373 | "hashes": [ 374 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 375 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 376 | ], 377 | "version": "==2021.1" 378 | }, 379 | "pyyaml": { 380 | "hashes": [ 381 | "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", 382 | "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", 383 | "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", 384 | "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", 385 | "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", 386 | "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", 387 | "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", 388 | "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", 389 | "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", 390 | "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", 391 | "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", 392 | "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", 393 | "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", 394 | "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", 395 | "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", 396 | "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", 397 | "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", 398 | "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", 399 | "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", 400 | "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", 401 | "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc" 402 | ], 403 | "version": "==5.4.1" 404 | }, 405 | "readme-renderer": { 406 | "hashes": [ 407 | "sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d", 408 | "sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a" 409 | ], 410 | "version": "==28.0" 411 | }, 412 | "requests": { 413 | "hashes": [ 414 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 415 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 416 | ], 417 | "version": "==2.25.1" 418 | }, 419 | "requests-toolbelt": { 420 | "hashes": [ 421 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 422 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 423 | ], 424 | "version": "==0.9.1" 425 | }, 426 | "rfc3986": { 427 | "hashes": [ 428 | "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", 429 | "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" 430 | ], 431 | "version": "==1.4.0" 432 | }, 433 | "secretstorage": { 434 | "hashes": [ 435 | "sha256:30cfdef28829dad64d6ea1ed08f8eff6aa115a77068926bcc9f5225d5a3246aa", 436 | "sha256:5c36f6537a523ec5f969ef9fad61c98eb9e017bc601d811e53aa25bece64892f" 437 | ], 438 | "markers": "sys_platform == 'linux'", 439 | "version": "==3.3.0" 440 | }, 441 | "six": { 442 | "hashes": [ 443 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 444 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 445 | ], 446 | "version": "==1.15.0" 447 | }, 448 | "snowballstemmer": { 449 | "hashes": [ 450 | "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", 451 | "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" 452 | ], 453 | "version": "==2.1.0" 454 | }, 455 | "sphinx": { 456 | "hashes": [ 457 | "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8", 458 | "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0" 459 | ], 460 | "index": "pypi", 461 | "version": "==3.2.1" 462 | }, 463 | "sphinx-autobuild": { 464 | "hashes": [ 465 | "sha256:66388f81884666e3821edbe05dd53a0cfb68093873d17320d0610de8db28c74e", 466 | "sha256:e60aea0789cab02fa32ee63c7acae5ef41c06f1434d9fd0a74250a61f5994692" 467 | ], 468 | "index": "pypi", 469 | "version": "==0.7.1" 470 | }, 471 | "sphinx-rtd-theme": { 472 | "hashes": [ 473 | "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d", 474 | "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82" 475 | ], 476 | "index": "pypi", 477 | "version": "==0.5.0" 478 | }, 479 | "sphinxcontrib-applehelp": { 480 | "hashes": [ 481 | "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", 482 | "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" 483 | ], 484 | "version": "==1.0.2" 485 | }, 486 | "sphinxcontrib-devhelp": { 487 | "hashes": [ 488 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 489 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 490 | ], 491 | "version": "==1.0.2" 492 | }, 493 | "sphinxcontrib-htmlhelp": { 494 | "hashes": [ 495 | "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", 496 | "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" 497 | ], 498 | "version": "==1.0.3" 499 | }, 500 | "sphinxcontrib-jsmath": { 501 | "hashes": [ 502 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 503 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 504 | ], 505 | "version": "==1.0.1" 506 | }, 507 | "sphinxcontrib-qthelp": { 508 | "hashes": [ 509 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 510 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 511 | ], 512 | "version": "==1.0.3" 513 | }, 514 | "sphinxcontrib-serializinghtml": { 515 | "hashes": [ 516 | "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", 517 | "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" 518 | ], 519 | "version": "==1.1.4" 520 | }, 521 | "toml": { 522 | "hashes": [ 523 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 524 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 525 | ], 526 | "version": "==0.10.2" 527 | }, 528 | "tornado": { 529 | "hashes": [ 530 | "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb", 531 | "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c", 532 | "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288", 533 | "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95", 534 | "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558", 535 | "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe", 536 | "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791", 537 | "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d", 538 | "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326", 539 | "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b", 540 | "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4", 541 | "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c", 542 | "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910", 543 | "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5", 544 | "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c", 545 | "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0", 546 | "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675", 547 | "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd", 548 | "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f", 549 | "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c", 550 | "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea", 551 | "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6", 552 | "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05", 553 | "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd", 554 | "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575", 555 | "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a", 556 | "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37", 557 | "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795", 558 | "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f", 559 | "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32", 560 | "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c", 561 | "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01", 562 | "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4", 563 | "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2", 564 | "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921", 565 | "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085", 566 | "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df", 567 | "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102", 568 | "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5", 569 | "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68", 570 | "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5" 571 | ], 572 | "version": "==6.1" 573 | }, 574 | "tox": { 575 | "hashes": [ 576 | "sha256:17e61a93afe5c49281fb969ab71f7a3f22d7586d1c56f9a74219910f356fe7d3", 577 | "sha256:3d94b6921a0b6dc90fd8128df83741f30bb41ccd6cd52d131a6a6944ca8f16e6" 578 | ], 579 | "index": "pypi", 580 | "version": "==3.19.0" 581 | }, 582 | "tqdm": { 583 | "hashes": [ 584 | "sha256:4621f6823bab46a9cc33d48105753ccbea671b68bab2c50a9f0be23d4065cb5a", 585 | "sha256:fe3d08dd00a526850568d542ff9de9bbc2a09a791da3c334f3213d8d0bbbca65" 586 | ], 587 | "version": "==4.56.0" 588 | }, 589 | "twine": { 590 | "hashes": [ 591 | "sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab", 592 | "sha256:ba9ff477b8d6de0c89dd450e70b2185da190514e91c42cc62f96850025c10472" 593 | ], 594 | "index": "pypi", 595 | "version": "==3.2.0" 596 | }, 597 | "urllib3": { 598 | "hashes": [ 599 | "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", 600 | "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" 601 | ], 602 | "version": "==1.26.3" 603 | }, 604 | "virtualenv": { 605 | "hashes": [ 606 | "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d", 607 | "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3" 608 | ], 609 | "version": "==20.4.2" 610 | }, 611 | "watchdog": { 612 | "hashes": [ 613 | "sha256:016b01495b9c55b5d4126ed8ae75d93ea0d99377084107c33162df52887cee18", 614 | "sha256:101532b8db506559e52a9b5d75a308729b3f68264d930670e6155c976d0e52a0", 615 | "sha256:27d9b4666938d5d40afdcdf2c751781e9ce36320788b70208d0f87f7401caf93", 616 | "sha256:2f1ade0d0802503fda4340374d333408831cff23da66d7e711e279ba50fe6c4a", 617 | "sha256:376cbc2a35c0392b0fe7ff16fbc1b303fd99d4dd9911ab5581ee9d69adc88982", 618 | "sha256:57f05e55aa603c3b053eed7e679f0a83873c540255b88d58c6223c7493833bac", 619 | "sha256:5f1f3b65142175366ba94c64d8d4c8f4015825e0beaacee1c301823266b47b9b", 620 | "sha256:602dbd9498592eacc42e0632c19781c3df1728ef9cbab555fab6778effc29eeb", 621 | "sha256:68744de2003a5ea2dfbb104f9a74192cf381334a9e2c0ed2bbe1581828d50b61", 622 | "sha256:85e6574395aa6c1e14e0f030d9d7f35c2340a6cf95d5671354ce876ac3ffdd4d", 623 | "sha256:b1d723852ce90a14abf0ec0ca9e80689d9509ee4c9ee27163118d87b564a12ac", 624 | "sha256:d948ad9ab9aba705f9836625b32e965b9ae607284811cd98334423f659ea537a", 625 | "sha256:e2a531e71be7b5cc3499ae2d1494d51b6a26684bcc7c3146f63c810c00e8a3cc", 626 | "sha256:e7c73edef48f4ceeebb987317a67e0080e5c9228601ff67b3c4062fa020403c7", 627 | "sha256:ee21aeebe6b3e51e4ba64564c94cee8dbe7438b9cb60f0bb350c4fa70d1b52c2", 628 | "sha256:f1d0e878fd69129d0d68b87cee5d9543f20d8018e82998efb79f7e412d42154a", 629 | "sha256:f84146f7864339c8addf2c2b9903271df21d18d2c721e9a77f779493234a82b5" 630 | ], 631 | "version": "==1.0.2" 632 | }, 633 | "webencodings": { 634 | "hashes": [ 635 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 636 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 637 | ], 638 | "version": "==0.5.1" 639 | }, 640 | "zipp": { 641 | "hashes": [ 642 | "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108", 643 | "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb" 644 | ], 645 | "version": "==3.4.0" 646 | } 647 | } 648 | } 649 | --------------------------------------------------------------------------------