├── bsync ├── __ini__.py ├── __main__.py ├── settings.py ├── __version__.py ├── log.py ├── cli.py ├── api.py └── sync.py ├── tests ├── __init__.py ├── base.py ├── test_cli.py ├── test_sync.py └── test_api.py ├── docs ├── static │ ├── fork.png │ ├── logo.png │ ├── app.new.png │ ├── folder.png │ ├── app.authz.png │ ├── app.create.png │ ├── app.editor.png │ ├── app.keys.png │ ├── app.perms.png │ ├── box.conf.png │ └── app.service.account.png ├── requirements.txt ├── api.rst ├── changelog.md ├── Makefile ├── index.md ├── usage.md ├── box.app.setup.md └── conf.py ├── .gitignore ├── .readthedocs.yml ├── tox.ini ├── Pipfile ├── README.md ├── setup.py ├── LICENSE └── Pipfile.lock /bsync/__ini__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/fork.png -------------------------------------------------------------------------------- /docs/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/logo.png -------------------------------------------------------------------------------- /docs/static/app.new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.new.png -------------------------------------------------------------------------------- /docs/static/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/folder.png -------------------------------------------------------------------------------- /docs/static/app.authz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.authz.png -------------------------------------------------------------------------------- /docs/static/app.create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.create.png -------------------------------------------------------------------------------- /docs/static/app.editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.editor.png -------------------------------------------------------------------------------- /docs/static/app.keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.keys.png -------------------------------------------------------------------------------- /docs/static/app.perms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.perms.png -------------------------------------------------------------------------------- /docs/static/box.conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/box.conf.png -------------------------------------------------------------------------------- /docs/static/app.service.account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacetelescope/bsync/main/docs/static/app.service.account.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | myst-parser 3 | sphinxcontrib-autoprogram 4 | karma-sphinx-theme 5 | sphinx-click 6 | boxsdk 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | files/ 3 | /*.json 4 | .envrc 5 | *.egg-info 6 | dist 7 | build 8 | .tox/ 9 | docs/build 10 | .DS_Store 11 | .coverage 12 | -------------------------------------------------------------------------------- /bsync/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from bsync.cli import bsync 4 | 5 | 6 | if __name__ == '__main__': 7 | bsync.main(sys.argv[1:], standalone_mode=False) 8 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.9" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = py36,py37,py38,py39,py310 4 | 5 | [testenv] 6 | deps = 7 | boxsdk[jwt] 8 | click 9 | pytest 10 | pytest-cov 11 | progress 12 | 13 | commands = 14 | pytest -s --cov=bsync -vv --pdb 15 | -------------------------------------------------------------------------------- /bsync/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | LOG_LEVELS = {name.lower(): value for name, value in logging._nameToLevel.items() if value} 5 | 6 | # 20MB limit for simple uploads, after that, use chunked 7 | BOX_UPLOAD_LIMIT = 2 * 10 ** 7 8 | 9 | # PATHS seperator for identifying source paths by glob 10 | PATH_SEP = '::' 11 | -------------------------------------------------------------------------------- /bsync/__version__.py: -------------------------------------------------------------------------------- 1 | __name__ = 'bsync' 2 | __title__ = 'Sync files on your computer to Box.com' 3 | __version__ = '1.2.0' 4 | __author__ = 'Justin Quick' 5 | __author_email__ = 'jquick@stsci.edu' 6 | __url__ = 'https://github.com/spacetelescope/bsync' 7 | __license__ = 'APACHE 2' 8 | __description__ = 'Sync files from your computer to Box.com using the Box API. Think rsync for Box.com' 9 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | ipython = "*" 8 | ipdb = "*" 9 | rich = "*" 10 | pytest = "*" 11 | sphinx = "*" 12 | myst-parser = "*" 13 | sphinxcontrib-autoprogram = "*" 14 | karma-sphinx-theme = "*" 15 | sphinx-click = "*" 16 | pytest-cov = "*" 17 | 18 | [packages] 19 | boxsdk = {extras = ["jwt"],version = "*"} 20 | click = "*" 21 | 22 | [requires] 23 | python_version = "3" 24 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ===== 3 | 4 | 5 | bsync.cli 6 | ----------------------- 7 | 8 | .. click:: bsync.cli:bsync 9 | :prog: bsync 10 | 11 | bsync.api 12 | ----------------------- 13 | 14 | .. automodule:: bsync.api 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | 19 | bsync.sync 20 | --------------------------- 21 | 22 | .. automodule:: bsync.sync 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | 28 | bsync.log 29 | ------------------------- 30 | 31 | .. automodule:: bsync.log 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Changes to the `bsync` package by version tag 4 | 5 | - 1.2.0 6 | - Changing `PATHS` seperator to two colons `::` instead of one to allow Windows named drive paths 7 | 8 | - 1.1.0 9 | - Do not use the as-user enterprise header. Works for regular user apps now using a service account. 10 | - remove deprecated `--user` flag 11 | - added docs about service account collaboration 12 | 13 | - 1.0.1 14 | - Added Windows to handle click Path args as strings or bytes (win cmd) 15 | - Improve sync performance and reduce complexity 16 | - Sphinx docs 17 | 18 | - 0.1.1 19 | - Initial release 20 | -------------------------------------------------------------------------------- /bsync/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bsync.settings import LOG_LEVELS 4 | 5 | 6 | def get_logger(log_level=None, log_file=None): 7 | """ 8 | Configures a logger w/ file/stream handler from settings 9 | """ 10 | level = LOG_LEVELS.get(log_level, logging.INFO) 11 | logger = logging.getLogger('bsync') 12 | logger.setLevel(level) 13 | handler = logging.FileHandler(log_file) if log_file else logging.StreamHandler() 14 | handler.setLevel(level) 15 | formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') 16 | handler.setFormatter(formatter) 17 | logger.addHandler(handler) 18 | return logger 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= pipenv run sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from bsync.log import get_logger 5 | 6 | FILES = Path(__file__).parent / 'files' 7 | FILE = FILES / 'settings.json' 8 | SIZE = os.stat(FILE).st_size 9 | CONTENT = open(FILE, 'rb').read() 10 | PARENT_ID = 1000 11 | LOGGER = get_logger() 12 | 13 | 14 | class Item: 15 | def __init__(self, type, id, name): 16 | self.type = type 17 | self.id = self._object_id = id 18 | self.name = name 19 | 20 | def _assert(self, type, id, name): 21 | assert self.type == type 22 | assert self.id == id 23 | assert self.name == name 24 | 25 | def __str__(self): 26 | return '%s %d %s' % (self.type.title(), self.id, self.name) 27 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # bsync 1.2.0 documentation 2 | 3 | ## Purpose 4 | 5 | Sync files from your computer to Box.com using the Box API 6 | 7 | Think rsync for Box 8 | 9 | ```{note} 10 | Right now, this only syncs your folder to Box and not Box to your folder 11 | ``` 12 | 13 | ## Install 14 | 15 | `pip install bsync` 16 | 17 | ## Features 18 | 19 | - Preserves directory structure when uploading 20 | - Can handle large files with chunked upload support 21 | - Large files are uploaded with a progress bar indicator 22 | - Only uploads existing files if they have changed on disk 23 | - Define which files to sync base on glob expressions 24 | - Reports what's been uploaded in a CSV artifact 25 | 26 | 27 | ```{toctree} 28 | box.app.setup 29 | usage 30 | api 31 | changelog 32 | ``` 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bsync 2 | Sync files from your computer to Box.com using the Box API 3 | 4 | Think rsync for Box 5 | 6 | ## Features 7 | 8 | - Preserves directory structure when uploading 9 | - Can handle large files with chunked upload support 10 | - Large files are uploaded with a progress bar indicator 11 | - Only uploads existing files if they have changed on disk 12 | - Define which files to sync base on glob expressions 13 | - Reports what's been uploaded in a CSV artifact 14 | 15 | ## Install 16 | 17 | `pip install bsync` 18 | 19 | Now the `bsync` command should be available in your shell (`bsync.exe` for Win) 20 | 21 | ## Documentation 22 | 23 | Please see the official documentation for more information and usage 24 | 25 | [https://bsync.readthedocs.io/en/latest/](https://bsync.readthedocs.io/en/latest/) 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from bsync import __version__ as pkg 4 | 5 | 6 | with open('README.md', 'r', encoding='utf-8') as fh: 7 | long_description = fh.read() 8 | 9 | setup( 10 | name=pkg.__name__, 11 | version=pkg.__version__, 12 | author=pkg.__author__, 13 | author_email=pkg.__author_email__, 14 | url=pkg.__url__, 15 | license=pkg.__license__, 16 | license_files=('LICENSE',), 17 | description=pkg.__description__, 18 | long_description=long_description, 19 | long_description_content_type='text/markdown', 20 | python_requires='>=3.6', 21 | project_urls={ 22 | 'Documentation': 'https://bsync.readthedocs.io/en/latest/', 23 | 'Source': pkg.__url__ 24 | }, 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Programming Language :: Python :: 3', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: OS Independent', 30 | 'Environment :: Console', 31 | ], 32 | package_data={ 33 | 34 | }, 35 | packages=['bsync'], 36 | install_requires=[ 37 | 'boxsdk[jwt]', 38 | 'click', 39 | ], 40 | tests_require=['pytest'], 41 | entry_points={ 42 | 'console_scripts': [ 43 | 'bsync=bsync.cli:bsync', 44 | ] 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from pathlib import Path 3 | 4 | from click.testing import CliRunner 5 | 6 | from .base import FILES, FILE 7 | 8 | 9 | @mock.patch('bsync.sync.BoxSync') 10 | @mock.patch('bsync.log.get_logger') 11 | @mock.patch('bsync.api.BoxAPI') 12 | def test_bsync(mocked_api, mocked_logger, mocked_sync): 13 | from bsync.cli import bsync 14 | 15 | runner = CliRunner() 16 | result = runner.invoke(bsync, f'{FILES}:*.json 1234 --settings={FILE} --output=foo.csv -l error --log-file bar.log -c 2') 17 | assert result.exit_code == 0 18 | args, _ = mocked_logger.call_args_list[0] 19 | assert args == ('error', Path('bar.log')) 20 | args, _ = mocked_api.call_args_list[0] 21 | assert args == (mocked_logger(), FILE) 22 | args, _ = mocked_sync.call_args_list[0] 23 | assert args == (mocked_api(), mocked_logger(), 2, 1234, f'{FILES}:*.json') 24 | args, _ = mocked_sync.return_value.output.call_args_list[0] 25 | assert args[0] == Path('foo.csv') 26 | 27 | 28 | def test_version(): 29 | from bsync.__version__ import __version__ 30 | from bsync.cli import bsync 31 | 32 | runner = CliRunner() 33 | result = runner.invoke(bsync, '--version') 34 | assert result.exit_code == 0 35 | assert result.stdout_bytes.strip().decode() == f'bsync, version {__version__}' 36 | 37 | 38 | def test_badargs(): 39 | from bsync.cli import bsync 40 | 41 | runner = CliRunner() 42 | result = runner.invoke(bsync, '. 123 --settings=does.not.exist.json') 43 | assert result.exit_code == 2 44 | assert b'does not exist' in result.stdout_bytes 45 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | from tempfile import NamedTemporaryFile 2 | from unittest import mock 3 | from os import getcwd 4 | from pathlib import Path 5 | 6 | from bsync.sync import BoxSync 7 | 8 | from .base import Item, FILES, FILE, LOGGER 9 | 10 | 11 | def test_cwd(): 12 | api = mock.MagicMock() 13 | sync = BoxSync(api, LOGGER, 2, 111, '.') 14 | assert sync.glob == '*' 15 | assert sync.source_folder.absolute() == Path(getcwd()) 16 | 17 | 18 | def test_sync(): 19 | api = mock.MagicMock() 20 | sync = BoxSync(api, LOGGER, 2, 111, f'{FILES}::*') 21 | assert sync.glob == '*' 22 | assert sync.source_folder.absolute() == FILES.absolute() 23 | 24 | parent_folder = Item('folder', 111, 'box folder') 25 | api.client.folder.return_value.get.return_value = parent_folder 26 | mocked_item = mock.MagicMock() 27 | mocked_item.__getitem__.return_value = 'foobar.py' 28 | mocked_item.get.return_value.path_collection = {'entries': [ 29 | Item('folder', 0, 'all files'), 30 | parent_folder, 31 | Item('folder', 222, 'subfolder') 32 | ]} 33 | mocked_item2 = mock.MagicMock() 34 | mocked_item2.__getitem__.return_value = 'settings.json' 35 | mocked_item2._object_id = 777 36 | mocked_item2.get.return_value.path_collection = {'entries': [ 37 | Item('folder', 0, 'all files'), 38 | parent_folder, 39 | ]} 40 | api.client.folder.return_value.get_items.return_value = [mocked_item, mocked_item2] 41 | box_paths = list(sync.get_box_paths()) 42 | assert len(box_paths) == 2 43 | path, item = box_paths[0] 44 | assert item == mocked_item 45 | assert path == FILES / 'subfolder' / 'foobar.py' 46 | assert len(api.client.folder.call_args_list) == 2 47 | 48 | sync.run() 49 | api.upload.assert_called() 50 | args, _ = api.create_folder.call_args_list[0] 51 | assert args == (111, 'subfolder') 52 | args, _ = api.update.call_args_list[0] 53 | assert args == (777, FILE) 54 | 55 | with NamedTemporaryFile() as outfile: 56 | sync.output(outfile.name) 57 | assert len(open(outfile.name).readlines()) == 4 58 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | (Usage)= 2 | # Usage 3 | 4 | ## Basic 5 | 6 | The `bsync` command is installed as a script on your computer when `pip install bsync` is run. 7 | On *nix systems it is simply `bsync` on Windoze it is `bsync.exe` 8 | 9 | The full command reference help text can be found by running `bsync --help` 10 | 11 | ``` 12 | Usage: bsync [OPTIONS] SOURCE_FOLDER[:PATHS] BOX_FOLDER_ID 13 | ``` 14 | 15 | ## Arguments 16 | 17 | ### `SOURCE_FOLDER` 18 | 19 | Environment Variable: `SOURCE_FOLDER` 20 | 21 | This is a folder that is on your local file system. 22 | This is the directory that will be copied into your Box.com instance. 23 | The directory file structure and subfolders will be preserved. 24 | The source folder is mapped directly to the destination. 25 | In rsync terms, think of bsync as doing a trailing slash. 26 | 27 | You can pass additional `PATHS` after the source folder (separated by a colon) 28 | to only sync paths in the source folder matching that glob expression 29 | 30 | Example 31 | 32 | `~/myfolder:*.json` 33 | 34 | Will copy all JSON files in the `myfolder` folder in your home directory. 35 | 36 | 37 | ### `BOX_FOLDER_ID` 38 | 39 | Environment Variable: `BOX_FOLDER_ID` 40 | 41 | This is the long integer that Box.com uses as a unique ID for every folder. 42 | It can be found in the URL of the directory you want to use 43 | 44 | 45 | ![Folder ID](./static/folder.png) 46 | 47 | ## Options 48 | 49 | 50 | ### `--settings` (required) 51 | 52 | Environment Variable: `BOX_SETTINGS_FILE` 53 | 54 | The source path to the JSON settings file for you Box app. 55 | 56 | See the [](App) page if you havent already 57 | 58 | ### `--output` 59 | 60 | The file path to write a log of which files and folders were created. 61 | Use this as an audit log as to what was done on Box.com 62 | 63 | ### `--log-flie` 64 | 65 | The file path to write application logs (and errors if any). 66 | Defaults to wrting to stderr 67 | 68 | ### `--log-level` 69 | 70 | The Python logging level to use when writing application logs. 71 | Defaults to `info` 72 | 73 | ### `--ipdb` 74 | 75 | Drop into an `ipdb` shell on application errors 76 | 77 | ## Example 78 | 79 | ``` 80 | bsync --settings 12345.json --log-level DEBUG images:*.jpg 123456789 81 | ``` 82 | 83 | Uses the `12345.json` as the Box settings file. 84 | 85 | Searches for all JPG images from `images` folder 86 | 87 | Uploads files found to the Box.com folder with ID `123456789` 88 | 89 | Logs application `DEBUG` messages to stderr 90 | -------------------------------------------------------------------------------- /bsync/cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from contextlib import contextmanager 3 | from multiprocessing import cpu_count 4 | 5 | import click 6 | 7 | from bsync.api import BoxAPI 8 | from bsync.sync import BoxSync 9 | from bsync.log import get_logger 10 | from bsync.settings import LOG_LEVELS 11 | from bsync.__version__ import __version__ 12 | 13 | 14 | @contextmanager 15 | def nullcontext(enter_result=None): 16 | yield enter_result 17 | 18 | 19 | @click.command() 20 | @click.version_option(__version__) 21 | @click.argument('source_folder_paths', metavar='SOURCE_FOLDER[::PATHS]', envvar='SOURCE_FOLDER') 22 | @click.argument('box_folder_id', envvar='BOX_FOLDER_ID', type=int) 23 | @click.option('-s', '--settings', envvar='BOX_SETTINGS_FILE', 24 | type=click.Path(exists=True, dir_okay=False, path_type=Path)) 25 | @click.option('-o', '--output', type=click.Path(dir_okay=False, path_type=Path), 26 | help='File to write created items as CSV report') 27 | @click.option('-c', '--concurrency', type=int, default=cpu_count(), 28 | help='Number of threads used to access the Box API using asyncio. ' 29 | 'The default is the number of CPUs available on your system.') 30 | @click.option('-l', '--log-level', type=click.Choice(LOG_LEVELS, case_sensitive=False), help='Log level') 31 | @click.option('--log-file', type=click.Path(dir_okay=False, path_type=Path), help='Log file') 32 | @click.option('-i', '--ipdb', is_flag=True, help='Drop into ipdb shell on error') 33 | def bsync(**options): 34 | """ 35 | Syncs the contents of local folder to your Box account 36 | 37 | SOURCE_FOLDER is the path of an existing local folder. 38 | Additional, optional PATHS can be added as a glob expression after the folder. 39 | 40 | BOX_FOLDER_ID is the ID for the folder in Box where you want the files sent 41 | 42 | Example: 43 | 44 | bsync -s 12345.json -l DEBUG images::*.jpg 123456789 45 | """ 46 | ctx = nullcontext 47 | if options['ipdb']: 48 | from ipdb import launch_ipdb_on_exception 49 | ctx = launch_ipdb_on_exception 50 | 51 | # windoze weirdness 52 | for field in ('source_folder_paths', 'settings', 'output', 'log_level', 'log_file'): 53 | if isinstance(options[field], bytes): 54 | options[field] = options[field].decode() 55 | 56 | with ctx(): 57 | logger = get_logger(options['log_level'], options['log_file']) 58 | api = BoxAPI(logger, options['settings']) 59 | bsync = BoxSync(api, logger, options['concurrency'], options['box_folder_id'], options['source_folder_paths']) 60 | bsync.run() 61 | if options['output']: 62 | bsync.output(options['output']) 63 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from unittest import mock 5 | 6 | 7 | from .base import Item, SIZE, FILE, PARENT_ID, CONTENT, LOGGER 8 | 9 | 10 | class MockedStat: 11 | st_size = 2 * 10 ** 7 12 | 13 | 14 | class MockedSession: 15 | total_parts = 10 16 | part_size = SIZE 17 | upload_part_bytes = mock.MagicMock() 18 | commit = mock.MagicMock() 19 | commit.return_value = Item('file', 333, 'chunked file') 20 | 21 | 22 | def test_logger(): 23 | assert LOGGER.level == logging.INFO 24 | assert len(LOGGER.handlers) == 1 25 | hdlr = LOGGER.handlers[0] 26 | assert hdlr.level == logging.INFO 27 | assert hdlr.stream == sys.stderr 28 | 29 | 30 | @mock.patch('boxsdk.Client') 31 | @mock.patch('boxsdk.JWTAuth') 32 | def test_api(mocked_jwt, mocked_client): 33 | from bsync.api import BoxAPI 34 | 35 | mocked_client.return_value.users.return_value = [1] 36 | api = BoxAPI(LOGGER, FILE) 37 | 38 | mocked_folder = mocked_client.return_value.folder 39 | mocked_file = mocked_client.return_value.file 40 | mocked_folder.return_value.create_subfolder.return_value = Item('folder', 111, 'test subfolder') 41 | subfolder = api.create_folder(PARENT_ID, 'test-folder') 42 | subfolder._assert('folder', 111, 'test subfolder') 43 | api.client.folder.assert_called_once_with(PARENT_ID) 44 | mocked_jwt.assert_called_once_with('id', 'secret', 'enterprise', 'public', 45 | rsa_private_key_data='private', rsa_private_key_passphrase='passphrase') 46 | 47 | mocked_folder.return_value.upload.return_value = Item('file', 222, 'test file') 48 | newfile = api.upload(PARENT_ID, FILE) 49 | assert api.client.folder.call_count == 2 50 | newfile._assert('file', 222, 'test file') 51 | 52 | with mock.patch('os.stat') as mocked_stat: 53 | mocked_stat.return_value = stat = MockedStat() 54 | mocked_folder.return_value.create_upload_session.return_value = session = MockedSession() 55 | newfile = api.upload(PARENT_ID, FILE) 56 | assert session.total_parts == len(session.upload_part_bytes.call_args_list) 57 | args, _ = session.upload_part_bytes.call_args_list[0] 58 | assert args == (CONTENT, 0, stat.st_size) 59 | args, _ = session.upload_part_bytes.call_args_list[1] 60 | assert args == (b'', SIZE, stat.st_size) 61 | session.commit.assert_called() 62 | newfile._assert('file', 333, 'chunked file') 63 | 64 | mocked_file.return_value.update_contents.return_value = Item('file', 444, 'test update') 65 | newfile = api.update(444, FILE) 66 | assert api.client.folder.call_count == 3 67 | assert api.client.file.call_count == 1 68 | newfile._assert('file', 444, 'test update') 69 | -------------------------------------------------------------------------------- /docs/box.app.setup.md: -------------------------------------------------------------------------------- 1 | (App)= 2 | # Creating a Box App for use with bsync 3 | 4 | ## Overview 5 | 6 | You need to create a Box app with access to read/write your files/folders. 7 | The app must have JWT server side auth enabled. 8 | The JSON settings file with your keys will be required to run `bsync` 9 | The whole process is also officially outlined by Box here 10 | 11 | 12 | [https://github.com/box/developer.box.com/blob/main/content/guides/tooling/cli/quick-start/1-create-jwt-app.md](https://github.com/box/developer.box.com/blob/main/content/guides/tooling/cli/quick-start/1-create-jwt-app.md) 13 | 14 | 15 | ## Create New App 16 | 17 | On your own Box.com instance, navigate `Dev Console > My Apps` and click `Create New App` 18 | 19 | [https://app.box.com/developers/console](https://app.box.com/developers/console) 20 | 21 | ![Create New App](./static/app.new.png) 22 | 23 | 24 | On the next page, use a `Custom App` with `Server Side Authentication (with JWT)`. 25 | Give your app a name and click `Create App` 26 | 27 | 28 | ![Custom App](./static/app.create.png) 29 | 30 | ## Configure the App 31 | 32 | In the `Configuration` section at the top, scroll down and add the `Write all files and folders stored in Box`. This is the only app permission that bsync requires. 33 | 34 | ![Create New App](./static/app.perms.png) 35 | 36 | Once you have checked the box, you can click `Save Changes` at the top to update your app's config. 37 | 38 | ## App Authorization 39 | 40 | In the top menu, click on the `Authorization` tab. This section lets you submit your custom app to your Box administrators for approval. Your app must be approved before continuing with the setup. Click `Review and Submit` to publish your changes. 41 | 42 | ![Review and Submit](./static/app.authz.png) 43 | 44 | ## Generate your App's Keys 45 | 46 | Once your app has been approved by your administrators, go back to the `Configuration` tab in the top menu. Scroll down to the `Add and Manage Public Keys` section. Click the `Generate a Public/Private Keypair`. This will trigger a download of a JSON file from Box.com. KEEP THIS FILE PRIVATE! It contains your private key. You will use this file when running `bsync` 47 | 48 | ![Generate Keys](./static/app.keys.png) 49 | 50 | ## Add Service Account to Folders 51 | 52 | In order for you to be able to use your app to access any of your folders, Box.com uses service accounts attached to the app. Under your app's `General Settings` tab on the top left, scroll down to the `Service Account Info` and use the email address there to let your app collaborate with the folders you want to sync. 53 | 54 | ![Service Account](./static/app.service.account.png) 55 | 56 | Now navigate to your target folders and add the service account by email to the folder as an `Editor`. Now your app has permissions to upload your files to the folder to which you have added your service account. 57 | 58 | ![Service Account Editor](./static/app.editor.png) 59 | 60 | You have complete'd your app install, now you can `bsync` 61 | 62 | Check out the [](Usage) page to see how 63 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | from datetime import datetime 14 | import os 15 | import sys 16 | sys.path.insert(0, os.path.abspath('..')) 17 | 18 | 19 | def getpkg(): 20 | from bsync import __version__ as pkg 21 | return pkg 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | 26 | pkg = getpkg() 27 | project = pkg.__name__ 28 | author = pkg.__author__ 29 | copyright = f'{datetime.now().year}, {author}' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'myst_parser', 39 | 'sphinx.ext.autodoc', 40 | 'sphinx.ext.viewcode', 41 | 'sphinxcontrib.autoprogram', 42 | 'sphinx_click' 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The language for content autogenerated by Sphinx. Refer to documentation 49 | # for a list of supported languages. 50 | # 51 | # This is also used if you do content translation via gettext catalogs. 52 | # Usually you set "language" from the command line for these cases. 53 | language = 'en' 54 | 55 | # List of patterns, relative to source directory, that match files and 56 | # directories to ignore when looking for source files. 57 | # This pattern also affects html_static_path and html_extra_path. 58 | exclude_patterns = ['build', 'Thumbs.db', '.DS_Store'] 59 | 60 | 61 | # -- Options for HTML output ------------------------------------------------- 62 | 63 | # The theme to use for HTML and HTML Help pages. See the documentation for 64 | # a list of builtin themes. 65 | # 66 | html_theme = 'alabaster' 67 | html_theme_options = { 68 | 'logo': 'logo.png', 69 | 'github_user': 'spacetelescope', 70 | 'github_repo': 'bsync', 71 | 'github_banner': 'fork.png', 72 | 'github_button': True, 73 | 'description': pkg.__description__, 74 | 'page_width': '1000px' 75 | } 76 | # Add any paths that contain custom static files (such as style sheets) here, 77 | # relative to this directory. They are copied after the builtin static files, 78 | # so a file named "default.css" will overwrite the builtin "default.css". 79 | html_static_path = ['static'] 80 | html_css_files = [ 81 | ] 82 | 83 | # -- Extension configuration ------------------------------------------------- 84 | 85 | # -- Options for todo extension ---------------------------------------------- 86 | 87 | # If true, `todo` and `todoList` produce output, else they produce nothing. 88 | todo_include_todos = True 89 | -------------------------------------------------------------------------------- /bsync/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import hashlib 4 | 5 | from boxsdk import Client, JWTAuth 6 | 7 | from bsync.settings import BOX_UPLOAD_LIMIT 8 | 9 | 10 | class BoxAPI: 11 | """ 12 | Wraps boxsdk to create a client and perform actions, logging results 13 | """ 14 | 15 | def __init__(self, logger, settings): 16 | self.settings = settings 17 | self.logger = logger 18 | self._client = None 19 | 20 | @property 21 | def client(self): 22 | """ 23 | Gets a boxsdk.Client instance from the JSON settings file using JWT 24 | """ 25 | if self._client: 26 | return self._client 27 | config = json.load(open(self.settings)) 28 | CLIENT_ID = config['boxAppSettings']['clientID'] 29 | CLIENT_SECRET = config['boxAppSettings']['clientSecret'] 30 | PUBLIC_KEY_ID = config['boxAppSettings']['appAuth']['publicKeyID'] 31 | PRIVATE_KEY = config['boxAppSettings']['appAuth']['privateKey'] 32 | PASSPHRASE = config['boxAppSettings']['appAuth']['passphrase'] 33 | ENTERPRISE_ID = config.get('enterpriseID') 34 | auth = JWTAuth(CLIENT_ID, CLIENT_SECRET, ENTERPRISE_ID, PUBLIC_KEY_ID, 35 | rsa_private_key_data=PRIVATE_KEY, rsa_private_key_passphrase=PASSPHRASE) 36 | auth.authenticate_instance() 37 | client = Client(auth) 38 | self.logger.info(f'Created client instance for ID {CLIENT_ID}') 39 | self._client = client 40 | return client 41 | 42 | def send_chunked(self, path, session_func): 43 | """ 44 | Uses the chunked upload API from Box to upload sequential segments of a file 45 | """ 46 | total_size = os.stat(path).st_size 47 | sha1 = hashlib.sha1() 48 | upload_session = session_func(total_size, path.name) 49 | part_array = [] 50 | content_stream = open(path, 'rb') 51 | 52 | for part_num in range(upload_session.total_parts): 53 | copied_length = 0 54 | chunk = b'' 55 | while copied_length < upload_session.part_size: 56 | bytes_read = content_stream.read(upload_session.part_size - copied_length) 57 | if bytes_read is None: 58 | continue 59 | if len(bytes_read) == 0: 60 | break 61 | chunk += bytes_read 62 | copied_length += len(bytes_read) 63 | percent = part_num * upload_session.part_size / total_size * 100 64 | self.logger.info(f'Uploading {path.name} {percent:.0f}%...') 65 | uploaded_part = upload_session.upload_part_bytes(chunk, part_num * upload_session.part_size, total_size) 66 | part_array.append(uploaded_part) 67 | sha1.update(chunk) 68 | content_sha1 = sha1.digest() 69 | uploaded_file = upload_session.commit(content_sha1=content_sha1, parts=part_array) 70 | return uploaded_file 71 | 72 | def upload(self, parent_id, fname): 73 | """ 74 | Uploads a file to the parent folder_id 75 | Handles large files by chunking 76 | """ 77 | total_size = os.stat(fname).st_size 78 | folder = self.client.folder(parent_id) 79 | if total_size < BOX_UPLOAD_LIMIT: 80 | uploaded_file = folder.upload(fname, upload_using_accelerator=True) 81 | else: 82 | uploaded_file = self.send_chunked(fname, folder.create_upload_session) 83 | self.logger.info(f'Uploaded File: {uploaded_file.name}({uploaded_file.id})') 84 | return uploaded_file 85 | 86 | def update(self, file_id, fname): 87 | """ 88 | Updates the contents of a file and uploads it to an existing file on Box 89 | """ 90 | total_size = os.stat(fname).st_size 91 | boxfile = self.client.file(file_id) 92 | if total_size < BOX_UPLOAD_LIMIT: 93 | updated_file = boxfile.update_contents(fname, upload_using_accelerator=True) 94 | else: 95 | updated_file = self.send_chunked(fname, boxfile.create_upload_session) 96 | self.logger.info(f'Updated File: {updated_file.name}({updated_file.id})') 97 | return updated_file 98 | 99 | def create_folder(self, parent_id, name): 100 | """ 101 | Creates a subfolder in an existing Box folder 102 | """ 103 | subfolder = self.client.folder(parent_id).create_subfolder(name) 104 | self.logger.info(f'Created subfolder {name}({subfolder.id}) in {parent_id}') 105 | return subfolder 106 | -------------------------------------------------------------------------------- /bsync/sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import csv 3 | import os 4 | import asyncio 5 | from concurrent.futures import ThreadPoolExecutor as Executor 6 | 7 | from click import UsageError 8 | from boxsdk.object.folder import Folder 9 | 10 | from bsync.settings import PATH_SEP 11 | 12 | 13 | class BoxSync: 14 | """ 15 | Syncs the parent folder files to Box. 16 | 17 | Compares current files, checks for any missing in Box or any changed locally, 18 | creates directory structure and finally uploads all files 19 | """ 20 | 21 | def __init__(self, api, logger, concurrency, box_folder_id, source_folder_paths): 22 | self.api = api 23 | self.logger = logger 24 | self.box_folder_id = int(box_folder_id) 25 | self.glob = '*' 26 | if PATH_SEP in str(source_folder_paths): 27 | self.source_folder, self.glob = source_folder_paths.split(PATH_SEP) 28 | self.source_folder = Path(self.source_folder).expanduser() 29 | else: 30 | self.source_folder = Path(source_folder_paths).expanduser() 31 | if not self.source_folder.is_dir(): 32 | raise UsageError(f'Source folder {self.source_folder} is not a directory') 33 | self.logger.debug(f'Scanning paths matching {self.glob} in folder {self.source_folder}') 34 | self.changes = [] 35 | self._parent = None 36 | self.executor = Executor(concurrency) 37 | self.loop = asyncio.get_event_loop() 38 | 39 | @property 40 | def parent_folder(self): 41 | """ 42 | Gets the parent folder in Box via API GET 43 | """ 44 | if self._parent: 45 | return self._parent 46 | self._parent = self.api.client.folder(self.box_folder_id).get() 47 | return self._parent 48 | 49 | def to_path(self, item): 50 | """ 51 | Converts a Box File/Folder to a filepath from the parent folder 52 | """ 53 | path_collection = item.get(fields=['path_collection']).path_collection 54 | item_path = None 55 | for entry in path_collection['entries']: 56 | if entry == self.parent_folder: 57 | item_path = self.source_folder 58 | elif item_path is not None: 59 | item_path = item_path / entry.name 60 | return item_path / item['name'] 61 | 62 | def get_box_paths(self, folder_id=None): 63 | """ 64 | Yields all paths recursively from the parent folder ID 65 | """ 66 | if folder_id is None: 67 | folder_id = self.box_folder_id 68 | for item in self.api.client.folder(folder_id).get_items(): 69 | yield self.to_path(item), item 70 | if isinstance(item, Folder): 71 | yield from self.get_box_paths(item._object_id) 72 | 73 | def prepare(self): 74 | """ 75 | Loads entries from local filesystem and Box 76 | Used to decide later which items to sync 77 | """ 78 | self.logger.info('Loading local path info') 79 | local_paths = list(sorted(self.source_folder.rglob(self.glob))) 80 | self.local_files = [path for path in local_paths if path.is_file()] 81 | self.local_dirs = [path for path in local_paths if path.is_dir()] 82 | self.new_dirs = {} 83 | self.logger.info('Loading Box.com path info') 84 | self.box_paths = dict(self.get_box_paths()) 85 | 86 | def get_parent(self, path): 87 | """ 88 | Returns the Box Folder object for the parent folder of path 89 | """ 90 | parent = path.parent 91 | if parent == self.source_folder: 92 | return self.parent_folder 93 | elif parent in self.box_paths: 94 | return self.box_paths[parent] 95 | elif parent in self.new_dirs: 96 | return self.new_dirs[parent] 97 | raise ValueError(f'Unable to resolve folder path: {parent}') 98 | 99 | def sync_folders(self): 100 | """ 101 | Creates the subfolders in Box.com to match local filesystem 102 | Runs before new files are updated/uploaded 103 | """ 104 | for path in self.local_dirs: 105 | if path not in self.box_paths: 106 | parent = self.get_parent(path) 107 | self.new_dirs[path] = subfolder = self.api.create_folder(parent._object_id, path.name) 108 | self.changes.append((parent, subfolder)) 109 | 110 | def has_changed(self, boxfile, path): 111 | """ 112 | Compares the file on Box with the path on disk 113 | Used to see if the local file has changed 114 | """ 115 | # TODO: compare sha1? expensive for large local files 116 | return boxfile.get(fields=['size']).size != os.stat(path).st_size 117 | 118 | def _run_upload(self, method, object_id, path): 119 | return self.loop.run_in_executor(self.executor, method, object_id, path) 120 | 121 | async def sync_files(self): 122 | """ 123 | Uploads the new or updated files to Box.com 124 | Folder structure must be created before running 125 | """ 126 | parents, tasks = [], [] 127 | for path in self.local_files: 128 | parent = self.get_parent(path) 129 | parents.append(parent) 130 | if path not in self.box_paths: 131 | tasks.append(self._run_upload(self.api.upload, parent._object_id, path)) 132 | else: 133 | boxfile = self.box_paths[path] 134 | if self.has_changed(boxfile, path): 135 | tasks.append(self._run_upload(self.api.update, boxfile._object_id, path)) 136 | completed, _ = await asyncio.wait(tasks) 137 | results = zip(parents, [t.result() for t in completed]) 138 | self.changes.extend(results) 139 | 140 | def run(self): 141 | """ 142 | Main method that finds local files and matching files on Box. 143 | Then syncs the folder/subfolder structure and finally syncs any files to Box from the local machine 144 | """ 145 | self.prepare() 146 | self.logger.info(f'Syncing {len(self.local_files)} files in ' 147 | f'{len(self.local_dirs) + 1} folders from {self.source_folder}') 148 | self.sync_folders() 149 | self.loop.run_until_complete(self.sync_files()) 150 | 151 | if not self.changes: 152 | self.logger.warning('No changes detected') 153 | 154 | def output(self, filename): 155 | """ 156 | Writes output CSV of what files are synced and their destinations in Box 157 | """ 158 | header = ('Item Type', 'Parent Folder ID', 'Parent Folder Name', 'Item ID', 'Item Name') 159 | with open(filename, 'w') as outfile: 160 | writer = csv.writer(outfile) 161 | writer.writerow(header) 162 | for parent, item in self.changes: 163 | writer.writerow([item.__class__.__name__, parent._object_id, parent.name, 164 | item._object_id, item.name]) 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Space Telescope Science institute, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "32aa4c48cb37c25fce608c9ce1844e52a7c429c20c687741d2517eeea376ef6d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "attrs": { 20 | "hashes": [ 21 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 22 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 23 | ], 24 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 25 | "version": "==21.4.0" 26 | }, 27 | "boxsdk": { 28 | "extras": [ 29 | "jwt" 30 | ], 31 | "hashes": [ 32 | "sha256:0d378c51088681b43b1c2f052c082043c683978eaba19437c1f9f73185c46c3e", 33 | "sha256:a57978ece697482c8c1d44d1611aa130a0cad1ddca553db2524f43a98e8b4a96" 34 | ], 35 | "index": "pypi", 36 | "version": "==3.0.1" 37 | }, 38 | "certifi": { 39 | "hashes": [ 40 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 41 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 42 | ], 43 | "version": "==2021.10.8" 44 | }, 45 | "cffi": { 46 | "hashes": [ 47 | "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", 48 | "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", 49 | "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", 50 | "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", 51 | "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", 52 | "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", 53 | "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", 54 | "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", 55 | "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", 56 | "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", 57 | "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", 58 | "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", 59 | "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", 60 | "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", 61 | "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", 62 | "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", 63 | "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", 64 | "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", 65 | "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", 66 | "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", 67 | "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", 68 | "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", 69 | "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", 70 | "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", 71 | "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", 72 | "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", 73 | "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", 74 | "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", 75 | "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", 76 | "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", 77 | "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", 78 | "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", 79 | "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", 80 | "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", 81 | "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", 82 | "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", 83 | "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", 84 | "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", 85 | "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", 86 | "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", 87 | "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", 88 | "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", 89 | "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", 90 | "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", 91 | "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", 92 | "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", 93 | "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", 94 | "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", 95 | "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", 96 | "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" 97 | ], 98 | "version": "==1.15.0" 99 | }, 100 | "charset-normalizer": { 101 | "hashes": [ 102 | "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", 103 | "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" 104 | ], 105 | "markers": "python_version >= '3'", 106 | "version": "==2.0.11" 107 | }, 108 | "click": { 109 | "hashes": [ 110 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 111 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 112 | ], 113 | "index": "pypi", 114 | "version": "==8.0.3" 115 | }, 116 | "cryptography": { 117 | "hashes": [ 118 | "sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e", 119 | "sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b", 120 | "sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7", 121 | "sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085", 122 | "sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc", 123 | "sha256:3c4129fc3fdc0fa8e40861b5ac0c673315b3c902bbdc05fc176764815b43dd1d", 124 | "sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a", 125 | "sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498", 126 | "sha256:695104a9223a7239d155d7627ad912953b540929ef97ae0c34c7b8bf30857e89", 127 | "sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9", 128 | "sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c", 129 | "sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7", 130 | "sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb", 131 | "sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14", 132 | "sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af", 133 | "sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e", 134 | "sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5", 135 | "sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06", 136 | "sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7" 137 | ], 138 | "version": "==3.4.8" 139 | }, 140 | "idna": { 141 | "hashes": [ 142 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 143 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 144 | ], 145 | "markers": "python_version >= '3'", 146 | "version": "==3.3" 147 | }, 148 | "pycparser": { 149 | "hashes": [ 150 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 151 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 152 | ], 153 | "version": "==2.21" 154 | }, 155 | "pyjwt": { 156 | "hashes": [ 157 | "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41", 158 | "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f" 159 | ], 160 | "version": "==2.3.0" 161 | }, 162 | "requests": { 163 | "hashes": [ 164 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 165 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 166 | ], 167 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 168 | "version": "==2.27.1" 169 | }, 170 | "requests-toolbelt": { 171 | "hashes": [ 172 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 173 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 174 | ], 175 | "version": "==0.9.1" 176 | }, 177 | "urllib3": { 178 | "hashes": [ 179 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 180 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 181 | ], 182 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 183 | "version": "==1.26.8" 184 | }, 185 | "wrapt": { 186 | "hashes": [ 187 | "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179", 188 | "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096", 189 | "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374", 190 | "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df", 191 | "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185", 192 | "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785", 193 | "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7", 194 | "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909", 195 | "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918", 196 | "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33", 197 | "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068", 198 | "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829", 199 | "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af", 200 | "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79", 201 | "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce", 202 | "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc", 203 | "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36", 204 | "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade", 205 | "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca", 206 | "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32", 207 | "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125", 208 | "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e", 209 | "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709", 210 | "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f", 211 | "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b", 212 | "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb", 213 | "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb", 214 | "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489", 215 | "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640", 216 | "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb", 217 | "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851", 218 | "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d", 219 | "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44", 220 | "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13", 221 | "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2", 222 | "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb", 223 | "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b", 224 | "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9", 225 | "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755", 226 | "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c", 227 | "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a", 228 | "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf", 229 | "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3", 230 | "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229", 231 | "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e", 232 | "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de", 233 | "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554", 234 | "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10", 235 | "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80", 236 | "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056", 237 | "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea" 238 | ], 239 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 240 | "version": "==1.13.3" 241 | } 242 | }, 243 | "develop": { 244 | "alabaster": { 245 | "hashes": [ 246 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 247 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 248 | ], 249 | "version": "==0.7.12" 250 | }, 251 | "appnope": { 252 | "hashes": [ 253 | "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442", 254 | "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a" 255 | ], 256 | "markers": "sys_platform == 'darwin'", 257 | "version": "==0.1.2" 258 | }, 259 | "asttokens": { 260 | "hashes": [ 261 | "sha256:0844691e88552595a6f4a4281a9f7f79b8dd45ca4ccea82e5e05b4bbdb76705c", 262 | "sha256:9a54c114f02c7a9480d56550932546a3f1fe71d8a02f1bc7ccd0ee3ee35cf4d5" 263 | ], 264 | "version": "==2.0.5" 265 | }, 266 | "attrs": { 267 | "hashes": [ 268 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 269 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 270 | ], 271 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 272 | "version": "==21.4.0" 273 | }, 274 | "babel": { 275 | "hashes": [ 276 | "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", 277 | "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" 278 | ], 279 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 280 | "version": "==2.9.1" 281 | }, 282 | "backcall": { 283 | "hashes": [ 284 | "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", 285 | "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" 286 | ], 287 | "version": "==0.2.0" 288 | }, 289 | "black": { 290 | "hashes": [ 291 | "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2", 292 | "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71", 293 | "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6", 294 | "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5", 295 | "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912", 296 | "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866", 297 | "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d", 298 | "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0", 299 | "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321", 300 | "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8", 301 | "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd", 302 | "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3", 303 | "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba", 304 | "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0", 305 | "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5", 306 | "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a", 307 | "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28", 308 | "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c", 309 | "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1", 310 | "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab", 311 | "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f", 312 | "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61", 313 | "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3" 314 | ], 315 | "markers": "python_full_version >= '3.6.2'", 316 | "version": "==22.1.0" 317 | }, 318 | "certifi": { 319 | "hashes": [ 320 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 321 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 322 | ], 323 | "version": "==2021.10.8" 324 | }, 325 | "charset-normalizer": { 326 | "hashes": [ 327 | "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", 328 | "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" 329 | ], 330 | "markers": "python_version >= '3'", 331 | "version": "==2.0.11" 332 | }, 333 | "click": { 334 | "hashes": [ 335 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 336 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 337 | ], 338 | "index": "pypi", 339 | "version": "==8.0.3" 340 | }, 341 | "colorama": { 342 | "hashes": [ 343 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 344 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 345 | ], 346 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 347 | "version": "==0.4.4" 348 | }, 349 | "commonmark": { 350 | "hashes": [ 351 | "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", 352 | "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" 353 | ], 354 | "version": "==0.9.1" 355 | }, 356 | "coverage": { 357 | "extras": [ 358 | "toml" 359 | ], 360 | "hashes": [ 361 | "sha256:1245ab82e8554fa88c4b2ab1e098ae051faac5af829efdcf2ce6b34dccd5567c", 362 | "sha256:1bc6d709939ff262fd1432f03f080c5042dc6508b6e0d3d20e61dd045456a1a0", 363 | "sha256:25e73d4c81efa8ea3785274a2f7f3bfbbeccb6fcba2a0bdd3be9223371c37554", 364 | "sha256:276b13cc085474e482566c477c25ed66a097b44c6e77132f3304ac0b039f83eb", 365 | "sha256:2aed4761809640f02e44e16b8b32c1a5dee5e80ea30a0ff0912158bde9c501f2", 366 | "sha256:2dd70a167843b4b4b2630c0c56f1b586fe965b4f8ac5da05b6690344fd065c6b", 367 | "sha256:352c68e233409c31048a3725c446a9e48bbff36e39db92774d4f2380d630d8f8", 368 | "sha256:3f2b05757c92ad96b33dbf8e8ec8d4ccb9af6ae3c9e9bd141c7cc44d20c6bcba", 369 | "sha256:448d7bde7ceb6c69e08474c2ddbc5b4cd13c9e4aa4a717467f716b5fc938a734", 370 | "sha256:463e52616ea687fd323888e86bf25e864a3cc6335a043fad6bbb037dbf49bbe2", 371 | "sha256:482fb42eea6164894ff82abbcf33d526362de5d1a7ed25af7ecbdddd28fc124f", 372 | "sha256:56c4a409381ddd7bbff134e9756077860d4e8a583d310a6f38a2315b9ce301d0", 373 | "sha256:56d296cbc8254a7dffdd7bcc2eb70be5a233aae7c01856d2d936f5ac4e8ac1f1", 374 | "sha256:5e15d424b8153756b7c903bde6d4610be0c3daca3986173c18dd5c1a1625e4cd", 375 | "sha256:618eeba986cea7f621d8607ee378ecc8c2504b98b3fdc4952b30fe3578304687", 376 | "sha256:61d47a897c1e91f33f177c21de897267b38fbb45f2cd8e22a710bcef1df09ac1", 377 | "sha256:621f6ea7260ea2ffdaec64fe5cb521669984f567b66f62f81445221d4754df4c", 378 | "sha256:6a5cdc3adb4f8bb8d8f5e64c2e9e282bc12980ef055ec6da59db562ee9bdfefa", 379 | "sha256:6c3f6158b02ac403868eea390930ae64e9a9a2a5bbfafefbb920d29258d9f2f8", 380 | "sha256:704f89b87c4f4737da2860695a18c852b78ec7279b24eedacab10b29067d3a38", 381 | "sha256:72128176fea72012063200b7b395ed8a57849282b207321124d7ff14e26988e8", 382 | "sha256:78fbb2be068a13a5d99dce9e1e7d168db880870f7bc73f876152130575bd6167", 383 | "sha256:7bff3a98f63b47464480de1b5bdd80c8fade0ba2832c9381253c9b74c4153c27", 384 | "sha256:84f2436d6742c01136dd940ee158bfc7cf5ced3da7e4c949662b8703b5cd8145", 385 | "sha256:9976fb0a5709988778ac9bc44f3d50fccd989987876dfd7716dee28beed0a9fa", 386 | "sha256:9ad0a117b8dc2061ce9461ea4c1b4799e55edceb236522c5b8f958ce9ed8fa9a", 387 | "sha256:9e3dd806f34de38d4c01416344e98eab2437ac450b3ae39c62a0ede2f8b5e4ed", 388 | "sha256:9eb494070aa060ceba6e4bbf44c1bc5fa97bfb883a0d9b0c9049415f9e944793", 389 | "sha256:9fde6b90889522c220dd56a670102ceef24955d994ff7af2cb786b4ba8fe11e4", 390 | "sha256:9fff3ff052922cb99f9e52f63f985d4f7a54f6b94287463bc66b7cdf3eb41217", 391 | "sha256:a06c358f4aed05fa1099c39decc8022261bb07dfadc127c08cfbd1391b09689e", 392 | "sha256:a4f923b9ab265136e57cc14794a15b9dcea07a9c578609cd5dbbfff28a0d15e6", 393 | "sha256:c5b81fb37db76ebea79aa963b76d96ff854e7662921ce742293463635a87a78d", 394 | "sha256:d5ed164af5c9078596cfc40b078c3b337911190d3faeac830c3f1274f26b8320", 395 | "sha256:d651fde74a4d3122e5562705824507e2f5b2d3d57557f1916c4b27635f8fbe3f", 396 | "sha256:de73fca6fb403dd72d4da517cfc49fcf791f74eee697d3219f6be29adf5af6ce", 397 | "sha256:e647a0be741edbb529a72644e999acb09f2ad60465f80757da183528941ff975", 398 | "sha256:e92c7a5f7d62edff50f60a045dc9542bf939758c95b2fcd686175dd10ce0ed10", 399 | "sha256:eeffd96882d8c06d31b65dddcf51db7c612547babc1c4c5db6a011abe9798525", 400 | "sha256:f5a4551dfd09c3bd12fca8144d47fe7745275adf3229b7223c2f9e29a975ebda", 401 | "sha256:fac0bcc5b7e8169bffa87f0dcc24435446d329cbc2b5486d155c2e0f3b493ae1" 402 | ], 403 | "markers": "python_version >= '3.7'", 404 | "version": "==6.3.1" 405 | }, 406 | "decorator": { 407 | "hashes": [ 408 | "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", 409 | "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186" 410 | ], 411 | "markers": "python_version >= '3.7'", 412 | "version": "==5.1.1" 413 | }, 414 | "docutils": { 415 | "hashes": [ 416 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 417 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 418 | ], 419 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 420 | "version": "==0.17.1" 421 | }, 422 | "executing": { 423 | "hashes": [ 424 | "sha256:32fc6077b103bd19e6494a72682d66d5763cf20a106d5aa7c5ccbea4e47b0df7", 425 | "sha256:c23bf42e9a7b9b212f185b1b2c3c91feb895963378887bb10e64a2e612ec0023" 426 | ], 427 | "version": "==0.8.2" 428 | }, 429 | "idna": { 430 | "hashes": [ 431 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 432 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 433 | ], 434 | "markers": "python_version >= '3'", 435 | "version": "==3.3" 436 | }, 437 | "imagesize": { 438 | "hashes": [ 439 | "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c", 440 | "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d" 441 | ], 442 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 443 | "version": "==1.3.0" 444 | }, 445 | "importlib-metadata": { 446 | "hashes": [ 447 | "sha256:6affcdb3aec542dd98df8211e730bba6c5f2bec8288d47bacacde898f548c9ad", 448 | "sha256:9e5e553bbba1843cb4a00823014b907616be46ee503d2b9ba001d214a8da218f" 449 | ], 450 | "markers": "python_version < '3.10'", 451 | "version": "==4.11.0" 452 | }, 453 | "iniconfig": { 454 | "hashes": [ 455 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 456 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 457 | ], 458 | "version": "==1.1.1" 459 | }, 460 | "ipdb": { 461 | "hashes": [ 462 | "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5" 463 | ], 464 | "index": "pypi", 465 | "version": "==0.13.9" 466 | }, 467 | "ipython": { 468 | "hashes": [ 469 | "sha256:ab564d4521ea8ceaac26c3a2c6e5ddbca15c8848fd5a5cc325f960da88d42974", 470 | "sha256:c503a0dd6ccac9c8c260b211f2dd4479c042b49636b097cc9a0d55fe62dff64c" 471 | ], 472 | "index": "pypi", 473 | "version": "==8.0.1" 474 | }, 475 | "jedi": { 476 | "hashes": [ 477 | "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d", 478 | "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab" 479 | ], 480 | "markers": "python_version >= '3.6'", 481 | "version": "==0.18.1" 482 | }, 483 | "jinja2": { 484 | "hashes": [ 485 | "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", 486 | "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" 487 | ], 488 | "markers": "python_version >= '3.6'", 489 | "version": "==3.0.3" 490 | }, 491 | "karma-sphinx-theme": { 492 | "hashes": [ 493 | "sha256:5a95f91818b458bd5bdf62690828361a369a222f0721cd0e1eb2ea29056e8c27" 494 | ], 495 | "index": "pypi", 496 | "version": "==0.0.8" 497 | }, 498 | "markdown-it-py": { 499 | "hashes": [ 500 | "sha256:31974138ca8cafbcb62213f4974b29571b940e78364584729233f59b8dfdb8bd", 501 | "sha256:7b5c153ae1ab2cde00a33938bce68f3ad5d68fbe363f946de7d28555bed4e08a" 502 | ], 503 | "markers": "python_version ~= '3.6'", 504 | "version": "==2.0.1" 505 | }, 506 | "markupsafe": { 507 | "hashes": [ 508 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 509 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 510 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 511 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", 512 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 513 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 514 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", 515 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 516 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", 517 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 518 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", 519 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", 520 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", 521 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", 522 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 523 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", 524 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", 525 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 526 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", 527 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", 528 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 529 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 530 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", 531 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 532 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 533 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", 534 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 535 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", 536 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", 537 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", 538 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 539 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", 540 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", 541 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 542 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 543 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 544 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 545 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", 546 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 547 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", 548 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", 549 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 550 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 551 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 552 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", 553 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", 554 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 555 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 556 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", 557 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", 558 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 559 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 560 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", 561 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 562 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", 563 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", 564 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", 565 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", 566 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 567 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", 568 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", 569 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", 570 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", 571 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", 572 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 573 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 574 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 575 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 576 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 577 | ], 578 | "markers": "python_version >= '3.6'", 579 | "version": "==2.0.1" 580 | }, 581 | "matplotlib-inline": { 582 | "hashes": [ 583 | "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee", 584 | "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c" 585 | ], 586 | "markers": "python_version >= '3.5'", 587 | "version": "==0.1.3" 588 | }, 589 | "mdit-py-plugins": { 590 | "hashes": [ 591 | "sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073", 592 | "sha256:ecc24f51eeec6ab7eecc2f9724e8272c2fb191c2e93cf98109120c2cace69750" 593 | ], 594 | "markers": "python_version ~= '3.6'", 595 | "version": "==0.3.0" 596 | }, 597 | "mdurl": { 598 | "hashes": [ 599 | "sha256:40654d6dcb8d21501ed13c21cc0bd6fc42ff07ceb8be30029e5ae63ebc2ecfda", 600 | "sha256:94873a969008ee48880fb21bad7de0349fef529f3be178969af5817239e9b990" 601 | ], 602 | "markers": "python_version >= '3.6'", 603 | "version": "==0.1.0" 604 | }, 605 | "mypy-extensions": { 606 | "hashes": [ 607 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", 608 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" 609 | ], 610 | "version": "==0.4.3" 611 | }, 612 | "myst-parser": { 613 | "hashes": [ 614 | "sha256:617a90ceda2162ebf81cd13ad17d879bd4f49e7fb5c4f177bb905272555a2268", 615 | "sha256:a6473b9735c8c74959b49b36550725464f4aecc4481340c9a5f9153829191f83" 616 | ], 617 | "index": "pypi", 618 | "version": "==0.16.1" 619 | }, 620 | "packaging": { 621 | "hashes": [ 622 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 623 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 624 | ], 625 | "markers": "python_version >= '3.6'", 626 | "version": "==21.3" 627 | }, 628 | "parso": { 629 | "hashes": [ 630 | "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0", 631 | "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75" 632 | ], 633 | "markers": "python_version >= '3.6'", 634 | "version": "==0.8.3" 635 | }, 636 | "pathspec": { 637 | "hashes": [ 638 | "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", 639 | "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" 640 | ], 641 | "version": "==0.9.0" 642 | }, 643 | "pexpect": { 644 | "hashes": [ 645 | "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", 646 | "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" 647 | ], 648 | "markers": "sys_platform != 'win32'", 649 | "version": "==4.8.0" 650 | }, 651 | "pickleshare": { 652 | "hashes": [ 653 | "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", 654 | "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" 655 | ], 656 | "version": "==0.7.5" 657 | }, 658 | "platformdirs": { 659 | "hashes": [ 660 | "sha256:30671902352e97b1eafd74ade8e4a694782bd3471685e78c32d0fdfd3aa7e7bb", 661 | "sha256:8ec11dfba28ecc0715eb5fb0147a87b1bf325f349f3da9aab2cd6b50b96b692b" 662 | ], 663 | "markers": "python_version >= '3.7'", 664 | "version": "==2.5.0" 665 | }, 666 | "pluggy": { 667 | "hashes": [ 668 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 669 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 670 | ], 671 | "markers": "python_version >= '3.6'", 672 | "version": "==1.0.0" 673 | }, 674 | "prompt-toolkit": { 675 | "hashes": [ 676 | "sha256:cb7dae7d2c59188c85a1d6c944fad19aded6a26bd9c8ae115a4e1c20eb90b713", 677 | "sha256:f2b6a8067a4fb959d3677d1ed764cc4e63e0f6f565b9a4fc7edc2b18bf80217b" 678 | ], 679 | "markers": "python_full_version >= '3.6.2'", 680 | "version": "==3.0.27" 681 | }, 682 | "ptyprocess": { 683 | "hashes": [ 684 | "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", 685 | "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" 686 | ], 687 | "version": "==0.7.0" 688 | }, 689 | "pure-eval": { 690 | "hashes": [ 691 | "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350", 692 | "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3" 693 | ], 694 | "version": "==0.2.2" 695 | }, 696 | "py": { 697 | "hashes": [ 698 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 699 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 700 | ], 701 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 702 | "version": "==1.11.0" 703 | }, 704 | "pygments": { 705 | "hashes": [ 706 | "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65", 707 | "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a" 708 | ], 709 | "markers": "python_version >= '3.5'", 710 | "version": "==2.11.2" 711 | }, 712 | "pyparsing": { 713 | "hashes": [ 714 | "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", 715 | "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" 716 | ], 717 | "markers": "python_version >= '3.6'", 718 | "version": "==3.0.7" 719 | }, 720 | "pytest": { 721 | "hashes": [ 722 | "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9", 723 | "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11" 724 | ], 725 | "index": "pypi", 726 | "version": "==7.0.0" 727 | }, 728 | "pytest-cov": { 729 | "hashes": [ 730 | "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", 731 | "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" 732 | ], 733 | "index": "pypi", 734 | "version": "==3.0.0" 735 | }, 736 | "pytz": { 737 | "hashes": [ 738 | "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", 739 | "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" 740 | ], 741 | "version": "==2021.3" 742 | }, 743 | "pyyaml": { 744 | "hashes": [ 745 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 746 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 747 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 748 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 749 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 750 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 751 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 752 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 753 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 754 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 755 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 756 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 757 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 758 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 759 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 760 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 761 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 762 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 763 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 764 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 765 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 766 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 767 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 768 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 769 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 770 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 771 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 772 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 773 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 774 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 775 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 776 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 777 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 778 | ], 779 | "markers": "python_version >= '3.6'", 780 | "version": "==6.0" 781 | }, 782 | "requests": { 783 | "hashes": [ 784 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 785 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 786 | ], 787 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 788 | "version": "==2.27.1" 789 | }, 790 | "rich": { 791 | "hashes": [ 792 | "sha256:1a6266a5738115017bb64a66c59c717e7aa047b3ae49a011ede4abdeffc6536e", 793 | "sha256:d5f49ad91fb343efcae45a2b2df04a9755e863e50413623ab8c9e74f05aee52b" 794 | ], 795 | "index": "pypi", 796 | "version": "==11.2.0" 797 | }, 798 | "setuptools": { 799 | "hashes": [ 800 | "sha256:43a5575eea6d3459789316e1596a3d2a0d215260cacf4189508112f35c9a145b", 801 | "sha256:66b8598da112b8dc8cd941d54cf63ef91d3b50657b374457eda5851f3ff6a899" 802 | ], 803 | "markers": "python_version >= '3.7'", 804 | "version": "==60.8.2" 805 | }, 806 | "six": { 807 | "hashes": [ 808 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 809 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 810 | ], 811 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 812 | "version": "==1.16.0" 813 | }, 814 | "snowballstemmer": { 815 | "hashes": [ 816 | "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", 817 | "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" 818 | ], 819 | "version": "==2.2.0" 820 | }, 821 | "sphinx": { 822 | "hashes": [ 823 | "sha256:5da895959511473857b6d0200f56865ed62c31e8f82dd338063b84ec022701fe", 824 | "sha256:6caad9786055cb1fa22b4a365c1775816b876f91966481765d7d50e9f0dd35cc" 825 | ], 826 | "index": "pypi", 827 | "version": "==4.4.0" 828 | }, 829 | "sphinx-click": { 830 | "hashes": [ 831 | "sha256:36dbf271b1d2600fb05bd598ddeed0b6b6acf35beaf8bc9d507ba7716b232b0e", 832 | "sha256:8fb0b048a577d346d741782e44d041d7e908922858273d99746f305870116121" 833 | ], 834 | "index": "pypi", 835 | "version": "==3.1.0" 836 | }, 837 | "sphinxcontrib-applehelp": { 838 | "hashes": [ 839 | "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", 840 | "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" 841 | ], 842 | "markers": "python_version >= '3.5'", 843 | "version": "==1.0.2" 844 | }, 845 | "sphinxcontrib-autoprogram": { 846 | "hashes": [ 847 | "sha256:746adb4214c3d2917af948499b3ed4b7b88b208a48c96368c0cff356474dba42", 848 | "sha256:bc642e3f2817a7539f306e021697f72b225bea5ad23b30dc14a7b9d1408d1f1a" 849 | ], 850 | "index": "pypi", 851 | "version": "==0.1.7" 852 | }, 853 | "sphinxcontrib-devhelp": { 854 | "hashes": [ 855 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 856 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 857 | ], 858 | "markers": "python_version >= '3.5'", 859 | "version": "==1.0.2" 860 | }, 861 | "sphinxcontrib-htmlhelp": { 862 | "hashes": [ 863 | "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", 864 | "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" 865 | ], 866 | "markers": "python_version >= '3.6'", 867 | "version": "==2.0.0" 868 | }, 869 | "sphinxcontrib-jsmath": { 870 | "hashes": [ 871 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 872 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 873 | ], 874 | "markers": "python_version >= '3.5'", 875 | "version": "==1.0.1" 876 | }, 877 | "sphinxcontrib-qthelp": { 878 | "hashes": [ 879 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 880 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 881 | ], 882 | "markers": "python_version >= '3.5'", 883 | "version": "==1.0.3" 884 | }, 885 | "sphinxcontrib-serializinghtml": { 886 | "hashes": [ 887 | "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", 888 | "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" 889 | ], 890 | "markers": "python_version >= '3.5'", 891 | "version": "==1.1.5" 892 | }, 893 | "stack-data": { 894 | "hashes": [ 895 | "sha256:02cc0683cbc445ae4ca8c4e3a0e58cb1df59f252efb0aa016b34804a707cf9bc", 896 | "sha256:7769ed2482ce0030e00175dd1bf4ef1e873603b6ab61cd3da443b410e64e9477" 897 | ], 898 | "version": "==0.1.4" 899 | }, 900 | "toml": { 901 | "hashes": [ 902 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 903 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 904 | ], 905 | "markers": "python_version >= '3.7'", 906 | "version": "==0.10.2" 907 | }, 908 | "tomli": { 909 | "hashes": [ 910 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 911 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 912 | ], 913 | "markers": "python_version >= '3.7'", 914 | "version": "==2.0.1" 915 | }, 916 | "traitlets": { 917 | "hashes": [ 918 | "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7", 919 | "sha256:2d313cc50a42cd6c277e7d7dc8d4d7fedd06a2c215f78766ae7b1a66277e0033" 920 | ], 921 | "markers": "python_version >= '3.7'", 922 | "version": "==5.1.1" 923 | }, 924 | "typing-extensions": { 925 | "hashes": [ 926 | "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e", 927 | "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b" 928 | ], 929 | "markers": "python_version < '3.10'", 930 | "version": "==4.0.1" 931 | }, 932 | "urllib3": { 933 | "hashes": [ 934 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 935 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 936 | ], 937 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 938 | "version": "==1.26.8" 939 | }, 940 | "wcwidth": { 941 | "hashes": [ 942 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 943 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 944 | ], 945 | "version": "==0.2.5" 946 | }, 947 | "zipp": { 948 | "hashes": [ 949 | "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d", 950 | "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375" 951 | ], 952 | "markers": "python_version >= '3.7'", 953 | "version": "==3.7.0" 954 | } 955 | } 956 | } 957 | --------------------------------------------------------------------------------