├── .circleci └── config.yml ├── .codecov.yml ├── .coveragerc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── publish-to-pypi.yml │ └── publish-to-test.pypi.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── Makefile ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst ├── index_en.rst ├── make.bat └── requirements.txt ├── hobbit ├── __init__.py ├── bootstrap.py ├── devtools.py ├── handlers │ ├── __init__.py │ ├── bootstrap.py │ └── devtools.py └── static │ ├── bootstrap │ ├── rivendell │ │ ├── .gitignore.jinja2 │ │ ├── Dockerfile.jinja2 │ │ ├── app │ │ │ ├── __init__.py.jinja2 │ │ │ ├── configs │ │ │ │ ├── __init__.py.jinja2 │ │ │ │ ├── default.py.jinja2 │ │ │ │ ├── development.py.jinja2 │ │ │ │ ├── production.py.jinja2 │ │ │ │ └── testing.py.jinja2 │ │ │ ├── core │ │ │ │ └── __init__.py.jinja2 │ │ │ ├── exts.py.jinja2 │ │ │ ├── models │ │ │ │ ├── __init__.py.jinja2 │ │ │ │ └── consts.py.jinja2 │ │ │ ├── run.py.jinja2 │ │ │ ├── schemas │ │ │ │ └── __init__.py.jinja2 │ │ │ ├── services │ │ │ │ ├── __init__.py.jinja2 │ │ │ │ └── tools.py.jinja2 │ │ │ ├── utils │ │ │ │ └── __init__.py.jinja2 │ │ │ └── views │ │ │ │ ├── __init__.py.jinja2 │ │ │ │ └── tools.py.jinja2 │ │ ├── configs │ │ │ └── gunicorn-logging.ini.jinja2 │ │ ├── deploy.sh.jinja2 │ │ ├── docker-compose.yml.jinja2 │ │ ├── docs │ │ │ └── index.apib.jinja2 │ │ ├── pytest.ini.jinja2 │ │ ├── requirements.txt.jinja2 │ │ └── tests │ │ │ ├── __init__.py.jinja2 │ │ │ ├── conftest.py.jinja2 │ │ │ └── test_tools.py.jinja2 │ └── shire │ │ ├── .gitignore.jinja2 │ │ ├── .gitlab-ci.yml.jinja2 │ │ ├── Dockerfile.jinja2 │ │ ├── app │ │ ├── __init__.py.jinja2 │ │ ├── configs │ │ │ ├── __init__.py.jinja2 │ │ │ ├── default.py.jinja2 │ │ │ ├── development.py.jinja2 │ │ │ ├── production.py.jinja2 │ │ │ └── testing.py.jinja2 │ │ ├── core │ │ │ └── __init__.py.jinja2 │ │ ├── exts.py.jinja2 │ │ ├── models │ │ │ ├── __init__.py.jinja2 │ │ │ └── consts.py.jinja2 │ │ ├── run.py.jinja2 │ │ ├── schemas │ │ │ └── __init__.py.jinja2 │ │ ├── utils │ │ │ └── __init__.py.jinja2 │ │ └── views │ │ │ ├── __init__.py.jinja2 │ │ │ └── tools.py.jinja2 │ │ ├── configs │ │ └── gunicorn-logging.ini.jinja2 │ │ ├── deploy.sh.jinja2 │ │ ├── docker-compose.yml.jinja2 │ │ ├── docs │ │ └── index.apib.jinja2 │ │ ├── pytest.ini.jinja2 │ │ ├── requirements.txt.jinja2 │ │ └── tests │ │ ├── __init__.py.jinja2 │ │ ├── conftest.py.jinja2 │ │ └── test_tools.py.jinja2 │ └── hooks │ ├── commit-msg │ └── pre-commit ├── hobbit_core ├── __init__.py ├── db.py ├── err_handler.py ├── pagination.py ├── py.typed ├── response.py ├── schemas.py ├── utils.py └── webargs.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── readthedocs.yml ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── importsub │ ├── __init__.py │ ├── models.py │ ├── others.py │ ├── schemas.py │ └── services.py ├── test_app │ ├── __init__.py │ ├── exts.py │ ├── models.py │ ├── run.py │ ├── schemas.py │ └── views.py ├── test_db.py ├── test_err_handler.py ├── test_fixture.py ├── test_hobbit.py ├── test_pagination.py ├── test_response.py ├── test_schemas.py └── test_utils.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | tox: 8 | docker: 9 | - image: ttwshell/pyenv-tox:latest 10 | user: root 11 | - image: circleci/postgres:alpine 12 | environment: 13 | POSTGRES_USER: root 14 | POSTGRES_DB: hobbit_core 15 | - image: circleci/mysql:latest 16 | environment: 17 | MYSQL_ROOT_PASSWORD: root 18 | MYSQL_DATABASE: hobbit_core 19 | MYSQL_PORT: 3306 20 | working_directory: ~/repo 21 | steps: 22 | - run: pyenv global 3.10.0 py38 py39 py310 py311 py312 23 | - checkout 24 | - run: 25 | name: Waiting for Postgres to be ready 26 | command: sleep 10 27 | - run: 28 | command: pip install codecov 29 | - run: 30 | command: | 31 | tox -- --cov-report=xml 32 | codecov 33 | 34 | test-py312: 35 | docker: 36 | - image: cimg/python:3.12.6 37 | user: root 38 | working_directory: ~/repo 39 | steps: 40 | - checkout 41 | - run: 42 | name: install dependencies 43 | command: | 44 | pip install --upgrade pip 45 | pip install flake8 pytest pytest-cov pytest-env 46 | pip install --editable ".[hobbit,hobbit_core]" 47 | - run: 48 | name: use flake8 check self 49 | command: flake8 . 50 | - run: 51 | name: run hobbit cmd 52 | command: hobbit --echo startproject -n demo -d ~/haha -f -p 1024 53 | - run: 54 | name: tree flask project 55 | command: | 56 | cd ~/haha 57 | apt-get update 58 | apt-get install -y tree 59 | tree 60 | - run: 61 | name: run tests 62 | command: | 63 | cd ~/haha 64 | FLASK_APP=app.run:app flask db init && FLASK_APP=app.run:app flask db migrate && FLASK_APP=app.run:app flask db upgrade 65 | flake8 . --exclude migrations 66 | py.test 67 | test-py38: 68 | docker: 69 | - image: circleci/python:3.8.0 70 | user: root 71 | working_directory: ~/repo 72 | steps: 73 | - checkout 74 | - run: 75 | name: install dependencies 76 | command: | 77 | pip install --upgrade pip 78 | pip install flake8 pytest pytest-cov pytest-env 79 | pip install --editable ".[hobbit,hobbit_core]" 80 | - run: 81 | name: use flake8 check self 82 | command: flake8 . 83 | - run: 84 | name: run hobbit cmd 85 | command: | 86 | mkdir ~/haha && cd ~/haha 87 | hobbit --echo startproject -n demo -f -p 1024 88 | mkdir ~/hahaha && cd ~/hahaha 89 | hobbit --echo startproject -n demo -t rivendell -p 1025 90 | - run: 91 | name: tree flask project 92 | command: | 93 | cd ~/haha 94 | apt-get install -y tree 95 | tree 96 | cd ~/hahaha 97 | tree 98 | - run: 99 | name: run tests 100 | command: | 101 | cd ~/haha 102 | FLASK_APP=app.run:app flask db init && FLASK_APP=app.run:app flask db migrate && FLASK_APP=app.run:app flask db upgrade 103 | flake8 . --exclude migrations 104 | py.test 105 | cd ~/hahaha 106 | FLASK_APP=app.run:app flask db init && FLASK_APP=app.run:app flask db migrate && FLASK_APP=app.run:app flask db upgrade 107 | flake8 . --exclude migrations 108 | py.test 109 | 110 | workflows: 111 | version: 2 112 | test: 113 | jobs: 114 | - tox 115 | - test-py312: 116 | requires: 117 | - tox 118 | - test-py38: 119 | requires: 120 | - tox 121 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: 5 | target: 85% 6 | project: 7 | default: 8 | target: 95% 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | include = 3 | hobbit_core/* 4 | tests/* 5 | omit = 6 | */static/bootstrap/* 7 | show_missing = true 8 | precision = 2 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '0 1 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Python 🐍 distributions 📦 to PyPI" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI 10 | runs-on: ubuntu-22.04 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Python 3.9 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.9 20 | 21 | - name: Install pypa/build 22 | run: >- 23 | python -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: >- 29 | python -m 30 | build 31 | --sdist 32 | --wheel 33 | --outdir dist/ 34 | 35 | - name: ls dist 36 | run: >- 37 | ls -al dist/ 38 | 39 | - name: Publish distribution 📦 to PyPI 40 | if: startsWith(github.ref, 'refs/tags') 41 | uses: pypa/gh-action-pypi-publish@master 42 | with: 43 | skip_existing: true 44 | password: ${{ secrets.PYPI_API_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test.pypi.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Python 🐍 distributions 📦 to TestPyPI" 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to TestPyPI 10 | runs-on: ubuntu-22.04 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Python 3.9 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.9 20 | 21 | - name: Install pypa/build 22 | run: >- 23 | python -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: >- 29 | python -m 30 | build 31 | --sdist 32 | --wheel 33 | --outdir dist/ 34 | 35 | - name: ls dist 36 | run: >- 37 | ls -al dist/ 38 | 39 | - name: Publish distribution 📦 to Test PyPI 40 | uses: pypa/gh-action-pypi-publish@master 41 | with: 42 | skip_existing: true 43 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 44 | repository_url: https://test.pypi.org/legacy/ 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache/* 3 | *.pyc 4 | *.sw[po] 5 | __pycache__/* 6 | */__pycache__/* 7 | .coverage 8 | .pytest_cache/* 9 | .tox/* 10 | .mypy_cache/* 11 | .vscode/* 12 | 13 | docs/_build/* 14 | docs/_static/* 15 | docs/_templates/* 16 | 17 | hobbit_core.egg-info/* 18 | dist/* 19 | build/* 20 | 21 | tests/tst_app.sqlite 22 | models.csv 23 | 24 | .coverage* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Legolas Bloom 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 | prune tests/ 2 | 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | tox = "*" 8 | ipython = "*" 9 | hobbit-core = {editable = true,extras = ["hobbit", "hobbit_core"],path = "."} 10 | 11 | [dev-packages] 12 | sphinx-autobuild = "*" 13 | psycopg2-binary = "*" 14 | cx-Oracle = "*" 15 | pytest = "*" 16 | pytest-cov = "*" 17 | pytest-env = "*" 18 | flake8 = "*" 19 | twine = "*" 20 | mypy = "*" 21 | mypy-extensions = "*" 22 | Sphinx = "*" 23 | Flask-Sphinx-Themes = "*" 24 | PyMySQL = "*" 25 | cryptography = "*" 26 | 27 | [requires] 28 | python_version = "3.9" 29 | 30 | [pipenv] 31 | allow_prereleases = true 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hobbit-core 2 | 3 | [![CircleCI](https://circleci.com/gh/TTWShell/hobbit-core.svg?style=svg)](https://circleci.com/gh/TTWShell/hobbit-core) 4 | [![Documentation Status](https://readthedocs.org/projects/hobbit-core/badge/?version=latest)](https://hobbit-core.readthedocs.io/zh/latest/?badge=latest) 5 | [![PyPi-Version](https://img.shields.io/pypi/v/hobbit-core.svg)](https://img.shields.io/pypi/v/hobbit-core.svg) 6 | [![Python-version](https://img.shields.io/pypi/pyversions/hobbit-core.svg)](https://img.shields.io/pypi/pyversions/hobbit-core.svg) 7 | [![codecov](https://codecov.io/gh/TTWShell/hobbit-core/branch/master/graph/badge.svg)](https://codecov.io/gh/TTWShell/hobbit-core) 8 | [![License](https://img.shields.io/:license-mit-blue.svg?style=flat-square)](https://hobbit-core.mit-license.org) 9 | 10 | A flask project generator. Based on Flask + SQLAlchemy + marshmallow + webargs. 11 | 12 | [https://hobbit-core.readthedocs.io/zh/latest/](https://hobbit-core.readthedocs.io/zh/latest/) 13 | 14 | # Installation 15 | 16 | Install and update using pip(**Still using Python 2? It is time to upgrade.**): 17 | 18 | ``` 19 | pip install -U "hobbit-core[hobbit]" # just install hobbit cmd 20 | pip install -U "hobbit-core[hobbit,hobbit_core]" # recommended when use virtualenv 21 | ``` 22 | 23 | # A Simple Example 24 | 25 | ## Init project: 26 | 27 | ``` 28 | hobbit --echo new -n demo -d /tmp/demo -p 5000 -t rivendell 29 | cd /tmp/demo 30 | pipenv install -r requirements.txt --pre && pipenv install --dev pytest pytest-cov pytest-env ipython flake8 ipdb 31 | pipenv shell 32 | ``` 33 | 34 | ## flask cli: 35 | 36 | ``` 37 | (demo) ➜ FLASK_APP=app.run:app flask 38 | Usage: flask [OPTIONS] COMMAND [ARGS]... 39 | 40 | A general utility script for Flask applications. 41 | 42 | An application to load must be given with the '--app' option, 'FLASK_APP' 43 | environment variable, or with a 'wsgi.py' or 'app.py' file in the current 44 | directory. 45 | 46 | Options: 47 | -e, --env-file FILE Load environment variables from this file. python- 48 | dotenv must be installed. 49 | -A, --app IMPORT The Flask application or factory function to load, in 50 | the form 'module:name'. Module can be a dotted import 51 | or file path. Name is not required if it is 'app', 52 | 'application', 'create_app', or 'make_app', and can be 53 | 'name(args)' to pass arguments. 54 | --debug / --no-debug Set debug mode. 55 | --version Show the Flask version. 56 | --help Show this message and exit. 57 | 58 | Commands: 59 | db Perform database migrations. 60 | routes Show the routes for the app. 61 | run Run a development server. 62 | shell Runs a shell in the app context. 63 | ``` 64 | 65 | ``` 66 | (demo) ➜ FLASK_APP=app.run:app flask routes 67 | Endpoint Methods Rule 68 | ------------ ------- ----------------------- 69 | static GET /static/ 70 | tools.option GET /api/options 71 | tools.ping GET /api/ping 72 | ``` 73 | 74 | ## Run server 75 | 76 | ``` 77 | (demo) ➜ flask -A app/run.py run 78 | * Serving Flask app 'app/run.py' 79 | * Debug mode: off 80 | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. 81 | * Running on http://127.0.0.1:5000 82 | ``` 83 | 84 | ``` 85 | ➜ ~ curl http://127.0.0.1:5000/api/ping 86 | {"ping":"ok"} 87 | ➜ ~ curl http://127.0.0.1:5000/api/options 88 | {} 89 | ``` 90 | 91 | ## Run test: 92 | 93 | ``` 94 | (demo) ➜ py.test 95 | ===================================================== test session starts ====================================================== 96 | platform darwin -- Python 3.7.0, pytest-5.0.1, py-1.8.0, pluggy-0.12.0 -- /Users/Legolas/.virtualenvs/demo-OzheZQoG/bin/python3.7 97 | cachedir: .pytest_cache 98 | rootdir: /private/tmp/demo, inifile: pytest.ini 99 | plugins: env-0.6.2, cov-2.7.1 100 | collected 2 items 101 | 102 | tests/test_option.py::TestOption::test_options PASSED 103 | tests/test_ping.py::TestAPIExample::test_ping_api PASSED 104 | 105 | ---------- coverage: platform darwin, python 3.7.0-final-0 ----------- 106 | Name Stmts Miss Cover Missing 107 | ---------------------------------------------------------- 108 | app/__init__.py 0 0 100% 109 | app/configs/__init__.py 0 0 100% 110 | app/configs/default.py 6 0 100% 111 | app/configs/development.py 1 1 0% 1 112 | app/configs/production.py 2 2 0% 1-3 113 | app/configs/testing.py 8 0 100% 114 | app/core/__init__.py 0 0 100% 115 | app/exts.py 8 0 100% 116 | app/models/__init__.py 2 0 100% 117 | app/models/consts.py 1 0 100% 118 | app/run.py 35 1 97% 49 119 | app/schemas/__init__.py 2 0 100% 120 | app/services/__init__.py 2 0 100% 121 | app/services/option.py 6 0 100% 122 | app/tasks/__init__.py 1 1 0% 1 123 | app/utils/__init__.py 0 0 100% 124 | app/views/__init__.py 2 0 100% 125 | app/views/option.py 5 0 100% 126 | app/views/ping.py 7 0 100% 127 | tests/__init__.py 17 1 94% 29 128 | tests/conftest.py 11 0 100% 129 | tests/test_option.py 5 0 100% 130 | tests/test_ping.py 5 0 100% 131 | ---------------------------------------------------------- 132 | TOTAL 126 6 95% 133 | 134 | 135 | =================================================== 2 passed in 0.24 seconds =================================================== 136 | ``` 137 | 138 | # Others 139 | 140 | ``` 141 | hobbit --help 142 | ``` 143 | 144 | # dev 145 | 146 | ``` 147 | pip install "hobbit-core[hobbit,hobbit_core]=={version}" --pre --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ 148 | ``` -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | Hobbit-core's API Documentation 2 | =============================== 3 | 4 | hobbit cmd 5 | ------------------ 6 | 7 | hobbit - A flask project generator. 8 | 9 | .. autofunction:: hobbit.bootstrap.new 10 | 11 | hobbit_core 12 | ------------------------ 13 | 14 | A flask extension that take care of base utils. 15 | 16 | .. automodule:: hobbit_core 17 | :members: 18 | :undoc-members: 19 | 20 | db 21 | ^^ 22 | 23 | .. automodule:: hobbit_core.db 24 | :members: 25 | :undoc-members: 26 | :exclude-members: SurrogatePK, EnumExt, EnumExtMeta 27 | 28 | .. autoclass:: BaseModel 29 | :members: __repr__ 30 | 31 | .. autoclass:: SurrogatePK 32 | :members: __repr__ 33 | 34 | .. autoclass:: EnumExt 35 | :members: 36 | 37 | .. automethod:: strict_dump 38 | .. automethod:: dump 39 | .. automethod:: load 40 | .. automethod:: to_opts 41 | 42 | pagination 43 | ^^^^^^^^^^ 44 | 45 | .. automodule:: hobbit_core.pagination 46 | :members: 47 | :undoc-members: 48 | 49 | schemas 50 | ^^^^^^^ 51 | 52 | .. automodule:: hobbit_core.schemas 53 | :members: 54 | :undoc-members: 55 | :exclude-members: ORMSchema, SchemaMixin, PagedSchema, EnumSetMeta 56 | 57 | .. autoclass:: ORMSchema 58 | :members: 59 | :exclude-members: make_instance 60 | 61 | .. autoclass:: SchemaMixin 62 | :members: 63 | 64 | .. autoclass:: PagedSchema 65 | :members: 66 | 67 | utils 68 | ^^^^^ 69 | 70 | .. automodule:: hobbit_core.utils 71 | :members: 72 | :undoc-members: 73 | 74 | 75 | response 76 | ^^^^^^^^ 77 | 78 | .. automodule:: hobbit_core.response 79 | :members: 80 | :undoc-members: 81 | 82 | err_handler 83 | ^^^^^^^^^^^ 84 | 85 | .. automodule:: hobbit_core.err_handler 86 | :members: 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Change history 2 | ============== 3 | 4 | 4.0.0 (2024-09-26) 5 | ********************** 6 | 7 | * Upgraded dependencies: Flask to 3.x, SQLAlchemy to 2.x. 8 | * Fixed issue with `get_env` import from `flask.helpers`, now use `hobbit_core.utils.get_env` . `HOBBIT_ENV` is now only used to determine the configuration file. 9 | * Fixed `hobbit_core.db.transaction`. 10 | 11 | 3.1.1 (2023-08-11) 12 | ****************** 13 | 14 | * Hotfix: lock flask version < 2.3 and pyyaml verison(https://github.com/yaml/pyyaml/issues/724) 15 | 16 | 3.1.0 (2023-01-29) 17 | ****************** 18 | 19 | * Support HOBBIT_RESPONSE_DETAIL config: Default return detail and must set to False in production env. Default is True. Only used in 500 server error response. 20 | 21 | 3.0.0 (2022-12-12) 22 | ****************** 23 | 24 | * Upgrade deps: Flask 1.x -> 2.x, SQLAlchemy 1.3.x -> 1.4.x, Flask-SQLAlchemy 2.5.1 -> 3.x. 25 | * **Notice:** [https://docs.sqlalchemy.org/en/14/tutorial/dbapi_transactions.html](https://docs.sqlalchemy.org/en/14/tutorial/dbapi_transactions.html). 26 | 27 | 2.2.3 (2022-05-18) 28 | ****************** 29 | 30 | * Support use nested=None(`@transaction(db.session, nested=None)`) to avoid bug from `flask_sqlalchemy.models_committed` signal. 31 | 32 | 2.2.2 (2022-02-17) 33 | ****************** 34 | 35 | * Refactor tpl: Auto nested blueprint. 36 | * Refactor tpl: ping and options api were merged into tools. 37 | * Enhance teardown_method in test: auto handle deps when delete table. 38 | * Fix some typo. 39 | 40 | 2.2.1 (2021-12-01) 41 | ****************** 42 | 43 | * Add `err_handler.HobbitException`: Base class for all hobbitcore-related errors. 44 | 45 | 2.2.0 (2021-11-18) 46 | ****************** 47 | 48 | * Support Python 3.10. 49 | 50 | 2.1.1 (2021-10-25) 51 | ****************** 52 | 53 | * Add util `bulk_create_or_update_on_duplicate`, support MySQL and postgreSQL. 54 | 55 | 2.1.0 (2021-10-25, unused) 56 | 57 | * This filename has already been used (Wrong file pushed to pypi.org). 58 | 59 | 2.0.4 (2021-07-13) 60 | ****************** 61 | 62 | * Support set `HOBBIT_RESPONSE_MESSAGE_MAPS` to use self-defined response message. 63 | 64 | 2.0.3 (2021-07-08) 65 | ****************** 66 | 67 | * Fix set response.xxxResult code = 0. 68 | 69 | 2.0.2 (2021-07-08) 70 | ****************** 71 | 72 | * Fix response message err when code is 200 or 400. 73 | * Support set `HOBBIT_USE_CODE_ORIGIN_TYPE = True` to return origin type of code in response. 74 | 75 | 2.0.1 (2021-06-21) 76 | ****************** 77 | 78 | * Add data field for response.Result: return Real response payload. 79 | * Bugfix: tests.BaseTest.teardown_method miss `app.app_context()`. 80 | 81 | 2.0.0 (2021-06-20) 82 | ****************** 83 | 84 | * Upgrade webargs to version 8.x.x. 85 | * Lock SQLAlchemy version less than 1.4.0 (session.autobegin feature doesn't look like a good idea). 86 | * Lock Flask version less than 2.x.x (because some bugs). 87 | * Upgrade and lock marshmallow>=3.0.0,<4. 88 | * Remove hobbit gen cmd. 89 | 90 | 1.4.4 (2020-03-25) 91 | ****************** 92 | 93 | * Fix webargs 6.x.x: limit version < 6. 94 | 95 | 1.4.3 (2019-07-24) 96 | ****************** 97 | 98 | * Add CustomParser for automatically trim leading/trailing whitespace from argument values(`from hobbit_core.webargs import use_args, use_kwargs`). 99 | * Add `HOBBIT_UPPER_SEQUENCE_NAME` config for upper db's sequence name. 100 | * Fixs some err in template. 101 | 102 | 1.4.2 (2019-06-13) 103 | ****************** 104 | 105 | * Add `db.BaseModel` for support Oracle id sequence. 106 | 107 | 1.4.1 (2019-05-23) 108 | ****************** 109 | 110 | * Add template for 4-layers (view、schema、service、model). 111 | * Add options api for query all consts defined in `app/models/consts`. 112 | * Add `create` command to generate a csv file that defines some models to use in the `gen` command. 113 | * Removed example code. 114 | * Split hobbit cmd and hobbit_core lib, now install cmd should be `pip install "hobbit-core[hobbit,hobbit_core]"`. 115 | * Remove flask_hobbit when import (`hobbit_core.flask_hobbit.db import transaction` --> `from hobbit_core.db import transaction`). 116 | * Enhance gen cmd: now can auto create CRUD API and tests. 117 | * Fix typo. 118 | * Update some test cases. 119 | 120 | 1.4.0 (Obsolete version) 121 | ************************ 122 | 123 | 1.3.1 (2019-02-26) 124 | ****************** 125 | 126 | * The strict parameter is removed in marshmallow >= 3.0.0. 127 | 128 | 1.3.0 (2019-01-14) 129 | ****************** 130 | 131 | * Add import_subs util for auto import models、schemas、views in module/__init__.py file. 132 | * Add index for created_at、updated_at cloumn and default order_by id. 133 | * Add validate for PageParams. 134 | * Add hobbit gen cmd for auto render views.py, models.py, schemas.py etc when start a feature dev. 135 | * Add ErrHandler.handler_assertion_error. 136 | * Add db.transaction decorator, worked either autocommit True or False. 137 | * pagination return dict instead of class, order_by can set None for 138 | * traceback.print_exc() --> logging.error. 139 | * Foreign key fields support ondelete, onupdate. 140 | * Hobbit startproject cmd support celery option. 141 | 142 | 1.2.5 (2018-10-30) 143 | ****************** 144 | 145 | * Add ModelSchema(Auto generate load and dump func for EnumField). 146 | * Add logging config file. 147 | * Add EnumExt implementation. 148 | * Fix use_kwargs with fileds.missing=None and enhanced. 149 | 150 | 1.2.4 (2018-10-18) 151 | ****************** 152 | 153 | * Fix SuccessResult status arg not used. 154 | 155 | 1.2.3 (2018-10-18) 156 | ****************** 157 | 158 | * Add utils.use_kwargs, fix webargs's bug. 159 | 160 | 1.2.2 (2018-10-16) 161 | ****************** 162 | 163 | * Add SchemaMixin & ORMSchema use in combination with db.SurrogatePK. 164 | * Now print traceback info when server 500. 165 | * Fix miss hidden files when sdist. 166 | 167 | 1.2.1 (2018-10-12) 168 | ****************** 169 | 170 | * secure_filename support py2 & py3. 171 | 172 | 1.2.0 (2018-10-11) 173 | ****************** 174 | 175 | * Gitlab CI/CD support. 176 | * Add secure_filename util. 177 | * Enhance deploy, can deploy to multiple servers. 178 | * Add --port option for startproject cmd. 179 | 180 | 1.1.0 (2018-09-29) 181 | ****************** 182 | 183 | * Beta release. 184 | * Fix hobbit create in curdir(.) err. 185 | * Add dict2object util. 186 | * Project tree confirmed. 187 | * Add tutorial、project tree doc. 188 | * Add example options for startproject cmd. 189 | 190 | 191 | 1.0.0 (2018-09-25) 192 | ****************** 193 | 194 | * Alpha release. 195 | * flask_hobbit release. 196 | 197 | 0.0.[1-9] 198 | ********* 199 | 200 | * hobbit cmd released. 201 | * Incompatible production version. 202 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | 15 | import os 16 | import sys 17 | from datetime import datetime 18 | 19 | from webargs.flaskparser import FlaskParser # noqa 20 | 21 | ROOT_PATH = os.path.split(os.path.abspath('.'))[0] 22 | sys.path.insert(0, os.path.join(ROOT_PATH, 'hobbit_core')) 23 | 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = 'hobbit-core' 28 | modified_at = datetime.utcfromtimestamp(os.path.getmtime("changelog.rst")) 29 | copyright = f'2018-{modified_at:%Y}, Legolas Bloom' 30 | author = 'Legolas Bloom' 31 | 32 | # The short X.Y version 33 | version = '' 34 | # The full version, including alpha/beta/rc tags 35 | release = '' 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # If your documentation needs a minimal Sphinx version, state it here. 41 | # 42 | # needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | 'sphinx.ext.autodoc', 49 | 'sphinx.ext.doctest', 50 | 'sphinx.ext.intersphinx', 51 | 'sphinx.ext.todo', 52 | 'sphinx.ext.coverage', 53 | 'sphinx.ext.mathjax', 54 | 'sphinx.ext.ifconfig', 55 | 'sphinx.ext.viewcode', 56 | 'sphinx.ext.githubpages', 57 | 'sphinx.ext.napoleon', 58 | ] 59 | 60 | # Add any paths that contain templates here, relative to this directory. 61 | templates_path = ['_templates'] 62 | 63 | # The suffix(es) of source filenames. 64 | # You can specify multiple suffix as a list of string: 65 | # 66 | # source_suffix = ['.rst', '.md'] 67 | source_suffix = '.rst' 68 | 69 | # The master toctree document. 70 | master_doc = 'index' 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # 75 | # This is also used if you do content translation via gettext catalogs. 76 | # Usually you set "language" from the command line for these cases. 77 | language = 'python' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | # This pattern also affects html_static_path and html_extra_path. 82 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = None 86 | 87 | 88 | # -- Options for HTML output ------------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | # https://pypi.org/project/Flask-Sphinx-Themes/ 94 | html_theme = 'flask' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | 100 | html_theme_options = { 101 | 'github_fork': 'TTWShell/hobbit-core', 102 | } 103 | 104 | # Add any paths that contain custom static files (such as style sheets) here, 105 | # relative to this directory. They are copied after the builtin static files, 106 | # so a file named "default.css" will overwrite the builtin "default.css". 107 | html_static_path = ['_static'] 108 | 109 | # Custom sidebar templates, must be a dictionary that maps document names 110 | # to template names. 111 | # 112 | # The default sidebars (for documents that don't match any pattern) are 113 | # defined by theme itself. Builtin themes are using these templates by 114 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 115 | # 'searchbox.html']``. 116 | # 117 | # html_sidebars = {} 118 | 119 | 120 | # -- Options for HTMLHelp output --------------------------------------------- 121 | 122 | # Output file base name for HTML help builder. 123 | htmlhelp_basename = 'hobbit-coredoc' 124 | 125 | 126 | # -- Options for LaTeX output ------------------------------------------------ 127 | 128 | latex_elements = { 129 | # The paper size ('letterpaper' or 'a4paper'). 130 | # 131 | # 'papersize': 'letterpaper', 132 | 133 | # The font size ('10pt', '11pt' or '12pt'). 134 | # 135 | # 'pointsize': '10pt', 136 | 137 | # Additional stuff for the LaTeX preamble. 138 | # 139 | # 'preamble': '', 140 | 141 | # Latex figure (float) alignment 142 | # 143 | # 'figure_align': 'htbp', 144 | } 145 | 146 | # Grouping the document tree into LaTeX files. List of tuples 147 | # (source start file, target name, title, 148 | # author, documentclass [howto, manual, or own class]). 149 | latex_documents = [ 150 | (master_doc, 'hobbit-core.tex', 'hobbit-core Documentation', 151 | 'Legolas Bloom', 'manual'), 152 | ] 153 | 154 | 155 | # -- Options for manual page output ------------------------------------------ 156 | 157 | # One entry per manual page. List of tuples 158 | # (source start file, name, description, authors, manual section). 159 | man_pages = [ 160 | (master_doc, 'hobbit-core', 'hobbit-core Documentation', 161 | [author], 1) 162 | ] 163 | 164 | 165 | # -- Options for Texinfo output ---------------------------------------------- 166 | 167 | # Grouping the document tree into Texinfo files. List of tuples 168 | # (source start file, target name, title, author, 169 | # dir menu entry, description, category) 170 | texinfo_documents = [ 171 | (master_doc, 'hobbit-core', 'hobbit-core Documentation', 172 | author, 'hobbit-core', 'One line description of project.', 173 | 'Miscellaneous'), 174 | ] 175 | 176 | 177 | # -- Options for Epub output ------------------------------------------------- 178 | 179 | # Bibliographic Dublin Core info. 180 | epub_title = project 181 | 182 | # The unique identifier of the text. This can be a ISBN number 183 | # or the project homepage. 184 | # 185 | # epub_identifier = '' 186 | 187 | # A unique identification for the text. 188 | # 189 | # epub_uid = '' 190 | 191 | # A list of files that should not be packed into the epub file. 192 | epub_exclude_files = ['search.html'] 193 | 194 | 195 | # -- Extension configuration ------------------------------------------------- 196 | 197 | # -- Options for intersphinx extension --------------------------------------- 198 | 199 | # Example configuration for intersphinx: refer to the Python standard library. 200 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 201 | 202 | # -- Options for todo extension ---------------------------------------------- 203 | 204 | # If true, `todo` and `todoList` produce output, else they produce nothing. 205 | todo_include_todos = True 206 | 207 | autodoc_member_order = 'bysource' 208 | 209 | 210 | # patch for flask app_context 211 | from flask import Flask # noqa 212 | from flask_sqlalchemy import SQLAlchemy # noqa 213 | from hobbit_core import HobbitManager # noqa 214 | 215 | app = Flask('', root_path='.') 216 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory" 217 | db = SQLAlchemy() 218 | db.init_app(app) 219 | hobbit = HobbitManager() 220 | 221 | with app.app_context(): 222 | hobbit.init_app(app, db) 223 | from hobbit_core import db 224 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Hobbit-core中文文档 2 | =================== 3 | 4 | `changelog `_ // 5 | `github `_ // 6 | `pypi `_ // 7 | `issues `_ // 8 | `API文档 `_ // 9 | `EN version `_ 10 | 11 | 基于 Flask + SQLAlchemy + marshmallow + webargs 的 flask 项目生成器。 12 | 13 | 包含 RESTful API、celery集成、单元测试、gitlab-ci/cd、docker compose 一套解决方案。后续考虑更好的自动文档工具(目前有 apispec )。 14 | 15 | **为什么我开发了这个项目?** 可以参考这一设计范式: `Convention over configuration `_ 。 16 | 17 | 18 | 简易教程 19 | ======== 20 | 21 | 快速安装 22 | ^^^^^^^^^^ 23 | 24 | :: 25 | 26 | pip install "hobbit-core[hobbit,hobbit_core]" # 安装全部功能 27 | pip install "hobbit-core[hobbit,hobbit_core]" --pre # 安装pre release版本 28 | # 仅安装命令依赖,不安装库依赖(安装命令到全局时推荐使用) 29 | pip install "hobbit-core[hobbit]" 30 | 31 | 快速生成项目 32 | ^^^^^^^^^^^^^ 33 | 34 | 使用 ``hobbit`` 命令自动生成你的flask项目:: 35 | 36 | hobbit --echo new -n demo -d . -p 5000 --celery # 建议试用 -t rivendell 新模版 37 | 38 | 建议使用pipenv创建虚拟环境:: 39 | 40 | pipenv install -r requirements.txt --pre && pipenv install --dev pytest pytest-cov pytest-env ipython flake8 ipdb 41 | 42 | 该命令会生成一个完整的api及其测试范例,使用以下项目启动server:: 43 | 44 | pipenv shell # 使用虚拟环境 45 | FLASK_APP=app/run.py flask run 46 | 47 | 你可以在控制台看到类似如下信息:: 48 | 49 | * Serving Flask app "app/run.py" 50 | * Environment: production 51 | WARNING: Do not use the development server in a production environment. 52 | Use a production WSGI server instead. 53 | * Debug mode: off 54 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 55 | 56 | 访问 ``http://127.0.0.1:5000/api/ping/`` 57 | 58 | 自动补全 59 | ^^^^^^^^^ 60 | 61 | :: 62 | 63 | # bash users add this to your .bashrc 64 | eval "$(_HOBBIT_COMPLETE=source hobbit)" 65 | # zsh users add this to your .zshrc 66 | eval "$(_HOBBIT_COMPLETE=source_zsh hobbit)" 67 | 68 | 项目结构 69 | ======== 70 | 71 | :: 72 | 73 | . 74 | ├── Dockerfile 75 | ├── app 76 | │ ├── __init__.py 77 | │ ├── configs 78 | │ │ ├── __init__.py 79 | │ │ ├── default.py 80 | │ │ ├── development.py 81 | │ │ ├── production.py 82 | │ │ └── testing.py 83 | │ ├── core 84 | │ │ └── __init__.py 85 | │ ├── exts.py 86 | │ ├── models 87 | │ │ └── __init__.py 88 | │ ├── run.py 89 | │ ├── schemas 90 | │ │ └── __init__.py 91 | │ ├── tasks 92 | │ │ └── __init__.py 93 | │ ├── utils 94 | │ │ └── __init__.py 95 | │ └── views 96 | │ ├── __init__.py 97 | │ └── ping.py 98 | ├── configs 99 | │ └── gunicorn-logging.ini 100 | ├── deploy.sh 101 | ├── docker-compose.yml 102 | ├── docs 103 | │ └── index.apib 104 | ├── pytest.ini 105 | ├── requirements.txt 106 | └── tests 107 | ├── __init__.py 108 | ├── conftest.py 109 | └── test_ping.py 110 | 111 | 112 | Dockerfile 113 | ^^^^^^^^^^ 114 | 115 | 使用docker来运行我们的web服务,基于同一个docker image运行测试,由此保证开发环境、测试环境、运行时环境一致。你可以在 `Dockerfile reference `_ 查看有关Dockerfile的语法。 116 | 117 | app 118 | ^^^ 119 | 120 | app文件夹保存了所有业务层代码。基于 **约定优于配置** 范式,这个文件夹名字及所有其他文件夹名字 **禁止修改** 。 121 | 122 | configs 123 | ^^^^^^^ 124 | 125 | 基于flask设计,我们使用环境变量 ``HOBBIT_ENV`` 加载不同环境的配置文件。例如 ``HOBBIT_ENV=production`` ,会自动加载 ``configs/production.py`` 这个文件作为配置文件。 126 | 127 | core 128 | ^^^^ 129 | 130 | core文件夹约定编写自定义的基础类库代码,或者临时扩展hobbit_core的基础组件(方便后续直接贡献到hobbit_core)。 131 | 132 | exts.py 133 | ^^^^^^^ 134 | 135 | flask项目很容易产生循环引用问题, ``exts.py`` 文件的目的就是避免产生这个问题。你可以看下这个解释: `Why use exts.py to instance extension? `_ 136 | 137 | models 138 | ^^^^^^ 139 | 140 | 所有数据库模型定义在这里。 141 | 142 | services 143 | ^^^^^^^^ 144 | 145 | 使用rivendell模版事会有此模块,类比java结构,这时候约定view不访问model层而去访问sevices层,由sevices层去访问model层。 146 | 147 | run.py 148 | ^^^^^^ 149 | 150 | web项目的入口。你将在这里注册路由、注册命令等等操作。 151 | 152 | schemas 153 | ^^^^^^^ 154 | 155 | 定义所有的 marshmallow scheams。我们使用marshmallow来序列化api输出,类似 ``django-rest-framework`` 的效果。 156 | 157 | utils 158 | ^^^^^ 159 | 160 | 定义所有的公用工具函数。 161 | 162 | views 163 | ^^^^^ 164 | 165 | 路由及简单业务逻辑。 166 | 167 | deploy.sh 168 | --------- 169 | 170 | 一个简易的部署脚本。配合ci/cd系统一起工作。 171 | 172 | docker-compose.yml 173 | ^^^^^^^^^^^^^^^^^^ 174 | 175 | 基本的 docker compose 配置文件。考虑到单机部署需求,自动生成了一个简易的配置,启动项目:: 176 | 177 | docker-compose up 178 | 179 | docs 180 | ---- 181 | 182 | API 文档、model 设计文档、架构设计文档、需求文档等等项目相关的文档。 183 | 184 | logs 185 | ---- 186 | 187 | 运行时的log文件。 188 | 189 | tests 190 | ----- 191 | 192 | 所有的测试case. 推荐使用 `pytest `_ 测试,项目也会自动生成基本的pytest配置。 193 | 194 | 195 | 配置 196 | ==== 197 | 198 | .. list-table:: Configuration 199 | :widths: 25 25 50 200 | :header-rows: 1 201 | 202 | * - Key 203 | - Value 204 | - Description 205 | * - HOBBIT_USE_CODE_ORIGIN_TYPE 206 | - `True` or `False` 207 | - Return origin type of code in response. Default is `False`. 208 | * - HOBBIT_RESPONSE_MESSAGE_MAPS 209 | - `dict`, `{code: message}` 210 | - Self-defined response message. Set to `{200: "success"}` will return `{"code": 200, "message": "success"}` in response. 211 | * - HOBBIT_RESPONSE_DETAIL 212 | - `True` or `False` 213 | - Default return detail and must set to `False` in production env. Default is `True`. Only used in 500 server error response. 214 | 215 | Others 216 | ====== 217 | 218 | .. toctree:: 219 | :maxdepth: 2 220 | 221 | changelog 222 | api 223 | -------------------------------------------------------------------------------- /docs/index_en.rst: -------------------------------------------------------------------------------- 1 | Hobbit-core's documentation 2 | =========================== 3 | 4 | `changelog `_ // 5 | `github `_ // 6 | `pypi `_ // 7 | `issues `_ // 8 | `API doc `_ // 9 | `中文文档 `_ 10 | 11 | A flask project generator. Based on Flask + SQLAlchemy + marshmallow + webargs. 12 | 13 | A hobbit app contains RESTful API, celery, unit test, gitlab-ci/cd、docker compose etc. 14 | 15 | **Why do we need this project?** Answer is `Convention over configuration. `_ 16 | 17 | 18 | Tutorial 19 | ======== 20 | 21 | Get it right now:: 22 | 23 | pip install hobbit-core 24 | 25 | Create your flask project:: 26 | 27 | hobbit --echo new -n demo -d . -p 5000 --celery 28 | 29 | Run flask app:: 30 | 31 | FLASK_APP=app/run.py flask run 32 | 33 | It works:: 34 | 35 | * Serving Flask app "app/run.py" 36 | * Environment: production 37 | WARNING: Do not use the development server in a production environment. 38 | Use a production WSGI server instead. 39 | * Debug mode: off 40 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 41 | 42 | You can request ``http://127.0.0.1:5000/api/ping/`` 43 | 44 | Other tips:: 45 | 46 | hobbit --help 47 | 48 | 49 | Project Tree 50 | ============ 51 | 52 | :: 53 | 54 | . 55 | ├── Dockerfile 56 | ├── app 57 | │ ├── __init__.py 58 | │ ├── configs 59 | │ │ ├── __init__.py 60 | │ │ ├── default.py 61 | │ │ ├── development.py 62 | │ │ ├── production.py 63 | │ │ └── testing.py 64 | │ ├── core 65 | │ │ └── __init__.py 66 | │ ├── exts.py 67 | │ ├── models 68 | │ │ └── __init__.py 69 | │ ├── run.py 70 | │ ├── schemas 71 | │ │ └── __init__.py 72 | │ ├── tasks 73 | │ │ └── __init__.py 74 | │ ├── utils 75 | │ │ └── __init__.py 76 | │ └── views 77 | │ ├── __init__.py 78 | │ └── ping.py 79 | ├── configs 80 | │ └── gunicorn-logging.ini 81 | ├── deploy.sh 82 | ├── docker-compose.yml 83 | ├── docs 84 | │ └── index.apib 85 | ├── pytest.ini 86 | ├── requirements.txt 87 | └── tests 88 | ├── __init__.py 89 | ├── conftest.py 90 | └── test_ping.py 91 | 92 | Dockerfile 93 | ---------- 94 | 95 | Build image for run web server. For more information about dockerfile, please visit : `Dockerfile reference `_. 96 | 97 | app 98 | --- 99 | 100 | App dir saved all business layer codes. You must ensure dir name is app based on *convention over configuration*. 101 | 102 | configs 103 | ^^^^^^^ 104 | 105 | In a hobbit app, we auto load config by HOBBIT_ENV. If HOBBIT_ENV=production, used ``configs/production.py`` file. 106 | 107 | core 108 | ^^^^ 109 | 110 | All complicated function, base class etc. 111 | 112 | exts.py 113 | ^^^^^^^ 114 | 115 | To avoid circular imports in Flask and flask extention, exts.py used. `Why use exts.py to instance extension? `_ 116 | 117 | models 118 | ^^^^^^ 119 | 120 | Create your models here. 121 | 122 | run.py 123 | ^^^^^^ 124 | 125 | schemas 126 | ^^^^^^^ 127 | 128 | Create your marshmallow scheams here. 129 | 130 | tasks 131 | ^^^^^ 132 | 133 | Celery tasks here. 134 | 135 | utils 136 | ^^^^^ 137 | 138 | All common utils here. 139 | 140 | views 141 | ^^^^^ 142 | 143 | Create your views here. 144 | 145 | deploy.sh 146 | --------- 147 | 148 | A script for deploy. 149 | 150 | docker-compose.yml 151 | ^^^^^^^^^^^^^^^^^^ 152 | 153 | Base docker compose file. Run app:: 154 | 155 | docker-compose up 156 | 157 | docs 158 | ---- 159 | 160 | API doc etc. 161 | 162 | logs 163 | ---- 164 | 165 | All logs for app, nginx etc. 166 | 167 | tests 168 | ----- 169 | 170 | Create your tests here. Recommended use `pytest `_. 171 | 172 | 173 | Others 174 | ====== 175 | 176 | .. toctree:: 177 | :maxdepth: 2 178 | 179 | changelog 180 | api 181 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask-Sphinx-Themes 2 | recommonmark 3 | Sphinx>=5.0 4 | sphinx-autobuild 5 | MarkupSafe>=2.0.1 6 | -------------------------------------------------------------------------------- /hobbit/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | from click import Command 5 | 6 | import inflect 7 | 8 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 9 | ROOT_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | inflect_engine = inflect.engine() 11 | 12 | 13 | class HobbitCommand(Command): 14 | 15 | def format_options(self, ctx, formatter): 16 | """Writes all the options into the formatter if they exist.""" 17 | # Borrowed from click.MultiCommand 18 | opts = [] 19 | for param in self.get_params(ctx): 20 | rv = param.get_help_record(ctx) 21 | if rv is not None: 22 | # rewrite for color 23 | rv = list(rv) 24 | rv[0] = click.style(rv[0], fg='green') 25 | opts.append(tuple(rv)) 26 | 27 | if opts: 28 | with formatter.section('Options'): 29 | formatter.write_dl(opts) 30 | 31 | 32 | class HobbitGroup(click.Group, HobbitCommand): 33 | 34 | def list_commands(self, ctx): 35 | return sorted(self.cmds.keys()) 36 | 37 | def get_command(self, ctx, cmd_name): 38 | # Alias startproject to new 39 | cmd_name = 'new' if cmd_name == 'startproject' else cmd_name 40 | try: 41 | return self.cmds[cmd_name] 42 | except KeyError: 43 | raise click.UsageError(click.style( 44 | "cmd not exist: {}\nAvailable ones are: {}".format( 45 | cmd_name, ', '.join(self.cmds), 46 | ), fg='red')) 47 | 48 | @property 49 | def cmds(self): 50 | from .bootstrap import cmd_list 51 | from .devtools import dev 52 | cmd_list.append(dev) 53 | return {func.name: func for func in cmd_list} 54 | 55 | def format_options(self, ctx, formatter): 56 | HobbitCommand.format_options(self, ctx, formatter) 57 | self.format_commands(ctx, formatter) 58 | 59 | def format_commands(self, ctx, formatter): 60 | """Extra format methods for multi methods that adds all the commands 61 | after the options. 62 | """ 63 | # Borrowed from click.MultiCommand 64 | commands = [] 65 | for subcommand in self.list_commands(ctx): 66 | cmd = self.get_command(ctx, subcommand) 67 | # What is this, the tool lied about a command. Ignore it 68 | if cmd is None: 69 | continue 70 | if cmd.hidden: 71 | continue 72 | 73 | commands.append((subcommand, cmd)) 74 | 75 | # allow for 3 times the default spacing 76 | if len(commands): 77 | limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) 78 | 79 | rows = [] 80 | for subcommand, cmd in commands: 81 | help = cmd.get_short_help_str(limit) 82 | rows.append((subcommand, help)) 83 | 84 | for i, row in enumerate(rows): # rewrite for color 85 | row = list(row) 86 | row[0] = click.style(row[0], fg='green') 87 | rows[i] = tuple(row) 88 | 89 | if rows: 90 | with formatter.section('Commands'): 91 | formatter.write_dl(rows) 92 | 93 | 94 | @click.group( 95 | cls=HobbitGroup, 96 | epilog='More details: https://hobbit-core.readthedocs.io/zh/latest/', 97 | context_settings=CONTEXT_SETTINGS) 98 | @click.version_option(package_name='hobbit_core') 99 | @click.option('--echo/--no-echo', default=False, 100 | help='Show the logs of command.') 101 | @click.pass_context 102 | def main(ctx, echo): 103 | ctx.obj = dict() 104 | ctx.obj['ECHO'] = echo 105 | 106 | 107 | if __name__ == '__main__': 108 | main() 109 | -------------------------------------------------------------------------------- /hobbit/bootstrap.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import string 4 | import pkg_resources 5 | 6 | import click 7 | 8 | from .handlers import echo 9 | from .handlers.bootstrap import render_project, validate_template_path 10 | from . import HobbitCommand, HobbitGroup, CONTEXT_SETTINGS 11 | 12 | templates = ['shire', 'rivendell'] 13 | 14 | 15 | @click.group(cls=HobbitGroup, context_settings=CONTEXT_SETTINGS) 16 | def cli(): 17 | pass 18 | 19 | 20 | def common_options(func): 21 | func = click.option( 22 | '-f', '--force', default=False, is_flag=True, 23 | help='Covered if file exist.')(func) 24 | func = click.option( 25 | '-t', '--template', type=click.Choice(templates), 26 | default='shire', callback=validate_template_path, 27 | help='Template name.')(func) 28 | func = click.option( 29 | '-d', '--dist', type=click.Path(), required=False, 30 | help='Target path.')(func) 31 | return func 32 | 33 | 34 | @cli.command(cls=HobbitCommand) 35 | @click.option('-n', '--name', help='Name of project.', required=True) 36 | @click.option('-p', '--port', help='Port of web server.', required=True, 37 | type=click.IntRange(1024, 65535)) 38 | @common_options 39 | @click.pass_context 40 | def new(ctx, name, port, dist, template, force): 41 | """Create a new flask project, render from different template. 42 | 43 | Examples:: 44 | 45 | hobbit --echo new -n blog -d /tmp/test -p 1024 46 | 47 | It is recommended to use pipenv to create venv:: 48 | 49 | pipenv install -r requirements.txt && pipenv install --dev pytest pytest-cov pytest-env ipython flake8 ipdb 50 | """ # noqa 51 | dist = os.getcwd() if dist is None else os.path.abspath(dist) 52 | ctx.obj['FORCE'] = force 53 | ctx.obj['JINJIA_CONTEXT'] = { 54 | 'project_name': name, 55 | 'port': port, 56 | 'secret_key': ''.join(random.choice( 57 | string.ascii_letters) for i in range(38)), 58 | 'version': pkg_resources.get_distribution("hobbit-core").version, 59 | } 60 | 61 | echo(f'Start init a hobbit project `{name}` to `{dist}`,' 62 | f' use template {template}') 63 | render_project(dist, template) 64 | echo(f'project `{name}` render finished.') 65 | 66 | 67 | cmd_list = [new] 68 | -------------------------------------------------------------------------------- /hobbit/devtools.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from . import HobbitGroup, CONTEXT_SETTINGS 4 | from .handlers.devtools import dev_init 5 | 6 | 7 | class BootstrapGroup(HobbitGroup): 8 | 9 | @property 10 | def cmds(self): 11 | return {func.name: func for func in [init]} 12 | 13 | 14 | @click.group(cls=BootstrapGroup, context_settings=CONTEXT_SETTINGS) 15 | def dev(): 16 | """Dev tools, more: hobbit dev --help. 17 | """ 18 | pass 19 | 20 | 21 | @dev.command() 22 | @click.option('-a', '--all', 'all_', default=False, is_flag=True, 23 | help='Run all.') 24 | @click.option('--hooks', default=False, is_flag=True, help='Install hooks.') 25 | @click.option('--pipenv', default=False, is_flag=True, 26 | help='Create virtualenv by pipenv.') 27 | @click.pass_context 28 | def init(ctx, all_, hooks, pipenv): 29 | """Init dev env: git hooks, pyenv install etc. 30 | """ 31 | dev_init(all_, hooks, pipenv) 32 | -------------------------------------------------------------------------------- /hobbit/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.pass_context 5 | def echo(ctx, msg): 6 | if not ctx.obj['ECHO']: 7 | return 8 | click.echo(msg) 9 | -------------------------------------------------------------------------------- /hobbit/handlers/bootstrap.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import os 3 | import re 4 | 5 | import click 6 | from jinja2 import Environment, FileSystemLoader, Template 7 | 8 | from . import echo 9 | 10 | SUFFIX = '.jinja2' 11 | 12 | 13 | def regex_replace(s, find, replace): 14 | """A non-optimal implementation of a regex filter""" 15 | return re.sub(find, replace, s) 16 | 17 | 18 | @contextmanager 19 | def chdir(dist): 20 | cwd = os.getcwd() 21 | # exist_ok py3 only 22 | if not os.path.exists(dist): 23 | echo(f'mkdir\t{dist}') 24 | os.makedirs(dist) 25 | os.chdir(dist) 26 | yield dist 27 | os.chdir(cwd) 28 | 29 | 30 | @click.pass_context 31 | def render_project(ctx, dist, tpl_path): 32 | context = ctx.obj['JINJIA_CONTEXT'] 33 | 34 | jinjia_env = Environment(loader=FileSystemLoader(tpl_path)) 35 | jinjia_env.filters['regex_replace'] = regex_replace 36 | 37 | with chdir(dist): 38 | for fn in os.listdir(tpl_path): 39 | origin_path = os.path.join(tpl_path, fn) 40 | 41 | if os.path.isfile(origin_path) and not fn.endswith(SUFFIX): 42 | continue 43 | 44 | if os.path.isfile(origin_path): 45 | data = jinjia_env.get_template(fn).render(context) 46 | fn = Template(fn).render(context) 47 | render_file(dist, fn[:-len(SUFFIX)], data) 48 | continue 49 | 50 | dir_name = Template(fn).render(context) 51 | render_project(os.path.join(dist, dir_name), 52 | os.path.join(tpl_path, fn)) 53 | 54 | 55 | @click.pass_context 56 | def render_file(ctx, dist, fn, data): 57 | target = os.path.join(dist, fn) 58 | if os.path.isfile(fn) and not ctx.obj['FORCE']: 59 | echo(f'exists {target}, ignore ...') 60 | return 61 | 62 | echo(f'render\t{target} ...') 63 | 64 | with open(fn, 'w') as wf: 65 | wf.write(data) 66 | 67 | if fn.endswith('.sh'): 68 | os.chmod(fn, 0o755) 69 | 70 | 71 | def validate_template_path(ctx, param, value): 72 | from hobbit import ROOT_PATH 73 | dir = 'feature' if ctx.command.name == 'gen' else 'bootstrap' 74 | tpl_path = os.path.join(ROOT_PATH, 'static', dir, value) 75 | 76 | if not os.path.exists(tpl_path): 77 | raise click.UsageError( 78 | click.style(f'Tpl `{value}` not exists.', fg='red')) 79 | 80 | return tpl_path 81 | -------------------------------------------------------------------------------- /hobbit/handlers/devtools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from subprocess import run, PIPE, STDOUT 4 | 5 | import click 6 | 7 | from hobbit import ROOT_PATH 8 | 9 | 10 | def dev_init(all_, hooks, pipenv): 11 | run('git init', shell=True) 12 | 13 | if all_ or hooks: 14 | HOOKS_PATH = os.path.join(ROOT_PATH, 'static', 'hooks') 15 | run(f'cp -r {HOOKS_PATH}/* .git/hooks', shell=True) 16 | 17 | if all_ or pipenv: 18 | sub = run('which pipenv', shell=True, stdout=PIPE, stderr=STDOUT) 19 | if sub.returncode != 0: 20 | click.echo(click.style('cmd pipenv not exist.', fg='red')) 21 | sys.exit(sub.returncode) 22 | pipenv_path = sub.stdout.strip().decode('utf8') 23 | 24 | pipenv_cmds = [ 25 | f'{pipenv_path} install --dev pytest pytest-cov pytest-env flake8', 26 | ] 27 | if 'requirements.txt' in os.listdir(): 28 | pipenv_cmds.insert( 29 | 0, f'{pipenv_path} install -r requirements.txt --pre') 30 | 31 | cmd = ' && '.join(pipenv_cmds) 32 | click.echo(click.style(cmd, fg='green')) 33 | # force pipenv to ignore that environment and create its own instead 34 | env = os.environ.copy() 35 | env.update({'PIPENV_IGNORE_VIRTUALENVS': '1'}) 36 | run(cmd, shell=True, env=env) 37 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/.gitignore.jinja2: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.swp 4 | */__pycache__/ 5 | .pytest_cache/ 6 | ui/ 7 | .test/ 8 | logs/ 9 | 10 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/Dockerfile.jinja2: -------------------------------------------------------------------------------- 1 | FROM python:3.6.5 2 | 3 | ENV FLASK_APP=app/run.py 4 | 5 | ENV TZ=Asia/Shanghai 6 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 7 | 8 | RUN apt-get update --fix-missing && apt-get install -y \ 9 | zip sshpass 10 | 11 | RUN pip install --upgrade pip -i https://pypi.doubanio.com/simple 12 | RUN pip install flake8 pytest pytest-cov gunicorn gevent 13 | 14 | RUN mkdir /app 15 | WORKDIR /app 16 | 17 | # COPY ./Pipfile /app 18 | # COPY ./Pipfile.lock /app 19 | COPY ./requirements.txt /app 20 | 21 | # RUN pip install -i https://pypi.doubanio.com/simple --no-cache-dir pipenv \ 22 | # && pipenv install --system --deploy --dev 23 | RUN pip install -r requirements.txt 24 | 25 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/rivendell/app/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/configs/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/rivendell/app/configs/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/configs/default.py.jinja2: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ROOT_PATH = os.path.split(os.path.abspath(__name__))[0] 4 | 5 | DEBUG = True 6 | SECRET_KEY = '{{ secret_key }}' 7 | SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format( 8 | os.path.join(ROOT_PATH, '{{ project_name }}.db')) 9 | SQLALCHEMY_TRACK_MODIFICATIONS = False 10 | 11 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/configs/development.py.jinja2: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/configs/production.py.jinja2: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | DEBUG = False 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/configs/testing.py.jinja2: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .default import ROOT_PATH 4 | from .default import * # NOQA F401 5 | 6 | 7 | TEST_BASE_DIR = os.path.join(ROOT_PATH, '.test') 8 | SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format( 9 | os.path.join(TEST_BASE_DIR, '{{ project_name }}.db')) 10 | # SQLALCHEMY_ECHO = True 11 | TESTING = True 12 | 13 | if not os.path.exists(TEST_BASE_DIR): 14 | os.makedirs(TEST_BASE_DIR) 15 | 16 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/core/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/rivendell/app/core/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/exts.py.jinja2: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_migrate import Migrate 3 | from flask_marshmallow import Marshmallow 4 | 5 | from hobbit_core import HobbitManager 6 | 7 | db = SQLAlchemy() 8 | migrate = Migrate() 9 | ma = Marshmallow() 10 | hobbit = HobbitManager() 11 | 12 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/models/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/models/consts.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.db import EnumExt # noqa 2 | 3 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/run.py.jinja2: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | 4 | from flask import Flask, request 5 | 6 | from hobbit_core.err_handler import ErrHandler 7 | from hobbit_core.utils import get_env 8 | 9 | from app.exts import db, migrate, ma, hobbit 10 | 11 | 12 | def register_extensions(app): 13 | db.init_app(app) 14 | migrate.init_app(app, db) 15 | ma.init_app(app) 16 | hobbit.init_app(app, db) 17 | 18 | 19 | def register_blueprints(app): 20 | from app import views 21 | for name in views.__all__: 22 | bp = getattr(importlib.import_module(f'app.views.{name}'), 'bp', None) 23 | if bp is not None: 24 | app.register_blueprint( 25 | bp, url_prefix=f"/api{bp.url_prefix if bp.url_prefix else ''}") 26 | 27 | 28 | def register_error_handler(app): 29 | app.register_error_handler(Exception, ErrHandler.handler) 30 | 31 | 32 | def register_cmds(app): 33 | pass 34 | 35 | 36 | def create_app(): 37 | app = Flask(__name__, instance_relative_config=True) 38 | app.config.from_object('app.configs.{}'.format(get_env())) 39 | 40 | with app.app_context(): 41 | register_extensions(app) 42 | register_blueprints(app) 43 | register_error_handler(app) 44 | register_cmds(app) 45 | 46 | @app.before_request 47 | def log_request_info(): 48 | logger = logging.getLogger('werkzeug') 49 | if request.method in ['POST', 'PUT']: 50 | logger.info('Body: %s', request.get_data()) 51 | 52 | return app 53 | 54 | 55 | app = create_app() 56 | 57 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/schemas/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/services/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/services/tools.py.jinja2: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | 4 | from hobbit_core.db import EnumExt 5 | 6 | 7 | class OptionService: 8 | 9 | @classmethod 10 | def get_options(cls): 11 | return { 12 | name: obj.to_opts(verbose=True) 13 | for name, obj in importlib.import_module( 14 | 'app.models.consts').__dict__.items() 15 | if inspect.isclass(obj) and issubclass(obj, EnumExt) and 16 | obj != EnumExt 17 | } 18 | 19 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/utils/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/rivendell/app/utils/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/views/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals(), modules_only=True) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/app/views/tools.py.jinja2: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | 3 | from hobbit_core.pagination import PageParams, pagination # NOQA 4 | 5 | from app.services import OptionService 6 | 7 | bp = Blueprint('tools', __name__) 8 | 9 | 10 | @bp.route('/ping', methods=['GET']) 11 | def ping(): 12 | """ For health check. 13 | """ 14 | return jsonify({'ping': 'ok'}) 15 | 16 | 17 | @bp.route('/options', methods=['GET']) 18 | def option(): 19 | """ List all enums for frontend. 20 | """ 21 | return jsonify(OptionService.get_options()) 22 | 23 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/configs/gunicorn-logging.ini.jinja2: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root, gunicorn.error, gunicorn.access 3 | 4 | [handlers] 5 | keys=console, file 6 | 7 | 8 | [formatters] 9 | keys=timedRotatingFormatter 10 | 11 | [formatter_timedRotatingFormatter] 12 | format=%(asctime)s pid:%(process)d - %(levelname)s: %(message)s 13 | datefmt=%Y-%m-%d %H:%M:%S 14 | 15 | 16 | [logger_root] 17 | level=NOTSET 18 | handlers=console, file 19 | qualname=root 20 | 21 | [logger_gunicorn.error] 22 | level=INFO 23 | handlers=console, file 24 | propagate=1 25 | qualname=gunicorn.error 26 | 27 | [logger_gunicorn.access] 28 | level=INFO 29 | handlers=console, file 30 | propagate=0 31 | qualname=gunicorn.access 32 | 33 | 34 | [handler_console] 35 | level=INFO 36 | class=StreamHandler 37 | formatter=timedRotatingFormatter 38 | args=(sys.stdout, ) 39 | 40 | [handler_file] 41 | level=DEBUG 42 | class=handlers.TimedRotatingFileHandler 43 | args=('logs/gunicorn.log', 'D', 1, 30) 44 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/deploy.sh.jinja2: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | : ' 3 | Usage: 4 | 5 | USER=root PASSWORD=root ./deploy.sh 192.168.x.1,192.168.x.2 6 | ' 7 | # auto gen by hoobit-core 8 | PROJECT_NAME='{{ project_name }}' 9 | 10 | 11 | INSTALL_PATH="/data/${PROJECT_NAME}" 12 | CURRENT_COMMITID=`git rev-parse --verify HEAD` 13 | ORIGIN=`git remote -v | grep origin | awk '{print $2}' | head -n 1` 14 | 15 | CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` 16 | if [[ ${CURRENT_BRANCH} == "HEAD" ]]; then # checkout by commit-id 17 | CURRENT_BRANCH=`git branch --contains ${CURRENT_COMMITID} | tail -n 1` 18 | fi 19 | if [[ ${CURRENT_BRANCH} =~ ^\*.* ]]; then 20 | # default release branch is master, for fix github Checking out {commit_id} as master 21 | CURRENT_BRANCH="master" 22 | fi 23 | 24 | 25 | function echoo { 26 | echo -e "\033[32m$@\033[0m" 27 | } 28 | 29 | 30 | function mssh() { 31 | sshpass -p ${PASSWORD} ssh -o StrictHostKeyChecking=no ${USER}@${host} $1 32 | } 33 | 34 | 35 | function deploy() { 36 | for host in $(echo $1 | tr "," "\n") 37 | do 38 | echoo "deploy to ${host} ..." 39 | 40 | isGitRepo=`mssh "[ -d ${INSTALL_PATH} ] && \ 41 | cd ${INSTALL_PATH} && \ 42 | git rev-parse --is-inside-work-tree" || echo $?` 43 | 44 | if [[ "${isGitRepo}" != "true" ]]; then 45 | echoo "not a git repo, first deploy ..." 46 | mssh "sudo rm -rf ${INSTALL_PATH} && \ 47 | cd /data && \ 48 | git clone ${ORIGIN} ${INSTALL_PATH}" 49 | fi 50 | 51 | REMOTE_BRANCH=`mssh "cd ${INSTALL_PATH} && git branch | grep \* | cut -d ' ' -f2"` 52 | if [[ ${REMOTE_BRANCH} != ${CURRENT_BRANCH} ]]; then 53 | mssh "cd ${INSTALL_PATH} && \ 54 | git fetch -f origin ${CURRENT_BRANCH}:${CURRENT_BRANCH}" 55 | else 56 | mssh "cd ${INSTALL_PATH} && \ 57 | git pull origin ${CURRENT_BRANCH}" 58 | fi 59 | 60 | mssh "cd ${INSTALL_PATH} && \ 61 | git checkout ${CURRENT_BRANCH} && git checkout ${CURRENT_COMMITID}" 62 | 63 | REMOTE_COMMITID=`mssh "cd ${INSTALL_PATH} && git rev-parse --verify HEAD"` 64 | echoo "remote ${REMOTE_COMMITID}" 65 | 66 | if [[ ${REMOTE_COMMITID} != ${CURRENT_COMMITID} ]]; then 67 | echoo "deploy failed, please check. Maybe need push curent branch to origin." 68 | exit 1 69 | fi 70 | 71 | mssh "cd ${INSTALL_PATH} && docker-compose down && docker-compose up -d" 72 | done 73 | } 74 | 75 | 76 | echoo "deploy...\n\tbranch: ${CURRENT_BRANCH}\n\tcommitid: ${CURRENT_COMMITID}\n\torigin: ${ORIGIN}" 77 | 78 | deploy $1 79 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/docker-compose.yml.jinja2: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: ttwshell/hobbit-app:latest 6 | restart: always 7 | ports: 8 | - {{ port }}:5000 9 | environment: 10 | - LANG=C.UTF-8 11 | - FLASK_APP=app/run.py 12 | - HOBBIT_ENV=production 13 | volumes: 14 | - ./logs:/logs 15 | - ./:/app 16 | networks: 17 | - web_nw 18 | command: 19 | bash -c "flask db upgrade && 20 | gunicorn --log-config configs/gunicorn-logging.ini -k gevent -w 4 -b 0.0.0.0:5000 app.run:app" 21 | 22 | networks: 23 | web_nw: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/docs/index.apib.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/rivendell/docs/index.apib.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/pytest.ini.jinja2: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env = 3 | FLASK_APP=app/run.py 4 | HOBBIT_ENV=testing 5 | addopts = --cov . --cov-report term-missing --no-cov-on-fail -s -x -vv -p no:warnings -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/requirements.txt.jinja2: -------------------------------------------------------------------------------- 1 | hobbit-core[hobbit,hobbit_core]=={{ version }} -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/tests/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import model 2 | 3 | from app.run import app, db 4 | 5 | 6 | class BaseTest: 7 | mimetype = 'application/json' 8 | headers = { 9 | 'Content-Type': mimetype, 10 | 'Accept': mimetype 11 | } 12 | 13 | @classmethod 14 | def setup_class(cls): 15 | with app.app_context(): 16 | db.create_all() 17 | 18 | @classmethod 19 | def teardown_class(cls): 20 | with app.app_context(): 21 | db.drop_all() 22 | 23 | def setup_method(self, method): 24 | pass 25 | 26 | def teardown_method(self, method): 27 | exclude_tables = [] 28 | models = { 29 | m.__tablename__: m 30 | for m in db.Model.registry._class_registry.values() 31 | if isinstance(m, model.DefaultMeta) 32 | } 33 | tables = db.metadata.sorted_tables 34 | tables.reverse() 35 | with app.app_context(): 36 | for table in tables: 37 | if table.name in exclude_tables: 38 | continue 39 | db.session.query(models[table.name]).delete() 40 | db.session.commit() 41 | 42 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/tests/conftest.py.jinja2: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.run import app as tapp 4 | 5 | 6 | @pytest.fixture(scope='session') 7 | def app(request): 8 | ctx = tapp.app_context() 9 | ctx.push() 10 | 11 | def teardown(): 12 | ctx.pop() 13 | 14 | request.addfinalizer(teardown) 15 | return tapp 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def client(app, request): 20 | return app.test_client() 21 | 22 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/rivendell/tests/test_tools.py.jinja2: -------------------------------------------------------------------------------- 1 | from . import BaseTest 2 | 3 | 4 | class TestAPIExample(BaseTest): 5 | 6 | def test_ping_api(self, client): 7 | resp = client.get('/api/ping') 8 | assert resp.status_code == 200 9 | 10 | 11 | class TestOption(BaseTest): 12 | 13 | def test_options(self, client): 14 | resp = client.get('/api/options') 15 | assert resp.status_code == 200 16 | 17 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/.gitignore.jinja2: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.swp 4 | */__pycache__/ 5 | .pytest_cache/ 6 | ui/ 7 | .test/ 8 | logs/ 9 | 10 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/.gitlab-ci.yml.jinja2: -------------------------------------------------------------------------------- 1 | image: ttwshell/hobbit-app:latest 2 | 3 | test_all: 4 | variables: 5 | FLASK_APP: 'app/run.py' 6 | HOBBIT_ENV: 'testing' 7 | LANG: C.UTF-8 8 | script: 9 | - flake8 . --max-line-length=120 --exclude migrations 10 | - py.test --cov . --cov-report term-missing -s -x 11 | 12 | staging-dev: 13 | type: deploy 14 | script: 15 | - ./deploy.sh 127.0.0.1 16 | only: 17 | - master 18 | 19 | staging-test: 20 | type: deploy 21 | script: 22 | - ./deploy.sh 127.0.0.1 23 | only: 24 | - tags 25 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/Dockerfile.jinja2: -------------------------------------------------------------------------------- 1 | FROM python:3.6.5 2 | 3 | ENV FLASK_APP=app/run.py 4 | 5 | ENV TZ=Asia/Shanghai 6 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 7 | 8 | RUN apt-get update --fix-missing && apt-get install -y \ 9 | zip sshpass 10 | 11 | RUN pip install --upgrade pip -i https://pypi.doubanio.com/simple 12 | RUN pip install flake8 pytest pytest-cov gunicorn gevent 13 | 14 | RUN mkdir /app 15 | WORKDIR /app 16 | 17 | # COPY ./Pipfile /app 18 | # COPY ./Pipfile.lock /app 19 | COPY ./requirements.txt /app 20 | 21 | # RUN pip install -i https://pypi.doubanio.com/simple --no-cache-dir pipenv \ 22 | # && pipenv install --system --deploy --dev 23 | RUN pip install -r requirements.txt 24 | 25 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/shire/app/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/configs/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/shire/app/configs/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/configs/default.py.jinja2: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ROOT_PATH = os.path.split(os.path.abspath(__name__))[0] 4 | 5 | DEBUG = True 6 | SECRET_KEY = '{{ secret_key }}' 7 | SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format( 8 | os.path.join(ROOT_PATH, '{{ project_name }}.db')) 9 | SQLALCHEMY_TRACK_MODIFICATIONS = False 10 | 11 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/configs/development.py.jinja2: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/configs/production.py.jinja2: -------------------------------------------------------------------------------- 1 | from .default import * # NOQA F401 2 | 3 | DEBUG = False 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/configs/testing.py.jinja2: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .default import ROOT_PATH 4 | from .default import * # NOQA F401 5 | 6 | 7 | TEST_BASE_DIR = os.path.join(ROOT_PATH, '.test') 8 | SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format( 9 | os.path.join(TEST_BASE_DIR, '{{ project_name }}.db')) 10 | # SQLALCHEMY_ECHO = True 11 | TESTING = True 12 | 13 | if not os.path.exists(TEST_BASE_DIR): 14 | os.makedirs(TEST_BASE_DIR) 15 | 16 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/core/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/shire/app/core/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/exts.py.jinja2: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_migrate import Migrate 3 | from flask_marshmallow import Marshmallow 4 | 5 | from hobbit_core import HobbitManager 6 | 7 | db = SQLAlchemy() 8 | migrate = Migrate() 9 | ma = Marshmallow() 10 | hobbit = HobbitManager() 11 | 12 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/models/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/models/consts.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.db import EnumExt # noqa 2 | 3 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/run.py.jinja2: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | 4 | from flask import Flask, request 5 | 6 | from hobbit_core.err_handler import ErrHandler 7 | from hobbit_core.utils import get_env 8 | 9 | from app.exts import db, migrate, ma, hobbit 10 | 11 | 12 | def register_extensions(app): 13 | db.init_app(app) 14 | migrate.init_app(app, db) 15 | ma.init_app(app) 16 | hobbit.init_app(app, db) 17 | 18 | 19 | def register_blueprints(app): 20 | from app import views 21 | for name in views.__all__: 22 | bp = getattr(importlib.import_module(f'app.views.{name}'), 'bp', None) 23 | if bp is not None: 24 | app.register_blueprint( 25 | bp, url_prefix=f"/api{bp.url_prefix if bp.url_prefix else ''}") 26 | 27 | 28 | def register_error_handler(app): 29 | app.register_error_handler(Exception, ErrHandler.handler) 30 | 31 | 32 | def register_cmds(app): 33 | pass 34 | 35 | 36 | def create_app(): 37 | app = Flask(__name__, instance_relative_config=True) 38 | app.config.from_object('app.configs.{}'.format(get_env())) 39 | 40 | with app.app_context(): 41 | register_extensions(app) 42 | register_blueprints(app) 43 | register_error_handler(app) 44 | register_cmds(app) 45 | 46 | @app.before_request 47 | def log_request_info(): 48 | logger = logging.getLogger('werkzeug') 49 | if request.method in ['POST', 'PUT']: 50 | logger.info('Body: %s', request.get_data()) 51 | 52 | return app 53 | 54 | 55 | app = create_app() 56 | 57 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/schemas/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/utils/__init__.py.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/shire/app/utils/__init__.py.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/views/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals(), modules_only=True) 4 | 5 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/app/views/tools.py.jinja2: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | from hobbit_core.db import EnumExt 4 | 5 | from flask import Blueprint, jsonify 6 | 7 | from hobbit_core.pagination import PageParams, pagination # NOQA 8 | 9 | bp = Blueprint('tools', __name__) 10 | 11 | 12 | @bp.route('/ping', methods=['GET']) 13 | def ping(): 14 | """ For health check. 15 | """ 16 | return jsonify({'ping': 'ok'}) 17 | 18 | 19 | @bp.route('/options', methods=['GET']) 20 | def option(): 21 | """ List all enums for frontend. 22 | """ 23 | return jsonify({ 24 | name: obj.to_opts(verbose=True) 25 | for name, obj in importlib.import_module( 26 | 'app.models.consts').__dict__.items() 27 | if inspect.isclass(obj) and issubclass(obj, EnumExt) and obj != EnumExt 28 | }) 29 | 30 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/configs/gunicorn-logging.ini.jinja2: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root, gunicorn.error, gunicorn.access 3 | 4 | [handlers] 5 | keys=console, file 6 | 7 | 8 | [formatters] 9 | keys=timedRotatingFormatter 10 | 11 | [formatter_timedRotatingFormatter] 12 | format=%(asctime)s pid:%(process)d - %(levelname)s: %(message)s 13 | datefmt=%Y-%m-%d %H:%M:%S 14 | 15 | 16 | [logger_root] 17 | level=NOTSET 18 | handlers=console, file 19 | qualname=root 20 | 21 | [logger_gunicorn.error] 22 | level=INFO 23 | handlers=console, file 24 | propagate=1 25 | qualname=gunicorn.error 26 | 27 | [logger_gunicorn.access] 28 | level=INFO 29 | handlers=console, file 30 | propagate=0 31 | qualname=gunicorn.access 32 | 33 | 34 | [handler_console] 35 | level=INFO 36 | class=StreamHandler 37 | formatter=timedRotatingFormatter 38 | args=(sys.stdout, ) 39 | 40 | [handler_file] 41 | level=DEBUG 42 | class=handlers.TimedRotatingFileHandler 43 | args=('logs/gunicorn.log', 'D', 1, 30) 44 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/deploy.sh.jinja2: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | : ' 3 | Usage: 4 | 5 | USER=root PASSWORD=root ./deploy.sh 192.168.x.1,192.168.x.2 6 | ' 7 | # auto gen by hoobit-core 8 | PROJECT_NAME='{{ project_name }}' 9 | 10 | 11 | INSTALL_PATH="/data/${PROJECT_NAME}" 12 | CURRENT_COMMITID=`git rev-parse --verify HEAD` 13 | ORIGIN=`git remote -v | grep origin | awk '{print $2}' | head -n 1` 14 | 15 | CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` 16 | if [[ ${CURRENT_BRANCH} == "HEAD" ]]; then # checkout by commit-id 17 | CURRENT_BRANCH=`git branch --contains ${CURRENT_COMMITID} | tail -n 1` 18 | fi 19 | if [[ ${CURRENT_BRANCH} =~ ^\*.* ]]; then 20 | # default release branch is master, for fix github Checking out {commit_id} as master 21 | CURRENT_BRANCH="master" 22 | fi 23 | 24 | 25 | function echoo { 26 | echo -e "\033[32m$@\033[0m" 27 | } 28 | 29 | 30 | function mssh() { 31 | sshpass -p ${PASSWORD} ssh -o StrictHostKeyChecking=no ${USER}@${host} $1 32 | } 33 | 34 | 35 | function deploy() { 36 | for host in $(echo $1 | tr "," "\n") 37 | do 38 | echoo "deploy to ${host} ..." 39 | 40 | isGitRepo=`mssh "[ -d ${INSTALL_PATH} ] && \ 41 | cd ${INSTALL_PATH} && \ 42 | git rev-parse --is-inside-work-tree" || echo $?` 43 | 44 | if [[ "${isGitRepo}" != "true" ]]; then 45 | echoo "not a git repo, first deploy ..." 46 | mssh "sudo rm -rf ${INSTALL_PATH} && \ 47 | cd /data && \ 48 | git clone ${ORIGIN} ${INSTALL_PATH}" 49 | fi 50 | 51 | REMOTE_BRANCH=`mssh "cd ${INSTALL_PATH} && git branch | grep \* | cut -d ' ' -f2"` 52 | if [[ ${REMOTE_BRANCH} != ${CURRENT_BRANCH} ]]; then 53 | mssh "cd ${INSTALL_PATH} && \ 54 | git fetch -f origin ${CURRENT_BRANCH}:${CURRENT_BRANCH}" 55 | else 56 | mssh "cd ${INSTALL_PATH} && \ 57 | git pull origin ${CURRENT_BRANCH}" 58 | fi 59 | 60 | mssh "cd ${INSTALL_PATH} && \ 61 | git checkout ${CURRENT_BRANCH} && git checkout ${CURRENT_COMMITID}" 62 | 63 | REMOTE_COMMITID=`mssh "cd ${INSTALL_PATH} && git rev-parse --verify HEAD"` 64 | echoo "remote ${REMOTE_COMMITID}" 65 | 66 | if [[ ${REMOTE_COMMITID} != ${CURRENT_COMMITID} ]]; then 67 | echoo "deploy failed, please check. Maybe need push curent branch to origin." 68 | exit 1 69 | fi 70 | 71 | mssh "cd ${INSTALL_PATH} && docker-compose down && docker-compose up -d" 72 | done 73 | } 74 | 75 | 76 | echoo "deploy...\n\tbranch: ${CURRENT_BRANCH}\n\tcommitid: ${CURRENT_COMMITID}\n\torigin: ${ORIGIN}" 77 | 78 | deploy $1 79 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/docker-compose.yml.jinja2: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: ttwshell/hobbit-app:latest 6 | restart: always 7 | ports: 8 | - {{ port }}:5000 9 | environment: 10 | - LANG=C.UTF-8 11 | - FLASK_APP=app/run.py 12 | - HOBBIT_ENV=production 13 | volumes: 14 | - ./logs:/logs 15 | - ./:/app 16 | networks: 17 | - web_nw 18 | command: 19 | bash -c "flask db upgrade && 20 | gunicorn --log-config configs/gunicorn-logging.ini -k gevent -w 4 -b 0.0.0.0:5000 app.run:app" 21 | 22 | networks: 23 | web_nw: 24 | driver: bridge 25 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/docs/index.apib.jinja2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit/static/bootstrap/shire/docs/index.apib.jinja2 -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/pytest.ini.jinja2: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env = 3 | FLASK_APP=app/run.py 4 | HOBBIT_ENV=testing 5 | addopts = --cov . --cov-report term-missing --no-cov-on-fail -s -x -vv -p no:warnings -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/requirements.txt.jinja2: -------------------------------------------------------------------------------- 1 | hobbit-core[hobbit,hobbit_core]=={{ version }} -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/tests/__init__.py.jinja2: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import model 2 | 3 | from app.run import app, db 4 | 5 | 6 | class BaseTest: 7 | mimetype = 'application/json' 8 | headers = { 9 | 'Content-Type': mimetype, 10 | 'Accept': mimetype 11 | } 12 | 13 | @classmethod 14 | def setup_class(cls): 15 | with app.app_context(): 16 | db.create_all() 17 | 18 | @classmethod 19 | def teardown_class(cls): 20 | with app.app_context(): 21 | db.drop_all() 22 | 23 | def setup_method(self, method): 24 | pass 25 | 26 | def teardown_method(self, method): 27 | exclude_tables = [] 28 | models = { 29 | m.__tablename__: m 30 | for m in db.Model.registry._class_registry.values() 31 | if isinstance(m, model.DefaultMeta) 32 | } 33 | tables = db.metadata.sorted_tables 34 | tables.reverse() 35 | with app.app_context(): 36 | for table in tables: 37 | if table.name in exclude_tables: 38 | continue 39 | db.session.query(models[table.name]).delete() 40 | db.session.commit() 41 | 42 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/tests/conftest.py.jinja2: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.run import app as tapp 4 | 5 | 6 | @pytest.fixture(scope='session') 7 | def app(request): 8 | ctx = tapp.app_context() 9 | ctx.push() 10 | 11 | def teardown(): 12 | ctx.pop() 13 | 14 | request.addfinalizer(teardown) 15 | return tapp 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def client(app, request): 20 | return app.test_client() 21 | 22 | -------------------------------------------------------------------------------- /hobbit/static/bootstrap/shire/tests/test_tools.py.jinja2: -------------------------------------------------------------------------------- 1 | from . import BaseTest 2 | 3 | 4 | class TestAPIExample(BaseTest): 5 | 6 | def test_ping_api(self, client): 7 | resp = client.get('/api/ping') 8 | assert resp.status_code == 200 9 | 10 | 11 | class TestOption(BaseTest): 12 | 13 | def test_options(self, client): 14 | resp = client.get('/api/options') 15 | assert resp.status_code == 200 16 | 17 | -------------------------------------------------------------------------------- /hobbit/static/hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | CURRENT_BRANCH=`git branch | grep \* | cut -d ' ' -f2` 21 | JIRA_ID_REGEX='[A-Za-z0-9]+-[0-9]+' 22 | JIRA_ID=`echo $CURRENT_BRANCH | grep -oE '${JIRA_ID_REGEX}$'` 23 | if [[ -z $JIRA_ID ]]; then 24 | echo "Warning: 'jira id' not found in current branch name, example: feature/test-SKYF-458" 25 | fi 26 | 27 | COMMENT=`cat $1 | head -n 1` 28 | if [[ -z $COMMENT ]]; then 29 | echo "Error: comment can't be empty" 30 | exit 1 31 | fi 32 | 33 | hasHead=`echo $COMMENT | grep -oE '^\(${JIRA_ID_REGEX}\)'` 34 | if [[ -z $hasHead && ! -z $JIRA_ID ]]; then 35 | COMMENT_TAIL=`tail -n +2 $1` 36 | COMMENT="($JIRA_ID) $COMMENT\n${COMMENT_TAIL}" 37 | printf "$COMMENT" > "$1" 38 | fi 39 | 40 | isOK=`echo $COMMENT | grep -oE '^\(${JIRA_ID_REGEX}\) .+'` 41 | if [[ -z $isOK ]]; then 42 | echo "Error: comment should be in format: (jira-id) comment msg. Example: (SKYF-458) finished xx" 43 | exit 1 44 | fi 45 | -------------------------------------------------------------------------------- /hobbit/static/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | flake8 . --max-line-length=120 2 | -------------------------------------------------------------------------------- /hobbit_core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | hobbit_core 3 | ~~~~~~~~~~~~ 4 | 5 | Common utils for flask app. 6 | """ 7 | 8 | from flask import Flask 9 | from flask_sqlalchemy import SQLAlchemy 10 | 11 | 12 | class HobbitManager: 13 | """Customizable utils management. 14 | """ 15 | 16 | def __init__(self, app=None, db=None, **kwargs): 17 | """ 18 | app: The Flask application instance. 19 | """ 20 | self.app = app 21 | if app is not None: 22 | self.init_app(app, db, **kwargs) 23 | 24 | def init_app(self, app, db, **kwargs): 25 | """ 26 | app: The Flask application instance. 27 | """ 28 | if not isinstance(app, Flask): 29 | raise TypeError( 30 | 'hobbit_core.HobbitManager.init_app(): ' 31 | 'Parameter "app" is an instance of class "{}" ' 32 | 'instead of a subclass of class "flask.Flask".'.format( 33 | app.__class__.__name__)) 34 | 35 | if not isinstance(db, SQLAlchemy): 36 | raise TypeError('hobbit-core be dependent on SQLAlchemy.') 37 | self.db = db 38 | 39 | app.config.setdefault('HOBBIT_UPPER_SEQUENCE_NAME', False) 40 | 41 | # Bind hobbit-core to app 42 | app.hobbit_manager = self 43 | -------------------------------------------------------------------------------- /hobbit_core/db.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, EnumMeta 2 | from functools import wraps 3 | import warnings 4 | 5 | from mypy_extensions import TypedDict 6 | from typing import Any, Union, List, Dict 7 | 8 | from flask import current_app 9 | from sqlalchemy import BigInteger, Column, ForeignKey, func, DateTime, Sequence 10 | from sqlalchemy.orm.session import Session 11 | from flask_sqlalchemy.model import DefaultMeta 12 | 13 | db = current_app.hobbit_manager.db 14 | 15 | 16 | class _BaseModel: 17 | 18 | def __repr__(self) -> str: 19 | """You can set label property. 20 | 21 | Returns: 22 | str: ``<{classname}({pk}:{label!r})>`` 23 | """ 24 | class_name = self.__class__.__name__ 25 | pk = self.id # type: ignore 26 | label = getattr(self, "label", "") 27 | return f'<{class_name}({pk}:{label!r})>' 28 | 29 | 30 | class SurrogatePK(_BaseModel): 31 | """A mixin that add ``id``、``created_at`` and ``updated_at`` columns 32 | to any declarative-mapped class. 33 | 34 | **id**: A surrogate biginteger 'primary key' column. 35 | 36 | **created_at**: Auto save ``datetime.now()`` when row created. 37 | 38 | **updated_at**: Auto save ``datetime.now()`` when row updated. 39 | 40 | **It is not recommended. See hobbit_core.db.BaseModel.** 41 | """ 42 | 43 | __table_args__ = {'extend_existing': True} # type: ignore 44 | 45 | id = Column(BigInteger, primary_key=True) 46 | created_at = Column( 47 | DateTime, index=True, nullable=False, server_default=func.now()) 48 | updated_at = Column( 49 | DateTime, index=True, nullable=False, server_default=func.now(), 50 | onupdate=func.now()) 51 | 52 | def __init_subclass__(cls, **kwargs): 53 | msg = 'SurrogatePK is Deprecated. See hobbit_core.db.BaseModel.' 54 | warnings.warn(msg) 55 | super().__init_subclass__(**kwargs) 56 | 57 | 58 | class BaseModelMeta(DefaultMeta): 59 | 60 | def __new__(cls, name, bases, attrs): 61 | if name in ('BaseModelMeta', 'BaseModel'): 62 | return super().__new__(cls, name, bases, attrs) 63 | 64 | primary_key_name = attrs.get('primary_key_name') or 'id' 65 | 66 | attrs[primary_key_name] = Column(BigInteger, primary_key=True) 67 | attrs['created_at'] = Column( 68 | DateTime, index=True, nullable=False, server_default=func.now()) 69 | attrs['updated_at'] = Column( 70 | DateTime, index=True, nullable=False, server_default=func.now(), 71 | onupdate=func.now()) 72 | 73 | if db.get_engine(bind_key=attrs.get('__bind_key__')).name == 'oracle': 74 | sequence_name = attrs.get('sequence_name') or \ 75 | f'{name}_{primary_key_name}_seq' 76 | if current_app.config['HOBBIT_UPPER_SEQUENCE_NAME']: 77 | sequence_name = sequence_name.upper() 78 | attrs[primary_key_name] = Column( 79 | BigInteger, 80 | Sequence(sequence_name), 81 | primary_key=True) 82 | 83 | exclude_columns = attrs.get('exclude_columns', []) 84 | for column in exclude_columns: 85 | attrs.pop(column) 86 | 87 | return super().__new__(cls, name, bases, attrs) 88 | 89 | 90 | class BaseModel(_BaseModel, db.Model, metaclass=BaseModelMeta): # type: ignore # noqa 91 | """Abstract base model class contains 92 | ``id``、``created_at`` and ``updated_at`` columns. 93 | 94 | **id**: A surrogate biginteger 'primary key' column. 95 | 96 | **created_at**: Auto save ``datetime.now()`` when row created. 97 | 98 | **updated_at**: Auto save ``datetime.now()`` when row updated. 99 | 100 | Support **oracle id sequence**, default name is ``{class_name}_id_seq``, 101 | can changed by ``sequence_name`` and ``HOBBIT_UPPER_SEQUENCE_NAME`` config. 102 | Default value of app.config['HOBBIT_UPPER_SEQUENCE_NAME'] is False. 103 | 104 | Examples:: 105 | 106 | from hobbit_core.db import Column, BaseModel 107 | 108 | class User(BaseModel): 109 | username = Column(db.String(32), nullable=False, index=True) 110 | 111 | print([i.name for i in User.__table__.columns]) 112 | # ['username', 'id', 'created_at', 'updated_at'] 113 | 114 | Can be blocked columns with **exclude_columns**:: 115 | 116 | class User(BaseModel): 117 | exclude_columns = ['created_at', 'updated_at'] 118 | username = Column(db.String(32), nullable=False, index=True) 119 | 120 | print([i.name for i in User.__table__.columns]) 121 | # ['username', 'id'] 122 | 123 | Can be changed primary_key's name using **primary_key_name**:: 124 | 125 | class User(BaseModel): 126 | primary_key_name = 'user_id' 127 | username = Column(db.String(32), nullable=False, index=True) 128 | 129 | print([i.name for i in User.__table__.columns]) 130 | # ['username', 'user_id', 'created_at', 'updated_at'] 131 | 132 | Can be changed sequence's name using **sequence_name** 133 | (worked with oracle):: 134 | 135 | class User(BaseModel): 136 | sequence_name = 'changed' 137 | username = Column(db.String(32), nullable=False, index=True) 138 | 139 | # print(User.__table__.columns['id']) 140 | Column('id', ..., default=Sequence('changed_id_seq')) 141 | """ 142 | 143 | __abstract__ = True 144 | 145 | 146 | def reference_col(tablename: str, nullable: bool = False, pk_name: str = 'id', 147 | onupdate: str = None, ondelete: str = None, **kwargs: Any) \ 148 | -> Column: 149 | """Column that adds primary key foreign key reference. 150 | 151 | Args: 152 | tablename (str): Model.__table_name__. 153 | nullable (bool): Default is False. 154 | pk_name (str): Primary column's name. 155 | onupdate (str): If Set, emit ON UPDATE when 156 | issuing DDL for this constraint. Typical values include CASCADE, 157 | DELETE and RESTRICT. 158 | ondelete (str): If set, emit ON DELETE when 159 | issuing DDL for this constraint. Typical values include CASCADE, 160 | DELETE and RESTRICT. 161 | 162 | Others: 163 | 164 | See ``sqlalchemy.Column``. 165 | 166 | Examples:: 167 | 168 | from sqlalchemy.orm import relationship 169 | 170 | role_id = reference_col('role') 171 | role = relationship('Role', backref='users', cascade='all, delete') 172 | """ 173 | 174 | return Column( 175 | ForeignKey('{0}.{1}'.format(tablename, pk_name), 176 | onupdate=onupdate, ondelete=ondelete), 177 | nullable=nullable, **kwargs) 178 | 179 | 180 | class EnumExtMeta(EnumMeta): 181 | 182 | def __new__(cls, name, bases, attrs): 183 | obj = super(EnumExtMeta, cls).__new__(cls, name, bases, attrs) 184 | 185 | keys, values = set(), set() 186 | for _, member in obj.__members__.items(): 187 | member = member.value 188 | if not isinstance(member, tuple) or len(member) != 2: 189 | raise TypeError( 190 | u'EnumExt member must be tuple type and length equal 2.') 191 | key, value = member 192 | if key in keys or value in values: 193 | raise ValueError(u'duplicate values found: `{}`, please check ' 194 | u'key or value.'.format(member)) 195 | keys.add(key) 196 | values.add(key) 197 | 198 | return obj 199 | 200 | 201 | class OptType(TypedDict, total=False): 202 | key: int 203 | value: str 204 | label: str 205 | 206 | 207 | class EnumExt(Enum, metaclass=EnumExtMeta): 208 | """ Extension for serialize/deserialize sqlalchemy enum field. 209 | 210 | Be sure ``type(key)`` is ``int`` and ``type(value)`` is ``str`` 211 | (``label = (key, value)``). 212 | 213 | Examples:: 214 | 215 | class TaskState(EnumExt): 216 | # label = (key, value) 217 | CREATED = (0, '新建') 218 | PENDING = (1, '等待') 219 | STARTING = (2, '开始') 220 | RUNNING = (3, '运行中') 221 | FINISHED = (4, '已完成') 222 | FAILED = (5, '失败') 223 | """ 224 | 225 | @classmethod 226 | def strict_dump(cls, label: str, verbose: bool = False) -> Union[int, str]: 227 | """Get key or value by label. 228 | 229 | Examples:: 230 | 231 | TaskState.strict_dump('CREATED') # 0 232 | TaskState.strict_dump('CREATED', verbose=True) # '新建' 233 | 234 | Returns: 235 | int|str: Key or value, If label not exist, raise ``KeyError``. 236 | """ 237 | 238 | return cls[label].value[1 if verbose else 0] 239 | 240 | @classmethod 241 | def dump(cls, label: str, verbose: bool = False) -> Dict[str, Any]: 242 | """Dump one label to option. 243 | 244 | Examples:: 245 | 246 | TaskState.dump('CREATED') # {'key': 0, 'value': '新建'} 247 | 248 | Returns: 249 | 250 | dict: Dict of label's key and value. If label not exist, 251 | raise ``KeyError``. 252 | """ 253 | 254 | ret = {'key': cls[label].value[0], 'value': cls[label].value[1]} 255 | if verbose: 256 | ret.update({'label': label}) 257 | return ret 258 | 259 | @classmethod 260 | def load(cls, val: Union[int, str]) -> str: # type: ignore 261 | """Get label by key or value. Return val when val is label. 262 | 263 | Examples:: 264 | 265 | TaskState.load('FINISHED') # 'FINISHED' 266 | TaskState.load(4) # 'FINISHED' 267 | TaskState.load('新建') # 'CREATED' 268 | 269 | Returns: 270 | str|None: Label. 271 | """ 272 | 273 | if val in cls.__members__: 274 | return val # type: ignore 275 | 276 | pos = 1 if isinstance(val, str) else 0 277 | for elem in cls: 278 | if elem.value[pos] == val: 279 | return elem.name 280 | 281 | @classmethod 282 | def to_opts(cls, verbose: bool = False) -> List[Dict[str, Any]]: 283 | """Enum to options. 284 | 285 | Examples:: 286 | 287 | opts = TaskState.to_opts(verbose=True) 288 | print(opts) 289 | 290 | [{'key': 0, 'label': 'CREATED', 'value': u'新建'}, ...] 291 | 292 | Returns: 293 | list: List of dict which key is `key`, `value`, label. 294 | """ 295 | 296 | opts = [] 297 | for elem in cls: 298 | opt = {'key': elem.value[0], 'value': elem.value[1]} 299 | if verbose: 300 | opt.update({'label': elem.name}) 301 | opts.append(opt) 302 | return opts 303 | 304 | 305 | def transaction(session: Session, nested: bool = False): 306 | """SQLAlchemy 1.4 deprecates “autocommit mode. 307 | See more: https://docs.sqlalchemy.org/en/14/orm/session_transaction.html 308 | 309 | 2024-09-25 ignore signal bug, See more: 310 | https://github.com/pallets-eco/flask-sqlalchemy/issues/645 311 | 312 | Tips: 313 | * **Can't** do ``session.commit()`` **in func**, 314 | **otherwise unknown beloved**. 315 | 316 | * **Must use the same session in decorator and decorated function**. 317 | 318 | * **We can use nested** if keep top decorated 319 | by ``@transaction(session, nested=False)`` and all subs decorated 320 | by ``@transaction(session, nested=True)``. 321 | 322 | Examples:: 323 | 324 | from hobbit_core.db import transaction 325 | 326 | from app.exts import db 327 | 328 | 329 | @bp.route('/users/', methods=['POST']) 330 | @transaction(db.session) 331 | def create(username, password): 332 | user = User(username=username, password=password) 333 | db.session.add(user) 334 | # db.session.commit() error 335 | 336 | We can nested use this decorator. Must set ``nested=True`` otherwise 337 | raise ``ResourceClosedError`` (session.autocommit=False) or 338 | raise ``InvalidRequestError`` (session.autocommit=True):: 339 | 340 | @transaction(db.session, nested=True) 341 | def set_role(user, role): 342 | user.role = role 343 | # db.session.commit() error 344 | 345 | 346 | @bp.route('/users/', methods=['POST']) 347 | @transaction(db.session) 348 | def create(username, password): 349 | user = User(username=username, password=password) 350 | db.session.add(user) 351 | db.session.flush() 352 | set_role(user, 'admin') 353 | """ 354 | 355 | def wrapper(func): 356 | @wraps(func) 357 | def inner(*args, **kwargs): 358 | try: 359 | if nested: 360 | with session.begin_nested(): 361 | return func(*args, **kwargs) 362 | else: 363 | with session.begin(): 364 | return func(*args, **kwargs) 365 | except Exception as e: 366 | session.rollback() 367 | raise e 368 | return inner 369 | return wrapper 370 | -------------------------------------------------------------------------------- /hobbit_core/err_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy.orm import exc as orm_exc 4 | 5 | from .response import Result, FailedResult, ServerErrorResult, \ 6 | gen_response, RESP_MSGS 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class HobbitException(Exception): 12 | """Base class for all hobbitcore-related errors.""" 13 | 14 | 15 | class ErrHandler(object): 16 | """Base error handler that catch all exceptions. Be sure response is:: 17 | 18 | { 19 | "code": "404", # error code, default is http status code, \ 20 | you can change it 21 | "message": "Not found", # for alert in web page 22 | "detail": "id number field length must be 18", # for debug 23 | } 24 | 25 | Examples:: 26 | 27 | app.register_error_handler(Exception, ErrHandler.handler) 28 | """ 29 | 30 | @classmethod 31 | def handler_werkzeug_exceptions(cls, e): 32 | return Result(gen_response( 33 | e.code, RESP_MSGS.get(e.code, e.name), 34 | None if not hasattr(e, 'data') else e.data['messages']), 35 | status=e.code) 36 | 37 | @classmethod 38 | def handler_sqlalchemy_exc(cls, e): 39 | code, message, detail = 500, RESP_MSGS[500], repr(e) 40 | 41 | if isinstance(e, orm_exc.NoResultFound): 42 | code, message, detail = 404, u'源数据未找到', repr(e) 43 | 44 | return Result(gen_response(code, message, detail), status=code) 45 | 46 | @classmethod 47 | def handler_assertion_error(cls, e): 48 | code, message, detail = 422, str(e), repr(e) 49 | return Result(gen_response(code, message, detail), status=code) 50 | 51 | @classmethod 52 | def handler_hobbit_core_err_handler(cls, e): 53 | return FailedResult(code=400, message=str(e), detail=repr(e)) 54 | 55 | @classmethod 56 | def handler_others(cls, e): 57 | logging.error('UncheckedException:', exc_info=1) 58 | return ServerErrorResult(code=500, detail=repr(e)) 59 | 60 | @classmethod 61 | def handler(cls, e): 62 | exc = 'others' 63 | if isinstance(e, AssertionError): 64 | exc = 'assertion_error' 65 | elif hasattr(e, '__module__'): 66 | exc = e.__module__.replace('.', '_') 67 | return getattr(cls, 'handler_{}'.format(exc), cls.handler_others)(e) 68 | -------------------------------------------------------------------------------- /hobbit_core/pagination.py: -------------------------------------------------------------------------------- 1 | from mypy_extensions import TypedDict 2 | from typing import Union, List 3 | 4 | from flask_sqlalchemy import model 5 | 6 | from marshmallow import fields 7 | from marshmallow import validate 8 | from webargs.fields import DelimitedList 9 | 10 | from .utils import ParamsDict 11 | 12 | 13 | #: Base params for list view func. 14 | PageParams = ParamsDict( 15 | page=fields.Int(missing=1, required=False, 16 | validate=validate.Range(min=1, max=2**31)), 17 | page_size=fields.Int( 18 | missing=10, required=False, validate=validate.Range(min=5, max=100)), 19 | order_by=DelimitedList( 20 | fields.String(validate=validate.Regexp(r'^-?[a-zA-Z_]*$')), 21 | required=False, missing=['-id']), 22 | ) 23 | """Base params for list view func which contains ``page``、``page_size``、\ 24 | ``order_by`` params. 25 | 26 | Example:: 27 | 28 | @use_kwargs(PageParams) 29 | def list_users(page, page_size, order_by): 30 | pass 31 | """ 32 | 33 | 34 | class PaginationType(TypedDict): 35 | items: list 36 | page: int 37 | page_size: int 38 | total: int 39 | 40 | 41 | def pagination(obj: 'model.DefaultMeta', page: int, page_size: int, 42 | order_by: Union[str, List[str], None] = 'id', query_exp=None) \ 43 | -> PaginationType: 44 | """A pagination for sqlalchemy query. 45 | 46 | Args: 47 | obj (db.Model): Model class like User. 48 | page (int): Page index. 49 | page_size (int): Row's count per page. 50 | order_by (str, list, None): Example: 'id'、['-id', 'column_name']. 51 | query_exp (flask_sqlalchemy.BaseQuery): Query like \ 52 | ``User.query.filter_by(id=1)``. 53 | 54 | Returns: 55 | dict: Dict contains ``items``、``page``、``page_size`` and \ 56 | ``total`` fileds. 57 | """ 58 | if not isinstance(obj, model.DefaultMeta): 59 | raise Exception('first arg obj must be model.') 60 | 61 | qexp = query_exp or getattr(obj, 'query') 62 | 63 | if order_by: 64 | if not isinstance(order_by, list): 65 | order_by = [order_by] 66 | 67 | order_by = [i for i in order_by if i] # exclude '' 68 | 69 | columns = {i.name for i in obj.__table__.columns} 70 | diff = {c.lstrip('-') for c in order_by} - columns 71 | if diff: 72 | raise Exception(f'columns {diff} not exist in {obj} model') 73 | 74 | order_exp = [] 75 | for column in order_by: 76 | if column.startswith('-'): 77 | order_exp.append(getattr(obj, column.lstrip('-')).desc()) 78 | else: 79 | order_exp.append(getattr(obj, column)) 80 | qexp = qexp.order_by(*order_exp) 81 | 82 | items = qexp.paginate(page=page, per_page=page_size, error_out=False) 83 | 84 | return { 85 | 'items': items.items, 'page': page, 'page_size': page_size, 86 | 'total': items.total, 87 | } 88 | -------------------------------------------------------------------------------- /hobbit_core/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/hobbit_core/py.typed -------------------------------------------------------------------------------- /hobbit_core/response.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | from mypy_extensions import TypedDict 3 | 4 | from flask.json import dumps 5 | from flask import current_app, Response 6 | 7 | RESP_MSGS = { 8 | 200: 'ok', 9 | 10 | 400: 'failed', 11 | 401: '未登录', 12 | 403: '未授权', 13 | 404: '不正确的链接地址', 14 | 422: '请求数据校验失败', 15 | 16 | 500: '服务器内部错误', 17 | } 18 | 19 | 20 | class RespType(TypedDict): 21 | code: str 22 | message: str 23 | detail: Any 24 | 25 | 26 | def gen_response(code: int, message: str = None, detail: Optional[str] = None, 27 | data=None) -> RespType: 28 | """Func for generate response body. 29 | 30 | Args: 31 | code (string, int): Extension to interact with web pages. Default is \ 32 | http response ``status_code`` like 200、404. 33 | message (string): For popup windows. 34 | data (object): Real response payload. 35 | detail (object): For debug, detail server error msg. 36 | 37 | Returns: 38 | dict: A dict contains all args. 39 | 40 | 2021-07-08 Updated: 41 | Default type of `code` in response is force conversion to `str`, now 42 | support set `HOBBIT_USE_CODE_ORIGIN_TYPE = True` to return origin type. 43 | 44 | 2021-07-13 Updated: 45 | Support set `HOBBIT_RESPONSE_MESSAGE_MAPS` to use self-defined 46 | response message. `HOBBIT_RESPONSE_MESSAGE_MAPS` must be dict. 47 | """ 48 | use_origin_type = current_app.config.get( 49 | 'HOBBIT_USE_CODE_ORIGIN_TYPE', False) 50 | 51 | resp_msgs = current_app.config.get('HOBBIT_RESPONSE_MESSAGE_MAPS', {}) 52 | assert isinstance(resp_msgs, dict), \ 53 | 'HOBBIT_RESPONSE_MESSAGE_MAPS must be dict type.' 54 | if not message: 55 | message = resp_msgs.get(code) 56 | if message is None: 57 | message = RESP_MSGS.get(code, 'unknown') 58 | 59 | if current_app.config.get("HOBBIT_RESPONSE_DETAIL", True) is False \ 60 | and code == 500: 61 | detail = None 62 | 63 | return { 64 | 'code': str(code) if use_origin_type is False else code, 65 | 'message': message, # type: ignore 66 | 'data': data, 67 | 'detail': detail, 68 | } 69 | 70 | 71 | class Result(Response): 72 | """Base json response. 73 | """ 74 | _hobbit_status = 200 # type: ignore 75 | 76 | def __init__(self, response=None, status=None, headers=None, 77 | mimetype='application/json', content_type=None, 78 | direct_passthrough=False): 79 | assert sorted(response.keys()) == [ 80 | 'code', 'data', 'detail', 'message'], \ 81 | 'Error response, must include keys: code, data, detail, message' 82 | super().__init__( 83 | response=dumps(response, indent=0, separators=(',', ':')) + '\n', 84 | status=status if status is not None else self._hobbit_status, 85 | headers=headers, mimetype=mimetype, 86 | content_type=content_type, direct_passthrough=direct_passthrough) 87 | 88 | 89 | class SuccessResult(Result): 90 | """Success response. Default status is 200, you can cover it by status arg. 91 | """ 92 | _hobbit_status = 200 93 | 94 | def __init__(self, message: str = '', code: Optional[int] = None, 95 | detail: Any = None, status: Optional[int] = None, data=None): 96 | super().__init__( 97 | gen_response(code if code is not None else self._hobbit_status, 98 | message, detail, data), 99 | status or self._hobbit_status) 100 | 101 | 102 | class FailedResult(Result): 103 | """Failed response. status always 400. 104 | """ 105 | _hobbit_status = 400 106 | 107 | def __init__(self, message: str = '', code: Optional[int] = None, 108 | detail: Any = None): 109 | super().__init__( 110 | gen_response( 111 | code if code is not None else self._hobbit_status, 112 | message, detail), 113 | self._hobbit_status) 114 | 115 | 116 | class UnauthorizedResult(FailedResult): 117 | _hobbit_status = 401 118 | 119 | 120 | class ForbiddenResult(FailedResult): 121 | _hobbit_status = 403 122 | 123 | 124 | class ValidationErrorResult(FailedResult): 125 | _hobbit_status = 422 126 | 127 | 128 | class ServerErrorResult(FailedResult): 129 | _hobbit_status = 500 130 | -------------------------------------------------------------------------------- /hobbit_core/schemas.py: -------------------------------------------------------------------------------- 1 | from marshmallow import ( 2 | Schema as Schema_, fields, pre_load, post_load, post_dump, 3 | ) 4 | from marshmallow_sqlalchemy.schema import SQLAlchemyAutoSchemaMeta 5 | from flask_marshmallow.sqla import \ 6 | SQLAlchemyAutoSchema as SQLAlchemyAutoSchema_ 7 | from marshmallow_enum import EnumField 8 | 9 | 10 | class ORMSchema(SQLAlchemyAutoSchema_): 11 | """Base schema for ModelSchema. See `webargs/issues/126 12 | `_. 13 | 14 | Example:: 15 | 16 | from hobbit_core.schemas import ORMSchema 17 | 18 | 19 | class UserSchema(ORMSchema): 20 | 21 | class Meta: 22 | model = User 23 | load_only = ('password') 24 | 25 | ``@use_kwargs(UserSchema())`` use in combination with ``load_only``:: 26 | 27 | @bp.route('/users/', methods=['POST']) 28 | @use_kwargs(UserSchema()) 29 | def create_user(username, password): 30 | pass 31 | """ 32 | 33 | @post_load() 34 | def make_instance(self, data, many, **kwargs): 35 | return data 36 | 37 | 38 | class SchemaMixin(object): 39 | """Add ``id``, ``created_at``, ``updated_at`` fields to schema, 40 | default ``dump_only=True``. 41 | 42 | Example:: 43 | 44 | from marshmallow import Schema 45 | 46 | from hobbit_core.schemas import SchemaMixin 47 | 48 | class UserSchema(Schema, SchemaMixin): 49 | pass 50 | """ 51 | 52 | id = fields.Int(dump_only=True) 53 | created_at = fields.DateTime('%Y-%m-%d %H:%M:%S', dump_only=True) 54 | updated_at = fields.DateTime('%Y-%m-%d %H:%M:%S', dump_only=True) 55 | 56 | class Meta: 57 | strict = True 58 | ordered = False 59 | dateformat = '%Y-%m-%d %H:%M:%S' 60 | 61 | 62 | class PagedSchema(Schema_): 63 | """Base schema for list api pagination. 64 | 65 | Example:: 66 | 67 | from marshmallow import fields 68 | 69 | from hobbit_core.schemas import PagedSchema 70 | 71 | from . import models 72 | from .exts import ma 73 | 74 | 75 | class UserSchema(ma.ModelSchema): 76 | 77 | class Meta: 78 | model = models.User 79 | 80 | 81 | class PagedUserSchema(PagedSchema): 82 | items = fields.Nested('UserSchema', many=True) 83 | 84 | 85 | paged_user_schemas = PagedUserSchema() 86 | """ 87 | 88 | total = fields.Int() 89 | page = fields.Int(missing=1, default=1) 90 | page_size = fields.Int(missing=10, default=10) 91 | 92 | class Meta: 93 | strict = True 94 | 95 | 96 | class EnumSetMeta(SQLAlchemyAutoSchemaMeta): 97 | """EnumSetMeta is a metaclass that can be used to auto generate load and 98 | dump func for EnumField. 99 | """ 100 | 101 | @classmethod 102 | def gen_func(cls, decorator, field_name, enum, verbose=True): 103 | 104 | @decorator 105 | def wrapper(self, data, many, **kwargs): 106 | if data.get(field_name) is None: 107 | return data 108 | 109 | if decorator is pre_load: 110 | data[field_name] = enum.load(data[field_name]) 111 | elif decorator is post_dump: 112 | data[field_name] = enum.dump(data[field_name], verbose) 113 | else: 114 | raise Exception( 115 | 'hobbit_core: decorator `{}` not support'.format( 116 | decorator)) 117 | 118 | return data 119 | return wrapper 120 | 121 | def __new__(cls, name, bases, attrs): 122 | schema = SQLAlchemyAutoSchemaMeta.__new__( 123 | cls, name, tuple(bases), attrs) 124 | verbose = getattr(schema.Meta, 'verbose', True) 125 | 126 | setattr(schema.Meta, 'dateformat', '%Y-%m-%d %H:%M:%S') 127 | 128 | for field_name, declared in schema._declared_fields.items(): 129 | if not isinstance(declared, EnumField): 130 | continue 131 | 132 | setattr(schema, 'load_{}'.format(field_name), cls.gen_func( 133 | pre_load, field_name, declared.enum)) 134 | setattr(schema, 'dump_{}'.format(field_name), cls.gen_func( 135 | post_dump, field_name, declared.enum, verbose=verbose)) 136 | 137 | return schema 138 | 139 | 140 | class ModelSchema(ORMSchema, SchemaMixin, metaclass=EnumSetMeta): 141 | """Base ModelSchema for ``class Model(db.SurrogatePK)``. 142 | 143 | * Auto generate load and dump func for EnumField. 144 | * Auto dump_only for ``id``, ``created_at``, ``updated_at`` fields. 145 | * Auto set dateformat to ``'%Y-%m-%d %H:%M:%S'``. 146 | * Auto use verbose for dump EnumField. See ``db.EnumExt``. You can define 147 | verbose in ``Meta``. 148 | 149 | Example:: 150 | 151 | class UserSchema(ModelSchema): 152 | role = EnumField(RoleEnum) 153 | 154 | class Meta: 155 | model = User 156 | 157 | data = UserSchema().dump(user).data 158 | assert data['role'] == {'key': 1, 'label': 'admin', 'value': '管理员'} 159 | 160 | """ 161 | pass 162 | -------------------------------------------------------------------------------- /hobbit_core/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections import Mapping 3 | except: # noqa E722 4 | from collections.abc import Mapping 5 | import inspect 6 | import importlib 7 | import os 8 | import re 9 | import sys 10 | from typing import Any, Dict, List, Optional 11 | from unicodedata import normalize 12 | from distutils.version import LooseVersion 13 | import logging 14 | import datetime 15 | 16 | from flask import request 17 | from flask_sqlalchemy import model 18 | from sqlalchemy import UniqueConstraint 19 | from sqlalchemy.sql import text 20 | import marshmallow 21 | from marshmallow import Schema 22 | 23 | from .webargs import use_kwargs as base_use_kwargs, parser 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class ParamsDict(dict): 29 | """Just available update func. 30 | 31 | Example:: 32 | 33 | @use_kwargs(PageParams.update({...})) 34 | def list_users(page, page_size, order_by): 35 | pass 36 | 37 | """ 38 | 39 | def update(self, other=None): 40 | """Update self by other Mapping and return self. 41 | """ 42 | ret = ParamsDict(self.copy()) 43 | if other is not None: 44 | for k, v in other.items() if isinstance(other, Mapping) else other: 45 | ret[k] = v 46 | return ret 47 | 48 | 49 | class dict2object(dict): 50 | """Dict to fake object that can use getattr. 51 | 52 | Examples:: 53 | 54 | In [2]: obj = dict2object({'a': 2, 'c': 3}) 55 | 56 | In [3]: obj.a 57 | Out[3]: 2 58 | 59 | In [4]: obj.c 60 | Out[4]: 3 61 | 62 | """ 63 | 64 | def __getattr__(self, name: str) -> Any: 65 | if name in self.keys(): 66 | return self[name] 67 | raise AttributeError('object has no attribute {}'.format(name)) 68 | 69 | def __setattr__(self, name: str, value: Any) -> None: 70 | if not isinstance(name, str): 71 | raise TypeError('key must be string type.') 72 | self[name] = value 73 | 74 | 75 | def secure_filename(filename: str) -> str: 76 | """Borrowed from werkzeug.utils.secure_filename. 77 | 78 | Pass it a filename and it will return a secure version of it. This 79 | filename can then safely be stored on a regular file system and passed 80 | to :func:`os.path.join`. 81 | 82 | On windows systems the function also makes sure that the file is not 83 | named after one of the special device files. 84 | 85 | >>> secure_filename(u'哈哈.zip') 86 | '哈哈.zip' 87 | >>> secure_filename('My cool movie.mov') 88 | 'My_cool_movie.mov' 89 | >>> secure_filename('../../../etc/passwd') 90 | 'etc_passwd' 91 | >>> secure_filename(u'i contain cool \xfcml\xe4uts.txt') 92 | 'i_contain_cool_umlauts.txt' 93 | 94 | """ 95 | 96 | for sep in os.path.sep, os.path.altsep: 97 | if sep: 98 | filename = filename.replace(sep, ' ') 99 | 100 | filename = normalize('NFKD', '_'.join(filename.split())) 101 | 102 | filename_strip_re = re.compile(u'[^A-Za-z0-9\u4e00-\u9fa5_.-]') 103 | filename = filename_strip_re.sub('', filename).strip('._') 104 | 105 | # on nt a couple of special files are present in each folder. We 106 | # have to ensure that the target file is not such a filename. In 107 | # this case we prepend an underline 108 | windows_device_files = ( 109 | 'CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', 110 | 'LPT2', 'LPT3', 'PRN', 'NUL', 111 | ) 112 | if os.name == 'nt' and filename and \ 113 | filename.split('.')[0].upper() in windows_device_files: 114 | filename = '_' + filename 115 | 116 | return filename 117 | 118 | 119 | def _get_init_args(instance, base_class): 120 | """Get instance's __init__ args and it's value when __call__. 121 | """ 122 | getargspec = inspect.getfullargspec 123 | argspec = getargspec(base_class.__init__) 124 | 125 | defaults = argspec.defaults 126 | kwargs = {} 127 | if defaults is not None: 128 | no_defaults = argspec.args[:-len(defaults)] 129 | has_defaults = argspec.args[-len(defaults):] 130 | 131 | kwargs = {k: getattr(instance, k) for k in no_defaults 132 | if k != 'self' and hasattr(instance, k)} 133 | kwargs.update({k: getattr(instance, k) if hasattr(instance, k) else 134 | getattr(instance, k, defaults[i]) 135 | for i, k in enumerate(has_defaults)}) 136 | assert len(kwargs) == len(argspec.args) - 1, 'exclude `self`' 137 | return kwargs 138 | 139 | 140 | def use_kwargs(argmap, schema_kwargs: Optional[Dict] = None, **kwargs: Any): 141 | """For fix ``Schema(partial=True)`` not work when used with 142 | ``@webargs.flaskparser.use_kwargs``. More details ``see webargs.core``. 143 | 144 | Args: 145 | 146 | argmap (marshmallow.Schema,dict,callable): Either a 147 | `marshmallow.Schema`, `dict` of argname -> 148 | `marshmallow.fields.Field` pairs, or a callable that returns a 149 | `marshmallow.Schema` instance. 150 | schema_kwargs (dict): kwargs for argmap. 151 | 152 | Returns: 153 | dict: A dictionary of parsed arguments. 154 | 155 | """ 156 | schema_kwargs = schema_kwargs or {} 157 | 158 | argmap = parser._get_schema(argmap, request) 159 | 160 | if not (argmap.partial or schema_kwargs.get('partial')): 161 | return base_use_kwargs(argmap, **kwargs) 162 | 163 | def factory(request): 164 | argmap_kwargs = _get_init_args(argmap, Schema) 165 | argmap_kwargs.update(schema_kwargs) 166 | 167 | # force set force_all=False 168 | only = parser.parse(argmap, request).keys() 169 | 170 | argmap_kwargs.update({ 171 | 'partial': False, # fix missing=None not work 172 | 'only': only or None, 173 | 'context': {"request": request}, 174 | }) 175 | if tuple(LooseVersion(marshmallow.__version__).version)[0] < 3: 176 | argmap_kwargs['strict'] = True 177 | 178 | return argmap.__class__(**argmap_kwargs) 179 | 180 | return base_use_kwargs(factory, **kwargs) 181 | 182 | 183 | def import_subs(locals_, modules_only: bool = False) -> List[str]: 184 | """ Auto import submodules, used in __init__.py. 185 | 186 | Args: 187 | 188 | locals_: `locals()`. 189 | modules_only: Only collect modules to __all__. 190 | 191 | Examples:: 192 | 193 | # app/models/__init__.py 194 | from hobbit_core.utils import import_subs 195 | 196 | __all__ = import_subs(locals()) 197 | 198 | Auto collect Model's subclass, Schema's subclass and instance. 199 | Others objects must defined in submodule.__all__. 200 | """ 201 | package = locals_['__package__'] 202 | path = locals_['__path__'] 203 | top_mudule = sys.modules[package] 204 | 205 | all_ = [] 206 | for name in os.listdir(path[0]): 207 | if not name.endswith(('.py', '.pyc')) or name.startswith('__init__.'): 208 | continue 209 | 210 | module_name = name.split('.')[0] 211 | submodule = importlib.import_module(f".{module_name}", package) 212 | all_.append(module_name) 213 | 214 | if modules_only: 215 | continue 216 | 217 | if hasattr(submodule, '__all__'): 218 | for name in getattr(submodule, '__all__'): 219 | if not isinstance(name, str): 220 | raise Exception(f'Invalid object {name} in __all__, ' 221 | f'must contain only strings.') 222 | setattr(top_mudule, name, getattr(submodule, name)) 223 | all_.append(name) 224 | else: 225 | for name, obj in submodule.__dict__.items(): 226 | if isinstance(obj, (model.DefaultMeta, Schema)) or \ 227 | (inspect.isclass(obj) and 228 | (issubclass(obj, Schema) or 229 | obj.__name__.endswith('Service'))): 230 | setattr(top_mudule, name, obj) 231 | all_.append(name) 232 | return all_ 233 | 234 | 235 | def bulk_create_or_update_on_duplicate( 236 | db, model_cls, items, updated_at='updated_at', batch_size=500): 237 | """ Support MySQL and postgreSQL. 238 | https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html 239 | 240 | Args: 241 | 242 | db: Instance of `SQLAlchemy`. 243 | model_cls: Model object. 244 | items: List of data,[ example: `[{key: value}, {key: value}, ...]`. 245 | updated_at: Field which recording row update time. 246 | batch_size: Batch size is max rows per execute. 247 | 248 | Returns: 249 | dict: A dictionary contains rowcount and items_count. 250 | """ 251 | if not items: 252 | logger.warning("bulk_create_or_update_on_duplicate save to " 253 | f"{model_cls} failed, empty items") 254 | return {'rowcount': 0, 'items_count': 0} 255 | 256 | items_count = len(items) 257 | table_name = model_cls.__tablename__ 258 | fields = list(items[0].keys()) 259 | unique_keys = [c.name for i in model_cls.__table_args__ if isinstance( 260 | i, UniqueConstraint) for c in i] 261 | columns = [c.name for c in model_cls.__table__.columns if c.name not in ( 262 | 'id', 'created_at')] 263 | 264 | if updated_at in columns and updated_at not in fields: 265 | fields.append(updated_at) 266 | updated_at_time = datetime.datetime.now() 267 | for item in items: 268 | item[updated_at] = updated_at_time 269 | 270 | assert set(fields) == set(columns), \ 271 | 'item fields not equal to columns in models:new: ' + \ 272 | f'{set(fields)-set(columns)}, delete: {set(columns)-set(fields)}' 273 | 274 | for item in items: 275 | for column in unique_keys: 276 | if column in item and item[column] is None: 277 | item[column] = '' 278 | 279 | engine = db.get_engine(bind_key=getattr(model_cls, '__bind_key__', None)) 280 | if engine.name == 'postgresql': 281 | sql_on_update = ', '.join([ 282 | f' {field} = excluded.{field}' 283 | for field in fields if field not in unique_keys]) 284 | sql = f"""INSERT INTO {table_name} ({", ".join(fields)}) VALUES 285 | ({", ".join([f':{key}' for key in fields])}) 286 | ON CONFLICT ({", ".join(unique_keys)}) DO UPDATE SET 287 | {sql_on_update}""" 288 | elif engine.name == 'mysql': 289 | sql_on_update = '`, `'.join([ 290 | f' `{field}` = new.{field}' for field in fields 291 | if field not in unique_keys]) 292 | sql = f"""INSERT INTO {table_name} (`{"`, `".join(fields)}`) VALUES 293 | ({", ".join([f':{key}' for key in fields])}) AS new 294 | ON DUPLICATE KEY UPDATE 295 | {sql_on_update}""" 296 | else: 297 | raise Exception(f'not support db: {engine.name}') 298 | 299 | rowcounts = 0 300 | while len(items) > 0: 301 | batch, items = items[:batch_size], items[batch_size:] 302 | try: 303 | result = db.session.execute( 304 | text(sql), batch, bind_arguments={'bind': engine}) 305 | except Exception as e: 306 | logger.error(e, exc_info=True) 307 | logger.info(sql) 308 | raise e 309 | rowcounts += result.rowcount 310 | logger.info(f'{model_cls} save_data: rowcount={rowcounts}, ' 311 | f'items_count: {items_count}') 312 | return {'rowcount': rowcounts, 'items_count': items_count} 313 | 314 | 315 | def get_env(): 316 | """ Determine the current environment setting and 317 | locate the configuration file. 318 | 319 | This function checks the environment variable "HOBBIT_ENV" to determine 320 | the current environment. The defaults to "production". 321 | 322 | The corresponding configuration file should be located in `app.configs` 323 | based on this environment. 324 | 325 | Returns: 326 | str: The current environment setting, either the value of 327 | "HOBBIT_ENV" or "production" if the variable is not set. 328 | """ 329 | return os.environ.get("HOBBIT_ENV") or "production" 330 | -------------------------------------------------------------------------------- /hobbit_core/webargs.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | 3 | from webargs.flaskparser import FlaskParser 4 | 5 | 6 | def strip_whitespace(value): 7 | if isinstance(value, str): 8 | value = value.strip() 9 | # you'll be getting a MultiDictProxy here potentially, but it should work 10 | elif isinstance(value, Mapping): 11 | return {k: strip_whitespace(value[k]) for k in value} 12 | elif isinstance(value, (list, set)): 13 | return type(value)(map(strip_whitespace, value)) 14 | return value 15 | 16 | 17 | class CustomParser(FlaskParser): 18 | 19 | def _load_location_data(self, **kwargs): 20 | data = super()._load_location_data(**kwargs) 21 | return strip_whitespace(data) 22 | 23 | 24 | parser = CustomParser() 25 | use_args = parser.use_args 26 | use_kwargs = parser.use_kwargs 27 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta:__legacy__" -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env = 3 | FLASK_APP=tests/run.py 4 | HOBBIT_ENV=testing 5 | addopts = --cov . --cov-report term-missing --no-cov-on-fail -s -x -vv -p no:warnings 6 | log_cli = true 7 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | - method: pip 24 | path: . 25 | extra_requirements: 26 | - hobbit 27 | - hobbit_core 28 | 29 | build: 30 | os: ubuntu-22.04 31 | tools: 32 | python: "3.10" 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path, PurePath 3 | from setuptools import setup, find_packages 4 | 5 | SUFFIX = '.jinja2' 6 | ROOT_PATH = os.path.split(os.path.abspath(os.path.join(__file__)))[0] 7 | src_path = os.path.join(ROOT_PATH, 'hobbit') 8 | 9 | 10 | def gen_data(data_root='static'): 11 | """just for collect static files. 12 | """ 13 | return [fpath.as_posix() for fpath in Path( 14 | PurePath(src_path) / data_root).glob(f'**/*{SUFFIX}')] 15 | 16 | 17 | package_data = gen_data() 18 | # The amount files of `shire[new]` + `rivendell[new]` 19 | assert len(package_data) == 27 + 28, \ 20 | 'nums of tepl files error, {}'.format(len(package_data)) 21 | package_data.append('py.typed') 22 | 23 | 24 | long_description_content_type = 'text/markdown' 25 | try: 26 | import pypandoc 27 | long_description = pypandoc.convert_file('README.md', 'rst') 28 | long_description_content_type = 'text/x-rst' 29 | except (OSError, ImportError): 30 | long_description = open('README.md').read() 31 | 32 | 33 | setup( 34 | name='hobbit-core', 35 | version='4.0.0', 36 | python_requires='>=3.8, <4', 37 | description='Hobbit - A flask project generator.', 38 | long_description=long_description, 39 | long_description_content_type=long_description_content_type, 40 | author='Legolas Bloom', 41 | author_email='zhanhsw@gmail.com', 42 | url='https://github.com/TTWShell/hobbit-core', 43 | classifiers=[ 44 | 'Topic :: Utilities', 45 | 'Programming Language :: Python :: 3.8', 46 | 'Programming Language :: Python :: 3.9', 47 | 'Programming Language :: Python :: 3.10', 48 | 'Programming Language :: Python :: 3.11', 49 | 'Programming Language :: Python :: 3.12', 50 | 'Programming Language :: Python :: Implementation :: CPython', 51 | 'License :: OSI Approved :: MIT License', 52 | ], 53 | zip_safe=False, 54 | packages=find_packages(), 55 | package_data={'hobbit': package_data}, 56 | install_requires=[], 57 | extras_require={ 58 | 'hobbit_core': [ 59 | 'Flask>=3.0,<4', 60 | 'flask-marshmallow>=1.0,<2', 61 | 'Flask-Migrate>=4,<5', 62 | 'flask-shell-ipython>=0.4.1', 63 | 'SQLAlchemy>=2.0,< 3', 64 | 'Flask-SQLAlchemy>=3.0.0,<4', 65 | 'marshmallow>=3.0.0,<4', 66 | 'marshmallow-enum>=1.5.1,<2', 67 | 'marshmallow-sqlalchemy>=0.26.1,<3', 68 | 'webargs>=8.0.0,<9', 69 | 'mypy-extensions>=0.4.3', 70 | 'pyyaml!=6.0.0,!=5.4.0,!=5.4.1', 71 | 72 | ], 73 | 'hobbit': [ 74 | 'Click>=6.7', 75 | 'Jinja2>=3.0', 76 | 'inflect>=2.1.0', 77 | 'markupsafe>=2.0.1', 78 | ], 79 | }, 80 | entry_points={ 81 | 'console_scripts': 'hobbit=hobbit:main' 82 | }, 83 | ) 84 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import functools 4 | 5 | from flask_sqlalchemy import model 6 | 7 | from .test_app.run import app, db 8 | 9 | 10 | class BaseTest(object): 11 | root_path = os.path.split(os.path.abspath(__name__))[0] 12 | 13 | @classmethod 14 | def setup_class(cls): 15 | with app.app_context(): 16 | db.create_all(bind_key=None) 17 | db.create_all(bind_key='mysql') 18 | 19 | @classmethod 20 | def teardown_class(cls): 21 | with app.app_context(): 22 | db.drop_all(bind_key=None) 23 | db.drop_all(bind_key='mysql') 24 | 25 | def teardown_method(self, method): 26 | with app.app_context(): 27 | for m in [m for m in db.Model.registry._class_registry.values() 28 | if isinstance(m, model.DefaultMeta) and 29 | getattr(m, '__bind_key__', None) != 'oracle']: 30 | db.session.query(m).delete() 31 | db.session.commit() 32 | 33 | 34 | def rmdir(path): 35 | if os.path.exists(path): 36 | shutil.rmtree(path) 37 | 38 | 39 | def chdir(path): 40 | def wrapper(func): 41 | @functools.wraps(func) 42 | def inner(*args, **kwargs): 43 | cwd = os.getcwd() 44 | if not os.path.exists(path): 45 | os.makedirs(path) 46 | os.chdir(path) 47 | func(*args, **kwargs) 48 | os.chdir(cwd) 49 | return inner 50 | return wrapper 51 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from webargs.core import Parser 4 | 5 | from .test_app.run import app as tapp 6 | from .test_app.exts import db as tdb 7 | 8 | 9 | @pytest.fixture(scope='session') 10 | def app(request): 11 | ctx = tapp.app_context() 12 | ctx.push() 13 | 14 | def teardown(): 15 | ctx.pop() 16 | 17 | request.addfinalizer(teardown) 18 | return tapp 19 | 20 | 21 | @pytest.fixture(scope='session') 22 | def client(app, request): 23 | return app.test_client() 24 | 25 | 26 | @pytest.fixture(scope='function') 27 | def assert_session(app): 28 | with app.app_context(): 29 | conn = tdb.engine.connect() 30 | options = dict(bind=conn, binds={}) 31 | sess = tdb._make_scoped_session(options=options) 32 | yield sess 33 | sess.remove() 34 | 35 | 36 | @pytest.fixture(scope='function') 37 | def db_session(app): 38 | with app.app_context(): 39 | sess = tdb.session 40 | return sess 41 | 42 | 43 | @pytest.fixture(scope='function', params=['db_session']) 44 | def session(request): 45 | return request.getfixturevalue(request.param) 46 | 47 | 48 | # borrowed from webargs 49 | class MockRequestParser(Parser): 50 | """A minimal parser implementation that parses mock requests.""" 51 | 52 | def load_querystring(self, req, schema): 53 | return req.query 54 | 55 | 56 | @pytest.fixture 57 | def request_context(app): 58 | """create the app and return the request context as a fixture 59 | so that this process does not need to be repeated in each test 60 | """ 61 | return app.test_request_context 62 | 63 | 64 | @pytest.yield_fixture(scope="function") 65 | def web_request(request_context): 66 | with request_context(): 67 | from flask import request 68 | req = request # mock.Mock() 69 | req.query = {} 70 | yield req 71 | req.query = {} 72 | 73 | 74 | @pytest.fixture 75 | def parser(): 76 | return MockRequestParser() 77 | -------------------------------------------------------------------------------- /tests/importsub/__init__.py: -------------------------------------------------------------------------------- 1 | from hobbit_core.utils import import_subs 2 | 3 | __all__ = import_subs(locals()) 4 | -------------------------------------------------------------------------------- /tests/importsub/models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | from hobbit_core.db import SurrogatePK, Column, BaseModel 4 | 5 | db = SQLAlchemy() 6 | 7 | 8 | class User(SurrogatePK, db.Model): # type: ignore 9 | username = Column(db.String(20), unique=True, nullable=False, 10 | doc='username') 11 | nick = Column(db.String(20), unique=True, nullable=False, doc='nick name') 12 | 13 | 14 | class OtherUser(BaseModel): 15 | username = Column(db.String(20), unique=True, nullable=False, 16 | doc='username') 17 | nick = Column(db.String(20), unique=True, nullable=False, doc='nick name') 18 | -------------------------------------------------------------------------------- /tests/importsub/others.py: -------------------------------------------------------------------------------- 1 | G_VAR = 1 2 | 3 | 4 | class A: 5 | pass 6 | 7 | 8 | def b(): 9 | pass 10 | 11 | 12 | __all__ = ['A', 'b', 'G_VAR'] 13 | -------------------------------------------------------------------------------- /tests/importsub/schemas.py: -------------------------------------------------------------------------------- 1 | from marshmallow import fields 2 | from flask_marshmallow import Marshmallow 3 | 4 | from hobbit_core.schemas import PagedSchema 5 | 6 | ma = Marshmallow() 7 | 8 | 9 | class UserSchema(ma.SQLAlchemyAutoSchema): # type: ignore 10 | 11 | class Meta: 12 | from .models import OtherUser 13 | model = OtherUser 14 | 15 | 16 | class PagedUserSchema(PagedSchema): 17 | items = fields.Nested('UserSchema', many=True) 18 | 19 | 20 | user_schemas = UserSchema() 21 | paged_user_schemas = PagedUserSchema() 22 | -------------------------------------------------------------------------------- /tests/importsub/services.py: -------------------------------------------------------------------------------- 1 | class FooService: 2 | pass 3 | 4 | 5 | class BarService: 6 | pass 7 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TTWShell/hobbit-core/b03db4adb9f8f9ada20bc7352e623ac8d12bfa7c/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/exts.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_marshmallow import Marshmallow 3 | 4 | from hobbit_core import HobbitManager 5 | 6 | db = SQLAlchemy() 7 | ma = Marshmallow() 8 | 9 | hobbit = HobbitManager() 10 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from sqlalchemy import UniqueConstraint, func, DateTime, BigInteger 3 | from flask_sqlalchemy.track_modifications import models_committed 4 | 5 | from hobbit_core.db import Column, BaseModel, EnumExt 6 | 7 | from .exts import db 8 | 9 | 10 | class RoleEnum(EnumExt): 11 | admin = (1, '管理员') 12 | normal = (2, '普通用户') 13 | 14 | 15 | class User(BaseModel): 16 | username = Column(db.String(50), nullable=False, unique=True) 17 | email = Column(db.String(50), nullable=False, unique=True) 18 | password = Column(db.String(255), nullable=False, server_default='') 19 | role = Column(db.Enum(RoleEnum), doc='角色', default=RoleEnum.admin) 20 | 21 | 22 | @models_committed.connect 23 | def signalling(app, changes, **kwargs): 24 | for instance, operation in changes: 25 | if instance.__tablename__ in [i.__tablename__ for i in [User]]: 26 | models_committed.disconnect(signalling) 27 | conn = db.engine.connect() 28 | options = dict(bind=conn, binds={}) 29 | session = db._make_scoped_session(options=options) 30 | user = session.query(User).first() 31 | if user and user.username == 'signalling_test': 32 | user.username = 'signalling_ok' 33 | session.merge(user) 34 | session.commit() 35 | session.remove() 36 | models_committed.connect(signalling) 37 | break 38 | 39 | 40 | class Role(BaseModel): # just for assert multi model worked 41 | name = Column(db.String(50), nullable=False, unique=True) 42 | 43 | 44 | class BulkModelMixin: 45 | x = Column(db.String(50), nullable=False) 46 | y = Column(db.String(50), nullable=False) 47 | z = Column(db.String(50), nullable=False) 48 | 49 | __table_args__ = ( 50 | UniqueConstraint('x', 'y', 'z', name='bulk_model_main_unique_key'), 51 | ) 52 | 53 | 54 | class BulkModel2Mixin: 55 | id = Column(BigInteger, primary_key=True) 56 | update = Column( 57 | DateTime, index=True, nullable=False, server_default=func.now(), 58 | onupdate=func.now()) 59 | x = Column(db.String(50), nullable=False) 60 | y = Column(db.String(50), nullable=False) 61 | z = Column(db.String(50), nullable=False) 62 | 63 | __table_args__ = ( 64 | UniqueConstraint('x', 'y', 'z', name='bulk_model2_main_unique_key'), 65 | ) 66 | 67 | 68 | class BulkModel(BaseModel, BulkModelMixin): 69 | pass 70 | 71 | 72 | class BulkModel2(db.Model, BulkModel2Mixin): 73 | pass 74 | 75 | 76 | class BulkModelMysql(BaseModel, BulkModelMixin): 77 | __bind_key__ = 'mysql' 78 | 79 | 80 | class BulkModel2Mysql(db.Model, BulkModel2Mixin): 81 | __bind_key__ = 'mysql' 82 | -------------------------------------------------------------------------------- /tests/test_app/run.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask 3 | 4 | from hobbit_core.err_handler import ErrHandler 5 | 6 | from .exts import db, ma, hobbit 7 | 8 | ROOT_PATH = os.path.split(os.path.abspath(__file__))[0] 9 | 10 | 11 | class ConfigClass: 12 | SECRET_KEY = 'test secret key' 13 | SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/hobbit_core' 14 | SQLALCHEMY_BINDS = { 15 | 'oracle': 'oracle://scott:tiger@localhost/test', 16 | 'mysql': 'mysql+pymysql://root:root@localhost/hobbit_core', 17 | } 18 | SQLALCHEMY_TRACK_MODIFICATIONS = True 19 | # SQLALCHEMY_ECHO = True 20 | TESTING = True 21 | 22 | 23 | def register_extensions(app): 24 | db.init_app(app) 25 | ma.init_app(app) 26 | hobbit.init_app(app, db) 27 | 28 | 29 | def register_blueprints(app): 30 | with app.app_context(): 31 | from .views import bp 32 | app.register_blueprint(bp) 33 | 34 | 35 | def register_error_handler(app): 36 | app.register_error_handler(Exception, ErrHandler.handler) 37 | 38 | 39 | def init_app(config=ConfigClass): 40 | app = Flask(__name__) 41 | app.config.from_object(config) 42 | 43 | register_extensions(app) 44 | register_blueprints(app) 45 | register_error_handler(app) 46 | 47 | return app 48 | 49 | 50 | app = init_app() 51 | -------------------------------------------------------------------------------- /tests/test_app/schemas.py: -------------------------------------------------------------------------------- 1 | from hobbit_core.schemas import ORMSchema, SchemaMixin 2 | 3 | from marshmallow import fields 4 | 5 | from .models import User 6 | 7 | 8 | class UserSchema(ORMSchema, SchemaMixin): 9 | password = fields.Str(dump_only=True) 10 | 11 | class Meta: 12 | model = User 13 | -------------------------------------------------------------------------------- /tests/test_app/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from marshmallow import fields 3 | from marshmallow.utils import missing 4 | from webargs.flaskparser import use_kwargs as base_use_kwargs 5 | 6 | from hobbit_core.utils import use_kwargs 7 | from hobbit_core.db import transaction 8 | 9 | from .schemas import UserSchema 10 | from .exts import db 11 | from .models import User 12 | 13 | bp = Blueprint('test', __name__) 14 | 15 | 16 | def wrapper_kwargs(kwargs): 17 | res = {} 18 | for k, v in kwargs.items(): 19 | if v is missing: 20 | res[k] = 'missing' 21 | continue 22 | res[k] = v 23 | return res 24 | 25 | 26 | @bp.route('/use_kwargs_with_partial/', methods=['POST']) 27 | @use_kwargs(UserSchema(partial=True, exclude=['role'])) 28 | def use_kwargs_with_partial(**kwargs): 29 | return jsonify(wrapper_kwargs(kwargs)) 30 | 31 | 32 | @bp.route('/use_kwargs_without_partial/', methods=['POST']) 33 | @use_kwargs(UserSchema(exclude=['role'])) 34 | def use_kwargs_without_partial(**kwargs): 35 | return jsonify(wrapper_kwargs(kwargs)) 36 | 37 | 38 | @bp.route('/use_kwargs_dictargmap_partial/', methods=['POST']) 39 | @use_kwargs({ 40 | 'username': fields.Str(missing=None), 41 | 'password': fields.Str(allow_none=True), 42 | }, schema_kwargs={'partial': True}) 43 | def use_kwargs_dictargmap_partial(**kwargs): 44 | return jsonify(wrapper_kwargs(kwargs)) 45 | 46 | 47 | @bp.route('/base_use_kwargs_dictargmap_partial/', methods=['POST']) 48 | @base_use_kwargs({ 49 | 'username': fields.Str(missing=None), 50 | 'password': fields.Str(allow_none=True), 51 | }) 52 | def base_use_kwargs_dictargmap_partial(**kwargs): 53 | return jsonify(wrapper_kwargs(kwargs)) 54 | 55 | 56 | @bp.route('/create_user/success/', methods=['POST']) 57 | @base_use_kwargs({'email': fields.Str()}) 58 | def create_user_success(email): 59 | @transaction(db.session, nested=None) 60 | def create_user(): 61 | user1 = User(username='signalling_test', email=email, password='1') 62 | db.session.add(user1) 63 | 64 | create_user() 65 | return jsonify({}) 66 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pytest 3 | import random 4 | 5 | from sqlalchemy.exc import InvalidRequestError 6 | 7 | from hobbit_core.db import EnumExt, transaction 8 | from hobbit_core.db import BaseModel, Column 9 | 10 | from .test_app.exts import db 11 | from .test_app.models import User 12 | 13 | from . import BaseTest 14 | 15 | 16 | class TestSurrogatePK(BaseTest): 17 | 18 | def test_surrogate_pk(self, assert_session): 19 | user = User(username='test1', email='1@b.com', password='1') 20 | assert str(user).startswith(' 1.4 158 | @transaction(session) 159 | def create_user(): 160 | user = User(username='test1', email='1@b.com', password='1') 161 | session.add(user) 162 | session.commit() 163 | 164 | create_user() 165 | assert assert_session.query(User).all() != [] 166 | 167 | def test_used_with_begin_nested(self, session, assert_session): 168 | # > 1.4 169 | @transaction(session) 170 | def create_user(commit_inner): 171 | with session.begin_nested(): 172 | user = User(username='test1', email='1@b.com', password='1') 173 | session.add(user) 174 | 175 | with session.begin_nested(): 176 | user = User(username='test2', email='2@b.com', password='1') 177 | session.add(user) 178 | if commit_inner: 179 | session.commit() 180 | 181 | create_user(commit_inner=False) 182 | assert len(assert_session.query(User).all()) == 2 183 | 184 | self.clear_user() 185 | create_user(commit_inner=True) 186 | assert len(assert_session.query(User).all()) == 2 187 | 188 | def test_fall_used(self, session, assert_session): 189 | @transaction(session) 190 | def create_user1(): 191 | user = User(username='test1', email='1@b.com', password='1') 192 | session.add(user) 193 | 194 | @transaction(session) 195 | def create_user2(): 196 | user = User(username='test2', email='2@b.com', password='1') 197 | session.add(user) 198 | 199 | def view_func1(): 200 | create_user1() 201 | create_user2() 202 | 203 | view_func1() 204 | assert len(assert_session.query(User).all()) == 2 205 | 206 | # test exception 207 | self.clear_user() 208 | 209 | def view_func2(): 210 | create_user1() 211 | raise Exception('') 212 | 213 | with pytest.raises(Exception, match=''): 214 | view_func2() 215 | 216 | assert len(assert_session.query(User).all()) == 1 217 | assert assert_session.query(User).first().username == 'test1' 218 | 219 | # v2.x should raise 220 | def test_nested_self_raise(self, session, assert_session): 221 | @transaction(session) 222 | def create_user(): 223 | user = User(username='test1', email='1@b.com', password='1') 224 | db.session.add(user) 225 | 226 | @transaction(session) 227 | def view_func(): 228 | user = User(username='test2', email='2@b.com', password='1') 229 | db.session.add(user) 230 | create_user() 231 | 232 | with pytest.raises(InvalidRequestError, match='A transaction is already begun on this Session.'): # NOQA E501 233 | view_func() 234 | assert len(assert_session.query(User).all()) == 0 235 | 236 | def test_nested_self_with_nested_arg_is_true( 237 | self, session, assert_session): 238 | @transaction(session, nested=True) 239 | def create_user(): 240 | user = User(username='test1', email='1@b.com', password='1') 241 | session.add(user) 242 | 243 | @transaction(session) 244 | def view_func(): 245 | create_user() 246 | assert session.query(User).first() is not None 247 | 248 | view_func() 249 | assert len(assert_session.query(User).all()) == 1 250 | 251 | def test_nested_self_with_nested_arg_is_true_commit_not_raise( 252 | self, session, assert_session): 253 | @transaction(session, nested=True) 254 | def create_user(): 255 | user = User(username='test1', email='1@b.com', password='1') 256 | session.add(user) 257 | session.commit() 258 | 259 | @transaction(session) 260 | def view_func(): 261 | create_user() 262 | 263 | view_func() 264 | 265 | 266 | class TestNestedSessionSignal(BaseTest): 267 | 268 | def test_transaction_signal_success(self, client, assert_session): 269 | email = "test@test.com" 270 | resp = client.post('/create_user/success/', json={"email": email}) 271 | assert resp.status_code == 200 272 | 273 | user = assert_session.query(User).filter(User.email == email).first() 274 | assert user and user.username == "signalling_ok" 275 | -------------------------------------------------------------------------------- /tests/test_err_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sqlalchemy.orm import exc as orm_exc 4 | from werkzeug import exceptions as wkz_exc 5 | 6 | from hobbit_core.err_handler import ErrHandler, HobbitException 7 | 8 | from . import BaseTest 9 | 10 | 11 | class TestErrHandler(BaseTest): 12 | 13 | def test_assertion_error(self, app): 14 | resp = ErrHandler.handler(AssertionError('message')) 15 | assert resp.status_code == 422 16 | data = json.loads(resp.get_data()) 17 | assert data['message'] == 'message' 18 | 19 | def test_sqlalchemy_orm_exc(self, app): 20 | resp = ErrHandler.handler(orm_exc.NoResultFound()) 21 | assert resp.status_code == 404, resp.get_json() 22 | data = json.loads(resp.get_data()) 23 | assert data['message'] == u'源数据未找到' 24 | 25 | resp = ErrHandler.handler(orm_exc.UnmappedError()) 26 | assert resp.status_code == 500 27 | data = json.loads(resp.get_data()) 28 | assert data['message'] == u'服务器内部错误' 29 | 30 | def test_werkzeug_exceptions(self, app): 31 | resp = ErrHandler.handler(wkz_exc.Unauthorized()) 32 | assert resp.status_code == 401 33 | data = json.loads(resp.get_data()) 34 | assert data['message'] == u'未登录' 35 | 36 | def test_hobbit_exception(self, app): 37 | resp = ErrHandler.handler(HobbitException('msg')) 38 | assert resp.status_code == 400 39 | data = json.loads(resp.get_data()) 40 | assert data['message'] == 'msg' 41 | # py27,py36 == "Exception('msg',)" 42 | # py37 == "Exception('msg')" 43 | assert data['detail'].startswith("HobbitException('msg'") 44 | 45 | def test_others(self, app): 46 | resp = ErrHandler.handler(Exception('msg')) 47 | assert resp.status_code == 500 48 | data = json.loads(resp.get_data()) 49 | assert data['message'] == u'服务器内部错误' 50 | # py27,py36 == "Exception('msg',)" 51 | # py37 == "Exception('msg')" 52 | assert data['detail'].startswith("Exception('msg'") 53 | -------------------------------------------------------------------------------- /tests/test_fixture.py: -------------------------------------------------------------------------------- 1 | from .test_app.models import User 2 | from .test_app.exts import db 3 | 4 | from . import BaseTest 5 | 6 | 7 | class TestFixture(BaseTest): 8 | 9 | def test_session(self, assert_session): 10 | """assert session is isolated from db.session 11 | """ 12 | assert assert_session is not db.session 13 | 14 | user = User(username='test1', email='1@b.com', password='1') 15 | db.session.add(user) 16 | assert User.query.first() is not None 17 | assert assert_session.query(User).first() is None 18 | 19 | db.session.commit() 20 | assert User.query.first() is not None 21 | db.session.remove() 22 | assert User.query.first() is not None 23 | assert assert_session.query(User).first() is not None 24 | 25 | User.query.delete() 26 | assert User.query.first() is None 27 | assert assert_session.query(User).first() is not None 28 | 29 | db.session.commit() 30 | assert User.query.first() is None 31 | db.session.remove() 32 | assert User.query.first() is None 33 | assert assert_session.query(User).first() is None 34 | -------------------------------------------------------------------------------- /tests/test_hobbit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import itertools 4 | 5 | import pytest 6 | from click.testing import CliRunner 7 | 8 | from hobbit import main as hobbit 9 | from hobbit.bootstrap import templates 10 | 11 | from . import BaseTest, rmdir, chdir 12 | 13 | 14 | class TestHobbit(BaseTest): 15 | wkdir = os.path.abspath('hobbit-tox-test') 16 | 17 | def setup_method(self, method): 18 | rmdir(self.wkdir) 19 | 20 | def teardown_method(self, method): 21 | os.chdir(self.root_path) 22 | rmdir(self.wkdir) 23 | 24 | @pytest.fixture 25 | def runner(self): 26 | yield CliRunner() 27 | 28 | def test_not_exist_cmd(self, runner): 29 | result = runner.invoke(hobbit) 30 | assert result.exit_code == 0 31 | 32 | result = runner.invoke(hobbit, ['doesnotexistcmd'], obj={}) 33 | assert 'Error: cmd not exist: doesnotexistcmd' in result.output 34 | 35 | @pytest.mark.parametrize( 36 | 'name,template,dist', 37 | itertools.product( 38 | ['haha'], templates, 39 | [None, '.', wkdir])) 40 | @chdir(wkdir) 41 | def test_new_cmd(self, runner, name, template, dist): 42 | options = [ 43 | '--echo', 'new', '-p 1024', '-n', name, '-t', template] 44 | if dist: 45 | assert os.getcwd() == os.path.abspath(dist) 46 | options.extend(['-d', dist]) 47 | 48 | result = runner.invoke(hobbit, options, obj={}) 49 | assert result.exit_code == 0, result.output 50 | assert 'mkdir\t{}'.format(self.wkdir) in result.output 51 | assert 'render\t{}'.format(self.wkdir) in result.output 52 | 53 | file_nums = { 54 | # tart + 29 files + 11 dir + 1 end + empty 55 | 'shire': 1 + 27 + 11 + 1 + 1 - 1, 56 | 'rivendell': 1 + 29 + 11 + 1, 57 | } 58 | assert len(result.output.split('\n')) == file_nums[f'{template}'] 59 | 60 | assert subprocess.call(['flake8', '.']) == 0 61 | assert subprocess.call( 62 | 'pip install -r requirements.txt ' 63 | '--upgrade-strategy=only-if-needed', 64 | shell=True, stdout=subprocess.DEVNULL, 65 | stderr=subprocess.DEVNULL) == 0 66 | assert subprocess.call(['pytest'], stdout=subprocess.DEVNULL) == 0 67 | 68 | # test --force option 69 | result = runner.invoke(hobbit, options, obj={}) 70 | assert all([i in result.output for i in ['exists ', 'ignore ...']]) 71 | options.extend(['-f']) 72 | result = runner.invoke(hobbit, options, obj={}) 73 | assert any([i in result.output for i in ['exists ', 'ignore ...']]) 74 | 75 | @chdir(wkdir) 76 | def test_dev_init_cmd(self, runner): 77 | # new project use rivendell template 78 | cmd = ['--echo', 'new', '-n', 'haha', '-p', '1024', '-t', 'rivendell'] 79 | result = runner.invoke(hobbit, cmd, obj={}) 80 | assert result.exit_code == 0 81 | 82 | result = runner.invoke(hobbit, ['dev', 'init', '--all'], obj={}) 83 | assert result.exit_code == 0, result.output 84 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from webargs.core import ValidationError 4 | 5 | from hobbit_core.pagination import PageParams, pagination 6 | 7 | from . import BaseTest 8 | from .test_app.models import User 9 | from .test_app.exts import db 10 | 11 | 12 | class TestPagination(BaseTest): 13 | 14 | def test_page_params(self, web_request, parser): 15 | # test default 16 | @parser.use_kwargs(PageParams, web_request, location='query') 17 | def viewfunc(page, page_size, order_by): 18 | return {'page': page, 'page_size': page_size, 'order_by': order_by} 19 | 20 | assert viewfunc() == {'order_by': ['-id'], 'page': 1, 'page_size': 10} 21 | 22 | # test page_size_range 23 | web_request.query = {'page_size': 101} 24 | msg = r".*page_size': .*Must be greater than or equal to 5 and" + \ 25 | " less than or equal to 100.*" 26 | with pytest.raises(ValidationError, match=msg): 27 | print(viewfunc()) 28 | 29 | # test order_by 30 | web_request.query = {'order_by': 'id,-11'} 31 | msg = r".*order_by': .*String does not match expected pattern.*" 32 | with pytest.raises(ValidationError, match=msg): 33 | viewfunc() 34 | 35 | web_request.query = {'order_by': 'id,-aaa'} 36 | assert viewfunc() == { 37 | 'order_by': ['id', '-aaa'], 'page': 1, 'page_size': 10} 38 | 39 | web_request.query = {'order_by': ''} 40 | assert viewfunc() == {'order_by': [], 'page': 1, 'page_size': 10} 41 | 42 | def test_pagination(self, client): 43 | user1 = User(username='test1', email='1@b.com', password='1') 44 | user2 = User(username='test2', email='1@a.com', password='1') 45 | db.session.add(user1) 46 | db.session.add(user2) 47 | db.session.commit() 48 | db.session.refresh(user1) 49 | db.session.refresh(user2) 50 | 51 | # test ?order_by= worked 52 | resp = pagination(User, 1, 10, order_by=['']) 53 | print(resp) 54 | assert resp['total'] == 2 55 | assert resp['page_size'] == 10 56 | assert resp['page'] == 1 57 | assert [i.id for i in resp['items']] == [user1.id, user2.id] 58 | 59 | # test order_by: str 60 | resp = pagination(User, 1, 10, order_by='role') 61 | assert [i.id for i in resp['items']] == [user1.id, user2.id] 62 | 63 | resp = pagination(User, 1, 10, order_by=['role', '-id']) 64 | assert [i.id for i in resp['items']] == [user2.id, user1.id] 65 | 66 | resp = pagination(User, 1, 10, order_by=['role', 'username']) 67 | assert [i.id for i in resp['items']] == [user1.id, user2.id] 68 | 69 | with pytest.raises(Exception, match='first arg obj must be model.'): 70 | pagination('User', 1, 10, order_by='role') 71 | 72 | msg = r'columns .*roles.* not exist in {} model'.format(User) 73 | with pytest.raises(Exception, match=msg): 74 | pagination(User, 1, 10, order_by='roles') 75 | db.session.commit() 76 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | import pytest 3 | 4 | from hobbit_core.response import gen_response, Result, \ 5 | SuccessResult, FailedResult, UnauthorizedResult, ForbiddenResult, \ 6 | ValidationErrorResult, ServerErrorResult 7 | 8 | from . import BaseTest 9 | 10 | 11 | class TestResponse(BaseTest): 12 | 13 | @pytest.mark.parametrize('input_, excepted', [ 14 | ((1, ), { 15 | 'code': '1', 'message': 'unknown', 'detail': None, 'data': None}), 16 | ((1, '测试', ['1'], {}), { 17 | 'code': '1', 'message': '测试', 'detail': ['1'], 'data': {}}), 18 | ((1, '测试', ['1'], []), { 19 | 'code': '1', 'message': '测试', 'detail': ['1'], 'data': []}), 20 | ]) 21 | def test_gen_response(self, app, input_, excepted): 22 | assert excepted == gen_response(*input_) 23 | 24 | def test_result(self): 25 | msg = 'Error response, must include keys: code, data, detail, message' 26 | with pytest.raises(AssertionError, match=msg): 27 | Result({}) 28 | 29 | response = { 30 | 'code': '1', 'message': u'unknown', 'detail': None, 'data': None} 31 | result = Result(response) 32 | print(result.__dict__) 33 | assert result.status_code == 200 34 | 35 | result = Result(response, status=201) 36 | assert result.status_code == 201 37 | 38 | def test_success_result(self, app): 39 | # assert status can rewrite 40 | excepted = b'{\n"code":"200",\n"data":null,\n"detail":null,\n"message":"message"\n}\n' # NOQA 41 | result = SuccessResult('message', status=301) 42 | assert result.status_code == 301 43 | assert excepted == result.data 44 | 45 | # assert default is 200 46 | result = SuccessResult() 47 | assert result.status_code == 200 48 | 49 | app.config['HOBBIT_USE_CODE_ORIGIN_TYPE'] = True 50 | result = SuccessResult(code=0) 51 | assert b'"code":0' in result.data 52 | 53 | app.config['HOBBIT_USE_CODE_ORIGIN_TYPE'] = False 54 | result = SuccessResult(code=0) 55 | assert b'"code":"0"' in result.data 56 | 57 | app.config['HOBBIT_RESPONSE_MESSAGE_MAPS'] = {100: 'testmsg'} 58 | result = SuccessResult(code=100) 59 | assert b'"message":"testmsg"' in result.data 60 | app.config['HOBBIT_RESPONSE_MESSAGE_MAPS'] = {} 61 | 62 | def test_500_result(self, app): 63 | result = ServerErrorResult('message', detail='detail') 64 | assert result.status_code == 500 65 | excepted = b'{\n"code":"500",\n"data":null,\n"detail":"detail",\n"message":"message"\n}\n' # NOQA 66 | assert excepted == result.data 67 | 68 | app.config['HOBBIT_RESPONSE_DETAIL'] = False 69 | result = ServerErrorResult('message', detail='detail') 70 | excepted = b'{\n"code":"500",\n"data":null,\n"detail":null,\n"message":"message"\n}\n' # NOQA 71 | assert excepted == result.data 72 | 73 | def test_failed_result(self): 74 | result = FailedResult() 75 | assert result.status_code == 400 76 | 77 | @pytest.mark.parametrize('result, excepted_status', [ 78 | (UnauthorizedResult, 401), 79 | (ForbiddenResult, 403), 80 | (ValidationErrorResult, 422), 81 | (ServerErrorResult, 500), 82 | ]) 83 | def test_results(self, result, excepted_status): 84 | assert result().status_code == excepted_status 85 | -------------------------------------------------------------------------------- /tests/test_schemas.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from marshmallow_enum import EnumField 3 | 4 | from hobbit_core.schemas import ModelSchema 5 | 6 | from .test_app.exts import db 7 | from .test_app.models import User, RoleEnum 8 | 9 | from . import BaseTest 10 | 11 | 12 | class TestSchema(BaseTest): 13 | 14 | def test_model_schema(self, client): 15 | 16 | class UserSchema(ModelSchema): 17 | role = EnumField(RoleEnum) 18 | pass 19 | 20 | class Meta: 21 | model = User 22 | include_relationships = True 23 | load_instance = True 24 | 25 | assert UserSchema.Meta.dateformat == '%Y-%m-%d %H:%M:%S' 26 | 27 | user = User(username='name', email='admin@test', role=RoleEnum.admin) 28 | db.session.add(user) 29 | db.session.commit() 30 | 31 | data = UserSchema().dump(user) 32 | print(data) 33 | assert data['role'] == {'key': 1, 'label': 'admin', 'value': '管理员'} 34 | 35 | class UserSchema(ModelSchema): 36 | role = EnumField(RoleEnum) 37 | 38 | class Meta: 39 | model = User 40 | verbose = False 41 | 42 | data = UserSchema().dump(user) 43 | assert data['role'] == {'key': 1, 'value': '管理员'} 44 | 45 | payload = {'username': 'name', 'email': 'admin@test'} 46 | for role in (RoleEnum.admin.name, RoleEnum.admin.value[0], 47 | RoleEnum.admin.value[1]): 48 | payload['role'] = role 49 | assert UserSchema().load(payload) == { 50 | 'role': RoleEnum.admin, 'email': 'admin@test', 51 | 'username': 'name', 52 | } 53 | db.session.commit() 54 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from importlib import reload 3 | import random 4 | import string 5 | 6 | from hobbit_core import utils 7 | 8 | from .test_app.exts import db 9 | from .test_app.models import BulkModel, BulkModel2, \ 10 | BulkModelMysql, BulkModel2Mysql 11 | 12 | from . import BaseTest 13 | 14 | 15 | class TestUtils(BaseTest): 16 | 17 | def test_params_dict(self): 18 | d = utils.ParamsDict({'1': 1}) 19 | 20 | updated_d = d.update({'2': 2}) 21 | # assert isinstance(updated_d, utils.ParamsDict) 22 | assert updated_d == {'1': 1, '2': 2} 23 | assert d == {'1': 1} 24 | 25 | reupdated = updated_d.update({'3': 3}) 26 | # assert isinstance(reupdated, utils.ParamsDict) 27 | assert reupdated == {'1': 1, '2': 2, '3': 3} 28 | assert updated_d == {'1': 1, '2': 2} 29 | assert d == {'1': 1} 30 | 31 | def test_dict2object(self): 32 | obj = utils.dict2object({'a': 2, 'c': 3}) 33 | assert obj.a == 2 34 | assert obj.c == 3 35 | 36 | # test setattr 37 | obj.a = 4 38 | assert obj.a == 4 39 | 40 | # test getattr 41 | with pytest.raises(AttributeError): 42 | obj.b 43 | 44 | # this func worked ok in py2 & py3, u'' is py2 test 45 | @pytest.mark.parametrize("filename, excepted", [ 46 | # (u'哈哈.zip', u'哈哈.zip'), 47 | ('哈哈.zip', '哈哈.zip'), 48 | ('../../../etc/passwd', 'etc_passwd'), 49 | ('My cool movie.mov', 'My_cool_movie.mov'), 50 | ('__filename__', 'filename'), 51 | ('foo$&^*)bar', 'foobar'), 52 | # (u'i contain cool \xfcml\xe4uts.txt', 'i_contain_cool_umlauts.txt'), 53 | ('i contain cool \xfcml\xe4uts.txt', 'i_contain_cool_umlauts.txt'), 54 | ]) 55 | def test_secure_filename(self, filename, excepted): 56 | assert utils.secure_filename(filename) == excepted 57 | 58 | 59 | class TestUseKwargs(BaseTest): 60 | 61 | def test_use_kwargs_with_partial(self, client): 62 | payload = {'username': 'username', 'email': 'email'} 63 | resp = client.post('/use_kwargs_with_partial/', json=payload) 64 | assert resp.json == payload 65 | 66 | def test_use_kwargs_without_partial(self, client): 67 | payload = {'username': 'username', 'email': 'email'} 68 | resp = client.post('/use_kwargs_without_partial/', json=payload) 69 | assert resp.json == payload 70 | 71 | def test_use_kwargs_with_partial2(self, client): 72 | payload = {'username': 'username'} 73 | resp = client.post('/use_kwargs_with_partial/', json=payload) 74 | assert resp.json == payload 75 | 76 | # TODO 暂时忽略,flask+werkzeug的bug 77 | # def test_use_kwargs_without_partial2(self, client): 78 | # payload = {'username': 'username'} 79 | # resp = client.post('/use_kwargs_without_partial/', json=payload) 80 | # print(resp) 81 | # assert resp.status == 422 # marshmallow==v3.0.0rc4', maybe a bug 82 | # # assert resp.json == {'username': 'username'} 83 | 84 | def test_use_kwargs_dictargmap_partial(self, client): 85 | resp = client.post('/use_kwargs_dictargmap_partial/', json={}) 86 | assert resp.json == {'username': None} 87 | 88 | def test_use_kwargs_dictargmap_partial2(self, client): 89 | resp = client.post('/use_kwargs_dictargmap_partial/', json={ 90 | 'username': None}) 91 | assert resp.json == {'username': None} 92 | 93 | def test_base_use_kwargs_dictargmap_whitout_partial(self, client): 94 | resp = client.post('/base_use_kwargs_dictargmap_partial/', json={}) 95 | assert resp.json == {'username': None} 96 | 97 | def test_auto_trim(self, client): 98 | payload = {'username': ' username', 'email': ' email '} 99 | resp = client.post('/use_kwargs_with_partial/', json=payload) 100 | assert resp.json == {'username': 'username', 'email': 'email'} 101 | 102 | 103 | class TestImportSubs(BaseTest): 104 | 105 | def test_import_subs(self, app): 106 | with app.app_context(): 107 | from . import importsub 108 | from .test_app.exts import db 109 | db.create_all(bind_key=None) 110 | all_ = getattr(importsub, '__all__') 111 | assert sorted(all_) == sorted([ 112 | 'A', 113 | 'BaseModel', 114 | 'G_VAR', 115 | 'OtherUser', 116 | 'PagedSchema', 117 | 'PagedUserSchema', 118 | 'User', 119 | 'UserSchema', 120 | 'FooService', 121 | 'BarService', 122 | 'b', 123 | 'models', 124 | 'others', 125 | 'paged_user_schemas', 126 | 'schemas', 127 | 'services', 128 | 'user_schemas' 129 | ]) 130 | for name in all_: 131 | exec(f'from .importsub import {name}') 132 | 133 | setattr(importsub.others, '__all__', [importsub.others.A]) 134 | msg = "Invalid object " + \ 135 | "in __all__, must contain only strings." 136 | with pytest.raises(Exception, match=msg): 137 | reload(importsub) 138 | 139 | 140 | class TestBulkInsertOrUpdate(BaseTest): 141 | 142 | @pytest.mark.parametrize('item_length', [0, 1, 2, 501]) 143 | @pytest.mark.parametrize( 144 | 'model_cls, updated_at_field_name', [ 145 | (BulkModel, None), 146 | (BulkModel2, 'update'), 147 | (BulkModelMysql, None), 148 | (BulkModel2Mysql, 'update'), 149 | ]) 150 | def test_bulk_create_or_update_on_duplicate( 151 | self, item_length, model_cls, updated_at_field_name): 152 | items = [] 153 | for i in range(item_length): 154 | items.append({key: ''.join(random.choices( 155 | string.ascii_letters + "'" + '"', k=50)) for key in ( 156 | 'x', 'y', 'z')}) 157 | 158 | params = { 159 | 'db': db, 'model_cls': model_cls, 'items': items, 160 | } 161 | if updated_at_field_name: 162 | params['updated_at'] = updated_at_field_name 163 | 164 | result = utils.bulk_create_or_update_on_duplicate(**params) 165 | assert result['items_count'] == item_length and \ 166 | result['rowcount'] == item_length, result 167 | 168 | result = utils.bulk_create_or_update_on_duplicate(**params) 169 | assert result['items_count'] == item_length and result['rowcount'] \ 170 | == item_length, result 171 | 172 | assert db.session.query(model_cls).count() == item_length 173 | db.session.commit() 174 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # TOX CONFIGURATION 2 | # 3 | # pyenv virtualenv -p 3.8.10 py38 4 | # pyenv virtualenv -p 3.9.5 py39 5 | # pyenv virtualenv -p 3.10.0 py310 6 | # pyenv virtualenv -p 3.11.0 py311 7 | # pyenv virtualenv -p 3.12.0 py312 8 | # 9 | # pyenv shell py38 py39 py310 py311 py312 10 | 11 | [tox] 12 | envlist = doc,py{38,39,310,311,312} 13 | 14 | [testenv:doc] 15 | basepython = python3 16 | changedir = docs 17 | deps = 18 | mypy 19 | sphinx 20 | sphinx-autobuild 21 | flask-sphinx-themes 22 | allowlist_externals = make 23 | commands = make html 24 | 25 | [testenv] 26 | extras = hobbit,hobbit_core 27 | deps = 28 | psycopg2-binary 29 | cx-oracle 30 | pymysql 31 | cryptography 32 | mypy 33 | pytest 34 | pytest-cov 35 | pytest-env 36 | flake8 37 | pipenv 38 | blinker[flask] 39 | commands = 40 | flake8 . 41 | # mypy hobbit hobbit_core tests 42 | py.test {posargs} 43 | --------------------------------------------------------------------------------