├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── chili.png ├── horn ├── __init__.py ├── cli │ ├── __init__.py │ ├── api.py │ ├── model.py │ ├── new.py │ ├── repo.py │ └── schema.py ├── path.py ├── templates │ ├── gen │ │ ├── tests │ │ │ └── views │ │ │ │ └── test_{{ singular }}.py.jinja │ │ └── {{ app }} │ │ │ ├── models │ │ │ └── {{ singular }}.py.jinja │ │ │ ├── schemas │ │ │ └── {{ singular }}.py.jinja │ │ │ └── views │ │ │ └── {{ singular }}.py.jinja │ └── new │ │ ├── .gitignore.jinja │ │ ├── MANIFEST.in │ │ ├── README.md.jinja │ │ ├── instance │ │ └── prod.secret.cfg.jinja │ │ ├── log │ │ └── .gitkeep │ │ ├── logging.ini.jinja │ │ ├── pyproject.toml.jinja │ │ ├── setup.cfg │ │ ├── setup.py.jinja │ │ ├── tests │ │ ├── __init__.py │ │ ├── conftest.py.jinja │ │ ├── factories.py.jinja │ │ ├── test_swagger.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── test_home.py │ │ │ ├── test_session.py │ │ │ └── test_user.py │ │ └── {{ app }} │ │ ├── __init__.py │ │ ├── cmds.py │ │ ├── config │ │ ├── __init__.py │ │ ├── default.py.jinja │ │ ├── development.py.jinja │ │ ├── production.py │ │ └── testing.py.jinja │ │ ├── core │ │ ├── __init__.py.jinja │ │ ├── database.py.jinja │ │ ├── errors.py.jinja │ │ └── schema.py.jinja │ │ ├── exts.py.jinja │ │ ├── helpers.py.jinja │ │ ├── models │ │ ├── __init__.py.jinja │ │ ├── helpers.py.jinja │ │ └── user.py.jinja │ │ ├── router.py.jinja │ │ ├── run.py.jinja │ │ ├── schemas │ │ ├── __init__.py.jinja │ │ ├── helpers.py.jinja │ │ └── user.py.jinja │ │ ├── swagger.py.jinja │ │ └── views │ │ ├── __init__.py.jinja │ │ ├── home.py.jinja │ │ ├── session.py.jinja │ │ └── user.py.jinja └── tpl.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── fixture.json ├── test_gen_api.py ├── test_gen_model.py ├── test_gen_schema.py └── test_new.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.egg-info/ 3 | __pycache__/ 4 | build/ 5 | .pytest_cache/ 6 | .coverage 7 | .coverage.* 8 | .tox/ 9 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Change pip's cache directory to be inside the project directory since we can 2 | # only cache local items. 3 | variables: 4 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 5 | 6 | cache: 7 | paths: 8 | - .cache/pip 9 | 10 | before_script: 11 | - python -V 12 | - pip install poetry 13 | - poetry install 14 | 15 | # py36: 16 | # image: python:3.6 17 | # stage: test 18 | # script: 19 | # - poetry run pip uninstall -y horn-py 20 | # - make clean 21 | # - poetry run pip freeze > r.txt 22 | # - poetry env remove 3.7 23 | # - pip uninstall -y poetry 24 | # - rm pyproject.toml 25 | # - sed -i '1,3d' r.txt 26 | # - sed -i -E '/^poetry.+$/d' r.txt 27 | # - pip install -r r.txt 28 | # - pip install -e . 29 | # - tox -epy36 30 | 31 | py37: 32 | image: python:3.7 33 | stage: test 34 | script: 35 | - poetry run pytest 36 | 37 | py38: 38 | image: python:3.8 39 | stage: test 40 | script: 41 | - poetry run pytest 42 | 43 | py39: 44 | image: python:3.9 45 | stage: test 46 | script: 47 | - poetry run pytest 48 | 49 | py310: 50 | image: python:3.10 51 | stage: test 52 | script: 53 | - poetry run pytest 54 | 55 | py311: 56 | image: python:3.11 57 | stage: test 58 | script: 59 | - poetry run pytest 60 | coverage: '/^TOTAL.+?(\d+\%)$/' 61 | 62 | pypy: 63 | image: pypy:3 64 | stage: test 65 | script: 66 | - pypy3 -V 67 | - poetry run pytest 68 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | sudo: false 4 | 5 | python: 6 | - 3.7 7 | - 3.8 8 | - 3.9 9 | - 3.10 10 | - 3.11 11 | - pypy3 12 | 13 | install: 14 | - pip install codecov 15 | - pip install poetry 16 | - poetry config virtualenvs.create false 17 | - poetry install 18 | - echo "$TRAVIS_PYTHON_VERSION" 19 | before_script: 20 | - poetry about 21 | script: 22 | - poetry run pytest 23 | 24 | after_success: 25 | - codecov 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 bigfang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | graft horn/templates 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "pkg - package python wheel" 3 | @echo "clean - remove built python artifacts" 4 | @echo "flake - check style with flake8" 5 | @echo "test - run unit tests" 6 | @echo "tox - run tox" 7 | @echo "install - install for development" 8 | @echo "uninstall - uninstall for development" 9 | @echo "upload - upload to pypi" 10 | @echo "pypi - clean package and upload" 11 | 12 | pkg: 13 | poetry run python setup.py bdist_wheel 14 | 15 | clean: clean-build clean-pyc 16 | 17 | clean-build: 18 | rm -rf build 19 | rm -rf dist 20 | rm -rf *.egg-info 21 | rm -rf .tox 22 | rm -rf .coverage* 23 | 24 | clean-pyc: 25 | find . -name '*.pyc' -exec rm -f {} + 26 | find . -name '*.pyo' -exec rm -f {} + 27 | find . -name '__pycache__' -exec rm -rf {} + 28 | find . -name '.pytest_cache' -exec rm -rf {} + 29 | tox: 30 | @tox 31 | 32 | flake: 33 | @poetry run flake8 34 | 35 | test: 36 | @poetry run pytest $(add) 37 | 38 | install: 39 | @poetry run pip install -e . 40 | 41 | uninstall: 42 | @poetry run pip uninstall horn-py 43 | 44 | upload: 45 | @poetry run twine upload dist/* 46 | 47 | pypi: clean pkg upload 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![flask](chili.png) Horn: A Flask scaffolding tool 2 | 3 | [![PyPI - License](https://img.shields.io/pypi/l/horn-py.svg)](https://pypi.org/project/horn-py) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/horn-py.svg)](https://pypi.org/project/horn-py) 5 | [![PyPI - Implementation](https://img.shields.io/pypi/implementation/horn-py.svg)](https://pypi.org/project/horn-py) 6 | [![PyPI](https://img.shields.io/pypi/v/horn-py.svg)](https://pypi.org/project/horn-py) 7 | [![Build Status](https://travis-ci.org/bigfang/horn-py.svg?branch=master)](https://travis-ci.org/bigfang/horn-py) 8 | [![Codecov](https://img.shields.io/codecov/c/gh/bigfang/horn-py.svg)](https://codecov.io/gh/bigfang/horn-py) 9 | 10 | 11 | ## Installation 12 | 13 | ```console 14 | $ pip install horn-py 15 | ``` 16 | 17 | 18 | ## Usage 19 | 20 | ```text 21 | 22 | Usage: 23 | horn new [--app= --proj= --pypi= --bare] 24 | horn new [] [--json=] [-f=PATH | --file=PATH] 25 | horn gen api ... 26 | horn gen model
... 27 | horn gen schema (... | --model= | ... --model=) 28 | horn -h | --help 29 | horn --version 30 | 31 | Options: 32 | --app= App name [default: app]. 33 | --proj= Project name. 34 | --pypi= Pypi domain. 35 | --bare Bare project. 36 | 37 | --json= Json string [default: {}]. 38 | -f=PATH, --file=PATH Json file PATH. 39 | 40 | --model= Schema baseed on model. 41 | 42 | -h, --help Show this screen. 43 | --version Show version. 44 | 45 | Examples: 46 | horn new tmp/foo_bar --app foobar --proj FooBar 47 | horn new tmp/foo_bar https://github.com/bigfang/drf-starter.git --json '{"app":"someapp"}' 48 | horn gen api Post posts title:string:uniq content:text:nonull author:ref:users 49 | horn gen model Post posts title:string:uniq:index content:string:nonull author:ref:users:nonull 50 | horn gen schema Post title:string content:string author:nest:user 51 | 52 | Notes: 53 | Model attrs: uniq => unique=True, nonull => nullable=False, 54 | index => index=True, default:val => default=val 55 | Schema attrs: dump => dump_only, load => load_only, exclude => exclude, 56 | required => required=True, none => allow_none=True 57 | ``` 58 | -------------------------------------------------------------------------------- /chili.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigfang/horn-py/367d474b7d2de1e594fc420eb9ee6fb12376afa4/chili.png -------------------------------------------------------------------------------- /horn/__init__.py: -------------------------------------------------------------------------------- 1 | """\t\t\033[1;33mHorn: A Flask scaffolding tool.\033[0m 2 | 3 | Usage: 4 | horn new [--app= --proj= --pypi= --bare] 5 | horn new [] [--json=] [-f=PATH | --file=PATH] 6 | horn gen api
... 7 | horn gen model
... 8 | horn gen schema (... | --model= | ... --model=) 9 | horn -h | --help 10 | horn --version 11 | 12 | Options: 13 | --app= App name [default: app]. 14 | --proj= Project name. 15 | --pypi= Pypi domain. 16 | --bare Bare project. 17 | 18 | --json= Json string [default: {}]. 19 | -f=PATH, --file=PATH Json file PATH. 20 | 21 | --model= Schema baseed on model. 22 | 23 | -h, --help Show this screen. 24 | --version Show version. 25 | 26 | Examples: 27 | horn \033[34mnew\033[0m tmp/foo_bar \033[32m--app\033[0m foobar \033[32m--proj\033[0m FooBar 28 | horn \033[34mnew\033[0m tmp/foo_bar https://github.com/bigfang/drf-starter.git \033[32m--json\033[0m '{"app":"someapp"}' 29 | horn \033[34mgen api\033[0m Post posts \033[36mtitle:string:uniq content:text:nonull author:ref:users\033[0m 30 | horn \033[34mgen model\033[0m Post posts \033[36mtitle:string:uniq:index content:string:nonull author:ref:users:nonull\033[0m 31 | horn \033[34mgen schema\033[0m Post \033[36mtitle:string content:string author:nest:user\033[0m 32 | 33 | Notes: 34 | Model attrs: uniq => unique=True, nonull => nullable=False, 35 | index => index=True, default:val => default=val 36 | Schema attrs: dump => dump_only, load => load_only, exclude => exclude, 37 | required => required=True, none => allow_none=True 38 | 39 | """ 40 | from docopt import docopt 41 | 42 | from . import cli 43 | 44 | __all__ = ['main', '__version__'] 45 | 46 | 47 | __version__ = '0.6.5' 48 | 49 | ACTION_MAP = { 50 | 'new': ['', '--app', '--proj', '--bare', '--pypi', '', '', '--json', '--file'], 51 | 'api': ['', '
', ''], 52 | 'model': ['', '
', ''], 53 | 'schema': ['', '', '--model'], 54 | } 55 | 56 | ACTIONS = list(ACTION_MAP.keys()) 57 | 58 | 59 | class Hub(object): 60 | 61 | @classmethod 62 | def filter_opts(cls, action, params): 63 | keys = ACTION_MAP.get(action) 64 | return {k: v for k, v in params.items() if k in keys} 65 | 66 | @classmethod 67 | def run(cls, args): 68 | for action in ACTIONS: 69 | if args.get(action): 70 | opts = cls.filter_opts(action, args) 71 | cls.dispatch_action(action, opts) 72 | break 73 | 74 | @classmethod 75 | def dispatch_action(cls, action, opts): 76 | if action == 'new': 77 | if opts.get(''): 78 | cli.repo.run(opts) 79 | else: 80 | cli.new.run(opts) 81 | else: 82 | getattr(cli, action).run(opts) 83 | 84 | 85 | def main(): 86 | args = docopt(__doc__, version=f'Horn {__version__}') 87 | Hub.run(args) 88 | -------------------------------------------------------------------------------- /horn/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from . import repo, new, model, schema, api 2 | 3 | 4 | __all__ = [ 5 | 'repo', 6 | 'new', 7 | 'model', 8 | 'schema', 9 | 'api' 10 | ] 11 | -------------------------------------------------------------------------------- /horn/cli/api.py: -------------------------------------------------------------------------------- 1 | import inflection 2 | from pampy import match, _, TAIL 3 | from copier import run_copy 4 | 5 | from horn.path import TPL_PATH, get_location 6 | from horn.tpl import get_proj_info 7 | from . import model, schema 8 | 9 | 10 | TYPE_MAP = { 11 | 'integer': 'integer', 12 | 'float': 'float', 13 | 'boolean': 'boolean', 14 | 'string': 'string', 15 | 'date': 'date', 16 | 'time': 'time', 17 | 'datetime': 'datetime', 18 | 'uuid': 'uuid', 19 | 20 | 'decimal': 'decimal', 21 | 'numeric': 'decimal', 22 | 23 | 'ref': 'ref', 24 | 'nest': 'nest', 25 | 26 | 'array': 'list', 27 | 'json': 'dict' 28 | } 29 | 30 | 31 | def run(opts): 32 | model.run(opts) 33 | sch_opts = opt_pipe(opts) 34 | schema.run(sch_opts) 35 | 36 | bindings = { 37 | 'module': opts.get(''), 38 | 'singular': inflection.underscore(opts.get('')), 39 | 'plural': opts.get('
'), 40 | 'table': opts.get('
'), 41 | 'fields': opts.get('') 42 | } 43 | bindings.update(get_proj_info()) 44 | 45 | location = get_location(bindings) or TPL_PATH 46 | run_copy(f'{location}/gen', '.', data=bindings, exclude=['*/models/*', '*/schemas/*', 'tests/*']) 47 | run_copy(f'{location}/gen/tests', './tests', data=bindings) 48 | 49 | 50 | def opt_pipe(opts): 51 | o = prepare_opts(opts) 52 | p = slim_field(o) 53 | t = convert_type(p) 54 | s = ref_to_nest(t) 55 | return s 56 | 57 | 58 | def prepare_opts(opts): 59 | """ 60 | >>> opts = {'': 'Blog'} 61 | >>> prepare_opts(opts) 62 | {'': 'Blog', '--model': 'Blog'} 63 | """ 64 | rv = opts.copy() 65 | rv.update({ 66 | '--model': opts.get('') 67 | }) 68 | return rv 69 | 70 | 71 | def slim_field(opts): 72 | opts[''] = [':'.join(match( 73 | field.split(':'), 74 | [_, _], lambda x, y: [x, y], # noqa: E241,E272 75 | [_, _, TAIL], lambda x, y, t: [x, y] + drop_pair(t) # noqa: E241,E272 76 | )) for field in opts['']] 77 | return opts 78 | 79 | 80 | def drop_pair(attrs, key='default'): 81 | """ 82 | >>> attrs = ['default', 'none', 'nonull', 'load'] 83 | >>> drop_pair(attrs) 84 | ['nonull', 'load'] 85 | """ 86 | if key in attrs: 87 | idx = attrs.index(key) 88 | del attrs[idx:idx+2] # noqa: E226 89 | return attrs 90 | 91 | 92 | def convert_type(opts): 93 | opts[''] = [':'.join(match( 94 | field.split(':'), 95 | [_, _], lambda x, y: [x, TYPE_MAP.get(y, 'string')], # noqa: E241,E272 96 | [_, _, TAIL], lambda x, y, t: [x, TYPE_MAP.get(y, 'string')] + t, # noqa: E241,E272 97 | )) for field in opts['']] 98 | return opts 99 | 100 | 101 | def ref_to_nest(opts): 102 | opts[''] = [':'.join(match( 103 | field.split(':'), 104 | [_, 'ref', _], lambda x, y: [x, 'nest', inflection.singularize(y)], # noqa: E241,E272 105 | [_, 'ref', _, TAIL], lambda x, y, t: [x, 'nest', inflection.singularize(y)] + t, # noqa: E241,E272 106 | list, lambda x: x # noqa: E241,E272 107 | )) for field in opts['']] 108 | return opts 109 | -------------------------------------------------------------------------------- /horn/cli/model.py: -------------------------------------------------------------------------------- 1 | import inflection 2 | from pampy import match, _, TAIL 3 | from copier import run_copy 4 | 5 | from horn.path import TPL_PATH, get_location 6 | from horn.tpl import get_proj_info, merge_fields, validate_type, validate_attr, validate_opts 7 | 8 | 9 | TYPES = { 10 | 'integer': 'Integer', 11 | 'float': 'Float', 12 | 'numeric': 'Numeric', 13 | 'boolean': 'Boolean', 14 | 'string': 'String', 15 | 'text': 'Text', 16 | 'date': 'Date', 17 | 'time': 'Time', 18 | 'datetime': 'DateTime', 19 | 'uuid': 'UUID', 20 | 'json': 'JSON', 21 | 'array': 'ARRAY', 22 | 23 | 'decimal': 'Numeric', 24 | 'ref': 'reference', 25 | } 26 | 27 | AFFIXES = ('uniq', 'nonull', 'index') 28 | 29 | 30 | def run(opts): 31 | validate_opts(opts) 32 | 33 | bindings = { 34 | 'module': opts.get(''), 35 | 'singular': inflection.underscore(opts.get('')), 36 | 'table': inflection.underscore(opts.get('
')), 37 | 'fields': parse_fields(opts.get('')), 38 | 'has_ref': any([':ref:' in f for f in opts['']]) 39 | } 40 | bindings.update(get_proj_info()) 41 | 42 | location = get_location(bindings) or TPL_PATH 43 | run_copy(f'{location}/gen', '.', data=bindings, exclude=['*/schemas/*', '*/views/*', 'tests/*']) 44 | 45 | 46 | def resolve_assign(ftype, default): 47 | """ 48 | >>> resolve_assign('xxx', 'none') 49 | 'None' 50 | >>> resolve_assign('ref', '100') 51 | 100 52 | >>> try: 53 | ... resolve_assign('ref', 'apple') 54 | ... except: 55 | ... pass 56 | Error: Default value must be an integer 57 | >>> resolve_assign('float', '99.9') 58 | '99.9' 59 | >>> resolve_assign('boolean', 'false') 60 | 'False' 61 | >>> try: 62 | ... resolve_assign('boolean', 'apple') 63 | ... except: 64 | ... pass 65 | Error: Boolean field error, apple 66 | >>> resolve_assign('ooo', 'elixir') 67 | "'elixir'" 68 | """ 69 | rv = default 70 | if default == 'none': 71 | rv = 'None' 72 | elif ftype == 'ref': 73 | try: 74 | rv = int(default) 75 | except ValueError: 76 | print('Error: Default value must be an integer') 77 | exit(1) 78 | elif ftype in ['integer', 'float', 'numeric']: 79 | pass 80 | elif ftype in ['boolean']: 81 | if default in ['true', 'false']: 82 | rv = inflection.camelize(default) 83 | else: 84 | print(f'Error: Boolean field error, {default}') 85 | exit(1) 86 | else: 87 | rv = f"'{rv}'" 88 | return rv 89 | 90 | 91 | def parse_fields(fields): 92 | from .schema import AFFIXES as SCH_AFFIXES 93 | 94 | attrs = [f.split(':') for f in fields] 95 | return [match( 96 | attr, 97 | [_, 'default', _, 'ref', _, TAIL], lambda x, val, tab, t: merge_fields({'field': x, 'cam_field': inflection.camelize(x), 'type': validate_type('ref', TYPES), 'table': tab, 'default': resolve_assign('ref', val)}, validate_attr(t, AFFIXES, SCH_AFFIXES)), # noqa: E241,E272 98 | [_, 'ref', _, 'default', _, TAIL], lambda x, tab, val, t: merge_fields({'field': x, 'cam_field': inflection.camelize(x), 'type': validate_type('ref', TYPES), 'table': tab, 'default': resolve_assign('ref', val)}, validate_attr(t, AFFIXES, SCH_AFFIXES)), # noqa: E241,E272 99 | [_, 'ref', _, TAIL], lambda x, tab, t: merge_fields({'field': x, 'cam_field': inflection.camelize(x), 'type': validate_type('ref', TYPES), 'table': tab}, validate_attr(t, AFFIXES, SCH_AFFIXES)), # noqa: E241,E272 100 | [_, _, 'default', _, TAIL], lambda x, y, val, t: merge_fields({'field': x, 'type': validate_type(y, TYPES), 'default': resolve_assign(y, val)}, validate_attr(t, AFFIXES, SCH_AFFIXES)), # noqa: E241,E272 101 | [_, _, TAIL], lambda x, y, t: merge_fields({'field': x, 'type': validate_type(y, TYPES)}, validate_attr(t, AFFIXES, SCH_AFFIXES)) # noqa: E241,E272 102 | ) for attr in attrs] 103 | -------------------------------------------------------------------------------- /horn/cli/new.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from pathlib import Path 3 | 4 | import inflection 5 | from copier import run_copy 6 | 7 | from horn.path import TPL_PATH 8 | 9 | 10 | def run(opts): 11 | bindings = { 12 | 'target': Path(opts.get('')).resolve().name, 13 | 'secret_key': secrets.token_urlsafe(12), 14 | 'prod_secret_key': secrets.token_urlsafe(24), 15 | 'app': inflection.underscore(opts.get('--app')), 16 | 'proj': inflection.camelize(opts.get('--proj') or Path(opts.get('')).resolve().name), 17 | 'bare': opts.get('--bare'), 18 | 'pypi': opts.get('--pypi'), 19 | } 20 | 21 | ignore_list = ['*/__pycache__/*'] 22 | if bindings.get('bare'): 23 | ignore_list.extend([ 24 | f'{bindings.get("app")}/helpers.py', 25 | '*/models/user.py', 26 | '*/views/user.py', 27 | '*/views/session.py', 28 | '*/schemas/user.py', 29 | '*/schemas/session.py', 30 | 'tests/views/test_user.py', 31 | 'tests/views/test_session.py' 32 | ]) 33 | 34 | run_copy(f'{TPL_PATH}/new', opts.get(''), data=bindings, exclude=ignore_list) 35 | -------------------------------------------------------------------------------- /horn/cli/repo.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import inflection 5 | from copier import run_copy 6 | 7 | from horn.path import get_location 8 | 9 | 10 | def run(opts): 11 | bindings = { 12 | 'target': Path(opts.get('')).resolve().name, 13 | 'from': convert_path(opts.get('')), 14 | 'checkout': opts.get(''), 15 | 'app': 'app', 16 | 'proj': inflection.camelize(Path(opts.get('')).resolve().name), 17 | 'file': opts.get('--file') 18 | } 19 | 20 | if bindings.get('file'): 21 | with open(bindings.get('file')) as f: 22 | conf = json.load(f) 23 | check_conflict(conf) 24 | bindings.update(conf) 25 | 26 | opt_json = json.loads(opts.get('--json')) 27 | check_conflict(opt_json) 28 | bindings.update(opt_json) 29 | 30 | location = get_location(bindings) 31 | try: 32 | run_copy(f'{location}/new', opts.get(''), data=bindings, exclude=['*/__pycache__/*']) 33 | except ValueError: 34 | try: 35 | run_copy(f'{location}', opts.get(''), data=bindings, exclude=['*/__pycache__/*']) 36 | except ValueError as err: 37 | print(f'Error: {err}') 38 | exit(1) 39 | 40 | 41 | def convert_path(path): 42 | """ 43 | >>> p = convert_path('foobar') 44 | >>> p.endswith('/foobar') 45 | True 46 | >>> convert_path('https://github.com') 47 | 'https://github.com' 48 | """ 49 | rv = path 50 | if not (path.startswith('http') or path.startswith('git@') or path.startswith('ssh://')): 51 | rv = str(Path(path).resolve()) 52 | return rv 53 | 54 | 55 | def check_conflict(opt): 56 | """ 57 | >>> check_conflict({'app': 'foobar'}) 58 | >>> try: 59 | ... check_conflict({'from': 'bbb'}) 60 | ... except: 61 | ... pass 62 | Error: Conflict field found, {from: bbb} 63 | """ 64 | reserved_words = ["target", 'from', 'checkout', 'file'] 65 | for rw in reserved_words: 66 | if rw in opt: 67 | print(f'Error: Conflict field found, {{{rw}: {opt.get(rw)}}}') 68 | exit(1) 69 | -------------------------------------------------------------------------------- /horn/cli/schema.py: -------------------------------------------------------------------------------- 1 | import inflection 2 | from pampy import match, _, TAIL 3 | from copier import run_copy 4 | 5 | from horn.path import TPL_PATH, get_location 6 | from horn.tpl import get_proj_info, merge_fields, validate_type, validate_attr, validate_opts 7 | 8 | 9 | TYPES = { 10 | 'integer': 'Integer', 11 | 'float': 'Float', 12 | 'number': 'Number', 13 | 'decimal': 'Decimal', 14 | 'boolean': 'Boolean', 15 | 'string': 'String', 16 | 'date': 'Date', 17 | 'time': 'Time', 18 | 'timedelta': 'TimeDelta', 19 | 'datetime': 'DateTime', 20 | 'uuid': 'UUID', 21 | 'tuple': 'Tuple', 22 | 'list': 'List', 23 | 'dict': 'Dict', 24 | 'map': 'Mapping', 25 | 'url': 'Url', 26 | 'email': 'Email', 27 | 28 | 'nest': 'Nested', 29 | } 30 | 31 | AFFIXES = ('none', 'required', 'dump', 'load', 'exclude') 32 | 33 | 34 | def run(opts): 35 | validate_opts(opts) 36 | 37 | bindings = { 38 | 'module': opts.get(''), 39 | 'singular': inflection.underscore(opts.get('')), 40 | 'model': inflection.camelize(opts.get('--model')) if opts.get('--model') else '', 41 | 'fields': parse_fields(opts.get('')), 42 | } 43 | bindings.update(get_proj_info()) 44 | bindings.update(collect_meta(bindings.get('fields'))) 45 | 46 | location = get_location(bindings) or TPL_PATH 47 | run_copy(f'{location}/gen', '.', data=bindings, exclude=['*/models/*', '*/views/*', 'tests/*']) 48 | 49 | 50 | def collect_meta(fields): 51 | """ 52 | >>> fields = [{'field': 'title', 'type': 'String', 'uniq': True, 'load': True}, 53 | ... {'field': 'content', 'type': 'String', 'nonull': True, 'dump': True}, 54 | ... {'field': 'author', 'type': 'Nested', 'schema': 'UserSchema', 'exclude': True}] 55 | >>> collect_meta(fields) 56 | {'dump_only': ['content'], 'load_only': ['title'], 'exclude': ['author']} 57 | """ 58 | dump_only = [] 59 | load_only = [] 60 | exclude = [] 61 | for field in fields: 62 | check_meta_keys(field) 63 | if field.get('dump'): 64 | dump_only.append(field['field']) 65 | elif field.get('load'): 66 | load_only.append(field['field']) 67 | elif field.get('exclude'): 68 | exclude.append(field['field']) 69 | return { 70 | 'dump_only': dump_only, 71 | 'load_only': load_only, 72 | 'exclude': exclude, 73 | } 74 | 75 | 76 | def parse_fields(fields): 77 | from .model import AFFIXES as MOD_AFFIXES 78 | 79 | attrs = [f.split(':') for f in fields] 80 | return [match( 81 | attr, 82 | [_, 'nest', _, TAIL], lambda x, schema, t: merge_fields({'field': x, 'type': 'Nested', 'schema': f'{inflection.camelize(schema)}Schema'}, validate_attr(t, AFFIXES, MOD_AFFIXES)), # noqa: E241,E272 83 | [_, _, TAIL], lambda x, y, t: merge_fields({'field': x, 'type': validate_type(y, TYPES)}, validate_attr(t, AFFIXES, MOD_AFFIXES)) # noqa: E241,E272 84 | ) for attr in attrs] 85 | 86 | 87 | def check_meta_keys(field): 88 | """ 89 | >>> check_meta_keys({'field': 'title', 'type': 'String', 'uniq': True}) 90 | 91 | >>> try: 92 | ... check_meta_keys({'field': 'title', 'type': 'String', 'dump': True, 'load': True}) 93 | ... except: 94 | ... pass 95 | Error: Field attributes error, ['dump', 'load'] conflict 96 | """ 97 | meta_keys = {'dump', 'load', 'exclude'} 98 | intersection = meta_keys & set(field) 99 | if len(intersection) > 1: 100 | print(f'Error: Field attributes error, {sorted(intersection)} conflict') 101 | exit(1) 102 | -------------------------------------------------------------------------------- /horn/path.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import shutil 3 | import subprocess 4 | 5 | from pathlib import Path 6 | 7 | 8 | TPL_PATH = Path(__file__).parent.joinpath('templates') 9 | 10 | 11 | def get_location(bindings): 12 | location = bindings.get('from') 13 | if location and (location.startswith('http') or location.startswith('git@') or location.startswith('ssh://')): 14 | location = clone(bindings.get('from'), bindings.get('checkout')) 15 | return location 16 | 17 | 18 | def clone(url, checkout=None): 19 | location = tempfile.mkdtemp() 20 | shutil.rmtree(location) # Path must not exists 21 | subprocess.check_call(["git", "clone", url, location]) 22 | if checkout: 23 | subprocess.check_call(["git", "checkout", checkout], cwd=location) 24 | return location 25 | -------------------------------------------------------------------------------- /horn/templates/gen/tests/views/test_{{ singular }}.py.jinja: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | 5 | class Test{{ module }}(object): 6 | 7 | def test_index(self, client): 8 | resp = client.get(url_for('{{ singular }}.index')) 9 | assert resp.status_code == 200 10 | 11 | @pytest.mark.skip() 12 | def test_create(self, client): 13 | payload = { 14 | } 15 | resp = client.post(url_for('{{ singular }}.create'), json=payload) 16 | assert resp.status_code == 201 17 | assert resp.json['url'] == payload['url'] 18 | 19 | @pytest.mark.skip() 20 | def test_update(self, client): 21 | payload = { 22 | } 23 | resp = client.put(url_for('{{ singular }}.update', pk=sample.id), json=payload) 24 | assert resp.status_code == 200 25 | 26 | @pytest.mark.skip() 27 | def test_show(self, client): 28 | resp = client.get(url_for('{{ singular }}.show', pk=sample.id)) 29 | assert resp.status_code == 200 30 | 31 | @pytest.mark.skip() 32 | def test_delete(self, client): 33 | resp = client.delete(url_for('{{ singular }}.delete', pk=sample.id)) 34 | assert resp.status_code == 200 35 | -------------------------------------------------------------------------------- /horn/templates/gen/{{ app }}/models/{{ singular }}.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.database import db, Column, Model 2 | {%- if has_ref -%} 3 | , reference_col, relationship 4 | {%- endif %} 5 | 6 | 7 | class {{ module }}(Model): 8 | __tablename__ = '{{ table }}' 9 | 10 | id = db.Column(db.Integer, primary_key=True, doc='id') 11 | {% for field in fields if not field.type == 'reference' -%} 12 | {{ field.field }} = Column(db.{{ field.type }} 13 | {%- if field.default %}, default={{ field.default|safe }} {%- endif %} 14 | {%- if field.uniq %}, unique=True {%- endif %} 15 | {%- if field.index %}, index=True {%- endif %} 16 | {%- if field.nonull %}, nullable=False {%- endif %}, doc='{{ module }} {{ field.field }}') 17 | {% if not loop.last %} {% endif -%} 18 | {%- endfor -%} 19 | {% for field in fields if field.type == 'reference' %} 20 | {{ field.field }}_id = reference_col('{{ field.table }}' 21 | {%- if field.default %}, default={{ field.default|safe }} {%- endif %} 22 | {%- if field.uniq %}, unique=True {%- endif %} 23 | {%- if field.index %}, index=True {%- endif %} 24 | {%- if field.nonull %}, nullable=False {%- endif %}, doc='{{ field.field }} id') 25 | {{ field.field }} = relationship('{{ field.cam_field }}', back_populates='{{ table }}') 26 | {% if not loop.last %}{% endif -%} 27 | {%- endfor %} 28 | inserted_at = db.Column(db.DateTime, nullable=False, index=True, 29 | server_default=db.func.now(), doc='insert time') 30 | updated_at = db.Column(db.DateTime, nullable=False, index=True, 31 | server_default=db.func.now(), onupdate=db.func.now(), 32 | doc='update time') 33 | 34 | def __repr__(self): 35 | return f'<{{ module }}({self.id!r})>' 36 | -------------------------------------------------------------------------------- /horn/templates/gen/{{ app }}/schemas/{{ singular }}.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.schema import {% if model %}Model{% endif %}Schema{% if fields %}, fields{% endif %} 2 | {%- if model %} 3 | from {{ app }}.models import {{ model }} 4 | {% endif %} 5 | from .helpers import SchemaMixin 6 | 7 | 8 | class {{ module }}Schema({% if model %}Model{% endif %}Schema, SchemaMixin): 9 | {%- for field in fields %} 10 | {% if field.type == 'Nested' -%} 11 | {{ field.field }} = fields.{{ field.type }}('{{ field.schema }}' 12 | {%- if field.required %}, required=True{% endif %} 13 | {%- if field.none and field.required %}, {% endif -%} 14 | {% if field.none %}allow_none=True{% endif %}) 15 | {%- else -%} 16 | {{ field.field }} = fields.{{ field.type }}( 17 | {%- if field.required %}required=True{% endif %} 18 | {%- if field.none and field.required %}, {% endif -%} 19 | {% if field.none %}allow_none=True{% endif %}) 20 | {%- endif -%} 21 | {% endfor %} 22 | 23 | class Meta(SchemaMixin.Meta): 24 | {%- if model %} 25 | model = {{ model }} 26 | {%- endif %} 27 | {%- if fields %} 28 | fields = ('id', {% for field in fields %}'{{ field.field }}', {% endfor %}) 29 | {%- endif %} 30 | {%- if dump_only %} 31 | dump_only = ({% for attr in dump_only %}'{{ attr }}', {% endfor %}) 32 | {%- endif %} 33 | {%- if load_only %} 34 | load_only = ({% for attr in load_only %}'{{ attr }}', {% endfor %}) 35 | {%- endif %} 36 | {%- if exclude %} 37 | exclude = ({% for attr in exclude %}'{{ attr }}', {% endfor %}) 38 | {%- endif %} 39 | 40 | 41 | {{ singular }}_schema = {{ module }}Schema() 42 | {{ singular }}_schemas = {{ module }}Schema(many=True) 43 | -------------------------------------------------------------------------------- /horn/templates/gen/{{ app }}/views/{{ singular }}.py.jinja: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from {{ app }}.core import doc, use_kwargs, marshal_with{% if not bare %}, jwt_required{% endif %} 6 | from {{ app }}.core.database import db, atomic # noqa: F401 7 | from {{ app }}.models import {{ module }} 8 | from {{ app }}.schemas.{{ singular }} import {{ singular }}_schema, {{ singular }}_schemas 9 | 10 | 11 | bp = Blueprint('{{ singular }}', __name__) 12 | 13 | 14 | @doc(tags=['{{ module }}'], description='list {{ plural }}') 15 | @bp.route('/{{ plural }}', methods=['GET'], provide_automatic_options=False) 16 | {%- if not bare %} 17 | @jwt_required 18 | {%- endif %} 19 | @marshal_with({{ singular }}_schemas, code=200) 20 | def index(): 21 | {{ plural }} = {{ module }}.query.all() 22 | return {{ plural }} 23 | 24 | 25 | @doc(tags=['{{ module }}'], description='create {{ singular }}') 26 | @bp.route('/{{ plural }}', methods=['POST'], provide_automatic_options=False) 27 | {%- if not bare %} 28 | @jwt_required 29 | {%- endif %} 30 | @use_kwargs({{ singular }}_schema) 31 | @marshal_with({{ singular }}_schema, code=201) 32 | def create(**attrs): 33 | {{ singular }} = {{ module }}.create(**attrs) 34 | return {{ singular }}, 201 35 | 36 | 37 | @doc(tags=['{{ module }}'], description='show {{ singular }}') 38 | @bp.route('/{{ plural }}/', methods=['GET'], provide_automatic_options=False) 39 | {%- if not bare %} 40 | @jwt_required 41 | {%- endif %} 42 | @marshal_with({{ singular }}_schema, code=200) 43 | def show(pk): 44 | {{ singular }} = {{ module }}.query.get_or_404(pk) 45 | return {{ singular }} 46 | 47 | 48 | @doc(tags=['{{ module }}'], description='update {{ singular }}') 49 | @bp.route('/{{ plural }}/', methods=['PUT', 'PATCH'], provide_automatic_options=False) 50 | {%- if not bare %} 51 | @jwt_required 52 | {%- endif %} 53 | @use_kwargs({{ singular }}_schema) 54 | @marshal_with({{ singular }}_schema, code=200) 55 | def update(pk, **attrs): 56 | {{ singular }} = {{ module }}.query.get_or_404(pk) 57 | return {{ singular }}.update(**attrs) 58 | 59 | 60 | @doc(tags=['{{ module }}'], description='delete {{ singular }}') 61 | @bp.route('/{{ plural }}/', methods=['DELETE'], provide_automatic_options=False) 62 | {%- if not bare %} 63 | @jwt_required 64 | {%- endif %} 65 | @marshal_with(None, code=204) # FIXME 66 | def delete(pk): 67 | {{ singular }} = {{ module }}.query.get_or_404(pk) 68 | try: 69 | {{ singular }}.delete() 70 | except IntegrityError as err: 71 | raise err 72 | return True 73 | -------------------------------------------------------------------------------- /horn/templates/new/.gitignore.jinja: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */__pycache__/ 3 | *.egg-info/ 4 | dist/ 5 | 6 | .coverage 7 | .pytest_cache/ 8 | log/ 9 | 10 | instance/ 11 | -------------------------------------------------------------------------------- /horn/templates/new/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pyproject.toml 2 | include poetry.lock 3 | include README.md 4 | include logging.ini 5 | graft migrations 6 | -------------------------------------------------------------------------------- /horn/templates/new/README.md.jinja: -------------------------------------------------------------------------------- 1 | # {{ proj }} 2 | 3 | Project {{ proj }} is generated by [`horn-py`](https://github.com/bigfang/horn-py) 4 | 5 | ## Development Environment 6 | To start your server: 7 | 8 | * Install dependencies with `poetry install` 9 | * Start flask server with `FLASK_ENV=development FLASK_APP={{ app }} poetry run flask run` 10 | 11 | Visit API: [`localhost:5000/api`](http://localhost:5000/api) 12 | 13 | Visit Swagger-UI: [`localhost:5000/spec`](http://localhost:5000/spec) 14 | 15 | ## Production Environment 16 | To start your server: 17 | 18 | * Install dependencies with `poetry install --extras pg` 19 | * Start flask server with `FLASK_APP={{ app }} poetry run flask run` 20 | 21 | Visit API: [`localhost:5000/api`](http://localhost:5000/api) 22 | 23 | ## Learn more 24 | 25 | * Flask: http://flask.pocoo.org 26 | * Marshmallow: https://marshmallow.readthedocs.io 27 | * SQLAlchemy: https://www.sqlalchemy.org 28 | * horn-py: https://github.com/bigfang/horn-py 29 | -------------------------------------------------------------------------------- /horn/templates/new/instance/prod.secret.cfg.jinja: -------------------------------------------------------------------------------- 1 | SECRET_KEY = '{{ prod_secret_key }}' 2 | 3 | -------------------------------------------------------------------------------- /horn/templates/new/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigfang/horn-py/367d474b7d2de1e594fc420eb9ee6fb12376afa4/horn/templates/new/log/.gitkeep -------------------------------------------------------------------------------- /horn/templates/new/logging.ini.jinja: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root 3 | 4 | [handlers] 5 | keys=console, file 6 | 7 | [formatters] 8 | keys=default 9 | 10 | [formatter_default] 11 | format=%(asctime)s pid:%(process)d - %(module)s::%(funcName)s(ln:%(lineno)d) - %(levelname)s: %(message)s 12 | datefmt=%m-%d %H:%M:%S 13 | 14 | 15 | [logger_root] 16 | level=INFO 17 | handlers=file, console 18 | qualname=root 19 | 20 | [handler_console] 21 | level=INFO 22 | class=StreamHandler 23 | formatter=default 24 | args=(sys.stdout, ) 25 | 26 | [handler_file] 27 | level=INFO 28 | class=handlers.TimedRotatingFileHandler 29 | formatter=default 30 | args=('%(logdir)s/app.log', 'D', 1, 30) 31 | -------------------------------------------------------------------------------- /horn/templates/new/pyproject.toml.jinja: -------------------------------------------------------------------------------- 1 | {{ '[' }}tool.poetry{{ ']' }} 2 | name = "{{ app }}" 3 | version = "0.1.0" 4 | description = "{{ proj }} backend" 5 | authors = ["Your Name "] 6 | {%- if pypi %} 7 | {%- set domain = pypi.split('.') %} 8 | 9 | {{ '{{ ' }}tool.poetry.source{{ ' }}' }} 10 | name = "{{ domain[1] }}" 11 | url = "{{ pypi }}" 12 | {%- endif %} 13 | 14 | {{ '[' }}tool.poetry.dependencies{{ ']' }} 15 | python = "^3.6" 16 | flask = "^1.1.2" 17 | flask-marshmallow = "^0.11.0" 18 | flask-sqlalchemy = "^2.4.1" 19 | flask-migrate = "^2.5.3" 20 | flask-apispec = "^0.8.8" 21 | {% if not bare -%} 22 | Flask-Bcrypt = "^0.7.1" 23 | Flask-Jwt-Extended = "^3.24.1" 24 | {%- endif %} 25 | marshmallow-sqlalchemy = "^0.22.3" 26 | psycopg2 = {version = "^2.8.5", optional = true} 27 | 28 | {{ '[' }}tool.poetry.dev-dependencies{{ ']' }} 29 | pytest = "^5.2" 30 | flake8 = "^3.7.9" 31 | pytest-cov = "^2.8.1" 32 | factory-boy = "^2.12.0" 33 | psycopg2-binary = "^2.8.5" 34 | 35 | {{ '[' }}tool.poetry.extras{{ ']' }} 36 | pg = ["psycopg2"] 37 | 38 | {{ '[' }}build-system{{ ']' }} 39 | requires = ["poetry>=0.12"] 40 | build-backend = "poetry.masonry.api" 41 | 42 | {{ '[' }}horn{{ ']' }} 43 | directory = "{{ target }}" 44 | project_name = "{{ proj }}" 45 | app_name = "{{ app }}" 46 | {%- if bare %} 47 | bare = true 48 | {%- endif %} 49 | {%- if from %} 50 | from = "{{ from }}" 51 | {%- endif %} 52 | {%- if checkout %} 53 | checkout = "{{ checkout }}" 54 | {%- endif -%} 55 | -------------------------------------------------------------------------------- /horn/templates/new/setup.cfg: -------------------------------------------------------------------------------- 1 | [sdist] 2 | formats = zip 3 | 4 | [flake8] 5 | exclude = .git, __pycache__, migrations, .pytest_cache 6 | max-line-length = 120 7 | 8 | [tool:pytest] 9 | addopts = --cov . --cov-report term-missing 10 | testpaths = tests 11 | norecursedirs = .git/* 12 | 13 | [coverage:run] 14 | omit = tests/* 15 | -------------------------------------------------------------------------------- /horn/templates/new/setup.py.jinja: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from {{ app }} import __version__ 4 | 5 | 6 | with open('README.md', 'r') as f: 7 | readme = f.read() 8 | 9 | setup( 10 | name='{{ app }}', 11 | version=__version__, 12 | packages=find_packages(exclude=['tests']), 13 | description='{{ proj }} backend', 14 | long_description=readme, 15 | ) 16 | -------------------------------------------------------------------------------- /horn/templates/new/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigfang/horn-py/367d474b7d2de1e594fc420eb9ee6fb12376afa4/horn/templates/new/tests/__init__.py -------------------------------------------------------------------------------- /horn/templates/new/tests/conftest.py.jinja: -------------------------------------------------------------------------------- 1 | import pytest 2 | {%- if not bare %} 3 | 4 | from flask import url_for 5 | {%- endif %} 6 | 7 | from {{ app }}.run import create_app 8 | from {{ app }}.core.database import db as _db 9 | {%- if not bare %} 10 | from .factories import UserFactory 11 | {%- endif %} 12 | 13 | 14 | @pytest.fixture(scope='session') 15 | def app(request): 16 | _app = create_app('test') 17 | 18 | ctx = _app.test_request_context() 19 | ctx.push() 20 | 21 | def teardown(): 22 | ctx.pop() 23 | 24 | request.addfinalizer(teardown) 25 | return _app 26 | 27 | 28 | @pytest.fixture(scope='session') 29 | def db(request, app): 30 | _db.app = app 31 | with app.app_context(): 32 | _db.create_all() 33 | 34 | def teardown(): 35 | _db.session.close() 36 | _db.drop_all() 37 | 38 | request.addfinalizer(teardown) 39 | return _db 40 | {%- if bare %} 41 | 42 | 43 | @pytest.fixture() 44 | def client(app): 45 | return app.test_client() 46 | {%- else %} 47 | 48 | 49 | @pytest.fixture(scope='module') 50 | def user(db): 51 | user = UserFactory(password='wordpass') 52 | user.save() 53 | return user 54 | 55 | 56 | @pytest.fixture() 57 | def client(app, user): 58 | _client = app.test_client() 59 | _client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {user.token}' 60 | return _client 61 | 62 | 63 | @pytest.fixture 64 | def login_user(client): 65 | user = UserFactory(password='iamloggedin') 66 | user.save() 67 | 68 | payload = { 69 | 'username': user.username, 70 | 'password': 'iamloggedin' 71 | } 72 | client.post(url_for('session.create'), json=payload) 73 | return user 74 | 75 | 76 | @pytest.fixture 77 | def headers(login_user): 78 | return { 79 | 'Authorization': f'Bearer {login_user.token}' 80 | } 81 | {%- endif %} 82 | -------------------------------------------------------------------------------- /horn/templates/new/tests/factories.py.jinja: -------------------------------------------------------------------------------- 1 | from factory.alchemy import SQLAlchemyModelFactory 2 | {%- if not bare %} 3 | from factory import PostGenerationMethodCall, Sequence 4 | {%- endif %} 5 | 6 | from {{ app }}.core.database import db 7 | {%- if not bare %} 8 | from {{ app }}.models import User 9 | {%- endif %} 10 | 11 | 12 | class BaseFactory(SQLAlchemyModelFactory): 13 | """Base factory.""" 14 | 15 | class Meta: 16 | """Factory configuration.""" 17 | abstract = True 18 | sqlalchemy_session = db.session 19 | {%- if not bare %} 20 | 21 | 22 | class UserFactory(BaseFactory): 23 | username = Sequence(lambda n: f'user{n}') 24 | email = Sequence(lambda n: f'user{n}@example.com') 25 | password = PostGenerationMethodCall('set_password', 'example') 26 | 27 | class Meta: 28 | model = User 29 | {%- endif %} 30 | -------------------------------------------------------------------------------- /horn/templates/new/tests/test_swagger.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | 4 | class TestSwagger(object): 5 | 6 | def test_swagger(self, client): 7 | resp = client.get(url_for('flask-apispec.swagger-ui')) 8 | assert resp.status_code == 200 9 | assert 'swagger' in resp.data.decode() 10 | -------------------------------------------------------------------------------- /horn/templates/new/tests/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigfang/horn-py/367d474b7d2de1e594fc420eb9ee6fb12376afa4/horn/templates/new/tests/views/__init__.py -------------------------------------------------------------------------------- /horn/templates/new/tests/views/test_home.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | 4 | class TestHome(object): 5 | 6 | def test_home(self, client): 7 | resp = client.get(url_for('home.home')) 8 | assert resp.status_code == 200 9 | assert 'Hello visitor,' in resp.json['message'] 10 | -------------------------------------------------------------------------------- /horn/templates/new/tests/views/test_session.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | 4 | class TestSession(object): 5 | 6 | def test_success_create(self, client, user): 7 | payload = { 8 | 'username': user.username, 9 | 'password': 'wordpass' 10 | } 11 | resp = client.post(url_for('session.create'), json=payload) 12 | assert resp.status_code == 201 13 | assert set(resp.json.keys()) == {'email', 'id', 'inserted_at', 14 | 'token', 'updated_at', 'username'} 15 | assert resp.json['username'] == user.username 16 | assert resp.json['email'] == user.email 17 | 18 | def test_failed_create(self, client): 19 | payload = { 20 | 'username': 'horn', 21 | 'password': 'xxxxx' 22 | } 23 | resp = client.post(url_for('session.create'), json=payload) 24 | assert resp.status_code == 404 25 | 26 | def test_delete(self, client, login_user): 27 | headers = { 28 | 'Authorization': f'Bearer {login_user.token}' 29 | } 30 | resp = client.delete(url_for('session.delete'), headers=headers) 31 | assert resp.status_code == 200 32 | -------------------------------------------------------------------------------- /horn/templates/new/tests/views/test_user.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | 4 | class TestUser(object): 5 | 6 | def test_index(self, client, user, headers): 7 | resp = client.get(url_for('user.index'), headers=headers) 8 | assert resp.status_code == 200 9 | 10 | def test_create(self, client): 11 | payload = { 12 | 'username': 'horn', 13 | 'email': 'test@horn.example', 14 | 'password': 'hornsecret' 15 | } 16 | resp = client.post(url_for('user.create'), json=payload) 17 | assert resp.status_code == 201 18 | assert set(resp.json.keys()) == {'email', 'id', 'inserted_at', 19 | 'token', 'updated_at', 'username'} 20 | assert resp.json['username'] == 'horn' 21 | assert resp.json['email'] == 'test@horn.example' 22 | 23 | def test_show(self, client, user, headers): 24 | resp = client.get(url_for('user.show', pk=user.id), headers=headers) 25 | assert resp.status_code == 200 26 | 27 | # TODO: implement it 28 | def test_update(self, client, user, headers): 29 | # resp = client.put(url_for('user.update', pk=user.id), {}) 30 | # assert resp.status_code == 200 31 | assert True 32 | 33 | def test_delete(self, client, user, headers): 34 | resp = client.delete(url_for('user.delete', pk=user.id), headers=headers) 35 | assert resp.status_code == 200 36 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/__init__.py: -------------------------------------------------------------------------------- 1 | from .run import create_app 2 | 3 | 4 | __version__ = '0.1.0' 5 | 6 | create_app() 7 | 8 | 9 | __all__ = ['__version__'] 10 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/cmds.py: -------------------------------------------------------------------------------- 1 | import os 2 | from glob import glob 3 | from subprocess import call 4 | 5 | import click 6 | 7 | 8 | HERE = os.path.abspath(os.path.dirname(__file__)) 9 | PROJECT_ROOT = os.path.join(HERE, os.pardir) 10 | TEST_PATH = os.path.join(PROJECT_ROOT, 'test') 11 | 12 | 13 | @click.command() 14 | def test(): 15 | """Run the tests.""" 16 | import pytest 17 | rv = pytest.main([TEST_PATH, '--verbose']) 18 | exit(rv) 19 | 20 | 21 | @click.command() 22 | def clean(): 23 | """Remove *.pyc and *.pyo files recursively starting at current directory. 24 | 25 | Borrowed from Flask-Script, converted to use Click. 26 | """ 27 | for dirpath, _, filenames in os.walk('.'): 28 | for filename in filenames: 29 | if filename.endswith('.pyc') or filename.endswith('.pyo'): 30 | full_pathname = os.path.join(dirpath, filename) 31 | click.echo('Removing {}'.format(full_pathname)) 32 | os.remove(full_pathname) 33 | if dirpath.endswith('/__pycache__'): 34 | click.echo('Removing directory {}'.format(dirpath)) 35 | os.rmdir(dirpath) 36 | 37 | 38 | @click.command() 39 | @click.option('-f', '--fix-imports', default=False, is_flag=True, 40 | help='Fix imports using isort, before linting') 41 | def lint(fix_imports): 42 | """Lint and check code style with flake8 and isort.""" 43 | skip = ['migrations', '__pycache__', 'node_modules'] 44 | root_files = glob('*.py') 45 | root_directories = [ 46 | name for name in next(os.walk('.'))[1] if not name.startswith('.')] 47 | files_and_directories = [ 48 | arg for arg in root_files + root_directories if arg not in skip] 49 | 50 | def execute_tool(description, *args): 51 | """Execute a checking tool with its arguments.""" 52 | command_line = list(args) + files_and_directories 53 | click.echo('{}: {}'.format(description, ' '.join(command_line))) 54 | rv = call(command_line) 55 | if rv != 0: 56 | exit(rv) 57 | 58 | if fix_imports: 59 | execute_tool('Fixing import order', 'isort', '-rc') 60 | execute_tool('Checking code style', 'flake8') 61 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigfang/horn-py/367d474b7d2de1e594fc420eb9ee6fb12376afa4/horn/templates/new/{{ app }}/config/__init__.py -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/config/default.py.jinja: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | PROJ_ROOT = os.path.split(os.path.abspath(__name__))[0] 5 | LOG_CONF_PATH = os.path.join(PROJ_ROOT, 'logging.ini') 6 | LOG_PATH = os.path.join(PROJ_ROOT, 'log') 7 | 8 | SECRET_KEY = '{{ secret_key }}' 9 | {%- if not bare %} 10 | BCRYPT_LOG_ROUNDS = 13 11 | {%- endif %} 12 | 13 | SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:postgres@localhost/{{ app }}' 14 | SQLALCHEMY_TRACK_MODIFICATIONS = False 15 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/config/development.py.jinja: -------------------------------------------------------------------------------- 1 | {% if not bare -%} 2 | from datetime import timedelta 3 | {%- endif %} 4 | 5 | from .default import * # NOQA F401 6 | 7 | 8 | ENV = 'development' 9 | DEBUG = True 10 | {%- if not bare %} 11 | JWT_ACCESS_TOKEN_EXPIRES = timedelta(10 ** 6) 12 | {%- endif %} 13 | 14 | SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:postgres@localhost/{{ app }}_dev' 15 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/config/production.py: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | 4 | ENV = 'production' 5 | DEBUG = False 6 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/config/testing.py.jinja: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | 4 | ENV = 'development' 5 | TESTING = True 6 | {%- if not bare %} 7 | BCRYPT_LOG_ROUNDS = 4 8 | {%- endif %} 9 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 10 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/core/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | from flask_apispec import doc, use_kwargs, marshal_with 2 | {%- if not bare %} 3 | from flask_jwt_extended import current_user, jwt_required, jwt_optional 4 | {%- endif %} 5 | 6 | from . import database 7 | from . import schema 8 | from . import errors 9 | 10 | 11 | __all__ = [ 12 | 'doc', 'use_kwargs', 'marshal_with', 13 | {%- if not bare %} 14 | 'current_user', 'jwt_required', 'jwt_optional', 15 | {%- endif %} 16 | 'database', 17 | 'schema', 18 | 'errors' 19 | ] 20 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/core/database.py.jinja: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from sqlalchemy.orm import relationship 4 | 5 | from {{ app }}.exts import db 6 | 7 | 8 | # Alias common SQLAlchemy names 9 | Column = db.Column 10 | 11 | 12 | class __CRUDMixin(object): 13 | @classmethod 14 | def create(cls, commit=True, **kwargs): 15 | instance = cls(**kwargs) 16 | return instance.save(commit=commit) 17 | 18 | def update(self, commit=True, **kwargs): 19 | for attr, value in kwargs.items(): 20 | setattr(self, attr, value) 21 | return self.save(commit=commit) 22 | 23 | def save(self, commit=True): 24 | db.session.add(self) 25 | if commit: 26 | self.commit() 27 | return self 28 | 29 | def delete(self, commit=True): 30 | db.session.delete(self) 31 | if commit: 32 | self.commit() 33 | 34 | @classmethod 35 | def exists(cls, id): 36 | rcd = cls.query.get(id) 37 | return True and rcd 38 | 39 | @classmethod 40 | def upsert(cls, constraint, commit=True, **kwargs): 41 | q = cls.query 42 | rcd = None 43 | if isinstance(constraint, str): 44 | rcd = cls.query.filter( 45 | getattr(cls, constraint) == kwargs.get(constraint)).first() 46 | elif isinstance(constraint, list) or isinstance(constraint, tuple): 47 | for c in constraint: 48 | q = q.filter(getattr(cls, c) == kwargs.get(c)) 49 | rcd = q.first() 50 | 51 | if not rcd: 52 | instance = cls(**kwargs) 53 | return instance.save(commit=commit) 54 | else: 55 | for k, v in kwargs.items(): 56 | setattr(rcd, k, v) 57 | return rcd.save(commit=commit) 58 | 59 | @classmethod 60 | def bulk_save(cls, rcds, commit=True): 61 | assert isinstance(rcds, list) 62 | db.session.add_all(rcds) 63 | if commit: 64 | cls.commit() 65 | return True 66 | 67 | @classmethod 68 | def bulk_delete(cls, rcds, commit=True): 69 | assert isinstance(rcds, list) 70 | for rcd in rcds: 71 | db.session.delete(rcd) 72 | if commit: 73 | cls.commit() 74 | return True 75 | 76 | @classmethod 77 | def commit(cls): 78 | try: 79 | db.session.commit() 80 | except Exception: 81 | db.session.rollback() 82 | raise 83 | 84 | @classmethod 85 | def flush(cls): 86 | db.session.flush() 87 | 88 | 89 | class Model(__CRUDMixin, db.Model): 90 | """Base model class that includes CRUD convenience methods.""" 91 | __abstract__ = True 92 | 93 | 94 | def reference_col(tablename, nullable=False, pk_name='id', **kwargs): 95 | """Column that adds primary key foreign key reference. 96 | 97 | Usage: :: 98 | 99 | category_id = reference_col('category') 100 | category = relationship('Category', backref='categories') 101 | """ 102 | return db.Column(db.ForeignKey('{0}.{1}'.format(tablename, pk_name)), 103 | nullable=nullable, **kwargs) 104 | 105 | 106 | def atomic(session, nested=False): 107 | def wrapper(func): 108 | @wraps(func) 109 | def inner(*args, **kwargs): 110 | if session.autocommit is True and nested is False: 111 | session.begin() # start a transaction 112 | 113 | try: 114 | with session.begin_nested(): 115 | resp = func(*args, **kwargs) 116 | if not nested: 117 | session.commit() # transaction finished 118 | except Exception as e: 119 | if not nested: 120 | session.rollback() 121 | session.remove() 122 | raise e 123 | return resp 124 | return inner 125 | return wrapper 126 | 127 | 128 | __all__ = [ 129 | 'db', 130 | 'Model', 131 | 'Column', 132 | 'relationship', 133 | 'reference_col', 134 | 'atomic' 135 | ] 136 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/core/errors.py.jinja: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from flask import jsonify, current_app 4 | 5 | 6 | ERR_CODE_MAP = { 7 | 400: 'Bad Request', 8 | 401: 'Unauthorized', 9 | 403: 'Forbidden', 10 | 404: 'Not Found', 11 | 409: 'Conflict', 12 | 13 | 500: 'Internal Server Error' 14 | } 15 | 16 | 17 | def template(msg=None, code=None, detail=None, status=500): 18 | 19 | code = code or status 20 | msg = msg or ERR_CODE_MAP.get(code) 21 | 22 | assert isinstance(status, int) 23 | assert isinstance(code, int) 24 | assert isinstance(msg, str) 25 | assert isinstance(detail, str) or detail is None 26 | 27 | payload = { 28 | 'message': msg, 29 | 'code': code, 30 | 'detail': detail 31 | } 32 | if current_app.env != 'production': 33 | payload.update({'traceback': traceback.format_exc()}) 34 | 35 | return { 36 | 'payload': payload, 37 | 'status': status 38 | } 39 | 40 | 41 | def make_err_resp(error): 42 | response = error.to_json() 43 | response.status_code = error.status 44 | return response 45 | 46 | 47 | class {{ proj }}Error(Exception): 48 | status = 500 49 | 50 | def __init__(self, payload, status=None): 51 | Exception.__init__(self) 52 | self.payload = payload 53 | if status is not None: 54 | self.status = status 55 | 56 | def to_json(self): 57 | return jsonify(self.payload) 58 | 59 | @classmethod 60 | def custom(cls, msg=None, code=None, err=None, status=500): 61 | detail = repr(err) 62 | if isinstance(err, str): 63 | detail = err 64 | if detail == 'None': 65 | detail = None 66 | 67 | tpl = template(msg=msg, code=code, detail=detail, status=status) 68 | return cls(**tpl) 69 | 70 | @classmethod 71 | def bad_request(cls, err=None, code=None): 72 | return cls.custom(err=err, code=code, status=400) 73 | 74 | @classmethod 75 | def unauthorized(cls, err=None, code=None): 76 | return cls.custom(err=err, code=code, status=401) 77 | 78 | @classmethod 79 | def forbidden(cls, err=None, code=None): 80 | return cls.custom(err=err, code=code, status=403) 81 | 82 | @classmethod 83 | def not_found(cls, err=None, code=None): 84 | return cls.custom(err=err, code=code, status=404) 85 | 86 | @classmethod 87 | def conflict(cls, err=None, code=None): 88 | return cls.custom(err=err, code=code, status=409) 89 | 90 | 91 | class ErrorHandler(object): 92 | @classmethod 93 | def handle_{{ app }}(cls, error): 94 | return make_err_resp(error) 95 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/core/schema.py.jinja: -------------------------------------------------------------------------------- 1 | from marshmallow import post_load 2 | from webargs.fields import DelimitedList 3 | 4 | from {{ app }}.exts import ma 5 | 6 | 7 | fields = ma 8 | Schema = ma.Schema 9 | 10 | fields.DelimitedList = DelimitedList 11 | 12 | 13 | # https://github.com/sloria/webargs/issues/126 14 | # https://github.com/jmcarp/flask-apispec/issues/73 15 | class ModelSchema(ma.ModelSchema): 16 | @post_load() 17 | def make_instance(self, data, **kwargs): 18 | return data 19 | 20 | 21 | __all__ = [ 22 | 'ma', 23 | 'ModelSchema', 24 | 'Schema', 25 | 'fields' 26 | ] 27 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/exts.py.jinja: -------------------------------------------------------------------------------- 1 | from flask_migrate import Migrate 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_marshmallow import Marshmallow 4 | {%- if not bare %} 5 | from flask_bcrypt import Bcrypt 6 | from flask_jwt_extended import JWTManager 7 | {%- endif %} 8 | 9 | {% if not bare -%} 10 | bcrypt = Bcrypt() 11 | {%- endif %} 12 | db = SQLAlchemy() 13 | migrate = Migrate() 14 | ma = Marshmallow() 15 | {%- if not bare %} 16 | 17 | from {{ app }}.helpers import jwt_identity, identity_loader # noqa 18 | 19 | jwt = JWTManager() 20 | jwt.user_loader_callback_loader(jwt_identity) 21 | jwt.user_identity_loader(identity_loader) 22 | {%- endif %} 23 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/helpers.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.models import User # noqa 2 | 3 | 4 | def jwt_identity(payload): 5 | return User.query.get(payload) 6 | 7 | 8 | def identity_loader(user): 9 | return user.id 10 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/models/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | {% if not bare -%} 2 | from .user import User 3 | 4 | 5 | __all__ = [ 6 | 'User' 7 | ] 8 | {% endif -%} 9 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/models/helpers.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.database import db 2 | 3 | 4 | # From Mike Bayer's "Building the app" talk 5 | # https://speakerdeck.com/zzzeek/building-the-app 6 | class SurrogatePK(object): 7 | __table_args__ = {'extend_existing': True} 8 | 9 | id = db.Column(db.Integer, primary_key=True, doc='id') 10 | inserted_at = db.Column(db.DateTime, nullable=False, index=True, 11 | server_default=db.func.now(), doc='insert time') 12 | updated_at = db.Column(db.DateTime, nullable=False, index=True, 13 | server_default=db.func.now(), onupdate=db.func.now(), 14 | doc='update time') 15 | 16 | def __repr__(self): 17 | return '<{classname}({pk})>'.format(classname=type(self).__name__, 18 | pk=self.id) 19 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/models/user.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.database import db, Column, Model 2 | from {{ app }}.exts import bcrypt 3 | 4 | 5 | class User(Model): 6 | __tablename__ = 'users' 7 | 8 | id = Column(db.Integer, primary_key=True, doc='id') 9 | username = Column(db.String(), unique=True, nullable=False, index=True, doc='user name') 10 | email = Column(db.String(), unique=True, nullable=False, index=True, doc='email') 11 | password = Column(db.Binary(128), nullable=True, doc='passowrd') 12 | inserted_at = Column(db.DateTime, nullable=False, index=True, 13 | server_default=db.func.now(), doc='insert time') 14 | updated_at = Column(db.DateTime, nullable=False, index=True, 15 | server_default=db.func.now(), onupdate=db.func.now(), 16 | doc='update time') 17 | token: str = None 18 | 19 | def __init__(self, username, email, password=None, **kwargs): 20 | Model.__init__(self, username=username, email=email, **kwargs) 21 | if password: 22 | self.set_password(password) 23 | else: 24 | self.password = None 25 | 26 | def set_password(self, password): 27 | self.password = bcrypt.generate_password_hash(password) 28 | 29 | def check_password(self, value): 30 | return bcrypt.check_password_hash(self.password, value) 31 | 32 | def __repr__(self): 33 | return f'' 34 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/router.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }} import views 2 | 3 | 4 | def register_blueprints(app): 5 | app.register_blueprint(views.home.bp, url_prefix='/api') 6 | {%- if not bare %} 7 | app.register_blueprint(views.user.bp, url_prefix='/api') 8 | app.register_blueprint(views.session.bp, url_prefix='/api') 9 | {%- endif %} 10 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/run.py.jinja: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | from flask import Flask 4 | from flask.helpers import get_env 5 | 6 | from {{ app }} import cmds 7 | from {{ app }}.exts import db, migrate, ma{% if not bare %}, bcrypt, jwt{% endif %} 8 | from {{ app }}.router import register_blueprints 9 | from {{ app }}.core.errors import {{ proj }}Error, ErrorHandler 10 | 11 | 12 | def register_extensions(app): 13 | db.init_app(app) 14 | migrate.init_app(app, db) 15 | ma.init_app(app) 16 | {%- if not bare %} 17 | bcrypt.init_app(app) 18 | jwt.init_app(app) 19 | {%- endif %} 20 | 21 | 22 | def register_shellcontext(app): 23 | def shell_context(): 24 | return { 25 | 'db': db, 26 | } 27 | app.shell_context_processor(shell_context) 28 | 29 | 30 | def register_commands(app): 31 | app.cli.add_command(cmds.clean) 32 | app.cli.add_command(cmds.test) 33 | app.cli.add_command(cmds.lint) 34 | 35 | 36 | def register_errorhandlers(app): 37 | app.register_error_handler({{ proj }}Error, 38 | ErrorHandler.handle_{{ app }}) 39 | 40 | 41 | def register_logging(app): 42 | logging.config.fileConfig( 43 | app.config.get('LOG_CONF_PATH'), 44 | defaults={"logdir": app.config.get('LOG_PATH')}) 45 | 46 | 47 | def create_app(config=None): 48 | conf_map = { 49 | 'dev': 'development', 50 | 'prod': 'production', 51 | 'test': 'testing' 52 | } 53 | config_obj = conf_map.get(config) or get_env() 54 | 55 | app = Flask(__name__, instance_relative_config=True) 56 | app.config.from_object(f'{{ app }}.config.{config_obj}') 57 | app.url_map.strict_slashes = False 58 | 59 | register_extensions(app) 60 | register_shellcontext(app) 61 | register_blueprints(app) 62 | register_errorhandlers(app) 63 | register_commands(app) 64 | 65 | if config_obj != 'testing': 66 | register_logging(app) 67 | 68 | if config_obj == 'production': 69 | app.config.from_pyfile('prod.secret.cfg', silent=True) 70 | else: 71 | from {{ app }}.swagger import register_apispec 72 | register_apispec(app) 73 | 74 | return app 75 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/schemas/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | {% if not bare -%} 2 | from .user import UserSchema 3 | 4 | 5 | __all__ = [ 6 | 'UserSchema' 7 | ] 8 | {% endif -%} 9 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/schemas/helpers.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.schema import fields, Schema 2 | 3 | 4 | class EmptySchema(Schema): 5 | pass 6 | 7 | 8 | class SchemaMixin(object): 9 | id = fields.Int(dump_only=True) 10 | inserted_at = fields.DateTime('%Y-%m-%d %H:%M:%S', dump_only=True) 11 | updated_at = fields.DateTime('%Y-%m-%d %H:%M:%S', dump_only=True) 12 | 13 | class Meta: 14 | ordered = True 15 | datetimeformat = '%Y-%m-%d %H:%M:%S' 16 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/schemas/user.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }}.core.schema import ModelSchema, fields 2 | 3 | from {{ app }}.models import User 4 | 5 | from .helpers import SchemaMixin 6 | 7 | 8 | class UserSchema(ModelSchema, SchemaMixin): 9 | username = fields.Str(required=True) 10 | password = fields.Str(required=True) 11 | email = fields.Email(missing=None) 12 | token = fields.Str() 13 | 14 | class Meta: 15 | model = User 16 | fields = ('id', 'username', 'password', 'email', 'token', 'inserted_at', 17 | 'updated_at') 18 | load_only = ('password',) 19 | dump_only = ('inserted_at', 'updated_at', 'token') 20 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/swagger.py.jinja: -------------------------------------------------------------------------------- 1 | from {{ app }} import __version__ 2 | from {{ app }}.views import home 3 | {%- if not bare %} 4 | from {{ app }}.views import user, session 5 | {%- endif %} 6 | 7 | 8 | def register_apispec(app): 9 | from apispec import APISpec 10 | from apispec.ext.marshmallow import MarshmallowPlugin 11 | from flask_apispec.extension import FlaskApiSpec 12 | 13 | app.config.update({ 14 | 'APISPEC_SPEC': 15 | APISpec( 16 | title='{{ proj }}', 17 | version=f'v{__version__}', 18 | openapi_version='2.0', 19 | plugins=[MarshmallowPlugin()] 20 | ), 21 | 'APISPEC_SWAGGER_URL': '/spec-json', 22 | 'APISPEC_SWAGGER_UI_URL': '/spec' 23 | }) 24 | 25 | spec = FlaskApiSpec(app) 26 | 27 | spec.register(home.home, blueprint='home') 28 | {%- if not bare %} 29 | 30 | spec.register(user.index, blueprint='user') 31 | spec.register(user.create, blueprint='user') 32 | spec.register(user.show, blueprint='user') 33 | spec.register(user.update, blueprint='user') 34 | spec.register(user.delete, blueprint='user') 35 | 36 | spec.register(session.create, blueprint='session') 37 | spec.register(session.delete, blueprint='session') 38 | {%- endif %} 39 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/views/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | from . import home 2 | {%- if not bare %} 3 | from . import user 4 | from . import session 5 | {%- endif %} 6 | 7 | 8 | __all__ = [ 9 | 'home'{%- if not bare -%}, 10 | 'user', 11 | 'session' 12 | {%- endif %} 13 | ] 14 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/views/home.py.jinja: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | 3 | from {{ app }}.core import doc 4 | 5 | 6 | bp = Blueprint('home', __name__) 7 | 8 | 9 | @doc(tags=['Home'], description='home', 10 | responses={'200': { 11 | 'description': 'success', 12 | 'schema': { 13 | 'type': 'object', 14 | 'properties': { 15 | 'message': {'type': 'string'} 16 | } 17 | } 18 | }}) 19 | @bp.route('', methods=('GET', ), provide_automatic_options=False) 20 | def home(): 21 | return jsonify({ 22 | 'message': 'Hello visitor, Welcome to {{ proj }}!' 23 | }) 24 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/views/session.py.jinja: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_jwt_extended import create_access_token 3 | 4 | from {{ app }}.core import jwt_required, doc, use_kwargs, marshal_with 5 | from {{ app }}.core.errors import {{ proj }}Error 6 | from {{ app }}.models import User 7 | from {{ app }}.schemas import UserSchema 8 | 9 | 10 | bp = Blueprint('session', __name__) 11 | 12 | 13 | @doc(tags=['Session'], description='create session') 14 | @bp.route('/sessions', methods=['POST'], provide_automatic_options=False) 15 | @use_kwargs(UserSchema) 16 | @marshal_with(UserSchema, code=201) 17 | def create(username, password, email): 18 | user = User.query.filter_by(username=username).first() 19 | if user and user.check_password(password): 20 | user.token = create_access_token(identity=user, fresh=True) 21 | return user, 201 22 | else: 23 | raise {{ proj }}Error.not_found(f'user {username} not found') 24 | 25 | 26 | # TODO: implement it 27 | @doc(tags=['Session'], description='delete session') 28 | @bp.route('/sessions', methods=['DELETE'], provide_automatic_options=False) 29 | @jwt_required 30 | @marshal_with(None, code=204) # FIXME 31 | def delete(): 32 | return True 33 | -------------------------------------------------------------------------------- /horn/templates/new/{{ app }}/views/user.py.jinja: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | from {{ app }}.core import jwt_required, doc, use_kwargs, marshal_with 6 | from {{ app }}.core.database import db, atomic 7 | from {{ app }}.models import User 8 | from {{ app }}.schemas import UserSchema 9 | 10 | 11 | bp = Blueprint('user', __name__) 12 | 13 | 14 | @doc(tags=['User'], description='list users') 15 | @bp.route('/users', methods=['GET'], provide_automatic_options=False) 16 | @jwt_required 17 | @marshal_with(UserSchema(many=True), code=200) 18 | def index(): 19 | users = User.query.all() 20 | return users 21 | 22 | 23 | @doc(tags=['User'], description='create user') 24 | @bp.route('/users', methods=['POST'], provide_automatic_options=False) 25 | @use_kwargs(UserSchema) 26 | @marshal_with(UserSchema, code=201) 27 | def create(username, email, password): 28 | user = User.create(username=username, email=email, password=password) 29 | return user, 201 30 | 31 | 32 | @doc(tags=['User'], description='show user') 33 | @bp.route('/users/', methods=['GET'], provide_automatic_options=False) 34 | @jwt_required 35 | @marshal_with(UserSchema, code=200) 36 | def show(pk): 37 | user = User.query.get_or_404(pk) 38 | return user 39 | 40 | 41 | # TODO: implement it 42 | @doc(tags=['User'], description='update user') 43 | @bp.route('/users/', methods=['PUT', 'PATCH'], provide_automatic_options=False) 44 | @jwt_required 45 | @use_kwargs(UserSchema) 46 | @marshal_with(UserSchema, code=200) 47 | def update(pk): 48 | pass 49 | 50 | 51 | @doc(tags=['User'], description='delete user') 52 | @bp.route('/users/', methods=['DELETE'], provide_automatic_options=False) 53 | @jwt_required 54 | @marshal_with(None, code=204) # FIXME 55 | @atomic(db.session) 56 | def delete(pk): 57 | user = User.query.get_or_404(pk) 58 | try: 59 | user.delete(commit=False) 60 | except IntegrityError as e: 61 | raise e 62 | return True 63 | -------------------------------------------------------------------------------- /horn/tpl.py: -------------------------------------------------------------------------------- 1 | import os 2 | import toml 3 | 4 | 5 | def get_proj_info(): 6 | proj_file = 'pyproject.toml' 7 | if not os.path.isfile(proj_file): 8 | print(f'Error: Can not found {proj_file}') 9 | exit(1) 10 | data = toml.load(proj_file) 11 | project = data['horn'] 12 | return { 13 | 'target': project.get('directory'), 14 | 'proj': project.get('project_name'), 15 | 'app': project.get('app_name'), 16 | 'bare': project.get('bare'), 17 | 'from': project.get('from'), 18 | 'checkout': project.get('checkout') 19 | } 20 | 21 | 22 | def merge_fields(base, attach={}): 23 | """ 24 | >>> merge_fields({'a': 1}) 25 | {'a': 1} 26 | >>> merge_fields({'a': 1}, {'b': 2}) 27 | {'a': 1, 'b': 2} 28 | """ 29 | base.update(attach) 30 | return base 31 | 32 | 33 | def validate_opts(opts): 34 | for k, v in opts.items(): 35 | if k.startswith('<'): 36 | if ':' in v: 37 | print(f'Error: Options error, {k}: {v}') 38 | exit(1) 39 | if k == '': 40 | for attrs in v: 41 | if ':' not in attrs: 42 | print(f'Error: Options error, {k}: {attrs}') 43 | exit(1) 44 | if k == '': 45 | if v[0].islower() or v == v.lower() or v == v.upper() or "_" in v: 46 | print('Error: Module name must be upper camel case') 47 | exit(1) 48 | 49 | 50 | def validate_type(arg, types): 51 | """ 52 | >>> types = {'string': 'String'} 53 | >>> validate_type('string', types) 54 | 'String' 55 | >>> try: 56 | ... validate_type('bbb', types) 57 | ... except: 58 | ... pass 59 | Error: Field type error, bbb 60 | """ 61 | if arg not in types: 62 | print(f'Error: Field type error, {arg}') 63 | exit(1) 64 | return types.get(arg) 65 | 66 | 67 | def validate_attr(attrs, affixes, exclude=tuple()): 68 | """ 69 | >>> attrs = ['nonull', 'load'] 70 | >>> affixes = ('uniq', 'nonull', 'index') 71 | >>> exclude = ('none', 'required', 'dump', 'load', 'exclude') 72 | >>> validate_attr(attrs, affixes, exclude) 73 | {'nonull': True} 74 | >>> attrs.append('bbb') 75 | >>> try: 76 | ... validate_attr(attrs, affixes, exclude) 77 | ... except: 78 | ... pass 79 | Error: Unknown attribute, bbb 80 | """ 81 | diff = set(attrs) - set(exclude) 82 | for attr in diff: 83 | if attr not in affixes: 84 | print(f'Error: Unknown attribute, {attr}') 85 | exit(1) 86 | return dict(zip(diff, [True for i in diff])) 87 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "3.7.1" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, 11 | {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, 12 | ] 13 | 14 | [package.dependencies] 15 | exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} 16 | idna = ">=2.8" 17 | sniffio = ">=1.1" 18 | 19 | [package.extras] 20 | doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] 21 | test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 22 | trio = ["trio (<0.22)"] 23 | 24 | [package.source] 25 | type = "legacy" 26 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 27 | reference = "tuna" 28 | 29 | [[package]] 30 | name = "certifi" 31 | version = "2023.5.7" 32 | description = "Python package for providing Mozilla's CA Bundle." 33 | optional = false 34 | python-versions = ">=3.6" 35 | files = [ 36 | {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, 37 | {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, 38 | ] 39 | 40 | [package.source] 41 | type = "legacy" 42 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 43 | reference = "tuna" 44 | 45 | [[package]] 46 | name = "cffi" 47 | version = "1.15.1" 48 | description = "Foreign Function Interface for Python calling C code." 49 | optional = false 50 | python-versions = "*" 51 | files = [ 52 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 53 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 54 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 55 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 56 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 57 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 58 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 59 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 60 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 61 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 62 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 63 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 64 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 65 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 66 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 67 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 68 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 69 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 70 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 71 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 72 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 73 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 74 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 75 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 76 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 77 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 78 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 79 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 80 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 81 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 82 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 83 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 84 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 85 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 86 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 87 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 88 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 89 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 90 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 91 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 92 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 93 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 94 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 95 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 96 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 97 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 98 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 99 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 100 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 101 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 102 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 103 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 104 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 105 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 106 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 107 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 108 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 109 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 110 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 111 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 112 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 113 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 114 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 115 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 116 | ] 117 | 118 | [package.dependencies] 119 | pycparser = "*" 120 | 121 | [package.source] 122 | type = "legacy" 123 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 124 | reference = "tuna" 125 | 126 | [[package]] 127 | name = "click" 128 | version = "8.1.6" 129 | description = "Composable command line interface toolkit" 130 | optional = false 131 | python-versions = ">=3.7" 132 | files = [ 133 | {file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"}, 134 | {file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"}, 135 | ] 136 | 137 | [package.dependencies] 138 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 139 | 140 | [package.source] 141 | type = "legacy" 142 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 143 | reference = "tuna" 144 | 145 | [[package]] 146 | name = "colorama" 147 | version = "0.4.6" 148 | description = "Cross-platform colored terminal text." 149 | optional = false 150 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 151 | files = [ 152 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 153 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 154 | ] 155 | 156 | [package.source] 157 | type = "legacy" 158 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 159 | reference = "tuna" 160 | 161 | [[package]] 162 | name = "copier" 163 | version = "8.1.0" 164 | description = "A library for rendering project templates." 165 | optional = false 166 | python-versions = ">=3.7,<4.0" 167 | files = [ 168 | {file = "copier-8.1.0-py3-none-any.whl", hash = "sha256:56537163b1b24441a63504ebf56b8db588dee20b48ef4fc374a6c5a2b43010c1"}, 169 | {file = "copier-8.1.0.tar.gz", hash = "sha256:902b4eb65fafe7a1621991234d2ebf3bc3fc9323e64e3a2560a00c05c73f6229"}, 170 | ] 171 | 172 | [package.dependencies] 173 | colorama = ">=0.4.3" 174 | decorator = ">=5.1.1" 175 | dunamai = ">=1.7.0" 176 | funcy = ">=1.17" 177 | jinja2 = ">=3.1.1" 178 | jinja2-ansible-filters = ">=1.3.1" 179 | packaging = ">=23.0" 180 | pathspec = ">=0.9.0" 181 | plumbum = ">=1.6.9" 182 | pydantic = ">=1.10.2,<2" 183 | pygments = ">=2.7.1" 184 | pyyaml = ">=5.3.1" 185 | pyyaml-include = ">=1.2" 186 | questionary = ">=1.8.1" 187 | 188 | [package.source] 189 | type = "legacy" 190 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 191 | reference = "tuna" 192 | 193 | [[package]] 194 | name = "coverage" 195 | version = "7.2.7" 196 | description = "Code coverage measurement for Python" 197 | optional = false 198 | python-versions = ">=3.7" 199 | files = [ 200 | {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, 201 | {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, 202 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, 203 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, 204 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, 205 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, 206 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, 207 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, 208 | {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, 209 | {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, 210 | {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, 211 | {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, 212 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, 213 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, 214 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, 215 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, 216 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, 217 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, 218 | {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, 219 | {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, 220 | {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, 221 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, 222 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, 223 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, 224 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, 225 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, 226 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, 227 | {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, 228 | {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, 229 | {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, 230 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, 231 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, 232 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, 233 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, 234 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, 235 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, 236 | {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, 237 | {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, 238 | {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, 239 | {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, 240 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, 241 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, 242 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, 243 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, 244 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, 245 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, 246 | {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, 247 | {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, 248 | {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, 249 | {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, 250 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, 251 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, 252 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, 253 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, 254 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, 255 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, 256 | {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, 257 | {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, 258 | {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, 259 | {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, 260 | ] 261 | 262 | [package.dependencies] 263 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 264 | 265 | [package.extras] 266 | toml = ["tomli"] 267 | 268 | [package.source] 269 | type = "legacy" 270 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 271 | reference = "tuna" 272 | 273 | [[package]] 274 | name = "cryptography" 275 | version = "41.0.2" 276 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 277 | optional = false 278 | python-versions = ">=3.7" 279 | files = [ 280 | {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, 281 | {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, 282 | {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, 283 | {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, 284 | {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, 285 | {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, 286 | {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, 287 | {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, 288 | {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, 289 | {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, 290 | {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, 291 | {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, 292 | {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, 293 | {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, 294 | {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, 295 | {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, 296 | {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, 297 | {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, 298 | {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, 299 | {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, 300 | {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, 301 | {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, 302 | {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, 303 | ] 304 | 305 | [package.dependencies] 306 | cffi = ">=1.12" 307 | 308 | [package.extras] 309 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 310 | docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] 311 | nox = ["nox"] 312 | pep8test = ["black", "check-sdist", "mypy", "ruff"] 313 | sdist = ["build"] 314 | ssh = ["bcrypt (>=3.1.5)"] 315 | test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] 316 | test-randomorder = ["pytest-randomly"] 317 | 318 | [package.source] 319 | type = "legacy" 320 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 321 | reference = "tuna" 322 | 323 | [[package]] 324 | name = "decorator" 325 | version = "5.1.1" 326 | description = "Decorators for Humans" 327 | optional = false 328 | python-versions = ">=3.5" 329 | files = [ 330 | {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 331 | {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 332 | ] 333 | 334 | [package.source] 335 | type = "legacy" 336 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 337 | reference = "tuna" 338 | 339 | [[package]] 340 | name = "distlib" 341 | version = "0.3.7" 342 | description = "Distribution utilities" 343 | optional = false 344 | python-versions = "*" 345 | files = [ 346 | {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, 347 | {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, 348 | ] 349 | 350 | [package.source] 351 | type = "legacy" 352 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 353 | reference = "tuna" 354 | 355 | [[package]] 356 | name = "docopt" 357 | version = "0.6.2" 358 | description = "Pythonic argument parser, that will make you smile" 359 | optional = false 360 | python-versions = "*" 361 | files = [ 362 | {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 363 | ] 364 | 365 | [package.source] 366 | type = "legacy" 367 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 368 | reference = "tuna" 369 | 370 | [[package]] 371 | name = "dunamai" 372 | version = "1.18.0" 373 | description = "Dynamic version generation" 374 | optional = false 375 | python-versions = ">=3.5,<4.0" 376 | files = [ 377 | {file = "dunamai-1.18.0-py3-none-any.whl", hash = "sha256:f9284a9f4048f0b809d11539896e78bde94c05b091b966a04a44ab4c48df03ce"}, 378 | {file = "dunamai-1.18.0.tar.gz", hash = "sha256:5200598561ea5ba956a6174c36e402e92206c6a6aa4a93a6c5cb8003ee1e0997"}, 379 | ] 380 | 381 | [package.dependencies] 382 | packaging = ">=20.9" 383 | 384 | [package.source] 385 | type = "legacy" 386 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 387 | reference = "tuna" 388 | 389 | [[package]] 390 | name = "editables" 391 | version = "0.4" 392 | description = "Editable installations" 393 | optional = false 394 | python-versions = ">=3.7" 395 | files = [ 396 | {file = "editables-0.4-py3-none-any.whl", hash = "sha256:dd430dcf41c78d1d68ebbb893e498b0a306b554be9e7cede73b1c876c6ddbc8d"}, 397 | {file = "editables-0.4.tar.gz", hash = "sha256:dc322c42e7ccaf19600874035a4573898d88aadd07e177c239298135b75da772"}, 398 | ] 399 | 400 | [package.source] 401 | type = "legacy" 402 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 403 | reference = "tuna" 404 | 405 | [[package]] 406 | name = "exceptiongroup" 407 | version = "1.1.2" 408 | description = "Backport of PEP 654 (exception groups)" 409 | optional = false 410 | python-versions = ">=3.7" 411 | files = [ 412 | {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, 413 | {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, 414 | ] 415 | 416 | [package.extras] 417 | test = ["pytest (>=6)"] 418 | 419 | [package.source] 420 | type = "legacy" 421 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 422 | reference = "tuna" 423 | 424 | [[package]] 425 | name = "filelock" 426 | version = "3.12.2" 427 | description = "A platform independent file lock." 428 | optional = false 429 | python-versions = ">=3.7" 430 | files = [ 431 | {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, 432 | {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, 433 | ] 434 | 435 | [package.extras] 436 | docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 437 | testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] 438 | 439 | [package.source] 440 | type = "legacy" 441 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 442 | reference = "tuna" 443 | 444 | [[package]] 445 | name = "flake8" 446 | version = "6.0.0" 447 | description = "the modular source code checker: pep8 pyflakes and co" 448 | optional = false 449 | python-versions = ">=3.8.1" 450 | files = [ 451 | {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, 452 | {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, 453 | ] 454 | 455 | [package.dependencies] 456 | mccabe = ">=0.7.0,<0.8.0" 457 | pycodestyle = ">=2.10.0,<2.11.0" 458 | pyflakes = ">=3.0.0,<3.1.0" 459 | 460 | [package.source] 461 | type = "legacy" 462 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 463 | reference = "tuna" 464 | 465 | [[package]] 466 | name = "funcy" 467 | version = "2.0" 468 | description = "A fancy and practical functional tools" 469 | optional = false 470 | python-versions = "*" 471 | files = [ 472 | {file = "funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0"}, 473 | {file = "funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb"}, 474 | ] 475 | 476 | [package.source] 477 | type = "legacy" 478 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 479 | reference = "tuna" 480 | 481 | [[package]] 482 | name = "h11" 483 | version = "0.14.0" 484 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 485 | optional = false 486 | python-versions = ">=3.7" 487 | files = [ 488 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 489 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 490 | ] 491 | 492 | [package.source] 493 | type = "legacy" 494 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 495 | reference = "tuna" 496 | 497 | [[package]] 498 | name = "hatch" 499 | version = "1.7.0" 500 | description = "Modern, extensible Python project management" 501 | optional = false 502 | python-versions = ">=3.7" 503 | files = [ 504 | {file = "hatch-1.7.0-py3-none-any.whl", hash = "sha256:efc84112fd02ca85b7bab54f5e2ef71393a98dc849eac9aca390504031f8a1a8"}, 505 | {file = "hatch-1.7.0.tar.gz", hash = "sha256:7afc701fd5b33684a6650e1ecab8957e19685f824240ba7458dcacd66f90fb46"}, 506 | ] 507 | 508 | [package.dependencies] 509 | click = ">=8.0.3" 510 | hatchling = ">=1.14.0" 511 | httpx = ">=0.22.0" 512 | hyperlink = ">=21.0.0" 513 | keyring = ">=23.5.0" 514 | packaging = ">=21.3" 515 | pexpect = ">=4.8,<5.0" 516 | platformdirs = ">=2.5.0" 517 | pyperclip = ">=1.8.2" 518 | rich = ">=11.2.0" 519 | shellingham = ">=1.4.0" 520 | tomli-w = ">=1.0" 521 | tomlkit = ">=0.11.1" 522 | userpath = ">=1.7,<2.0" 523 | virtualenv = ">=20.16.2" 524 | 525 | [package.source] 526 | type = "legacy" 527 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 528 | reference = "tuna" 529 | 530 | [[package]] 531 | name = "hatchling" 532 | version = "1.18.0" 533 | description = "Modern, extensible Python build backend" 534 | optional = false 535 | python-versions = ">=3.8" 536 | files = [ 537 | {file = "hatchling-1.18.0-py3-none-any.whl", hash = "sha256:b66dc254931ec42aa68b5febd1d342c58142cc5267b7ff3b12ba3fa5b4900c93"}, 538 | {file = "hatchling-1.18.0.tar.gz", hash = "sha256:50e99c3110ce0afc3f7bdbadff1c71c17758e476731c27607940cfa6686489ca"}, 539 | ] 540 | 541 | [package.dependencies] 542 | editables = ">=0.3" 543 | packaging = ">=21.3" 544 | pathspec = ">=0.10.1" 545 | pluggy = ">=1.0.0" 546 | tomli = {version = ">=1.2.2", markers = "python_version < \"3.11\""} 547 | trove-classifiers = "*" 548 | 549 | [package.source] 550 | type = "legacy" 551 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 552 | reference = "tuna" 553 | 554 | [[package]] 555 | name = "httpcore" 556 | version = "0.17.3" 557 | description = "A minimal low-level HTTP client." 558 | optional = false 559 | python-versions = ">=3.7" 560 | files = [ 561 | {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, 562 | {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, 563 | ] 564 | 565 | [package.dependencies] 566 | anyio = ">=3.0,<5.0" 567 | certifi = "*" 568 | h11 = ">=0.13,<0.15" 569 | sniffio = "==1.*" 570 | 571 | [package.extras] 572 | http2 = ["h2 (>=3,<5)"] 573 | socks = ["socksio (==1.*)"] 574 | 575 | [package.source] 576 | type = "legacy" 577 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 578 | reference = "tuna" 579 | 580 | [[package]] 581 | name = "httpx" 582 | version = "0.24.1" 583 | description = "The next generation HTTP client." 584 | optional = false 585 | python-versions = ">=3.7" 586 | files = [ 587 | {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, 588 | {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, 589 | ] 590 | 591 | [package.dependencies] 592 | certifi = "*" 593 | httpcore = ">=0.15.0,<0.18.0" 594 | idna = "*" 595 | sniffio = "*" 596 | 597 | [package.extras] 598 | brotli = ["brotli", "brotlicffi"] 599 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 600 | http2 = ["h2 (>=3,<5)"] 601 | socks = ["socksio (==1.*)"] 602 | 603 | [package.source] 604 | type = "legacy" 605 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 606 | reference = "tuna" 607 | 608 | [[package]] 609 | name = "hyperlink" 610 | version = "21.0.0" 611 | description = "A featureful, immutable, and correct URL for Python." 612 | optional = false 613 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 614 | files = [ 615 | {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, 616 | {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, 617 | ] 618 | 619 | [package.dependencies] 620 | idna = ">=2.5" 621 | 622 | [package.source] 623 | type = "legacy" 624 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 625 | reference = "tuna" 626 | 627 | [[package]] 628 | name = "idna" 629 | version = "3.4" 630 | description = "Internationalized Domain Names in Applications (IDNA)" 631 | optional = false 632 | python-versions = ">=3.5" 633 | files = [ 634 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 635 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 636 | ] 637 | 638 | [package.source] 639 | type = "legacy" 640 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 641 | reference = "tuna" 642 | 643 | [[package]] 644 | name = "importlib-metadata" 645 | version = "6.8.0" 646 | description = "Read metadata from Python packages" 647 | optional = false 648 | python-versions = ">=3.8" 649 | files = [ 650 | {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, 651 | {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, 652 | ] 653 | 654 | [package.dependencies] 655 | zipp = ">=0.5" 656 | 657 | [package.extras] 658 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 659 | perf = ["ipython"] 660 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] 661 | 662 | [package.source] 663 | type = "legacy" 664 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 665 | reference = "tuna" 666 | 667 | [[package]] 668 | name = "importlib-resources" 669 | version = "6.0.0" 670 | description = "Read resources from Python packages" 671 | optional = false 672 | python-versions = ">=3.8" 673 | files = [ 674 | {file = "importlib_resources-6.0.0-py3-none-any.whl", hash = "sha256:d952faee11004c045f785bb5636e8f885bed30dc3c940d5d42798a2a4541c185"}, 675 | {file = "importlib_resources-6.0.0.tar.gz", hash = "sha256:4cf94875a8368bd89531a756df9a9ebe1f150e0f885030b461237bc7f2d905f2"}, 676 | ] 677 | 678 | [package.dependencies] 679 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 680 | 681 | [package.extras] 682 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 683 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 684 | 685 | [package.source] 686 | type = "legacy" 687 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 688 | reference = "tuna" 689 | 690 | [[package]] 691 | name = "inflection" 692 | version = "0.5.1" 693 | description = "A port of Ruby on Rails inflector to Python" 694 | optional = false 695 | python-versions = ">=3.5" 696 | files = [ 697 | {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, 698 | {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, 699 | ] 700 | 701 | [package.source] 702 | type = "legacy" 703 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 704 | reference = "tuna" 705 | 706 | [[package]] 707 | name = "iniconfig" 708 | version = "2.0.0" 709 | description = "brain-dead simple config-ini parsing" 710 | optional = false 711 | python-versions = ">=3.7" 712 | files = [ 713 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 714 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 715 | ] 716 | 717 | [package.source] 718 | type = "legacy" 719 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 720 | reference = "tuna" 721 | 722 | [[package]] 723 | name = "jaraco-classes" 724 | version = "3.3.0" 725 | description = "Utility functions for Python class constructs" 726 | optional = false 727 | python-versions = ">=3.8" 728 | files = [ 729 | {file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"}, 730 | {file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"}, 731 | ] 732 | 733 | [package.dependencies] 734 | more-itertools = "*" 735 | 736 | [package.extras] 737 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 738 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 739 | 740 | [package.source] 741 | type = "legacy" 742 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 743 | reference = "tuna" 744 | 745 | [[package]] 746 | name = "jeepney" 747 | version = "0.8.0" 748 | description = "Low-level, pure Python DBus protocol wrapper." 749 | optional = false 750 | python-versions = ">=3.7" 751 | files = [ 752 | {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, 753 | {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, 754 | ] 755 | 756 | [package.extras] 757 | test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] 758 | trio = ["async_generator", "trio"] 759 | 760 | [package.source] 761 | type = "legacy" 762 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 763 | reference = "tuna" 764 | 765 | [[package]] 766 | name = "jinja2" 767 | version = "3.1.2" 768 | description = "A very fast and expressive template engine." 769 | optional = false 770 | python-versions = ">=3.7" 771 | files = [ 772 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 773 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 774 | ] 775 | 776 | [package.dependencies] 777 | MarkupSafe = ">=2.0" 778 | 779 | [package.extras] 780 | i18n = ["Babel (>=2.7)"] 781 | 782 | [package.source] 783 | type = "legacy" 784 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 785 | reference = "tuna" 786 | 787 | [[package]] 788 | name = "jinja2-ansible-filters" 789 | version = "1.3.2" 790 | description = "A port of Ansible's jinja2 filters without requiring ansible core." 791 | optional = false 792 | python-versions = "*" 793 | files = [ 794 | {file = "jinja2-ansible-filters-1.3.2.tar.gz", hash = "sha256:07c10cf44d7073f4f01102ca12d9a2dc31b41d47e4c61ed92ef6a6d2669b356b"}, 795 | {file = "jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34"}, 796 | ] 797 | 798 | [package.dependencies] 799 | Jinja2 = "*" 800 | PyYAML = "*" 801 | 802 | [package.extras] 803 | test = ["pytest", "pytest-cov"] 804 | 805 | [package.source] 806 | type = "legacy" 807 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 808 | reference = "tuna" 809 | 810 | [[package]] 811 | name = "keyring" 812 | version = "24.2.0" 813 | description = "Store and access your passwords safely." 814 | optional = false 815 | python-versions = ">=3.8" 816 | files = [ 817 | {file = "keyring-24.2.0-py3-none-any.whl", hash = "sha256:4901caaf597bfd3bbd78c9a0c7c4c29fcd8310dab2cffefe749e916b6527acd6"}, 818 | {file = "keyring-24.2.0.tar.gz", hash = "sha256:ca0746a19ec421219f4d713f848fa297a661a8a8c1504867e55bfb5e09091509"}, 819 | ] 820 | 821 | [package.dependencies] 822 | importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} 823 | importlib-resources = {version = "*", markers = "python_version < \"3.9\""} 824 | "jaraco.classes" = "*" 825 | jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} 826 | pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} 827 | SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} 828 | 829 | [package.extras] 830 | completion = ["shtab"] 831 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 832 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 833 | 834 | [package.source] 835 | type = "legacy" 836 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 837 | reference = "tuna" 838 | 839 | [[package]] 840 | name = "markdown-it-py" 841 | version = "3.0.0" 842 | description = "Python port of markdown-it. Markdown parsing, done right!" 843 | optional = false 844 | python-versions = ">=3.8" 845 | files = [ 846 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 847 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 848 | ] 849 | 850 | [package.dependencies] 851 | mdurl = ">=0.1,<1.0" 852 | 853 | [package.extras] 854 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 855 | code-style = ["pre-commit (>=3.0,<4.0)"] 856 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 857 | linkify = ["linkify-it-py (>=1,<3)"] 858 | plugins = ["mdit-py-plugins"] 859 | profiling = ["gprof2dot"] 860 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 861 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 862 | 863 | [package.source] 864 | type = "legacy" 865 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 866 | reference = "tuna" 867 | 868 | [[package]] 869 | name = "markupsafe" 870 | version = "2.1.3" 871 | description = "Safely add untrusted strings to HTML/XML markup." 872 | optional = false 873 | python-versions = ">=3.7" 874 | files = [ 875 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, 876 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, 877 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, 878 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, 879 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, 880 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, 881 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, 882 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, 883 | {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, 884 | {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, 885 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, 886 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, 887 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, 888 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, 889 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, 890 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, 891 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, 892 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, 893 | {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, 894 | {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, 895 | {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, 896 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, 897 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, 898 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, 899 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, 900 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, 901 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, 902 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, 903 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, 904 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, 905 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, 906 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, 907 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, 908 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, 909 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, 910 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, 911 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, 912 | {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, 913 | {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, 914 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, 915 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, 916 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, 917 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, 918 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, 919 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, 920 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, 921 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, 922 | {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, 923 | {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, 924 | {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, 925 | ] 926 | 927 | [package.source] 928 | type = "legacy" 929 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 930 | reference = "tuna" 931 | 932 | [[package]] 933 | name = "mccabe" 934 | version = "0.7.0" 935 | description = "McCabe checker, plugin for flake8" 936 | optional = false 937 | python-versions = ">=3.6" 938 | files = [ 939 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 940 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 941 | ] 942 | 943 | [package.source] 944 | type = "legacy" 945 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 946 | reference = "tuna" 947 | 948 | [[package]] 949 | name = "mdurl" 950 | version = "0.1.2" 951 | description = "Markdown URL utilities" 952 | optional = false 953 | python-versions = ">=3.7" 954 | files = [ 955 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 956 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 957 | ] 958 | 959 | [package.source] 960 | type = "legacy" 961 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 962 | reference = "tuna" 963 | 964 | [[package]] 965 | name = "more-itertools" 966 | version = "9.1.0" 967 | description = "More routines for operating on iterables, beyond itertools" 968 | optional = false 969 | python-versions = ">=3.7" 970 | files = [ 971 | {file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"}, 972 | {file = "more_itertools-9.1.0-py3-none-any.whl", hash = "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3"}, 973 | ] 974 | 975 | [package.source] 976 | type = "legacy" 977 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 978 | reference = "tuna" 979 | 980 | [[package]] 981 | name = "packaging" 982 | version = "23.1" 983 | description = "Core utilities for Python packages" 984 | optional = false 985 | python-versions = ">=3.7" 986 | files = [ 987 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 988 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 989 | ] 990 | 991 | [package.source] 992 | type = "legacy" 993 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 994 | reference = "tuna" 995 | 996 | [[package]] 997 | name = "pampy" 998 | version = "0.3.0" 999 | description = "The Pattern Matching for Python you always dreamed of" 1000 | optional = false 1001 | python-versions = ">3.6" 1002 | files = [ 1003 | {file = "pampy-0.3.0-py3-none-any.whl", hash = "sha256:304470a6562173096fc88c22dc0ab50401ca2e3875a8725ee510759ec8ba58ff"}, 1004 | {file = "pampy-0.3.0.tar.gz", hash = "sha256:82054212e4478fc22163c55321a3583eda9918aff4440eed6c197e872a2a667b"}, 1005 | ] 1006 | 1007 | [package.source] 1008 | type = "legacy" 1009 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1010 | reference = "tuna" 1011 | 1012 | [[package]] 1013 | name = "pathspec" 1014 | version = "0.11.1" 1015 | description = "Utility library for gitignore style pattern matching of file paths." 1016 | optional = false 1017 | python-versions = ">=3.7" 1018 | files = [ 1019 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 1020 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 1021 | ] 1022 | 1023 | [package.source] 1024 | type = "legacy" 1025 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1026 | reference = "tuna" 1027 | 1028 | [[package]] 1029 | name = "pexpect" 1030 | version = "4.8.0" 1031 | description = "Pexpect allows easy control of interactive console applications." 1032 | optional = false 1033 | python-versions = "*" 1034 | files = [ 1035 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 1036 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 1037 | ] 1038 | 1039 | [package.dependencies] 1040 | ptyprocess = ">=0.5" 1041 | 1042 | [package.source] 1043 | type = "legacy" 1044 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1045 | reference = "tuna" 1046 | 1047 | [[package]] 1048 | name = "platformdirs" 1049 | version = "3.9.1" 1050 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 1051 | optional = false 1052 | python-versions = ">=3.7" 1053 | files = [ 1054 | {file = "platformdirs-3.9.1-py3-none-any.whl", hash = "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f"}, 1055 | {file = "platformdirs-3.9.1.tar.gz", hash = "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421"}, 1056 | ] 1057 | 1058 | [package.extras] 1059 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 1060 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] 1061 | 1062 | [package.source] 1063 | type = "legacy" 1064 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1065 | reference = "tuna" 1066 | 1067 | [[package]] 1068 | name = "pluggy" 1069 | version = "1.2.0" 1070 | description = "plugin and hook calling mechanisms for python" 1071 | optional = false 1072 | python-versions = ">=3.7" 1073 | files = [ 1074 | {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, 1075 | {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, 1076 | ] 1077 | 1078 | [package.extras] 1079 | dev = ["pre-commit", "tox"] 1080 | testing = ["pytest", "pytest-benchmark"] 1081 | 1082 | [package.source] 1083 | type = "legacy" 1084 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1085 | reference = "tuna" 1086 | 1087 | [[package]] 1088 | name = "plumbum" 1089 | version = "1.8.2" 1090 | description = "Plumbum: shell combinators library" 1091 | optional = false 1092 | python-versions = ">=3.6" 1093 | files = [ 1094 | {file = "plumbum-1.8.2-py3-none-any.whl", hash = "sha256:3ad9e5f56c6ec98f6f7988f7ea8b52159662ea9e915868d369dbccbfca0e367e"}, 1095 | {file = "plumbum-1.8.2.tar.gz", hash = "sha256:9e6dc032f4af952665f32f3206567bc23b7858b1413611afe603a3f8ad9bfd75"}, 1096 | ] 1097 | 1098 | [package.dependencies] 1099 | pywin32 = {version = "*", markers = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\""} 1100 | 1101 | [package.extras] 1102 | dev = ["paramiko", "psutil", "pytest (>=6.0)", "pytest-cov", "pytest-mock", "pytest-timeout"] 1103 | docs = ["sphinx (>=4.0.0)", "sphinx-rtd-theme (>=1.0.0)"] 1104 | ssh = ["paramiko"] 1105 | 1106 | [package.source] 1107 | type = "legacy" 1108 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1109 | reference = "tuna" 1110 | 1111 | [[package]] 1112 | name = "prompt-toolkit" 1113 | version = "3.0.39" 1114 | description = "Library for building powerful interactive command lines in Python" 1115 | optional = false 1116 | python-versions = ">=3.7.0" 1117 | files = [ 1118 | {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, 1119 | {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, 1120 | ] 1121 | 1122 | [package.dependencies] 1123 | wcwidth = "*" 1124 | 1125 | [package.source] 1126 | type = "legacy" 1127 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1128 | reference = "tuna" 1129 | 1130 | [[package]] 1131 | name = "ptyprocess" 1132 | version = "0.7.0" 1133 | description = "Run a subprocess in a pseudo terminal" 1134 | optional = false 1135 | python-versions = "*" 1136 | files = [ 1137 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 1138 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 1139 | ] 1140 | 1141 | [package.source] 1142 | type = "legacy" 1143 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1144 | reference = "tuna" 1145 | 1146 | [[package]] 1147 | name = "pycodestyle" 1148 | version = "2.10.0" 1149 | description = "Python style guide checker" 1150 | optional = false 1151 | python-versions = ">=3.6" 1152 | files = [ 1153 | {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, 1154 | {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, 1155 | ] 1156 | 1157 | [package.source] 1158 | type = "legacy" 1159 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1160 | reference = "tuna" 1161 | 1162 | [[package]] 1163 | name = "pycparser" 1164 | version = "2.21" 1165 | description = "C parser in Python" 1166 | optional = false 1167 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 1168 | files = [ 1169 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 1170 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 1171 | ] 1172 | 1173 | [package.source] 1174 | type = "legacy" 1175 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1176 | reference = "tuna" 1177 | 1178 | [[package]] 1179 | name = "pydantic" 1180 | version = "1.10.11" 1181 | description = "Data validation and settings management using python type hints" 1182 | optional = false 1183 | python-versions = ">=3.7" 1184 | files = [ 1185 | {file = "pydantic-1.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ff44c5e89315b15ff1f7fdaf9853770b810936d6b01a7bcecaa227d2f8fe444f"}, 1186 | {file = "pydantic-1.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c098d4ab5e2d5b3984d3cb2527e2d6099d3de85630c8934efcfdc348a9760e"}, 1187 | {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16928fdc9cb273c6af00d9d5045434c39afba5f42325fb990add2c241402d151"}, 1188 | {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0588788a9a85f3e5e9ebca14211a496409cb3deca5b6971ff37c556d581854e7"}, 1189 | {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9baf78b31da2dc3d3f346ef18e58ec5f12f5aaa17ac517e2ffd026a92a87588"}, 1190 | {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:373c0840f5c2b5b1ccadd9286782852b901055998136287828731868027a724f"}, 1191 | {file = "pydantic-1.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:c3339a46bbe6013ef7bdd2844679bfe500347ac5742cd4019a88312aa58a9847"}, 1192 | {file = "pydantic-1.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08a6c32e1c3809fbc49debb96bf833164f3438b3696abf0fbeceb417d123e6eb"}, 1193 | {file = "pydantic-1.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a451ccab49971af043ec4e0d207cbc8cbe53dbf148ef9f19599024076fe9c25b"}, 1194 | {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b02d24f7b2b365fed586ed73582c20f353a4c50e4be9ba2c57ab96f8091ddae"}, 1195 | {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f34739a89260dfa420aa3cbd069fbcc794b25bbe5c0a214f8fb29e363484b66"}, 1196 | {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e297897eb4bebde985f72a46a7552a7556a3dd11e7f76acda0c1093e3dbcf216"}, 1197 | {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d185819a7a059550ecb85d5134e7d40f2565f3dd94cfd870132c5f91a89cf58c"}, 1198 | {file = "pydantic-1.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:4400015f15c9b464c9db2d5d951b6a780102cfa5870f2c036d37c23b56f7fc1b"}, 1199 | {file = "pydantic-1.10.11-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2417de68290434461a266271fc57274a138510dca19982336639484c73a07af6"}, 1200 | {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:331c031ba1554b974c98679bd0780d89670d6fd6f53f5d70b10bdc9addee1713"}, 1201 | {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8268a735a14c308923e8958363e3a3404f6834bb98c11f5ab43251a4e410170c"}, 1202 | {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:44e51ba599c3ef227e168424e220cd3e544288c57829520dc90ea9cb190c3248"}, 1203 | {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d7781f1d13b19700b7949c5a639c764a077cbbdd4322ed505b449d3ca8edcb36"}, 1204 | {file = "pydantic-1.10.11-cp37-cp37m-win_amd64.whl", hash = "sha256:7522a7666157aa22b812ce14c827574ddccc94f361237ca6ea8bb0d5c38f1629"}, 1205 | {file = "pydantic-1.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc64eab9b19cd794a380179ac0e6752335e9555d214cfcb755820333c0784cb3"}, 1206 | {file = "pydantic-1.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8dc77064471780262b6a68fe67e013298d130414d5aaf9b562c33987dbd2cf4f"}, 1207 | {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe429898f2c9dd209bd0632a606bddc06f8bce081bbd03d1c775a45886e2c1cb"}, 1208 | {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:192c608ad002a748e4a0bed2ddbcd98f9b56df50a7c24d9a931a8c5dd053bd3d"}, 1209 | {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ef55392ec4bb5721f4ded1096241e4b7151ba6d50a50a80a2526c854f42e6a2f"}, 1210 | {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e0bb6efe86281623abbeeb0be64eab740c865388ee934cd3e6a358784aca6e"}, 1211 | {file = "pydantic-1.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:265a60da42f9f27e0b1014eab8acd3e53bd0bad5c5b4884e98a55f8f596b2c19"}, 1212 | {file = "pydantic-1.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:469adf96c8e2c2bbfa655fc7735a2a82f4c543d9fee97bd113a7fb509bf5e622"}, 1213 | {file = "pydantic-1.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6cbfbd010b14c8a905a7b10f9fe090068d1744d46f9e0c021db28daeb8b6de1"}, 1214 | {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abade85268cc92dff86d6effcd917893130f0ff516f3d637f50dadc22ae93999"}, 1215 | {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9738b0f2e6c70f44ee0de53f2089d6002b10c33264abee07bdb5c7f03038303"}, 1216 | {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:787cf23e5a0cde753f2eabac1b2e73ae3844eb873fd1f5bdbff3048d8dbb7604"}, 1217 | {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:174899023337b9fc685ac8adaa7b047050616136ccd30e9070627c1aaab53a13"}, 1218 | {file = "pydantic-1.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:1954f8778489a04b245a1e7b8b22a9d3ea8ef49337285693cf6959e4b757535e"}, 1219 | {file = "pydantic-1.10.11-py3-none-any.whl", hash = "sha256:008c5e266c8aada206d0627a011504e14268a62091450210eda7c07fabe6963e"}, 1220 | {file = "pydantic-1.10.11.tar.gz", hash = "sha256:f66d479cf7eb331372c470614be6511eae96f1f120344c25f3f9bb59fb1b5528"}, 1221 | ] 1222 | 1223 | [package.dependencies] 1224 | typing-extensions = ">=4.2.0" 1225 | 1226 | [package.extras] 1227 | dotenv = ["python-dotenv (>=0.10.4)"] 1228 | email = ["email-validator (>=1.0.3)"] 1229 | 1230 | [package.source] 1231 | type = "legacy" 1232 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1233 | reference = "tuna" 1234 | 1235 | [[package]] 1236 | name = "pyflakes" 1237 | version = "3.0.1" 1238 | description = "passive checker of Python programs" 1239 | optional = false 1240 | python-versions = ">=3.6" 1241 | files = [ 1242 | {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, 1243 | {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, 1244 | ] 1245 | 1246 | [package.source] 1247 | type = "legacy" 1248 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1249 | reference = "tuna" 1250 | 1251 | [[package]] 1252 | name = "pygments" 1253 | version = "2.15.1" 1254 | description = "Pygments is a syntax highlighting package written in Python." 1255 | optional = false 1256 | python-versions = ">=3.7" 1257 | files = [ 1258 | {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, 1259 | {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, 1260 | ] 1261 | 1262 | [package.extras] 1263 | plugins = ["importlib-metadata"] 1264 | 1265 | [package.source] 1266 | type = "legacy" 1267 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1268 | reference = "tuna" 1269 | 1270 | [[package]] 1271 | name = "pyperclip" 1272 | version = "1.8.2" 1273 | description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" 1274 | optional = false 1275 | python-versions = "*" 1276 | files = [ 1277 | {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, 1278 | ] 1279 | 1280 | [package.source] 1281 | type = "legacy" 1282 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1283 | reference = "tuna" 1284 | 1285 | [[package]] 1286 | name = "pytest" 1287 | version = "7.4.0" 1288 | description = "pytest: simple powerful testing with Python" 1289 | optional = false 1290 | python-versions = ">=3.7" 1291 | files = [ 1292 | {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, 1293 | {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, 1294 | ] 1295 | 1296 | [package.dependencies] 1297 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 1298 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 1299 | iniconfig = "*" 1300 | packaging = "*" 1301 | pluggy = ">=0.12,<2.0" 1302 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 1303 | 1304 | [package.extras] 1305 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 1306 | 1307 | [package.source] 1308 | type = "legacy" 1309 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1310 | reference = "tuna" 1311 | 1312 | [[package]] 1313 | name = "pytest-cov" 1314 | version = "4.1.0" 1315 | description = "Pytest plugin for measuring coverage." 1316 | optional = false 1317 | python-versions = ">=3.7" 1318 | files = [ 1319 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 1320 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 1321 | ] 1322 | 1323 | [package.dependencies] 1324 | coverage = {version = ">=5.2.1", extras = ["toml"]} 1325 | pytest = ">=4.6" 1326 | 1327 | [package.extras] 1328 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 1329 | 1330 | [package.source] 1331 | type = "legacy" 1332 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1333 | reference = "tuna" 1334 | 1335 | [[package]] 1336 | name = "pytest-testdox" 1337 | version = "3.1.0" 1338 | description = "A testdox format reporter for pytest" 1339 | optional = false 1340 | python-versions = ">=3.7" 1341 | files = [ 1342 | {file = "pytest-testdox-3.1.0.tar.gz", hash = "sha256:f48c49c517f0fb926560b383062db4961112078ec6ca555f91692c661bb5c765"}, 1343 | {file = "pytest_testdox-3.1.0-py2.py3-none-any.whl", hash = "sha256:f3a8f0789d668ccfb60f15aab81fb927b75066cfd19209176166bd7cecae73e6"}, 1344 | ] 1345 | 1346 | [package.dependencies] 1347 | pytest = ">=4.6.0" 1348 | 1349 | [package.source] 1350 | type = "legacy" 1351 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1352 | reference = "tuna" 1353 | 1354 | [[package]] 1355 | name = "pywin32" 1356 | version = "306" 1357 | description = "Python for Window Extensions" 1358 | optional = false 1359 | python-versions = "*" 1360 | files = [ 1361 | {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, 1362 | {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, 1363 | {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, 1364 | {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, 1365 | {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, 1366 | {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, 1367 | {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, 1368 | {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, 1369 | {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, 1370 | {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, 1371 | {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, 1372 | {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, 1373 | {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, 1374 | {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, 1375 | ] 1376 | 1377 | [package.source] 1378 | type = "legacy" 1379 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1380 | reference = "tuna" 1381 | 1382 | [[package]] 1383 | name = "pywin32-ctypes" 1384 | version = "0.2.2" 1385 | description = "A (partial) reimplementation of pywin32 using ctypes/cffi" 1386 | optional = false 1387 | python-versions = ">=3.6" 1388 | files = [ 1389 | {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, 1390 | {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, 1391 | ] 1392 | 1393 | [package.source] 1394 | type = "legacy" 1395 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1396 | reference = "tuna" 1397 | 1398 | [[package]] 1399 | name = "pyyaml" 1400 | version = "6.0.1" 1401 | description = "YAML parser and emitter for Python" 1402 | optional = false 1403 | python-versions = ">=3.6" 1404 | files = [ 1405 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 1406 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 1407 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 1408 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 1409 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 1410 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 1411 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 1412 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 1413 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 1414 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 1415 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 1416 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 1417 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 1418 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 1419 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 1420 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 1421 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 1422 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 1423 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 1424 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 1425 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 1426 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 1427 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 1428 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 1429 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 1430 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 1431 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 1432 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 1433 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 1434 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 1435 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 1436 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 1437 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 1438 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 1439 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 1440 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 1441 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 1442 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 1443 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 1444 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 1445 | ] 1446 | 1447 | [package.source] 1448 | type = "legacy" 1449 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1450 | reference = "tuna" 1451 | 1452 | [[package]] 1453 | name = "pyyaml-include" 1454 | version = "1.3.1" 1455 | description = "Extending PyYAML with a custom constructor for including YAML files within YAML files" 1456 | optional = false 1457 | python-versions = ">=3.7" 1458 | files = [ 1459 | {file = "pyyaml-include-1.3.1.tar.gz", hash = "sha256:4cb3b4e1baae2ec251808fe1e8aed5d3d20699c541864c8e47ed866ab2f15039"}, 1460 | {file = "pyyaml_include-1.3.1-py3-none-any.whl", hash = "sha256:e58525721a2938d29c4046350f8aad86f848660a770c29605e6f2700925fa753"}, 1461 | ] 1462 | 1463 | [package.dependencies] 1464 | PyYAML = ">=5.1,<7.0" 1465 | 1466 | [package.extras] 1467 | toml = ["toml"] 1468 | 1469 | [package.source] 1470 | type = "legacy" 1471 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1472 | reference = "tuna" 1473 | 1474 | [[package]] 1475 | name = "questionary" 1476 | version = "1.10.0" 1477 | description = "Python library to build pretty command line user prompts ⭐️" 1478 | optional = false 1479 | python-versions = ">=3.6,<4.0" 1480 | files = [ 1481 | {file = "questionary-1.10.0-py3-none-any.whl", hash = "sha256:fecfcc8cca110fda9d561cb83f1e97ecbb93c613ff857f655818839dac74ce90"}, 1482 | {file = "questionary-1.10.0.tar.gz", hash = "sha256:600d3aefecce26d48d97eee936fdb66e4bc27f934c3ab6dd1e292c4f43946d90"}, 1483 | ] 1484 | 1485 | [package.dependencies] 1486 | prompt_toolkit = ">=2.0,<4.0" 1487 | 1488 | [package.extras] 1489 | docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphinx-autodoc-typehints (>=1.11.1,<2.0.0)", "sphinx-copybutton (>=0.3.1,<0.4.0)", "sphinx-rtd-theme (>=0.5.0,<0.6.0)"] 1490 | 1491 | [package.source] 1492 | type = "legacy" 1493 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1494 | reference = "tuna" 1495 | 1496 | [[package]] 1497 | name = "rich" 1498 | version = "13.4.2" 1499 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 1500 | optional = false 1501 | python-versions = ">=3.7.0" 1502 | files = [ 1503 | {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"}, 1504 | {file = "rich-13.4.2.tar.gz", hash = "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898"}, 1505 | ] 1506 | 1507 | [package.dependencies] 1508 | markdown-it-py = ">=2.2.0" 1509 | pygments = ">=2.13.0,<3.0.0" 1510 | typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} 1511 | 1512 | [package.extras] 1513 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 1514 | 1515 | [package.source] 1516 | type = "legacy" 1517 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1518 | reference = "tuna" 1519 | 1520 | [[package]] 1521 | name = "secretstorage" 1522 | version = "3.3.3" 1523 | description = "Python bindings to FreeDesktop.org Secret Service API" 1524 | optional = false 1525 | python-versions = ">=3.6" 1526 | files = [ 1527 | {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, 1528 | {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, 1529 | ] 1530 | 1531 | [package.dependencies] 1532 | cryptography = ">=2.0" 1533 | jeepney = ">=0.6" 1534 | 1535 | [package.source] 1536 | type = "legacy" 1537 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1538 | reference = "tuna" 1539 | 1540 | [[package]] 1541 | name = "shellingham" 1542 | version = "1.5.0.post1" 1543 | description = "Tool to Detect Surrounding Shell" 1544 | optional = false 1545 | python-versions = ">=3.7" 1546 | files = [ 1547 | {file = "shellingham-1.5.0.post1-py2.py3-none-any.whl", hash = "sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744"}, 1548 | {file = "shellingham-1.5.0.post1.tar.gz", hash = "sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"}, 1549 | ] 1550 | 1551 | [package.source] 1552 | type = "legacy" 1553 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1554 | reference = "tuna" 1555 | 1556 | [[package]] 1557 | name = "sniffio" 1558 | version = "1.3.0" 1559 | description = "Sniff out which async library your code is running under" 1560 | optional = false 1561 | python-versions = ">=3.7" 1562 | files = [ 1563 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 1564 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 1565 | ] 1566 | 1567 | [package.source] 1568 | type = "legacy" 1569 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1570 | reference = "tuna" 1571 | 1572 | [[package]] 1573 | name = "toml" 1574 | version = "0.10.2" 1575 | description = "Python Library for Tom's Obvious, Minimal Language" 1576 | optional = false 1577 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 1578 | files = [ 1579 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1580 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1581 | ] 1582 | 1583 | [package.source] 1584 | type = "legacy" 1585 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1586 | reference = "tuna" 1587 | 1588 | [[package]] 1589 | name = "tomli" 1590 | version = "2.0.1" 1591 | description = "A lil' TOML parser" 1592 | optional = false 1593 | python-versions = ">=3.7" 1594 | files = [ 1595 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1596 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1597 | ] 1598 | 1599 | [package.source] 1600 | type = "legacy" 1601 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1602 | reference = "tuna" 1603 | 1604 | [[package]] 1605 | name = "tomli-w" 1606 | version = "1.0.0" 1607 | description = "A lil' TOML writer" 1608 | optional = false 1609 | python-versions = ">=3.7" 1610 | files = [ 1611 | {file = "tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463"}, 1612 | {file = "tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9"}, 1613 | ] 1614 | 1615 | [package.source] 1616 | type = "legacy" 1617 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1618 | reference = "tuna" 1619 | 1620 | [[package]] 1621 | name = "tomlkit" 1622 | version = "0.11.8" 1623 | description = "Style preserving TOML library" 1624 | optional = false 1625 | python-versions = ">=3.7" 1626 | files = [ 1627 | {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, 1628 | {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, 1629 | ] 1630 | 1631 | [package.source] 1632 | type = "legacy" 1633 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1634 | reference = "tuna" 1635 | 1636 | [[package]] 1637 | name = "trove-classifiers" 1638 | version = "2023.7.6" 1639 | description = "Canonical source for classifiers on PyPI (pypi.org)." 1640 | optional = false 1641 | python-versions = "*" 1642 | files = [ 1643 | {file = "trove-classifiers-2023.7.6.tar.gz", hash = "sha256:8a8e168b51d20fed607043831d37632bb50919d1c80a64e0f1393744691a8b22"}, 1644 | {file = "trove_classifiers-2023.7.6-py3-none-any.whl", hash = "sha256:b420d5aa048ee7c456233a49203f7d58d1736af4a6cde637657d78c13ab7969b"}, 1645 | ] 1646 | 1647 | [package.source] 1648 | type = "legacy" 1649 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1650 | reference = "tuna" 1651 | 1652 | [[package]] 1653 | name = "typing-extensions" 1654 | version = "4.7.1" 1655 | description = "Backported and Experimental Type Hints for Python 3.7+" 1656 | optional = false 1657 | python-versions = ">=3.7" 1658 | files = [ 1659 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 1660 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 1661 | ] 1662 | 1663 | [package.source] 1664 | type = "legacy" 1665 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1666 | reference = "tuna" 1667 | 1668 | [[package]] 1669 | name = "userpath" 1670 | version = "1.9.0" 1671 | description = "Cross-platform tool for adding locations to the user PATH" 1672 | optional = false 1673 | python-versions = ">=3.7" 1674 | files = [ 1675 | {file = "userpath-1.9.0-py3-none-any.whl", hash = "sha256:8069f754d31edfbdb2c3b2e9abada057ce7518290838dac35d1c8cee1b4cc7b0"}, 1676 | {file = "userpath-1.9.0.tar.gz", hash = "sha256:85e3274543174477c62d5701ed43a3ef1051824a9dd776968adc411e58640dd1"}, 1677 | ] 1678 | 1679 | [package.dependencies] 1680 | click = "*" 1681 | 1682 | [package.source] 1683 | type = "legacy" 1684 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1685 | reference = "tuna" 1686 | 1687 | [[package]] 1688 | name = "virtualenv" 1689 | version = "20.24.1" 1690 | description = "Virtual Python Environment builder" 1691 | optional = false 1692 | python-versions = ">=3.7" 1693 | files = [ 1694 | {file = "virtualenv-20.24.1-py3-none-any.whl", hash = "sha256:01aacf8decd346cf9a865ae85c0cdc7f64c8caa07ff0d8b1dfc1733d10677442"}, 1695 | {file = "virtualenv-20.24.1.tar.gz", hash = "sha256:2ef6a237c31629da6442b0bcaa3999748108c7166318d1f55cc9f8d7294e97bd"}, 1696 | ] 1697 | 1698 | [package.dependencies] 1699 | distlib = ">=0.3.6,<1" 1700 | filelock = ">=3.12,<4" 1701 | platformdirs = ">=3.5.1,<4" 1702 | 1703 | [package.extras] 1704 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 1705 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] 1706 | 1707 | [package.source] 1708 | type = "legacy" 1709 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1710 | reference = "tuna" 1711 | 1712 | [[package]] 1713 | name = "wcwidth" 1714 | version = "0.2.6" 1715 | description = "Measures the displayed width of unicode strings in a terminal" 1716 | optional = false 1717 | python-versions = "*" 1718 | files = [ 1719 | {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, 1720 | {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, 1721 | ] 1722 | 1723 | [package.source] 1724 | type = "legacy" 1725 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1726 | reference = "tuna" 1727 | 1728 | [[package]] 1729 | name = "zipp" 1730 | version = "3.16.2" 1731 | description = "Backport of pathlib-compatible object wrapper for zip files" 1732 | optional = false 1733 | python-versions = ">=3.8" 1734 | files = [ 1735 | {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, 1736 | {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, 1737 | ] 1738 | 1739 | [package.extras] 1740 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 1741 | testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 1742 | 1743 | [package.source] 1744 | type = "legacy" 1745 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 1746 | reference = "tuna" 1747 | 1748 | [metadata] 1749 | lock-version = "2.0" 1750 | python-versions = "^3.8.1" 1751 | content-hash = "eb9d633c302fc9dd220c16651a11fe0ee0a49ebc8312605290b7cb64d72f8263" 1752 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "horn-py" 3 | authors = [{ name="bigfang", email="bitair@gmail.com" }] 4 | description = "A Flask scaffolding tool" 5 | readme = "README.md" 6 | dynamic = ["version"] 7 | requires-python = ">=3.7" 8 | dependencies = [ 9 | 'docopt>=0.6', 10 | 'toml>=0.10.2', 11 | 'inflection>=0.5.1', 12 | 'pampy>=0.3.0', 13 | 'copier>=8.1.0', 14 | ] 15 | classifiers = [ 16 | 'Topic :: Utilities', 17 | 'Programming Language :: Python :: 3.7', 18 | 'Programming Language :: Python :: 3.8', 19 | 'Programming Language :: Python :: 3.9', 20 | 'Programming Language :: Python :: 3.10', 21 | 'Programming Language :: Python :: 3.11', 22 | 'Programming Language :: Python :: Implementation :: CPython', 23 | 'Programming Language :: Python :: Implementation :: PyPy', 24 | 'License :: OSI Approved :: MIT License', 25 | ] 26 | 27 | [project.scripts] 28 | horn = "horn:main" 29 | 30 | [project.urls] 31 | Documentation = "https://bigfang.github.io/horn-py" 32 | "Source code" = "https://github.com/bigfang/horn-py" 33 | 34 | 35 | [tool.hatch.version] 36 | path = "horn/__init__.py" 37 | 38 | [tool.hatch.build.targets.wheel] 39 | packages = ["horn"] 40 | 41 | [build-system] 42 | requires = ["hatchling"] 43 | build-backend = "hatchling.build" 44 | 45 | 46 | [tool.poetry] 47 | name = "horn-py" 48 | version = "0.0.0" 49 | description = "A Flask scaffolding tool" 50 | authors = ["bigfang "] 51 | license = "MIT" 52 | packages = [{ include="horn", from="." }] 53 | readme = "README.md" 54 | 55 | [tool.poetry.scripts] 56 | horn = "horn:main" 57 | 58 | [[tool.poetry.source]] 59 | name = "tuna" 60 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 61 | priority = "default" 62 | 63 | [tool.poetry.dependencies] 64 | python = "^3.8.1" 65 | docopt = "^0.6.2" 66 | copier = "^8.1.0" 67 | pampy = "^0.3.0" 68 | toml = "^0.10.2" 69 | inflection = "^0.5.1" 70 | 71 | [tool.poetry.group.dev.dependencies] 72 | flake8 = "^6.0.0" 73 | pytest = "^7.4.0" 74 | pytest-cov = "^4.1.0" 75 | pytest-testdox = "^3.0.1" 76 | hatch = "^1.7.0" 77 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | 4 | [flake8] 5 | ignore = E501 6 | exclude = .git, .tox, __pycache__, .pytest_cache, docs, build, dist, tmp, .cache, .local 7 | 8 | [tool:pytest] 9 | addopts = --doctest-modules --disable-warnings --cov=. --cov-report=term-missing --basetemp=tmp 10 | testdox_format = plaintext 11 | testpaths = 12 | horn 13 | tests 14 | norecursedirs = 15 | .git/* 16 | horn/templates/* 17 | website/* 18 | .local/* 19 | .cache/* 20 | 21 | [coverage:run] 22 | # branch = true # Can't combine line data with arc data 23 | omit = 24 | setup.py 25 | tests/* 26 | horn/templates/* 27 | .tox/* 28 | .local/* 29 | .cache/* 30 | 31 | [tox] 32 | envlist = py36,py37,py38,py39,py310,py311,pypy3 33 | 34 | [testenv] 35 | deps = 36 | docopt 37 | toml 38 | inflection 39 | pampy 40 | copier 41 | flake8 42 | pytest 43 | pytest-cov 44 | commands = 45 | flake8 46 | pytest {posargs} 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from docopt import docopt 4 | 5 | import horn 6 | 7 | 8 | def execli(params, cwd=None): 9 | if cwd: 10 | os.chdir(str(cwd)) 11 | opts = docopt(horn.__doc__, params.split()) 12 | horn.Hub.run(opts) 13 | return opts 14 | 15 | 16 | def lint(path): 17 | out = os.popen(f'flake8 {str(path)}').read() 18 | print(out) 19 | return False if out else True 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | import pytest 5 | 6 | from . import execli, lint 7 | 8 | 9 | @pytest.fixture(scope='module', 10 | params=['', '--bare', '--app=foobar --proj=FooBar']) 11 | def proj_path(tmp_path_factory, request): 12 | basetmp = tmp_path_factory.getbasetemp() 13 | path = basetmp / 'test_site' 14 | fn = tmp_path_factory.mktemp(str(path)) 15 | execli(f'new {basetmp.name}/{fn.name} {request.param}') 16 | lint(fn) 17 | yield fn 18 | 19 | os.chdir(str(basetmp / '..')) 20 | shutil.rmtree(str(fn)) 21 | -------------------------------------------------------------------------------- /tests/fixture.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "ohmygod", 3 | "bare": false 4 | } 5 | -------------------------------------------------------------------------------- /tests/test_gen_api.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from io import StringIO 6 | 7 | from . import execli, lint 8 | 9 | 10 | class TestGenAPI: 11 | @pytest.mark.parametrize('module,table,fields', 12 | [('Post', 'posts', 'title:string:uniq:nonull:index content:text:default:awesome author:ref:users:nonull')]) 13 | def test_gen_schema(self, proj_path, module, table, fields, capsys, monkeypatch): 14 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 15 | execli(f'gen api {module} {table} {fields}', proj_path) 16 | captured = capsys.readouterr() 17 | 18 | mm = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re. M) 19 | ms = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re. M) 20 | mv = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/views/post.py)$', captured.err, re. M) 21 | for m in [mm, ms, mv]: 22 | assert m.group(1) 23 | genf = (proj_path / m.group(1)) 24 | assert genf.is_file() 25 | assert lint(genf) 26 | 27 | @pytest.mark.parametrize('module,table,fields', 28 | [('Post', 'posts', 'title:string content:text author:ref:users')]) 29 | def test_with_wrong_path(self, tmp_path, module, table, fields, capsys, monkeypatch): 30 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 31 | with pytest.raises(SystemExit): 32 | execli(f'gen api {module} {table} {fields}', tmp_path) 33 | captured = capsys.readouterr() 34 | 35 | assert 'Error: Can not found pyproject.toml\n' == captured.out 36 | 37 | @pytest.mark.parametrize('module,table,fields', 38 | [('Post', 'posts', 'title:string content:json')]) 39 | def test_should_convert_json_to_dict(self, proj_path, module, table, fields, capsys, monkeypatch): 40 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 41 | execli(f'gen api {module} {table} {fields}', proj_path) 42 | captured = capsys.readouterr() 43 | 44 | mm = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re. M) 45 | ms = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re. M) 46 | assert mm.group(1) 47 | assert ms.group(1) 48 | 49 | with open(proj_path / mm.group(1)) as f: 50 | text = f.read() 51 | assert "\n content = Column(db.JSON, doc='Post content')\n" in text 52 | 53 | with open(proj_path / ms.group(1)) as f: 54 | text = f.read() 55 | assert '\n content = fields.Dict()\n' in text 56 | 57 | @pytest.mark.parametrize('module,table,fields', 58 | [('Post', 'posts', 'title:string author:ref:users')]) 59 | def test_should_convert_ref_to_nest(self, proj_path, module, table, fields, capsys, monkeypatch): 60 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 61 | execli(f'gen api {module} {table} {fields}', proj_path) 62 | captured = capsys.readouterr() 63 | 64 | mm = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re. M) 65 | ms = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re. M) 66 | assert mm.group(1) 67 | assert ms.group(1) 68 | 69 | with open(proj_path / mm.group(1)) as f: 70 | text = f.read() 71 | assert "\n author = relationship('Author', back_populates='posts')\n" in text 72 | 73 | with open(proj_path / ms.group(1)) as f: 74 | text = f.read() 75 | assert "\n author = fields.Nested('UserSchema')\n" in text 76 | 77 | @pytest.mark.parametrize('module,table,fields', 78 | [('Post', 'posts', 'title:string:nonull:dump content:text:uniq:load author:ref:users:default:1:exclude')]) 79 | def test_should_ignore_useless_attrs(self, proj_path, module, table, fields, capsys, monkeypatch): 80 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 81 | execli(f'gen api {module} {table} {fields}', proj_path) 82 | captured = capsys.readouterr() 83 | 84 | mm = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re. M) 85 | ms = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re. M) 86 | assert mm.group(1) 87 | assert ms.group(1) 88 | 89 | with open(proj_path / mm.group(1)) as f: 90 | text = f.read() 91 | assert re.search(r"^\s{4}title = .+nullable=False, .+$", text, re.M) 92 | assert re.search(r"^\s{4}content = .+unique=True, .+$", text, re.M) 93 | assert re.search(r"^\s{4}author_id = .+'users', default=1, .+$$", text, re.M) 94 | 95 | with open(proj_path / ms.group(1)) as f: 96 | text = f.read() 97 | assert "\n dump_only = ('title', )\n" in text 98 | assert "\n load_only = ('content', )\n" in text 99 | assert "\n exclude = ('author', )\n" in text 100 | -------------------------------------------------------------------------------- /tests/test_gen_model.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from io import StringIO 6 | 7 | from . import execli, lint 8 | 9 | 10 | class TestGenModel: 11 | @pytest.mark.parametrize('module,table,fields', 12 | [('Post', 'posts', 'title:string:uniq:nonull:index content:text:default:awesome author:ref:users:nonull')]) 13 | def test_gen_model(self, proj_path, module, table, fields, capsys): 14 | execli(f'gen model {module} {table} {fields}', proj_path) 15 | captured = capsys.readouterr() 16 | 17 | match = re.search(r'^.+create.+\s+(\w+\/models/post.py)$', captured.err, re.M) 18 | assert match.group(1) 19 | genf = (proj_path / match.group(1)) 20 | assert genf.is_file() 21 | assert lint(genf) 22 | with open(genf, 'r') as f: 23 | text = f.read() 24 | assert re.search(r'^class Post\(Model\):$', text, re.M) 25 | assert re.search(r"^\s{4}__tablename__ = 'posts'$", text, re.M) 26 | assert "\n title = Column(db.String, unique=True, index=True, nullable=False, doc='Post title')\n" in text 27 | assert "\n content = Column(db.Text, default='awesome', doc='Post content')\n" in text 28 | assert "\n author_id = reference_col('users', nullable=False, doc='author id')" in text 29 | assert "\n author = relationship('Author', back_populates='posts')" in text 30 | 31 | @pytest.mark.parametrize('module,table,fields', 32 | [('Post', 'posts', 'title:string content:text author:ref:users')]) 33 | def test_with_wrong_path(self, tmp_path, module, table, fields, capsys): 34 | with pytest.raises(SystemExit): 35 | execli(f'gen model {module} {table} {fields}', tmp_path) 36 | captured = capsys.readouterr() 37 | 38 | assert 'Error: Can not found pyproject.toml\n' == captured.out 39 | 40 | @pytest.mark.parametrize('module,table,fields', 41 | [('blogPost', 'posts', 'title:string content:text author:ref:users'), 42 | ('Blog_Post', 'posts', 'title:string content:text author:ref:users'), 43 | ('BLOGPOST', 'posts', 'title:string content:text author:ref:users'), 44 | ('BLOG_POST', 'posts', 'title:string content:text author:ref:users'), 45 | ('blog_post', 'posts', 'title:string content:text author:ref:users')]) 46 | def test_module_name_should_be_camelcase(self, proj_path, module, table, fields, capsys): 47 | with pytest.raises(SystemExit): 48 | execli(f'gen model {module} {table} {fields}', proj_path) 49 | captured = capsys.readouterr() 50 | 51 | assert 'Error: Module name must be upper camel case\n' == captured.out 52 | 53 | @pytest.mark.parametrize('module,table,fields', 54 | [('Post', 'blog_posts', 'title:string content:text author:ref:users'), 55 | ('Post', 'BlogPosts', 'title:string content:text author:ref:users'), 56 | ('Post', 'Blog_Posts', 'title:string content:text author:ref:users'), 57 | ('Post', 'BLOG_POSTS', 'title:string content:text author:ref:users')]) 58 | def test_table_name_should_be_lowercase(self, proj_path, module, table, fields, monkeypatch, capsys): 59 | monkeypatch.setattr('sys.stdin', StringIO('Y\n')) 60 | execli(f'gen model {module} {table} {fields}', proj_path) 61 | captured = capsys.readouterr() 62 | 63 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re.M) 64 | assert match.group(1) 65 | genf = (proj_path / match.group(1)) 66 | assert genf.is_file() 67 | assert lint(genf) 68 | with open(genf, 'r') as f: 69 | text = f.read() 70 | assert re.search(r"^\s{4}__tablename__ = 'blog_posts'$", text, re.M) 71 | 72 | @pytest.mark.parametrize('module,table,fields', 73 | [('Post', 'posts', 'xxx'), 74 | ('Post', '', 'title:string content:text author:ref:users'), 75 | ('', '', 'title:string content:text author:ref:users')]) 76 | def test_with_wrong_opts(self, proj_path, module, table, fields, capsys): 77 | with pytest.raises(SystemExit): 78 | execli(f'gen model {module} {table} {fields}', proj_path) 79 | captured = capsys.readouterr() 80 | 81 | assert re.search('^Error: Options error, <.+>: .+$', captured.out, re.M) 82 | 83 | @pytest.mark.parametrize('module,table,fields', 84 | [('Post', 'posts', 'title:int author:ref:users'), 85 | ('Post', 'posts', 'title:string author:nest:users')]) 86 | def test_with_wrong_field_type(self, proj_path, module, table, fields, capsys): 87 | with pytest.raises(SystemExit): 88 | execli(f'gen model {module} {table} {fields}', proj_path) 89 | captured = capsys.readouterr() 90 | 91 | assert re.search('^Error: Field type error, .+$', captured.out, re.M) 92 | 93 | @pytest.mark.parametrize('module,table,fields', 94 | [('Post', 'posts', 'title:string:null author:ref:users'), 95 | ('Post', 'posts', 'title:string author:ref:users:xxx')]) 96 | def test_with_wrong_attr(self, proj_path, module, table, fields, capsys, monkeypatch): 97 | monkeypatch.setattr('builtins.input', lambda x: 'y') 98 | with pytest.raises(SystemExit): 99 | execli(f'gen model {module} {table} {fields}', proj_path) 100 | captured = capsys.readouterr() 101 | 102 | assert re.search('^Error: Unknown attribute, .+$', captured.out, re.M) 103 | 104 | @pytest.mark.parametrize('module,table,fields', 105 | [('Post', 'posts', 'title:string:dump content:text:load author:ref:users:exclude'), 106 | ('Post', 'posts', 'title:string:dump:load content:text author:default:1:ref:users:load'), 107 | ('Post', 'posts', 'title:string:nonull:dump content:text author:default:1:ref:users:nonull')]) 108 | def test_model_should_ignore_schema_attrs(self, proj_path, module, table, fields, monkeypatch, capsys): 109 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 110 | execli(f'gen model {module} {table} {fields}', proj_path) 111 | captured = capsys.readouterr() 112 | 113 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re.M) 114 | assert match.group(1) 115 | genf = (proj_path / match.group(1)) 116 | assert genf.is_file() 117 | assert lint(genf) 118 | with open(genf, 'r') as f: 119 | text = f.read() 120 | assert 'dump' not in text 121 | assert 'load' not in text 122 | assert 'exclude' not in text 123 | 124 | @pytest.mark.parametrize('module,table,fields', 125 | [('Post', 'posts', 'title:string content:text author:ref:users:default:1'), 126 | ('Post', 'posts', 'title:string content:text author:default:1:ref:users'), 127 | ('Post', 'posts', 'title:string content:text author:default:1:ref:users:nonull')]) 128 | def test_ref_with_default(self, proj_path, module, table, fields, monkeypatch, capsys): 129 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 130 | execli(f'gen model {module} {table} {fields}', proj_path) 131 | captured = capsys.readouterr() 132 | 133 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/models/post.py)$', captured.err, re.M) 134 | assert match.group(1) 135 | genf = (proj_path / match.group(1)) 136 | assert genf.is_file() 137 | assert lint(genf) 138 | with open(genf, 'r') as f: 139 | text = f.read() 140 | assert re.search(r'^class Post\(Model\):$', text, re.M) 141 | assert re.search(r"^ {4}__tablename__ = 'posts'$", text, re.M) 142 | assert re.search(r"^ {4}author_id = reference_col\('users', default=1, (nullable=False, )?doc='author id'\)$", text, re.M) 143 | assert "\n author = relationship('Author', back_populates='posts')" in text 144 | -------------------------------------------------------------------------------- /tests/test_gen_schema.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from io import StringIO 6 | 7 | from . import execli, lint 8 | 9 | 10 | class TestGenSchema: 11 | @pytest.mark.parametrize('module,model,fields', 12 | [('Post', 'Post', 'title:string content:string author:nest:user'), 13 | ('Post', '', 'title:string content:string author:nest:user'), 14 | ('Post', 'Post', '')]) 15 | def test_gen_schema(self, proj_path, module, model, fields, capsys, monkeypatch): 16 | model_opt = f'--model={model}' if model else '' 17 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 18 | execli(f'gen schema {module} {fields} {model_opt}', proj_path) 19 | captured = capsys.readouterr() 20 | 21 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re. M) 22 | assert match.group(1) 23 | genf = (proj_path / match.group(1)) 24 | assert genf.is_file() 25 | assert lint(genf) 26 | with open(genf, 'r') as f: 27 | text = f.read() 28 | 29 | if fields: 30 | assert "\n title = fields.String()\n" in text 31 | assert "\n content = fields.String()\n" in text 32 | assert "\n author = fields.Nested('UserSchema')\n" in text 33 | assert "\n fields = ('id', 'title', 'content', 'author', )\n" in text 34 | else: 35 | assert not re.search(r'^\s+fields.+$', text, re.M) 36 | 37 | if model: 38 | assert re.search(r'^from \w+\.models import Post$', text, re.M) 39 | assert "\nclass PostSchema(ModelSchema, SchemaMixin):\n" in text 40 | assert "\n model = Post\n" in text 41 | else: 42 | assert "\nclass PostSchema(Schema, SchemaMixin):\n" in text 43 | 44 | @pytest.mark.parametrize('module,model,fields', 45 | [('Post', 'Post', 'title:string content:string author:nest:user'), 46 | ('Post', '', 'title:string content:string author:nest:user'), 47 | ('Post', 'Post', '')]) 48 | def test_with_wrong_path(self, tmp_path, module, model, fields, capsys, monkeypatch): 49 | model_opt = f'--model={model}' if model else '' 50 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 51 | with pytest.raises(SystemExit): 52 | execli(f'gen schema {module} {model_opt} {fields}', tmp_path) 53 | captured = capsys.readouterr() 54 | 55 | assert 'Error: Can not found pyproject.toml\n' == captured.out 56 | 57 | @pytest.mark.parametrize('module,model,fields', 58 | [('blogPost', 'Post', 'title:string content:string author:nest:user'), 59 | ('Blog_Post', '', 'title:string content:string author:nest:user'), 60 | ('blog_post', 'Post', 'title:string content:string author:nest:user'), 61 | ('BLOG_POST', 'Post', 'title:string content:string author:nest:user'), 62 | ('BLOGPOST', 'Post', '')]) 63 | def test_module_name_should_be_camelcase(self, proj_path, module, model, fields, capsys, monkeypatch): 64 | model_opt = f'--model={model}' if model else '' 65 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 66 | with pytest.raises(SystemExit): 67 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 68 | captured = capsys.readouterr() 69 | 70 | assert 'Error: Module name must be upper camel case\n' == captured.out 71 | 72 | @pytest.mark.parametrize('module,model,fields', 73 | [('Post', 'blog_post', 'title:string content:string author:nest:user'), 74 | ('Post', 'BlogPost', 'title:string content:string author:nest:user'), 75 | ('Post', 'Blog_Post', 'title:string content:string author:nest:user')]) 76 | def test_model_name_should_be_camelcase(self, proj_path, module, model, fields, capsys, monkeypatch): 77 | model_opt = f'--model={model}' if model else '' 78 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 79 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 80 | captured = capsys.readouterr() 81 | 82 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re.M) 83 | assert match.group(1) 84 | genf = (proj_path / match.group(1)) 85 | assert genf.is_file() 86 | assert lint(genf) 87 | with open(genf, 'r') as f: 88 | text = f.read() 89 | assert re.search(r"^class PostSchema\(.+\):$", text, re.M) 90 | assert "\n model = BlogPost\n" in text 91 | 92 | @pytest.mark.parametrize('module,model,fields', 93 | [('Post', '', 'xxx'), 94 | ('', '', 'title:string content:string author:nest:user')]) 95 | def test_with_wrong_opts(self, proj_path, module, model, fields, capsys): 96 | model_opt = f'--model={model}' if model else '' 97 | with pytest.raises(SystemExit): 98 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 99 | captured = capsys.readouterr() 100 | 101 | assert re.search('^Error: Options error, <.+>: .+$', captured.out, re.M) 102 | 103 | @pytest.mark.parametrize('module,model,fields', 104 | [('Post', 'Post', 'title:int content:string author:nest:user'), 105 | ('Post', '', 'title:string content:string author:ref:user')]) 106 | def test_with_wrong_field_type(self, proj_path, module, model, fields, capsys, monkeypatch): 107 | model_opt = f'--model={model}' if model else '' 108 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 109 | with pytest.raises(SystemExit): 110 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 111 | captured = capsys.readouterr() 112 | 113 | assert re.search('^Error: Field type error, .+$', captured.out, re.M) 114 | 115 | @pytest.mark.parametrize('module,model,fields', 116 | [('Post', 'Post', 'title:string:null author:ref:user'), 117 | ('Post', '', 'title:string author:nest:user:xxx')]) 118 | def test_with_wrong_attr(self, proj_path, module, model, fields, capsys, monkeypatch): 119 | model_opt = f'--model={model}' if model else '' 120 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 121 | with pytest.raises(SystemExit): 122 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 123 | captured = capsys.readouterr() 124 | 125 | assert re.search('^Error: Unknown attribute, .+$', captured.out, re.M) 126 | 127 | @pytest.mark.parametrize('module,model,fields', 128 | [('Post', 'Post', 'title:string:nonull content:string:dump author:nest:user'), 129 | ('Post', '', 'title:string:uniq content:string:index author:nest:user')]) 130 | def test_schema_should_ignore_model_attrs(self, proj_path, module, model, fields, capsys, monkeypatch): 131 | model_opt = f'--model={model}' if model else '' 132 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 133 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 134 | captured = capsys.readouterr() 135 | 136 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re.M) 137 | assert match.group(1) 138 | genf = (proj_path / match.group(1)) 139 | assert genf.is_file() 140 | assert lint(genf) 141 | with open(genf, 'r') as f: 142 | text = f.read() 143 | assert 'null' not in text 144 | assert 'uniq' not in text 145 | assert 'index' not in text 146 | 147 | @pytest.mark.parametrize('module,model,fields', 148 | [('Post', 'Post', 'title:string:dump:load author:nest:user'), 149 | ('Post', 'Post', 'title:string:load:dump author:nest:user'), 150 | ('Post', 'Post', 'title:string:dump:exclude author:nest:user'), 151 | ('Post', '', 'title:string:dump:exclude:load author:nest:user:exclude:load')]) 152 | def test_schema_meta_key_should_be_mutex(self, proj_path, module, model, fields, capsys, monkeypatch): 153 | model_opt = f'--model={model}' if model else '' 154 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 155 | with pytest.raises(SystemExit): 156 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 157 | captured = capsys.readouterr() 158 | 159 | assert re.search(r'Error: Field attributes error, \[.+\] conflict', captured.out, re.M) 160 | 161 | @pytest.mark.parametrize('module,model,fields', 162 | [('Post', 'Post', 'title:string author:nest:blog_user'), 163 | ('Post', '', 'title:string author:nest:blogUser'), 164 | ('Post', '', 'title:string author:nest:BlogUser'), 165 | ('Post', '', 'title:string author:nest:Blog_User')]) 166 | def test_nested_field_should_be_convert_to_camelcase(self, proj_path, module, model, fields, capsys, monkeypatch): 167 | model_opt = f'--model={model}' if model else '' 168 | monkeypatch.setattr('sys.stdin', StringIO('y\n')) 169 | execli(f'gen schema {module} {model_opt} {fields}', proj_path) 170 | captured = capsys.readouterr() 171 | 172 | match = re.search(r'^.+(?:create|conflict|identical).+\s+(\w+\/schemas/post.py)$', captured.err, re.M) 173 | assert match.group(1) 174 | genf = (proj_path / match.group(1)) 175 | assert genf.is_file() 176 | assert lint(genf) 177 | with open(genf, 'r') as f: 178 | text = f.read() 179 | assert "\n author = fields.Nested('BlogUserSchema')\n" in text 180 | -------------------------------------------------------------------------------- /tests/test_new.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from pathlib import Path 4 | 5 | import inflection 6 | import pytest 7 | 8 | from . import execli, lint 9 | 10 | 11 | class TestNew: 12 | def test_new_with_default_options(self, tmp_path, capsys): 13 | execli(f'new {tmp_path}') 14 | lint(tmp_path) 15 | captured = capsys.readouterr() 16 | fset = {i.name for i in tmp_path.glob('*')} 17 | 18 | assert len(re.findall(r'^.+create.+$', captured.err, re.M)) == \ 19 | len([i for i in tmp_path.glob('**/*')]) 20 | assert fset == {'.gitignore', 'app', 'tests', 'log', 'instance', 'MANIFEST.in', 21 | 'logging.ini', 'README.md', 'pyproject.toml', 'setup.cfg', 'setup.py'} 22 | assert 'user.py' in {i.name for i in tmp_path.glob('app/models/*py')} 23 | assert 'user.py' in {i.name for i in tmp_path.glob('app/schemas/*.py')} 24 | assert 'user.py' in {i.name for i in tmp_path.glob('app/views/*.py')} 25 | with open(tmp_path / 'README.md') as f: 26 | text = f.read() 27 | directory = inflection.camelize(tmp_path.name) 28 | assert re.search(f'^# {directory}$', text, re.M) 29 | 30 | @pytest.mark.parametrize('opts', ['--bare']) 31 | def test_new_bare_project(self, tmp_path, opts): 32 | execli(f'new {tmp_path} {opts}') 33 | lint(tmp_path) 34 | fset = {i.name for i in tmp_path.glob('*')} 35 | 36 | assert fset == {'.gitignore', 'app', 'tests', 'log', 'instance', 'MANIFEST.in', 37 | 'logging.ini', 'README.md', 'pyproject.toml', 'setup.cfg', 'setup.py'} 38 | assert 'user.py' not in {i.name for i in tmp_path.glob('app/**/*.py')} 39 | 40 | @pytest.mark.parametrize('opts', ['--bare']) 41 | def test_with_dot_target_should_be_convert_to_proj(self, tmp_path, opts): 42 | cwd = os.getcwd() 43 | 44 | wdir = tmp_path / 'foo_bar' 45 | os.mkdir(str(wdir)) 46 | os.chdir(str(wdir)) 47 | execli(f'new . {opts}') 48 | os.chdir(cwd) 49 | 50 | lint(wdir) 51 | 52 | with open(wdir / 'README.md') as f: 53 | text = f.read() 54 | assert '# FooBar\n' in text 55 | assert '# .\n' not in text 56 | assert '\nProject .' not in text 57 | 58 | @pytest.mark.parametrize('opts', ['--app=foobar']) 59 | def test_new_with_options_app(self, tmp_path, opts): 60 | execli(f'new {tmp_path} {opts}') 61 | lint(tmp_path) 62 | fset = {i.name for i in tmp_path.glob('*')} 63 | 64 | assert 'app' not in fset 65 | assert 'foobar' in fset 66 | assert 'FooBar' not in fset 67 | 68 | @pytest.mark.parametrize('opts', ['--proj=FooBar']) 69 | def test_new_with_options_proj(self, tmp_path, opts): 70 | execli(f'new {tmp_path} {opts}') 71 | lint(tmp_path) 72 | fset = {i.name for i in tmp_path.glob('*')} 73 | 74 | assert 'app' in fset 75 | assert 'foobar' not in fset 76 | assert 'FooBar' not in fset 77 | with open(tmp_path / 'README.md') as f: 78 | text = f.read() 79 | assert re.search('^# FooBar$', text, re.M) 80 | 81 | @pytest.mark.parametrize('opts', ['--pypi https://pypi.doubanio.com/simple']) 82 | def test_new_with_options_pypi(self, tmp_path, opts): 83 | execli(f'new {tmp_path} {opts}') 84 | lint(tmp_path) 85 | fset = {i.name for i in tmp_path.glob('*')} 86 | 87 | assert 'app' in fset 88 | assert 'foobar' not in fset 89 | assert 'FooBar' not in fset 90 | with open(tmp_path / 'pyproject.toml') as f: 91 | text = f.read() 92 | assert re.search('^url = "https://pypi.doubanio.com/simple"$', text, re.M) 93 | 94 | @pytest.mark.parametrize('opts', ['--app=foobar --proj=FooBar --pypi=https://pypi.doubanio.com/simple']) 95 | def test_new_with_options_app_proj_pypi(self, tmp_path, opts): 96 | execli(f'new {tmp_path} {opts}') 97 | lint(tmp_path) 98 | fset = {i.name for i in tmp_path.glob('*')} 99 | 100 | assert 'app' not in fset 101 | assert 'foobar' in fset 102 | assert 'FooBar' not in fset 103 | assert 'user.py' in {i.name for i in tmp_path.glob('foobar/**/*.py')} 104 | with open(tmp_path / 'README.md') as f: 105 | text = f.read() 106 | assert re.search('^# FooBar$', text, re.M) 107 | with open(tmp_path / 'pyproject.toml') as f: 108 | text = f.read() 109 | assert re.search('^url = "https://pypi.doubanio.com/simple"$', text, re.M) 110 | 111 | @pytest.mark.parametrize('opts', ['--app=foo_bar', '--app=FooBar']) 112 | def test_new_app_should_be_underscore(self, tmp_path, opts): 113 | execli(f'new {tmp_path} {opts}') 114 | lint(tmp_path) 115 | fset = {i.name for i in tmp_path.glob('*')} 116 | 117 | assert 'app' not in fset 118 | assert 'foo_bar' in fset 119 | assert 'FooBar' not in fset 120 | 121 | @pytest.mark.parametrize('opts', ['--proj=foo_bar', '--proj=FooBar']) 122 | def test_new_with_proj_should_be_camel(self, tmp_path, opts): 123 | execli(f'new {tmp_path} {opts}') 124 | lint(tmp_path) 125 | fset = {i.name for i in tmp_path.glob('*')} 126 | 127 | assert 'app' in fset 128 | assert 'foo_bar' not in fset 129 | assert 'FooBar' not in fset 130 | with open(tmp_path / 'README.md') as f: 131 | text = f.read() 132 | assert re.search('^# FooBar$', text, re.M) 133 | 134 | 135 | class TestRepo: 136 | @pytest.mark.parametrize('opts', ['--json={"app":"foobar","bare":true}']) 137 | def test_from_local(self, tmp_path, opts): 138 | options = execli(f'new {tmp_path} ./horn/templates {opts}') 139 | lint(tmp_path) 140 | 141 | assert options[''] == './horn/templates' 142 | with open(tmp_path / 'pyproject.toml') as f: 143 | text = f.read() 144 | assert re.search(r'^from = ".+/horn/templates"$', text, re.M) 145 | assert '\napp_name = "foobar"\n' in text 146 | assert '\nbare = true\n' in text 147 | 148 | @pytest.mark.xfail(reason='remote repo not ready') 149 | def test_remote_repo(self): 150 | assert False 151 | 152 | # @pytest.mark.parametrize('checkout', ['', 'master']) 153 | # def test_remote_with_wrong_repo(self, tmp_path, checkout, capsys): 154 | # repo = 'https://gist.github.com/bb1f8b136f5a9e4abc0bfc07b832257e.git' 155 | # with pytest.raises(SystemExit): 156 | # execli(f'new {tmp_path} {repo} {checkout}') 157 | # captured = capsys.readouterr() 158 | 159 | # assert 'Error: Project template not found\n' == captured.out 160 | 161 | @pytest.mark.parametrize('opts', ['--json={"app":"foobar","proj":"FooBar"}']) 162 | def test_with_json_config(self, tmp_path, opts): 163 | opts = execli(f'new {tmp_path} ./horn/templates {opts}') 164 | lint(tmp_path) 165 | fset = {i.name for i in tmp_path.glob('*')} 166 | 167 | assert 'app' not in fset 168 | assert 'foobar' in fset 169 | assert 'FooBar' not in fset 170 | with open(tmp_path / 'README.md') as f: 171 | text = f.read() 172 | assert re.search('^# FooBar$', text, re.M) 173 | 174 | def test_with_file_config(self, tmp_path): 175 | file_opt = Path(__file__).parent.joinpath('fixture.json') 176 | options = execli(f'new {tmp_path} ./horn/templates --file={file_opt}') 177 | lint(tmp_path) 178 | 179 | assert options[''] == './horn/templates' 180 | with open(tmp_path / 'pyproject.toml') as f: 181 | text = f.read() 182 | assert re.search(r'^from = ".+/horn/templates"$', text, re.M) 183 | assert '\napp_name = "ohmygod"\n' in text 184 | assert 'bare' not in text 185 | 186 | @pytest.mark.parametrize('opts', ['--json={"app":"foobar","bare":true}']) 187 | def test_json_config_should_override_file_config(self, tmp_path, opts): 188 | file_opt = Path(__file__).parent.joinpath('fixture.json') 189 | options = execli(f'new {tmp_path} ./horn/templates {opts} --file={file_opt}') 190 | lint(tmp_path) 191 | 192 | assert options[''] == './horn/templates' 193 | with open(tmp_path / 'pyproject.toml') as f: 194 | text = f.read() 195 | assert re.search(r'^from = ".+/horn/templates"$', text, re.M) 196 | assert '\napp_name = "foobar"\n' in text 197 | assert '\nbare = true\n' in text 198 | --------------------------------------------------------------------------------