├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature-request.md ├── actions │ └── comment-docs-preview-in-pr │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── action.yml │ │ └── app │ │ └── main.py ├── dependabot.yml └── workflows │ ├── build-docs.yml │ ├── latest-changes.yml │ ├── new_contributor_pr.yml │ ├── preview-docs.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── codecov.yml ├── docs ├── en │ ├── data │ │ └── .gitignore │ ├── docs │ │ ├── contributing.md │ │ ├── css │ │ │ ├── custom.css │ │ │ └── termynal.css │ │ ├── features.md │ │ ├── help-reforms.md │ │ ├── img │ │ │ ├── favicon.png │ │ │ ├── icon-white.png │ │ │ ├── index │ │ │ │ ├── index-01-web-form.png │ │ │ │ └── index-02-web-form-passed.png │ │ │ ├── logo-white.png │ │ │ └── logo-white.svg │ │ ├── index.md │ │ ├── js │ │ │ ├── custom.js │ │ │ └── termynal.js │ │ ├── release-notes.md │ │ └── usage │ │ │ ├── fields.md │ │ │ ├── helpers.md │ │ │ └── validators.md │ ├── mkdocs.yml │ └── overrides │ │ └── main.html ├── missing-translation.md └── ru │ ├── docs │ └── index.md │ ├── mkdocs.yml │ └── overrides │ └── .gitignore ├── docs_src ├── first-steps │ ├── main_fastapi.py │ ├── main_starlette.py │ ├── models.py │ └── templates │ │ └── index.html └── validators │ ├── tutorial001.py │ ├── tutorial002.py │ └── tutorial003.py ├── mypy.ini ├── pyproject.toml ├── reforms ├── __init__.py ├── contrib │ ├── __init__.py │ └── fastapi │ │ └── __init__.py ├── fields │ ├── __init__.py │ ├── base.py │ ├── bool_field.py │ ├── email_field.py │ ├── hidden.py │ └── str_field.py ├── forms.py ├── main.py ├── py.typed ├── templates │ └── forms │ │ ├── checkbox.html │ │ ├── email.html │ │ ├── hidden.html │ │ ├── input.html │ │ └── text.html ├── validators │ ├── __init__.py │ ├── any_of.py │ ├── base.py │ ├── length.py │ └── none_of.py └── widgets.py ├── scripts ├── build-docs.sh ├── clean.sh ├── docs-live.sh ├── docs.py ├── format-imports.sh ├── format.sh ├── lint.sh ├── publish.sh ├── test-cov-html.sh ├── test.sh └── zip-docs.sh ├── setup.cfg ├── tests ├── __init__.py ├── conftest.py ├── contrib │ ├── __init__.py │ └── fastapi │ │ ├── __init__.py │ │ └── test_from_model.py ├── test_default_rendering.py ├── test_fields.py ├── test_loader.py ├── test_validators │ ├── __init__.py │ ├── test_any_of.py │ ├── test_length.py │ ├── test_multiply_validators.py │ └── test_none_of.py └── test_widgets.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = reforms 3 | omit = reforms/__main__.py 4 | 5 | [report] 6 | exclude_lines = 7 | # Have to re-enable the standard pragma 8 | pragma: no cover 9 | 10 | # Don't complain about missing debug-only code: 11 | def __repr__ 12 | if self\.debug 13 | 14 | # Don't complain if tests don't hit defensive assertion code: 15 | raise AssertionError 16 | raise NotImplementedError 17 | 18 | # Don't complain if non-runnable code isn't run: 19 | if 0: 20 | if __name__ == .__main__.: 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** 12 | 13 | Steps to reproduce the behavior: 14 | 1. 15 | 16 | ### Environment 17 | 18 | * OS: [e.g. Linux / Windows / macOS]: 19 | * Reforms version [e.g. 0.1.0]: 20 | 21 | To know the Reforms version use: 22 | 23 | ```bash 24 | python -c "import reforms; print(reforms.__version__)" 25 | ``` 26 | 27 | * Python version: 28 | 29 | To know the Python version use: 30 | 31 | ```bash 32 | python --version 33 | ``` 34 | 35 | ### Additional context 36 | 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: enhancement 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | 9 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | 11 | **Describe the solution you'd like** 12 | 13 | A clear and concise description of what you want to happen. 14 | 15 | **Additional context** 16 | 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/actions/comment-docs-preview-in-pr/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | RUN pip install httpx "pydantic==1.5.1" pygithub 4 | 5 | COPY ./app /app 6 | 7 | CMD ["python", "/app/main.py"] 8 | -------------------------------------------------------------------------------- /.github/actions/comment-docs-preview-in-pr/README.md: -------------------------------------------------------------------------------- 1 | # Comment docs preview in PR 2 | 3 | This action was used from the [FastAPI](https://github.com/tiangolo/fastapi) project, original one can be found [here](https://github.com/tiangolo/fastapi/tree/master/.github/actions/comment-docs-preview-in-pr). Special thanks to [Sebastián Ramírez](https://github.com/tiangolo). 4 | 5 | ## License 6 | 7 | This project is licensed under the terms of the MIT license. 8 | -------------------------------------------------------------------------------- /.github/actions/comment-docs-preview-in-pr/action.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Comment Docs Preview in PR 3 | description: Comment with the docs URL preview in the PR 4 | author: Sebastián Ramírez 5 | inputs: 6 | token: 7 | description: Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }} 8 | required: true 9 | deploy_url: 10 | description: The deployment URL to comment in the PR 11 | required: true 12 | runs: 13 | using: docker 14 | image: Dockerfile 15 | 16 | -------------------------------------------------------------------------------- /.github/actions/comment-docs-preview-in-pr/app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import httpx 7 | from github import Github 8 | from github.PullRequest import PullRequest 9 | from pydantic import BaseModel, BaseSettings, SecretStr, ValidationError 10 | 11 | github_api = "https://api.github.com" 12 | 13 | 14 | class Settings(BaseSettings): 15 | github_repository: str 16 | github_event_path: Path 17 | github_event_name: Optional[str] = None 18 | input_token: SecretStr 19 | input_deploy_url: str 20 | 21 | 22 | class PartialGithubEventHeadCommit(BaseModel): 23 | id: str 24 | 25 | 26 | class PartialGithubEventWorkflowRun(BaseModel): 27 | head_commit: PartialGithubEventHeadCommit 28 | 29 | 30 | class PartialGithubEvent(BaseModel): 31 | workflow_run: PartialGithubEventWorkflowRun 32 | 33 | 34 | if __name__ == "__main__": 35 | logging.basicConfig(level=logging.INFO) 36 | settings = Settings() 37 | logging.info(f"Using config: {settings.json()}") 38 | g = Github(settings.input_token.get_secret_value()) 39 | repo = g.get_repo(settings.github_repository) 40 | try: 41 | event = PartialGithubEvent.parse_file(settings.github_event_path) 42 | except ValidationError as e: 43 | logging.error(f"Error parsing event file: {e.errors()}") 44 | sys.exit(0) 45 | use_pr: Optional[PullRequest] = None 46 | for pr in repo.get_pulls(): 47 | if pr.head.sha == event.workflow_run.head_commit.id: 48 | use_pr = pr 49 | break 50 | if not use_pr: 51 | logging.error( 52 | f"No PR found for hash: {event.workflow_run.head_commit.id}" 53 | ) 54 | sys.exit(0) 55 | github_headers = { 56 | "Authorization": f"token {settings.input_token.get_secret_value()}" 57 | } 58 | url = f"{github_api}/repos/{settings.github_repository}/issues/{use_pr.number}/comments" 59 | logging.info(f"Using comments URL: {url}") 60 | response = httpx.post( 61 | url, 62 | headers=github_headers, 63 | json={ 64 | "body": f"📝 Docs preview for commit {use_pr.head.sha} at: {settings.input_deploy_url}" 65 | }, 66 | ) 67 | if not (200 <= response.status_code <= 300): 68 | logging.error(f"Error posting comment: {response.text}") 69 | sys.exit(1) 70 | logging.info("Finished") 71 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | pull-request-branch-name: 9 | separator: "-" 10 | commit-message: 11 | prefix: "⬆" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | pull-request-branch-name: 18 | separator: "-" 19 | commit-message: 20 | prefix: "⬆" 21 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build Docs 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize] 9 | jobs: 10 | build: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - name: Dump GitHub context 14 | env: 15 | GITHUB_CONTEXT: ${{ toJson(github) }} 16 | run: echo "$GITHUB_CONTEXT" 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.8" 23 | - uses: actions/cache@v2 24 | id: cache 25 | with: 26 | path: ${{ env.pythonLocation }} 27 | key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-docs 28 | 29 | - name: Install Flit 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: python3.8 -m pip install flit 32 | 33 | - name: Install docs extras 34 | if: steps.cache.outputs.cache-hit != 'true' 35 | run: python3.8 -m flit install --extras doc 36 | 37 | - name: Build Docs 38 | run: python3.8 ./scripts/docs.py build-all 39 | 40 | - name: Zip docs 41 | run: bash ./scripts/zip-docs.sh 42 | - uses: actions/upload-artifact@v2 43 | with: 44 | name: docs-zip 45 | path: ./docs.zip 46 | 47 | - name: Deploy to Netlify 48 | uses: nwtgck/actions-netlify@v1.2.2 49 | with: 50 | publish-dir: './site' 51 | production-branch: master 52 | github-token: ${{ secrets.GITHUB_TOKEN }} 53 | enable-commit-comment: false 54 | env: 55 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 56 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 57 | 58 | -------------------------------------------------------------------------------- /.github/workflows/latest-changes.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Latest Changes 3 | 4 | on: 5 | pull_request_target: 6 | branches: 7 | - master 8 | types: 9 | - closed 10 | workflow_dispatch: 11 | inputs: 12 | number: 13 | description: PR number 14 | required: true 15 | 16 | jobs: 17 | latest-changes: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: docker://tiangolo/latest-changes:0.0.3 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | latest_changes_file: docs/en/docs/release-notes.md 25 | latest_changes_header: '## Latest Changes\n\n' 26 | debug_logs: true 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/new_contributor_pr.yml: -------------------------------------------------------------------------------- 1 | name: New contributor message 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened] 6 | 7 | jobs: 8 | build: 9 | name: Hello new contributor 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | pr-message: | 16 | Hello! Thank you for your contribution 💪 17 | 18 | As it's your first contribution be sure to check out the [contribution notes](https://reforms.boardpack.org/contributing/). 19 | 20 | Welcome aboard ⛵️! 21 | -------------------------------------------------------------------------------- /.github/workflows/preview-docs.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Preview Docs 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Build Docs 7 | types: 8 | - completed 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Download Artifact Docs 17 | uses: dawidd6/action-download-artifact@v2.14.1 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | workflow: build-docs.yml 21 | run_id: ${{ github.event.workflow_run.id }} 22 | name: docs-zip 23 | 24 | - name: Unzip docs 25 | run: | 26 | rm -rf ./site 27 | unzip docs.zip 28 | rm -f docs.zip 29 | 30 | - name: Deploy to Netlify 31 | id: netlify 32 | uses: nwtgck/actions-netlify@v1.2.2 33 | with: 34 | publish-dir: './site' 35 | production-deploy: false 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | enable-commit-comment: false 38 | env: 39 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 40 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 41 | 42 | - name: Comment Deploy 43 | uses: ./.github/actions/comment-docs-preview-in-pr 44 | with: 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | deploy_url: "${{ steps.netlify.outputs.deploy-url }}" 47 | 48 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Dump GitHub context 13 | env: 14 | GITHUB_CONTEXT: ${{ toJson(github) }} 15 | run: echo "$GITHUB_CONTEXT" 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: "3.6" 21 | - name: Install Flit 22 | run: pip install flit 23 | - name: Install Dependencies 24 | run: flit install --symlink 25 | - name: Publish 26 | env: 27 | FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 28 | FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 29 | run: bash scripts/publish.sh 30 | - name: Dump GitHub context 31 | env: 32 | GITHUB_CONTEXT: ${{ toJson(github) }} 33 | run: echo "$GITHUB_CONTEXT" 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [3.6, 3.7, 3.8, 3.9] 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install flit and tox-related libs 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flit tox tox-gh-actions 30 | 31 | - name: Install dependencies 32 | run: flit install --symlink 33 | 34 | - name: Test 35 | run: tox 36 | 37 | - name: Upload coverage 38 | uses: codecov/codecov-action@v2.1.0 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDE settings 132 | .vscode/ 133 | .idea/ 134 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com/ for usage and config 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: test 6 | name: test 7 | stages: [commit] 8 | language: system 9 | entry: bash ./scripts/test.sh 10 | types: [python] 11 | pass_filenames: false 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, Roman Sadzhenytsia 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Reforms 3 |

4 | 7 |

8 | 9 | Test 10 | 11 | 12 | Coverage 13 | 14 | 15 | Package version 16 | 17 | Code style: black 18 | Imports: isort 19 |

20 | 21 |

CURRENTLY, LIBRARY ISN'T UNDER ACTIVE DEVELOPMENT!

22 | 23 | --- 24 | 25 | **Documentation**: https://reforms.boardpack.org 26 | 27 | **Source Code**: https://github.com/boardpack/reforms 28 | 29 | --- 30 | 31 | Reforms is a fresh pydantic-based forms validation and rendering library for Python 3.6+. 32 | 33 | The key features are: 34 | 35 | * **Familiar**: Expanded Pydantic retaining all data validation and model creation 36 | capabilities. 37 | * **Easy**: Designed to be easy to use and learn. Less time reading docs. 38 | * **Theming**: Supported the usage of existing template pack and the creation of your 39 | own. 40 | 41 | ## Requirements 42 | 43 | Python 3.6+ 44 | 45 | Reforms has the next hard dependencies: 46 | 47 | * Pydantic for the data parts. 48 | * Jinja2 for the templates. 49 | 50 | ## Installation 51 | 52 |
53 | 54 | ```console 55 | $ pip install git+http://github.com/boardpack/reforms 56 | 57 | ---> 100% 58 | ``` 59 | 60 |
61 | 62 | ## Example 63 | 64 | In the next example, we will use FastAPI 65 | as a web framework. So you need to install `fastapi` and `uvicorn` first. Also, you 66 | need to install `python-multipart` library to turn on forms support in FastAPI. 67 | 68 |
69 | 70 | ```console 71 | $ pip install fastapi uvicorn python-multipart 72 | 73 | ---> 100% 74 | ``` 75 | 76 |
77 | 78 | ### Create it 79 | 80 | * Create a file `models.py` with `UserModel` pydantic model: 81 | 82 | ```Python 83 | from pydantic import BaseModel 84 | 85 | from reforms import StringField, BooleanField, EmailField 86 | from reforms.validators import Length 87 | 88 | 89 | class UserModel(BaseModel): 90 | first_name: StringField( 91 | label="First Name", 92 | field_id="firstName", 93 | placeholder="John", 94 | validators=[Length(min=5)], 95 | ) 96 | last_name: StringField( 97 | label="Last Name", 98 | field_id="lastName", 99 | placeholder="Doe", 100 | validators=[Length(min=5)], 101 | ) 102 | email: EmailField( 103 | label="Email", 104 | field_id="email", 105 | placeholder="john.doe@example.com", 106 | ) 107 | has_github: BooleanField(label="Has Github account?", field_id="hasGithub") = False 108 | 109 | ``` 110 | _(This script is complete, it should run "as is")_ 111 | 112 | * Then you can create a FastAPI application and use this model to generate form 113 | layout and validate data. Reforms has special `on_model` function, which works 114 | with `Depends` from FastAPI to convert raw form data into pydantic model object. 115 | Create a file `main.py` with: 116 | 117 | ```Python hl_lines="8 19 23 28" 118 | import uvicorn 119 | from fastapi import FastAPI, Request, Depends 120 | from fastapi.responses import HTMLResponse, RedirectResponse 121 | from fastapi.templating import Jinja2Templates 122 | from starlette.status import HTTP_302_FOUND 123 | from reforms import Reforms, on_model 124 | 125 | from models import UserModel 126 | 127 | app = FastAPI() 128 | 129 | forms = Reforms(package="reforms") 130 | 131 | templates = Jinja2Templates(directory="templates") 132 | 133 | 134 | @app.get("/", response_class=HTMLResponse) 135 | async def index(request: Request): 136 | user_form = forms.Form(UserModel) 137 | 138 | return templates.TemplateResponse( 139 | "index.html", 140 | {"request": request, "form": user_form}, 141 | ) 142 | 143 | 144 | @app.post("/", response_class=RedirectResponse) 145 | async def handle_form(form: UserModel = Depends(on_model(UserModel))): 146 | print(form) 147 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 148 | 149 | 150 | if __name__ == '__main__': 151 | uvicorn.run(app) 152 | 153 | ``` 154 | _(This script is complete, it should run "as is")_ 155 | 156 | * As the last coding step, you need to create a template (now **reforms** supports only 157 | **jinja2** templates). You can use just form object to render all fields 158 | simultaneously or render every field separately (as it mentions in the selected 159 | commented line). 160 | 161 | ```HTML hl_lines="10" 162 | 163 | 164 | 165 | 166 | Example reforms page 167 | 168 | 169 |
170 | {{ form }} 171 | {#{{ form.first_name }}#} 172 |
173 | 174 |
175 | 176 | 177 | ``` 178 | _(This template is complete, it should use "as is")_ 179 | 180 | ### Run it 181 | 182 | Run the server with: 183 | 184 |
185 | 186 | ```console 187 | $ uvicorn main:app --reload 188 | 189 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 190 | INFO: Started reloader process [28720] 191 | INFO: Started server process [28722] 192 | INFO: Waiting for application startup. 193 | INFO: Application startup complete. 194 | ``` 195 | 196 |
197 | 198 |
199 | About the command uvicorn main:app --reload... 200 | 201 | The command `uvicorn main:app` refers to: 202 | 203 | * `main`: the file `main.py` (the Python "module"). 204 | * `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 205 | * `--reload`: make the server restart after code changes. Only do this for development. 206 | 207 |
208 | 209 | or just with: 210 | 211 |
212 | 213 | ```console 214 | $ python main.py 215 | 216 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 217 | INFO: Started reloader process [28720] 218 | INFO: Started server process [28722] 219 | INFO: Waiting for application startup. 220 | INFO: Application startup complete. 221 | ``` 222 | 223 |
224 | 225 | ### Send it 226 | 227 | Open your browser at http://127.0.0.1:8000. 228 | 229 | You will see the web form: 230 | 231 | ![Example form](docs/en/docs/img/index/index-01-web-form.png) 232 | 233 | Add some information like this and click "Send" button: 234 | 235 | ![Passed example form](docs/en/docs/img/index/index-02-web-form-passed.png) 236 | 237 | ### Check it 238 | 239 | Finally, you can see a printed validated model object in your console: 240 | 241 | ```bash hl_lines="8" 242 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 243 | INFO: Started reloader process [28720] 244 | INFO: Started server process [28722] 245 | INFO: Waiting for application startup. 246 | INFO: Application startup complete. 247 | 248 | INFO: 127.0.0.1:33612 - "GET / HTTP/1.1" 200 OK 249 | first_name='Roman' last_name='Dukkee' email='example@example.com' has_github=True 250 | INFO: 127.0.0.1:33872 - "POST / HTTP/1.1" 302 Found 251 | INFO: 127.0.0.1:33872 - "GET / HTTP/1.1" 200 OK 252 | ``` 253 | 254 | ## Acknowledgments 255 | 256 | Special thanks to: 257 | 258 | * [Sebastián Ramírez](https://github.com/tiangolo) and his [FastAPI](https://github.com/tiangolo/fastapi) project, some scripts and documentation structure and parts were used from there. 259 | 260 | * [Samuel Colvin](https://github.com/samuelcolvin) and his [Pydantic](https://github.com/samuelcolvin/pydantic/) project. 261 | 262 | ## License 263 | 264 | This project is licensed under the terms of the MIT license. 265 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 90% 6 | threshold: 5% 7 | patch: 8 | default: 9 | target: 90% 10 | threshold: 5% 11 | -------------------------------------------------------------------------------- /docs/en/data/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/data/.gitignore -------------------------------------------------------------------------------- /docs/en/docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Development - Contributing 2 | 3 | First, you might want to see the basic ways to [help Reforms and get help](help-reforms.md){.internal-link target=_blank}. 4 | 5 | ## Developing 6 | 7 | If you already cloned the repository and you know that you need to deep dive in the code, here are some guidelines to set up your environment. 8 | 9 | ### Virtual environment with `venv` 10 | 11 | You can create a virtual environment in a directory using Python's `venv` module: 12 | 13 |
14 | 15 | ```console 16 | $ python -m venv env 17 | ``` 18 | 19 |
20 | 21 | That will create a directory `./env/` with the Python binaries and then you will be able to install packages for that isolated environment. 22 | 23 | ### Activate the environment 24 | 25 | Activate the new environment with: 26 | 27 | === "Linux, macOS" 28 | 29 |
30 | 31 | ```console 32 | $ source ./env/bin/activate 33 | ``` 34 | 35 |
36 | 37 | === "Windows PowerShell" 38 | 39 |
40 | 41 | ```console 42 | $ .\env\Scripts\Activate.ps1 43 | ``` 44 | 45 |
46 | 47 | === "Windows Bash" 48 | 49 | Or if you use Bash for Windows (e.g. Git Bash): 50 | 51 |
52 | 53 | ```console 54 | $ source ./env/Scripts/activate 55 | ``` 56 | 57 |
58 | 59 | To check it worked, use: 60 | 61 | === "Linux, macOS, Windows Bash" 62 | 63 |
64 | 65 | ```console 66 | $ which pip 67 | 68 | some/directory/reforms/env/bin/pip 69 | ``` 70 | 71 |
72 | 73 | === "Windows PowerShell" 74 | 75 |
76 | 77 | ```console 78 | $ Get-Command pip 79 | 80 | some/directory/reforms/env/bin/pip 81 | ``` 82 | 83 |
84 | 85 | If it shows the `pip` binary at `env/bin/pip` then it worked. 🎉 86 | 87 | 88 | 89 | !!! tip 90 | Every time you install a new package with `pip` under that environment, activate the environment again. 91 | 92 | This makes sure that if you use a terminal program installed by that package (like `flit`), you use the one from your local environment and not any other that could be installed globally. 93 | 94 | ### Flit 95 | 96 | **Reforms** uses Flit to build, package and publish the project. 97 | 98 | After activating the environment as described above, install `flit`: 99 | 100 |
101 | 102 | ```console 103 | $ pip install flit 104 | 105 | ---> 100% 106 | ``` 107 | 108 |
109 | 110 | Now re-activate the environment to make sure you are using the `flit` you just installed (and not a global one). 111 | 112 | And now use `flit` to install the development dependencies: 113 | 114 | === "Linux, macOS" 115 | 116 |
117 | 118 | ```console 119 | $ flit install --deps develop --symlink 120 | 121 | ---> 100% 122 | ``` 123 | 124 |
125 | 126 | === "Windows" 127 | 128 | If you are on Windows, use `--pth-file` instead of `--symlink`: 129 | 130 |
131 | 132 | ```console 133 | $ flit install --deps develop --pth-file 134 | 135 | ---> 100% 136 | ``` 137 | 138 |
139 | 140 | It will install all the dependencies and your local Reforms in your local environment. 141 | 142 | #### Using your local Reforms 143 | 144 | If you create a Python file that imports and uses Reforms, and run it with the Python from your local environment, it will use your local Reforms source code. 145 | 146 | And if you update that local Reforms source code, as it is installed with `--symlink` (or `--pth-file` on Windows), when you run that Python file again, it will use the fresh version of Reforms you just edited. 147 | 148 | That way, you don't have to "install" your local version to be able to test every change. 149 | 150 | ### Format 151 | 152 | There is a script that you can run that will format and clean all your code: 153 | 154 |
155 | 156 | ```console 157 | $ bash scripts/format.sh 158 | ``` 159 | 160 |
161 | 162 | It will also auto-sort all your imports. 163 | 164 | For it to sort them correctly, you need to have Reforms installed locally in your environment, with the command in the section above using `--symlink` (or `--pth-file` on Windows). 165 | 166 | ### Format imports 167 | 168 | There is another script that formats all the imports and makes sure you don't have unused imports: 169 | 170 |
171 | 172 | ```console 173 | $ bash scripts/format-imports.sh 174 | ``` 175 | 176 |
177 | 178 | As it runs one command after the other and modifies and reverts many files, it takes a bit longer to run, so it might be easier to use `scripts/format.sh` frequently and `scripts/format-imports.sh` only before committing. 179 | 180 | ## Docs 181 | 182 | First, make sure you set up your environment as described above, that will install all the requirements. 183 | 184 | The documentation uses MkDocs. 185 | 186 | And there are extra tools/scripts in place to handle translations in `./scripts/docs.py`. 187 | 188 | !!! tip 189 | You don't need to see the code in `./scripts/docs.py`, you just use it in the command line. 190 | 191 | All the documentation is in Markdown format in the directory `./docs/en/`. 192 | 193 | Many of the tutorials have blocks of code. 194 | 195 | In most of the cases, these blocks of code are actual complete applications that can be run as is. 196 | 197 | In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs_src/` directory. 198 | 199 | And those Python files are included/injected in the documentation when generating the site. 200 | 201 | ### Docs for tests 202 | 203 | Most of the tests actually run against the example source files in the documentation. 204 | 205 | This helps making sure that: 206 | 207 | * The documentation is up to date. 208 | * The documentation examples can be run as is. 209 | * Most of the features are covered by the documentation, ensured by test coverage. 210 | 211 | During local development, there is a script that builds the site and checks for any changes, live-reloading: 212 | 213 |
214 | 215 | ```console 216 | $ python ./scripts/docs.py live 217 | 218 | [INFO] Serving on http://127.0.0.1:8008 219 | [INFO] Start watching changes 220 | [INFO] Start detecting changes 221 | ``` 222 | 223 |
224 | 225 | It will serve the documentation on `http://127.0.0.1:8008`. 226 | 227 | That way, you can edit the documentation/source files and see the changes live. 228 | 229 | #### Typer CLI (optional) 230 | 231 | The instructions here show you how to use the script at `./scripts/docs.py` with the `python` program directly. 232 | 233 | But you can also use Typer CLI, and you will get autocompletion in your terminal for the commands after installing completion. 234 | 235 | If you install Typer CLI, you can install completion with: 236 | 237 |
238 | 239 | ```console 240 | $ typer --install-completion 241 | 242 | zsh completion installed in /home/user/.bashrc. 243 | Completion will take effect once you restart the terminal. 244 | ``` 245 | 246 |
247 | 248 | ### Apps and docs at the same time 249 | 250 | If you run the examples with, e.g.: 251 | 252 |
253 | 254 | ```console 255 | $ uvicorn tutorial001:app --reload 256 | 257 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 258 | ``` 259 | 260 |
261 | 262 | as Uvicorn by default will use the port `8000`, the documentation on port `8008` won't clash. 263 | 264 | ### Translations 265 | 266 | Help with translations is VERY MUCH appreciated! And it can't be done without the help from the community. 🌎 🚀 267 | 268 | Here are the steps to help with translations. 269 | 270 | #### Tips and guidelines 271 | 272 | * Check the currently existing pull requests for your language and add reviews requesting changes or approving them. 273 | 274 | !!! tip 275 | You can add comments with change suggestions to existing pull requests. 276 | 277 | Check the docs about adding a pull request review to approve it or request changes. 278 | 279 | * Check in the issues to see if there's one coordinating translations for your language. 280 | 281 | * Add a single pull request per page translated. That will make it much easier for others to review it. 282 | 283 | For the languages I don't speak, I'll wait for several others to review the translation before merging. 284 | 285 | * You can also check if there are translations for your language and add a review to them, that will help me know that the translation is correct and I can merge it. 286 | 287 | * Use the same Python examples and only translate the text in the docs. You don't have to change anything for this to work. 288 | 289 | * Use the same images, file names, and links. You don't have to change anything for it to work. 290 | 291 | * To check the 2-letter code for the language you want to translate you can use the table List of ISO 639-1 codes. 292 | 293 | #### Existing language 294 | 295 | Let's say you want to translate a page for a language that already has translations for some pages, like Russian. 296 | 297 | In the case of Russian, the 2-letter code is `ru`. So, the directory for Russian translations is located at `docs/ru/`. 298 | 299 | !!! tip 300 | The main ("official") language is English, located at `docs/en/`. 301 | 302 | Now run the live server for the docs in Russian: 303 | 304 |
305 | 306 | ```console 307 | // Use the command "live" and pass the language code as a CLI argument 308 | $ python ./scripts/docs.py live ru 309 | 310 | [INFO] Serving on http://127.0.0.1:8008 311 | [INFO] Start watching changes 312 | [INFO] Start detecting changes 313 | ``` 314 | 315 |
316 | 317 | Now you can go to http://127.0.0.1:8008 and see your changes live. 318 | 319 | If you look at the Reforms docs website, you will see that every language has all the pages. But some pages are not translated and have a notification about the missing translation. 320 | 321 | But when you run it locally like this, you will only see the pages that are already translated. 322 | 323 | Now let's say that you want to add a translation for the section [Features](features.md){.internal-link target=_blank}. 324 | 325 | * Copy the file at: 326 | 327 | ``` 328 | docs/en/docs/features.md 329 | ``` 330 | 331 | * Paste it in exactly the same location but for the language you want to translate, e.g.: 332 | 333 | ``` 334 | docs/ru/docs/features.md 335 | ``` 336 | 337 | !!! tip 338 | Notice that the only change in the path and file name is the language code, from `en` to `ru`. 339 | 340 | * Now open the MkDocs config file for English at: 341 | 342 | ``` 343 | docs/en/docs/mkdocs.yml 344 | ``` 345 | 346 | * Find the place where that `docs/features.md` is located in the config file. Somewhere like: 347 | 348 | ```YAML hl_lines="8" 349 | site_name: Reforms 350 | # More stuff 351 | nav: 352 | - Reforms: index.md 353 | - Languages: 354 | - en: / 355 | - ru: /ru/ 356 | - features.md 357 | ``` 358 | 359 | * Open the MkDocs config file for the language you are editing, e.g.: 360 | 361 | ``` 362 | docs/ru/docs/mkdocs.yml 363 | ``` 364 | 365 | * Add it there at the exact same location it was for English, e.g.: 366 | 367 | ```YAML hl_lines="8" 368 | site_name: Reforms 369 | # More stuff 370 | nav: 371 | - Reforms: index.md 372 | - Languages: 373 | - en: / 374 | - ru: /ru/ 375 | - features.md 376 | ``` 377 | 378 | Make sure that if there are other entries, the new entry with your translation is exactly in the same order as in the English version. 379 | 380 | If you go to your browser you will see that now the docs show your new section. 🎉 381 | 382 | Now you can translate it all and see how it looks as you save the file. 383 | 384 | #### New Language 385 | 386 | Let's say that you want to add translations for a language that is not yet translated, not even some pages. 387 | 388 | Let's say you want to add translations for Creole, and it's not yet there in the docs. 389 | 390 | Checking the link from above, the code for "Creole" is `ht`. 391 | 392 | The next step is to run the script to generate a new translation directory: 393 | 394 |
395 | 396 | ```console 397 | // Use the command new-lang, pass the language code as a CLI argument 398 | $ python ./scripts/docs.py new-lang ht 399 | 400 | Successfully initialized: docs/ht 401 | Updating ht 402 | Updating en 403 | ``` 404 | 405 |
406 | 407 | Now you can check in your code editor the newly created directory `docs/ht/`. 408 | 409 | !!! tip 410 | Create a first pull request with just this, to set up the configuration for the new language, before adding translations. 411 | 412 | That way others can help with other pages while you work on the first one. 🚀 413 | 414 | Start by translating the main page, `docs/ht/index.md`. 415 | 416 | Then you can continue with the previous instructions, for an "Existing Language". 417 | 418 | ##### New Language not supported 419 | 420 | If when running the live server script you get an error about the language not being supported, something like: 421 | 422 | ``` 423 | raise TemplateNotFound(template) 424 | jinja2.exceptions.TemplateNotFound: partials/language/xx.html 425 | ``` 426 | 427 | That means that the theme doesn't support that language (in this case, with a fake 2-letter code of `xx`). 428 | 429 | But don't worry, you can set the theme language to English and then translate the content of the docs. 430 | 431 | If you need to do that, edit the `mkdocs.yml` for your new language, it will have something like: 432 | 433 | ```YAML hl_lines="5" 434 | site_name: Reforms 435 | # More stuff 436 | theme: 437 | # More stuff 438 | language: xx 439 | ``` 440 | 441 | Change that language from `xx` (from your language code) to `en`. 442 | 443 | Then you can start the live server again. 444 | 445 | #### Preview the result 446 | 447 | When you use the script at `./scripts/docs.py` with the `live` command it only shows the files and translations available for the current language. 448 | 449 | But once you are done, you can test it all as it would look online. 450 | 451 | To do that, first build all the docs: 452 | 453 |
454 | 455 | ```console 456 | // Use the command "build-all", this will take a bit 457 | $ python ./scripts/docs.py build-all 458 | 459 | Updating ru 460 | Updating en 461 | Building docs for: en 462 | Building docs for: ru 463 | Successfully built docs for: ru 464 | Copying en index.md to README.md 465 | ``` 466 | 467 |
468 | 469 | That generates all the docs at `./docs_build/` for each language. This includes adding any files with missing translations, with a note saying that "this file doesn't have a translation yet". But you don't have to do anything with that directory. 470 | 471 | Then it builds all those independent MkDocs sites for each language, combines them, and generates the final output at `./site/`. 472 | 473 | Then you can serve that with the command `serve`: 474 | 475 |
476 | 477 | ```console 478 | // Use the command "serve" after running "build-all" 479 | $ python ./scripts/docs.py serve 480 | 481 | Warning: this is a very simple server. For development, use mkdocs serve instead. 482 | This is here only to preview a site with translations already built. 483 | Make sure you run the build-all command first. 484 | Serving at: http://127.0.0.1:8008 485 | ``` 486 | 487 |
488 | 489 | ## Tests 490 | 491 | There is a script that you can run locally to test all the code and generate coverage reports in HTML: 492 | 493 |
494 | 495 | ```console 496 | $ bash scripts/test-cov-html.sh 497 | ``` 498 | 499 |
500 | 501 | This command generates a directory `./htmlcov/`, if you open the file `./htmlcov/index.html` in your browser, you can explore interactively the regions of code that are covered by the tests, and notice if there is any region missing. 502 | -------------------------------------------------------------------------------- /docs/en/docs/css/custom.css: -------------------------------------------------------------------------------- 1 | 2 | a.external-link::after { 3 | /* \00A0 is a non-breaking space 4 | to make the mark be on the same line as the link 5 | */ 6 | content: "\00A0[↪]"; 7 | } 8 | 9 | a.internal-link::after { 10 | /* \00A0 is a non-breaking space 11 | to make the mark be on the same line as the link 12 | */ 13 | content: "\00A0↪"; 14 | } 15 | -------------------------------------------------------------------------------- /docs/en/docs/css/termynal.css: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * 4 | * @author Ines Montani 5 | * @version 0.0.1 6 | * @license MIT 7 | */ 8 | 9 | :root { 10 | --color-bg: #252a33; 11 | --color-text: #eee; 12 | --color-text-subtle: #a2a2a2; 13 | } 14 | 15 | [data-termynal] { 16 | width: 750px; 17 | max-width: 100%; 18 | background: var(--color-bg); 19 | color: var(--color-text); 20 | font-size: 18px; 21 | /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ 22 | font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; 23 | border-radius: 4px; 24 | padding: 75px 45px 35px; 25 | position: relative; 26 | -webkit-box-sizing: border-box; 27 | box-sizing: border-box; 28 | } 29 | 30 | [data-termynal]:before { 31 | content: ''; 32 | position: absolute; 33 | top: 15px; 34 | left: 15px; 35 | display: inline-block; 36 | width: 15px; 37 | height: 15px; 38 | border-radius: 50%; 39 | /* A little hack to display the window buttons in one pseudo element. */ 40 | background: #d9515d; 41 | -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 42 | box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 43 | } 44 | 45 | [data-termynal]:after { 46 | content: 'bash'; 47 | position: absolute; 48 | color: var(--color-text-subtle); 49 | top: 5px; 50 | left: 0; 51 | width: 100%; 52 | text-align: center; 53 | } 54 | 55 | a[data-terminal-control] { 56 | text-align: right; 57 | display: block; 58 | color: #aebbff; 59 | } 60 | 61 | [data-ty] { 62 | display: block; 63 | line-height: 2; 64 | } 65 | 66 | [data-ty]:before { 67 | /* Set up defaults and ensure empty lines are displayed. */ 68 | content: ''; 69 | display: inline-block; 70 | vertical-align: middle; 71 | } 72 | 73 | [data-ty="input"]:before, 74 | [data-ty-prompt]:before { 75 | margin-right: 0.75em; 76 | color: var(--color-text-subtle); 77 | } 78 | 79 | [data-ty="input"]:before { 80 | content: '$'; 81 | } 82 | 83 | [data-ty][data-ty-prompt]:before { 84 | content: attr(data-ty-prompt); 85 | } 86 | 87 | [data-ty-cursor]:after { 88 | content: attr(data-ty-cursor); 89 | font-family: monospace; 90 | margin-left: 0.5em; 91 | -webkit-animation: blink 1s infinite; 92 | animation: blink 1s infinite; 93 | } 94 | 95 | 96 | /* Cursor animation */ 97 | 98 | @-webkit-keyframes blink { 99 | 50% { 100 | opacity: 0; 101 | } 102 | } 103 | 104 | @keyframes blink { 105 | 50% { 106 | opacity: 0; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/en/docs/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ## Reforms features 4 | 5 | **Reforms** gives you the following: 6 | 7 | * Use updated default types like *str*, *bool* etc. and pydantic types like 8 | *EmailStr* with additional information about HTML rendering. 9 | * Choose one of prepared template packs or create your own. 10 | * Callable-based validators like in [wtforms](https://wtforms.readthedocs.io/en/3.0.x/validators/). 11 | 12 | ## Pydantic features 13 | 14 | **Reforms** supports **all** Pydantic features, all fields are inherited from native 15 | Python or pydantic types, so you can use the same models as before with a new 16 | opportunity to render it into HTML-layout. 17 | -------------------------------------------------------------------------------- /docs/en/docs/help-reforms.md: -------------------------------------------------------------------------------- 1 | # Help Reforms - Get Help 2 | 3 | Do you like **Reforms**? 4 | 5 | Would you like to help Reforms, other users, and the author? 6 | 7 | Or would you like to get help with **Reforms**? 8 | 9 | There are very simple ways to help (several involve just one or two clicks). 10 | 11 | And there are several ways to get help too. 12 | 13 | ## Star **Reforms** in GitHub 14 | 15 | You can "star" Reforms in GitHub (clicking the star button at the top right): https://github.com/boardpack/reforms. ⭐️ 16 | 17 | By adding a star, other users will be able to find it more easily and see that it has been already useful for others. 18 | 19 | ## Watch the GitHub repository for releases 20 | 21 | You can "watch" Reforms in GitHub (clicking the "watch" button at the top right): https://github.com/boardpack/reforms. 👀 22 | 23 | There you can select "Releases only". 24 | 25 | Doing it, you will receive notifications (in your email) whenever there's a new release (a new version) of **Reforms** with bug fixes and new features. 26 | 27 | ## Help others with issues in GitHub 28 | 29 | You can see existing issues and try and help others, most of the times they are questions that you might already know the answer for. 🤓 30 | 31 | ## Watch the GitHub repository 32 | 33 | You can "watch" Reforms in GitHub (clicking the "watch" button at the top right): https://github.com/boardpack/reforms. 👀 34 | 35 | If you select "Watching" instead of "Releases only", you will receive notifications when someone creates a new issue. 36 | 37 | Then you can try and help them solving those issues. 38 | 39 | ## Create issues 40 | 41 | You can create a new issue in the GitHub repository, for example to: 42 | 43 | * Ask a **question** or ask about a **problem**. 44 | * Suggest a new **feature**. 45 | 46 | **Note**: if you create an issue then I'm going to ask you to also help others. 😉 47 | 48 | ## Create a Pull Request 49 | 50 | You can [contribute](contributing.md){.internal-link target=_blank} to the source code with Pull Requests, for example: 51 | 52 | * To fix a typo you found on the documentation. 53 | * To help [translate the documentation](contributing.md#translations){.internal-link target=_blank} to your language. 54 | * You can also help reviewing the translations created by others. 55 | * To propose new documentation sections. 56 | * To fix an existing issue/bug. 57 | * To add a new feature. 58 | 59 | --- 60 | 61 | Thanks! 🚀 62 | -------------------------------------------------------------------------------- /docs/en/docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/docs/img/favicon.png -------------------------------------------------------------------------------- /docs/en/docs/img/icon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/docs/img/icon-white.png -------------------------------------------------------------------------------- /docs/en/docs/img/index/index-01-web-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/docs/img/index/index-01-web-form.png -------------------------------------------------------------------------------- /docs/en/docs/img/index/index-02-web-form-passed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/docs/img/index/index-02-web-form-passed.png -------------------------------------------------------------------------------- /docs/en/docs/img/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/en/docs/img/logo-white.png -------------------------------------------------------------------------------- /docs/en/docs/img/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/en/docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | Reforms 3 |

4 | 7 |

8 | 9 | Test 10 | 11 | 12 | Coverage 13 | 14 | 15 | Package version 16 | 17 | Code style: black 18 | Imports: isort 19 |

20 | 21 | --- 22 | 23 | **Documentation**: https://reforms.boardpack.org 24 | 25 | **Source Code**: https://github.com/boardpack/reforms 26 | 27 | --- 28 | 29 | Reforms is a fresh pydantic-based forms validation and rendering library for Python 3.6+. 30 | 31 | The key features are: 32 | 33 | * **Familiar**: Expanded Pydantic retaining all data validation and model creation 34 | capabilities. 35 | * **Easy**: Designed to be easy to use and learn. Less time reading docs. 36 | * **Theming**: Supported the usage of existing template pack and the creation of your 37 | own. 38 | 39 | ## Requirements 40 | 41 | Python 3.6+ 42 | 43 | Reforms has the next hard dependencies: 44 | 45 | * Pydantic for the data parts. 46 | * Jinja2 for the templates. 47 | 48 | ## Installation 49 | 50 |
51 | 52 | ```console 53 | $ pip install git+http://github.com/boardpack/reforms 54 | 55 | ---> 100% 56 | ``` 57 | 58 |
59 | 60 | ## Example 61 | 62 | In the next example, we will use FastAPI 63 | as a web framework. So you need to install `fastapi` and `uvicorn` first. Also, you 64 | need to install `python-multipart` library to turn on forms support in FastAPI. 65 | 66 |
67 | 68 | ```console 69 | $ pip install fastapi uvicorn python-multipart 70 | 71 | ---> 100% 72 | ``` 73 | 74 |
75 | 76 | ### Create it 77 | 78 | * Create a file `models.py` with `UserModel` pydantic model: 79 | 80 | ```Python 81 | {!../../../docs_src/first-steps/models.py!} 82 | ``` 83 | _(This script is complete, it should run "as is")_ 84 | 85 | * Then you can create a FastAPI or Starlette application and use this model to generate 86 | form layout and validate data. Reforms has special `from_model` function, which works 87 | with `Depends` from FastAPI to convert raw form data into pydantic model object. 88 | Create a file `main.py` with: 89 | 90 | === "FastAPI" 91 | 92 | ```Python hl_lines="7 9 20 24 29" 93 | import uvicorn 94 | from fastapi import Depends, FastAPI, Request 95 | from fastapi.responses import HTMLResponse, RedirectResponse 96 | from fastapi.templating import Jinja2Templates 97 | from starlette.status import HTTP_302_FOUND 98 | from reforms import Reforms 99 | from reforms.contrib.fastapi import from_model 100 | 101 | from models import UserModel 102 | 103 | app = FastAPI() 104 | 105 | forms = Reforms(package="reforms") 106 | 107 | templates = Jinja2Templates(directory="templates") 108 | 109 | 110 | @app.get("/", response_class=HTMLResponse) 111 | async def index(request: Request): 112 | user_form = forms.Form(UserModel) 113 | 114 | return templates.TemplateResponse( 115 | "index.html", 116 | {"request": request, "form": user_form}, 117 | ) 118 | 119 | 120 | @app.post("/", response_class=RedirectResponse) 121 | async def handle_form(form: from_model(UserModel) = Depends()): 122 | print(form) 123 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 124 | 125 | 126 | if __name__ == "__main__": 127 | uvicorn.run(app) 128 | ``` 129 | 130 | 131 | === "Starlette" 132 | 133 | ```Python hl_lines="10 18 22 28" 134 | import uvicorn 135 | from starlette.applications import Starlette 136 | from starlette.routing import Route 137 | from starlette.requests import Request 138 | from starlette.responses import RedirectResponse 139 | from starlette.templating import Jinja2Templates 140 | from starlette.status import HTTP_302_FOUND 141 | from reforms import Reforms 142 | 143 | from models import UserModel 144 | 145 | forms = Reforms(package="reforms") 146 | 147 | templates = Jinja2Templates(directory="templates") 148 | 149 | 150 | async def index(request: Request): 151 | user_form = forms.Form(UserModel) 152 | 153 | return templates.TemplateResponse( 154 | "index.html", 155 | {"request": request, "form": user_form}, 156 | ) 157 | 158 | 159 | async def handle_form(request: Request): 160 | raw_form = await request.form() 161 | form = UserModel(**raw_form) 162 | 163 | print(form) 164 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 165 | 166 | 167 | if __name__ == "__main__": 168 | app = Starlette( 169 | routes=[ 170 | Route('/', endpoint=index), 171 | Route('/', endpoint=handle_form, methods=["POST"]), 172 | ], 173 | ) 174 | uvicorn.run(app) 175 | ``` 176 | 177 | 178 | _(This script is complete, it should run "as is")_ 179 | 180 | * As the last coding step, you need to create a template (now **reforms** supports only 181 | **jinja2** templates). You can use just form object to render all fields 182 | simultaneously or render every field separately (as it mentions in the selected 183 | commented line). 184 | 185 | ```HTML hl_lines="10" 186 | {!../../../docs_src/first-steps/templates/index.html!} 187 | ``` 188 | _(This template is complete, it should use "as is")_ 189 | 190 | ### Run it 191 | 192 | Run the server with: 193 | 194 |
195 | 196 | ```console 197 | $ uvicorn main:app --reload 198 | 199 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 200 | INFO: Started reloader process [28720] 201 | INFO: Started server process [28722] 202 | INFO: Waiting for application startup. 203 | INFO: Application startup complete. 204 | ``` 205 | 206 |
207 | 208 |
209 | About the command uvicorn main:app --reload... 210 | 211 | The command `uvicorn main:app` refers to: 212 | 213 | * `main`: the file `main.py` (the Python "module"). 214 | * `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 215 | * `--reload`: make the server restart after code changes. Only do this for development. 216 | 217 |
218 | 219 | or just with: 220 | 221 |
222 | 223 | ```console 224 | $ python main.py 225 | 226 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 227 | INFO: Started reloader process [28720] 228 | INFO: Started server process [28722] 229 | INFO: Waiting for application startup. 230 | INFO: Application startup complete. 231 | ``` 232 | 233 |
234 | 235 | ### Send it 236 | 237 | Open your browser at http://127.0.0.1:8000. 238 | 239 | You will see the web form: 240 | 241 | ![Example form](https://reforms-boardpack.netlify.app/img/index/index-01-web-form.png) 242 | 243 | Add some information like this and click "Send" button: 244 | 245 | ![Passed example form](https://reforms-boardpack.netlify.app/img/index/index-02-web-form-passed.png) 246 | 247 | ### Check it 248 | 249 | Finally, you can see a printed validated model object in your console: 250 | 251 | ```bash hl_lines="8" 252 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 253 | INFO: Started reloader process [28720] 254 | INFO: Started server process [28722] 255 | INFO: Waiting for application startup. 256 | INFO: Application startup complete. 257 | 258 | INFO: 127.0.0.1:33612 - "GET / HTTP/1.1" 200 OK 259 | first_name='Roman' last_name='Dukkee' email='example@example.com' has_github=True 260 | INFO: 127.0.0.1:33872 - "POST / HTTP/1.1" 302 Found 261 | INFO: 127.0.0.1:33872 - "GET / HTTP/1.1" 200 OK 262 | ``` 263 | 264 | ## Acknowledgments 265 | 266 | Special thanks to: 267 | 268 | * [Sebastián Ramírez](https://github.com/tiangolo) and his [FastAPI](https://github.com/tiangolo/fastapi) project, some scripts and documentation structure and parts were used from there. 269 | 270 | * [Samuel Colvin](https://github.com/samuelcolvin) and his [Pydantic](https://github.com/samuelcolvin/pydantic/) project. 271 | 272 | ## License 273 | 274 | This project is licensed under the terms of the MIT license. 275 | -------------------------------------------------------------------------------- /docs/en/docs/js/custom.js: -------------------------------------------------------------------------------- 1 | 2 | function setupTermynal() { 3 | document.querySelectorAll(".use-termynal").forEach(node => { 4 | node.style.display = "block"; 5 | new Termynal(node, { 6 | lineDelay: 500 7 | }); 8 | }); 9 | const progressLiteralStart = "---> 100%"; 10 | const promptLiteralStart = "$ "; 11 | const customPromptLiteralStart = "# "; 12 | const termynalActivateClass = "termy"; 13 | let termynals = []; 14 | 15 | function createTermynals() { 16 | document 17 | .querySelectorAll(`.${termynalActivateClass} .highlight`) 18 | .forEach(node => { 19 | const text = node.textContent; 20 | const lines = text.split("\n"); 21 | const useLines = []; 22 | let buffer = []; 23 | function saveBuffer() { 24 | if (buffer.length) { 25 | let isBlankSpace = true; 26 | buffer.forEach(line => { 27 | if (line) { 28 | isBlankSpace = false; 29 | } 30 | }); 31 | dataValue = {}; 32 | if (isBlankSpace) { 33 | dataValue["delay"] = 0; 34 | } 35 | if (buffer[buffer.length - 1] === "") { 36 | // A last single
won't have effect 37 | // so put an additional one 38 | buffer.push(""); 39 | } 40 | const bufferValue = buffer.join("
"); 41 | dataValue["value"] = bufferValue; 42 | useLines.push(dataValue); 43 | buffer = []; 44 | } 45 | } 46 | for (let line of lines) { 47 | if (line === progressLiteralStart) { 48 | saveBuffer(); 49 | useLines.push({ 50 | type: "progress" 51 | }); 52 | } else if (line.startsWith(promptLiteralStart)) { 53 | saveBuffer(); 54 | const value = line.replace(promptLiteralStart, "").trimEnd(); 55 | useLines.push({ 56 | type: "input", 57 | value: value 58 | }); 59 | } else if (line.startsWith("// ")) { 60 | saveBuffer(); 61 | const value = "💬 " + line.replace("// ", "").trimEnd(); 62 | useLines.push({ 63 | value: value, 64 | class: "termynal-comment", 65 | delay: 0 66 | }); 67 | } else if (line.startsWith(customPromptLiteralStart)) { 68 | saveBuffer(); 69 | const promptStart = line.indexOf(promptLiteralStart); 70 | if (promptStart === -1) { 71 | console.error("Custom prompt found but no end delimiter", line) 72 | } 73 | const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") 74 | let value = line.slice(promptStart + promptLiteralStart.length); 75 | useLines.push({ 76 | type: "input", 77 | value: value, 78 | prompt: prompt 79 | }); 80 | } else { 81 | buffer.push(line); 82 | } 83 | } 84 | saveBuffer(); 85 | const div = document.createElement("div"); 86 | node.replaceWith(div); 87 | const termynal = new Termynal(div, { 88 | lineData: useLines, 89 | noInit: true, 90 | lineDelay: 500 91 | }); 92 | termynals.push(termynal); 93 | }); 94 | } 95 | 96 | function loadVisibleTermynals() { 97 | termynals = termynals.filter(termynal => { 98 | if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { 99 | termynal.init(); 100 | return false; 101 | } 102 | return true; 103 | }); 104 | } 105 | window.addEventListener("scroll", loadVisibleTermynals); 106 | createTermynals(); 107 | loadVisibleTermynals(); 108 | } 109 | 110 | setupTermynal(); 111 | -------------------------------------------------------------------------------- /docs/en/docs/js/termynal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * A lightweight, modern and extensible animated terminal window, using 4 | * async/await. 5 | * 6 | * @author Ines Montani 7 | * @version 0.0.1 8 | * @license MIT 9 | */ 10 | 11 | 'use strict'; 12 | 13 | /** Generate a terminal widget. */ 14 | class Termynal { 15 | /** 16 | * Construct the widget's settings. 17 | * @param {(string|Node)=} container - Query selector or container element. 18 | * @param {Object=} options - Custom settings. 19 | * @param {string} options.prefix - Prefix to use for data attributes. 20 | * @param {number} options.startDelay - Delay before animation, in ms. 21 | * @param {number} options.typeDelay - Delay between each typed character, in ms. 22 | * @param {number} options.lineDelay - Delay between each line, in ms. 23 | * @param {number} options.progressLength - Number of characters displayed as progress bar. 24 | * @param {string} options.progressChar – Character to use for progress bar, defaults to █. 25 | * @param {number} options.progressPercent - Max percent of progress. 26 | * @param {string} options.cursor – Character to use for cursor, defaults to ▋. 27 | * @param {Object[]} lineData - Dynamically loaded line data objects. 28 | * @param {boolean} options.noInit - Don't initialise the animation. 29 | */ 30 | constructor(container = '#termynal', options = {}) { 31 | this.container = (typeof container === 'string') ? document.querySelector(container) : container; 32 | this.pfx = `data-${options.prefix || 'ty'}`; 33 | this.originalStartDelay = this.startDelay = options.startDelay 34 | || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; 35 | this.originalTypeDelay = this.typeDelay = options.typeDelay 36 | || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; 37 | this.originalLineDelay = this.lineDelay = options.lineDelay 38 | || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; 39 | this.progressLength = options.progressLength 40 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; 41 | this.progressChar = options.progressChar 42 | || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; 43 | this.progressPercent = options.progressPercent 44 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; 45 | this.cursor = options.cursor 46 | || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; 47 | this.lineData = this.lineDataToElements(options.lineData || []); 48 | this.loadLines() 49 | if (!options.noInit) this.init() 50 | } 51 | 52 | loadLines() { 53 | // Load all the lines and create the container so that the size is fixed 54 | // Otherwise it would be changing and the user viewport would be constantly 55 | // moving as she/he scrolls 56 | const finish = this.generateFinish() 57 | finish.style.visibility = 'hidden' 58 | this.container.appendChild(finish) 59 | // Appends dynamically loaded lines to existing line elements. 60 | this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); 61 | for (let line of this.lines) { 62 | line.style.visibility = 'hidden' 63 | this.container.appendChild(line) 64 | } 65 | const restart = this.generateRestart() 66 | restart.style.visibility = 'hidden' 67 | this.container.appendChild(restart) 68 | this.container.setAttribute('data-termynal', ''); 69 | } 70 | 71 | /** 72 | * Initialise the widget, get lines, clear container and start animation. 73 | */ 74 | init() { 75 | /** 76 | * Calculates width and height of Termynal container. 77 | * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. 78 | */ 79 | const containerStyle = getComputedStyle(this.container); 80 | this.container.style.width = containerStyle.width !== '0px' ? 81 | containerStyle.width : undefined; 82 | this.container.style.minHeight = containerStyle.height !== '0px' ? 83 | containerStyle.height : undefined; 84 | 85 | this.container.setAttribute('data-termynal', ''); 86 | this.container.innerHTML = ''; 87 | for (let line of this.lines) { 88 | line.style.visibility = 'visible' 89 | } 90 | this.start(); 91 | } 92 | 93 | /** 94 | * Start the animation and rener the lines depending on their data attributes. 95 | */ 96 | async start() { 97 | this.addFinish() 98 | await this._wait(this.startDelay); 99 | 100 | for (let line of this.lines) { 101 | const type = line.getAttribute(this.pfx); 102 | const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; 103 | 104 | if (type == 'input') { 105 | line.setAttribute(`${this.pfx}-cursor`, this.cursor); 106 | await this.type(line); 107 | await this._wait(delay); 108 | } 109 | 110 | else if (type == 'progress') { 111 | await this.progress(line); 112 | await this._wait(delay); 113 | } 114 | 115 | else { 116 | this.container.appendChild(line); 117 | await this._wait(delay); 118 | } 119 | 120 | line.removeAttribute(`${this.pfx}-cursor`); 121 | } 122 | this.addRestart() 123 | this.finishElement.style.visibility = 'hidden' 124 | this.lineDelay = this.originalLineDelay 125 | this.typeDelay = this.originalTypeDelay 126 | this.startDelay = this.originalStartDelay 127 | } 128 | 129 | generateRestart() { 130 | const restart = document.createElement('a') 131 | restart.onclick = (e) => { 132 | e.preventDefault() 133 | this.container.innerHTML = '' 134 | this.init() 135 | } 136 | restart.href = '#' 137 | restart.setAttribute('data-terminal-control', '') 138 | restart.innerHTML = "restart ↻" 139 | return restart 140 | } 141 | 142 | generateFinish() { 143 | const finish = document.createElement('a') 144 | finish.onclick = (e) => { 145 | e.preventDefault() 146 | this.lineDelay = 0 147 | this.typeDelay = 0 148 | this.startDelay = 0 149 | } 150 | finish.href = '#' 151 | finish.setAttribute('data-terminal-control', '') 152 | finish.innerHTML = "fast →" 153 | this.finishElement = finish 154 | return finish 155 | } 156 | 157 | addRestart() { 158 | const restart = this.generateRestart() 159 | this.container.appendChild(restart) 160 | } 161 | 162 | addFinish() { 163 | const finish = this.generateFinish() 164 | this.container.appendChild(finish) 165 | } 166 | 167 | /** 168 | * Animate a typed line. 169 | * @param {Node} line - The line element to render. 170 | */ 171 | async type(line) { 172 | const chars = [...line.textContent]; 173 | line.textContent = ''; 174 | this.container.appendChild(line); 175 | 176 | for (let char of chars) { 177 | const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; 178 | await this._wait(delay); 179 | line.textContent += char; 180 | } 181 | } 182 | 183 | /** 184 | * Animate a progress bar. 185 | * @param {Node} line - The line element to render. 186 | */ 187 | async progress(line) { 188 | const progressLength = line.getAttribute(`${this.pfx}-progressLength`) 189 | || this.progressLength; 190 | const progressChar = line.getAttribute(`${this.pfx}-progressChar`) 191 | || this.progressChar; 192 | const chars = progressChar.repeat(progressLength); 193 | const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) 194 | || this.progressPercent; 195 | line.textContent = ''; 196 | this.container.appendChild(line); 197 | 198 | for (let i = 1; i < chars.length + 1; i++) { 199 | await this._wait(this.typeDelay); 200 | const percent = Math.round(i / chars.length * 100); 201 | line.textContent = `${chars.slice(0, i)} ${percent}%`; 202 | if (percent>progressPercent) { 203 | break; 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * Helper function for animation delays, called with `await`. 210 | * @param {number} time - Timeout, in ms. 211 | */ 212 | _wait(time) { 213 | return new Promise(resolve => setTimeout(resolve, time)); 214 | } 215 | 216 | /** 217 | * Converts line data objects into line elements. 218 | * 219 | * @param {Object[]} lineData - Dynamically loaded lines. 220 | * @param {Object} line - Line data object. 221 | * @returns {Element[]} - Array of line elements. 222 | */ 223 | lineDataToElements(lineData) { 224 | return lineData.map(line => { 225 | let div = document.createElement('div'); 226 | div.innerHTML = `${line.value || ''}`; 227 | 228 | return div.firstElementChild; 229 | }); 230 | } 231 | 232 | /** 233 | * Helper function for generating attributes string. 234 | * 235 | * @param {Object} line - Line data object. 236 | * @returns {string} - String of attributes. 237 | */ 238 | _attributes(line) { 239 | let attrs = ''; 240 | for (let prop in line) { 241 | // Custom add class 242 | if (prop === 'class') { 243 | attrs += ` class=${line[prop]} ` 244 | continue 245 | } 246 | if (prop === 'type') { 247 | attrs += `${this.pfx}="${line[prop]}" ` 248 | } else if (prop !== 'value') { 249 | attrs += `${this.pfx}-${prop}="${line[prop]}" ` 250 | } 251 | } 252 | 253 | return attrs; 254 | } 255 | } 256 | 257 | /** 258 | * HTML API: If current script has container(s) specified, initialise Termynal. 259 | */ 260 | if (document.currentScript.hasAttribute('data-termynal-container')) { 261 | const containers = document.currentScript.getAttribute('data-termynal-container'); 262 | containers.split('|') 263 | .forEach(container => new Termynal(container)) 264 | } 265 | -------------------------------------------------------------------------------- /docs/en/docs/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## Latest Changes 4 | 5 | * ⬆ Bump codecov/codecov-action from 2.0.3 to 2.1.0. PR [#17](https://github.com/boardpack/reforms/pull/17) by [@dependabot[bot]](https://github.com/apps/dependabot). 6 | * ⬆ Bump codecov/codecov-action from 2.0.2 to 2.0.3. PR [#16](https://github.com/boardpack/reforms/pull/16) by [@dependabot[bot]](https://github.com/apps/dependabot). 7 | * ⬆ Bump dawidd6/action-download-artifact from 2.14.0 to 2.14.1. PR [#15](https://github.com/boardpack/reforms/pull/15) by [@dependabot[bot]](https://github.com/apps/dependabot). 8 | * ⬆ Bump codecov/codecov-action from 1 to 2.0.2. PR [#14](https://github.com/boardpack/reforms/pull/14) by [@dependabot[bot]](https://github.com/apps/dependabot). 9 | * ✨ Add hidden field. PR [#12](https://github.com/boardpack/reforms/pull/12) by [@dukkee](https://github.com/dukkee). 10 | * ✨ Change field name convention. PR [#11](https://github.com/boardpack/reforms/pull/11) by [@dukkee](https://github.com/dukkee). 11 | * Add fixes to codebase and tests. PR [#10](https://github.com/boardpack/reforms/pull/10) by [@dukkee](https://github.com/dukkee). 12 | * 👷 Add widgets, update templates overload. PR [#8](https://github.com/boardpack/reforms/pull/8) by [@dukkee](https://github.com/dukkee). 13 | * 🔧 Update codecov.yml. PR [#9](https://github.com/boardpack/reforms/pull/9) by [@dukkee](https://github.com/dukkee). 14 | * 📝 Add a snippet with Starlette to the docs. PR [#7](https://github.com/boardpack/reforms/pull/7) by [@dukkee](https://github.com/dukkee). 15 | * 👷 Add disabled option. PR [#6](https://github.com/boardpack/reforms/pull/6) by [@dukkee](https://github.com/dukkee). 16 | * 🔧 Update on_model helper for FastAPI. PR [#5](https://github.com/boardpack/reforms/pull/5) by [@dukkee](https://github.com/dukkee). 17 | * 📝 Remove extra spaces from the rendered fields templates. PR [#4](https://github.com/boardpack/reforms/pull/4) by [@dukkee](https://github.com/dukkee). 18 | * 🐛 Replace Required validator with default value handling. PR [#3](https://github.com/boardpack/reforms/pull/3) by [@dukkee](https://github.com/dukkee). 19 | * ⬆ Bump dawidd6/action-download-artifact from 2.9.0 to 2.14.0. PR [#2](https://github.com/boardpack/reforms/pull/2) by [@dependabot[bot]](https://github.com/apps/dependabot). 20 | * ⬆ Bump nwtgck/actions-netlify from 1.1.5 to 1.2.2. PR [#1](https://github.com/boardpack/reforms/pull/1) by [@dependabot[bot]](https://github.com/apps/dependabot). 21 | 22 | -------------------------------------------------------------------------------- /docs/en/docs/usage/fields.md: -------------------------------------------------------------------------------- 1 | # Fields 2 | 3 | ## Built-in fields 4 | 5 | Currently, reforms supports the next fields, which are expanded versions of 6 | well-known types. 7 | 8 | #### `BooleanField` 9 | : 10 | 11 | * `widget: Type[BaseWidget] = Checkbox`: a widget class, which is responsible for the 12 | field rendering; 13 | * `field_id: str = ""`: a field `id` in the layout; 14 | * `field_class: str = ""`: a field `class` in the layout; 15 | * `label: str = ""`: a label content in the layout; 16 | * `disabled: bool = False"`: a disabled field option; 17 | * `render_kw: Dict = None`: a dictionary to pass all other data that be needed in the 18 | field template; 19 | * `validators: Optional[List[BaseValidator]] = None`: a list of validators that will 20 | be described in one of the next parts. 21 | 22 | #### `StringField` 23 | : 24 | 25 | * `widget: Type[BaseWidget] = TextInput`: a widget class, which is responsible for the 26 | field rendering; 27 | * `field_id: str = ""`: a field `id` in the layout; 28 | * `field_class: str = ""`: a field `class` in the layout; 29 | * `label: str = ""`: a label content in the layout; 30 | * `placeholder: str = ""`: a placeholder content in the layout; 31 | * `disabled: bool = False"`: a disabled field option; 32 | * `render_kw: Dict = None`: a dictionary to pass all other data that be needed in the 33 | field template; 34 | * `validators: Optional[List[BaseValidator]] = None`: a list of validators that will 35 | be described in one of the next parts. 36 | 37 | #### `EmailField` 38 | : implements the input string that must be a valid email address. 39 | 40 | * `widget: Type[BaseWidget] = EmailInput`: a widget class, which is responsible for 41 | the field rendering; 42 | * `field_id: str = ""`: a field `id` in the layout; 43 | * `field_class: str = ""`: a field `class` in the layout; 44 | * `label: str = ""`: a label content in the layout; 45 | * `placeholder: str = ""`: a placeholder content in the layout; 46 | * `disabled: bool = False"`: a disabled field option; 47 | * `render_kw: Dict = None`: a dictionary to pass all other data that be needed in the 48 | field template; 49 | * `validators: Optional[List[BaseValidator]] = None`: a list of validators that will 50 | be described in one of the next parts. 51 | 52 | -------------------------------------------------------------------------------- /docs/en/docs/usage/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | ## FastAPI 4 | 5 | ### on_model 6 | 7 | To parse input raw data into your pydantic model, you can use `on_model` helper with 8 | a `Depends` function from FastAPI. 9 | 10 | An example of the `on_model` helper usage you can find in the start tutorial here. 11 | -------------------------------------------------------------------------------- /docs/en/docs/usage/validators.md: -------------------------------------------------------------------------------- 1 | # Validators 2 | 3 | ## Introduction 4 | 5 | As Reforms expands Pydantic, then you can use all existed features including 6 | [validators](https://pydantic-docs.helpmanual.io/usage/validators/). Here you can 7 | see a simple example, for more information please go to the appropriate page of the 8 | pydantic documentation. 9 | 10 | ```Python 11 | {!../../../docs_src/validators/tutorial001.py!} 12 | ``` 13 | _(This script is complete, it should run "as is")_ 14 | 15 | ## Built-in validators 16 | 17 | If you used [wtforms](https://wtforms.readthedocs.io/en/2.3.x/validators/) before, 18 | you have definitely used their built-in validators and implemented yours owns in 19 | cases, when you needed some custom behavior. Reforms also contains a similar 20 | validators list. Here is a simple example. 21 | 22 | ```Python 23 | {!../../../docs_src/validators/tutorial002.py!} 24 | ``` 25 | _(This script is complete, it should run "as is")_ 26 | 27 | All current built-in validators you can see below. 28 | 29 | #### `Length` 30 | : validates the length of a string. 31 | 32 | * `min: int =- 1`: The minimum required length of the string. If not provided, minimum length 33 | will not be checked. 34 | * `max: int =- 1`: The maximum length of the string. If not provided, maximum length will not 35 | be checked. 36 | * `message: str = ""`: Error message to raise in case of a validation error. Can be interpolated 37 | using {min} and {max} if desired. Useful defaults are provided depending on the 38 | existence of min and max. 39 | 40 | #### `AnyOf` 41 | : compares the incoming data to a sequence of valid inputs. 42 | 43 | * `values: Sequence[Any]`: A sequence of valid inputs. 44 | * `message: str = "Invalid value, must be one of: {values}."`: Error message to 45 | raise in case of a validation error. {values} contains the list of values. 46 | * `values_formatter: Callable = None`: Function used to format the list of values in 47 | the error message. 48 | 49 | #### `NoneOf` 50 | : compares the incoming data to a sequence of invalid inputs. 51 | 52 | * `values: Sequence[Any]`: A sequence of valid inputs. 53 | * `message: str = "Invalid value, must be one of: {values}."`: Error message to 54 | raise in case of a validation error. {values} contains the list of values. 55 | * `values_formatter: Callable = None`: Function used to format the list of values in 56 | the error message. 57 | 58 | ## Write your custom validator 59 | 60 | To write custom validator, you need to create a callable object (function or class 61 | with implemented `__call__` method) with the parameters below. 62 | 63 | 64 | !!! warning 65 | Validators should either return the parsed value or raise a `ValueError`, 66 | `TypeError`, or `AssertionError` (`assert` statements may be used). It's the 67 | same as with default pydantic validators. 68 | 69 | 70 | | Parameter | Type | Description | 71 | |-----------|--------------------------------------------------------------------------------------------|---------------------------------------------| 72 | | value | Any | Current field value | 73 | | field (Optional) | [ModelField](https://github.com/samuelcolvin/pydantic/blob/master/pydantic/fields.py#L309){:target="_blank"} | Parameter to get access to the field itself | 74 | 75 | ```Python 76 | {!../../../docs_src/validators/tutorial003.py!} 77 | ``` 78 | _(This script is complete, it should run "as is")_ 79 | -------------------------------------------------------------------------------- /docs/en/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Reforms 2 | site_description: Reforms is a modern pydantic-based forms validation and rendering library for Python 3.6+. 3 | site_url: https://reforms.boardpack.org/ 4 | theme: 5 | name: material 6 | palette: 7 | - scheme: default 8 | primary: deep purple 9 | accent: amber 10 | toggle: 11 | icon: material/lightbulb-outline 12 | name: Switch to dark mode 13 | - scheme: slate 14 | primary: deep purple 15 | accent: amber 16 | toggle: 17 | icon: material/lightbulb 18 | name: Switch to light mode 19 | features: 20 | - search.suggest 21 | - search.highlight 22 | icon: 23 | repo: fontawesome/brands/github-alt 24 | logo: img/icon-white.png 25 | favicon: img/favicon.png 26 | language: en 27 | repo_name: boardpack/reforms 28 | repo_url: https://github.com/boardpack/reforms 29 | edit_uri: '' 30 | copyright: Copyright © 2021 Roman Sadzhenytsia 31 | plugins: 32 | - search 33 | - markdownextradata: 34 | data: data 35 | nav: 36 | - Reforms: index.md 37 | - Languages: 38 | - English: / 39 | - Русский: /ru/ 40 | - features.md 41 | - Usage: 42 | - usage/fields.md 43 | - usage/validators.md 44 | - usage/helpers.md 45 | - help-reforms.md 46 | - contributing.md 47 | - release-notes.md 48 | markdown_extensions: 49 | - toc: 50 | permalink: true 51 | - markdown.extensions.codehilite: 52 | guess_lang: false 53 | - markdown_include.include: 54 | base_path: docs 55 | - admonition 56 | - codehilite 57 | - extra 58 | - pymdownx.superfences: 59 | custom_fences: 60 | - name: mermaid 61 | class: mermaid 62 | format: !!python/name:pymdownx.superfences.fence_div_format '' 63 | - pymdownx.tabbed 64 | extra: 65 | social: 66 | - icon: fontawesome/brands/github-alt 67 | link: https://github.com/boardpack/reforms 68 | - icon: fontawesome/brands/telegram 69 | link: https://t.me/dukkee 70 | - icon: fontawesome/solid/globe 71 | link: https://boardpack.org 72 | alternate: 73 | - link: / 74 | name: English 75 | - link: /ru/ 76 | name: Русский 77 | extra_css: 78 | - css/termynal.css 79 | - css/custom.css 80 | extra_javascript: 81 | - https://unpkg.com/mermaid@8.4.6/dist/mermaid.min.js 82 | - js/termynal.js 83 | - js/custom.js 84 | -------------------------------------------------------------------------------- /docs/en/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/missing-translation.md: -------------------------------------------------------------------------------- 1 | !!! warning 2 | The current page still doesn't have a translation for this language. 3 | 4 | But you can help translating it: [Contributing](https://reforms.boardpack.org/contributing/){.internal-link target=_blank}. 5 | -------------------------------------------------------------------------------- /docs/ru/docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | Reforms 3 |

4 | 7 |

8 | 9 | Test 10 | 11 | 12 | Coverage 13 | 14 | 15 | Package version 16 | 17 | Code style: black 18 | Imports: isort 19 |

20 | 21 | --- 22 | 23 | **Documentation**: https://reforms.boardpack.org 24 | 25 | **Source Code**: https://github.com/boardpack/reforms 26 | 27 | --- 28 | 29 | Reforms is a fresh pydantic-based forms validation and rendering library for Python 3.6+. 30 | 31 | The key features are: 32 | 33 | * **Familiar**: Expanded Pydantic retaining all data validation and model creation 34 | capabilities. 35 | * **Easy**: Designed to be easy to use and learn. Less time reading docs. 36 | * **Theming**: Supported the usage of existing template pack and the creation of your 37 | own. 38 | 39 | ## Requirements 40 | 41 | Python 3.6+ 42 | 43 | Reforms has the next hard dependencies: 44 | 45 | * Pydantic for the data parts. 46 | * Jinja2 for the templates. 47 | 48 | ## Installation 49 | 50 |
51 | 52 | ```console 53 | $ pip install git+http://github.com/boardpack/reforms 54 | 55 | ---> 100% 56 | ``` 57 | 58 |
59 | 60 | ## Example 61 | 62 | In the next example, we will use FastAPI 63 | as a web framework. So you need to install `fastapi` and `uvicorn` first. Also, you 64 | need to install `python-multipart` library to turn on forms support in FastAPI. 65 | 66 |
67 | 68 | ```console 69 | $ pip install fastapi uvicorn python-multipart 70 | 71 | ---> 100% 72 | ``` 73 | 74 |
75 | 76 | ### Create it 77 | 78 | * Create a file `models.py` with `UserModel` pydantic model: 79 | 80 | ```Python 81 | {!../../../docs_src/first-steps/models.py!} 82 | ``` 83 | _(This script is complete, it should run "as is")_ 84 | 85 | * Then you can create a FastAPI application and use this model to generate form 86 | layout and validate data. Reforms has special `on_model` function, which works 87 | with `Depends` from FastAPI to convert raw form data into pydantic model object. 88 | Create a file `main.py` with: 89 | 90 | ```Python hl_lines="8 19 23 28" 91 | {!../../../docs_src/first-steps/main.py!} 92 | ``` 93 | _(This script is complete, it should run "as is")_ 94 | 95 | * As the last coding step, you need to create a template (now **reforms** supports only 96 | **jinja2** templates). You can use just form object to render all fields 97 | simultaneously or render every field separately (as it mentions in the selected 98 | commented line). 99 | 100 | ```HTML hl_lines="10" 101 | {!../../../docs_src/first-steps/templates/index.html!} 102 | ``` 103 | _(This template is complete, it should use "as is")_ 104 | 105 | ### Run it 106 | 107 | Run the server with: 108 | 109 |
110 | 111 | ```console 112 | $ uvicorn main:app --reload 113 | 114 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 115 | INFO: Started reloader process [28720] 116 | INFO: Started server process [28722] 117 | INFO: Waiting for application startup. 118 | INFO: Application startup complete. 119 | ``` 120 | 121 |
122 | 123 |
124 | About the command uvicorn main:app --reload... 125 | 126 | The command `uvicorn main:app` refers to: 127 | 128 | * `main`: the file `main.py` (the Python "module"). 129 | * `app`: the object created inside of `main.py` with the line `app = FastAPI()`. 130 | * `--reload`: make the server restart after code changes. Only do this for development. 131 | 132 |
133 | 134 | or just with: 135 | 136 |
137 | 138 | ```console 139 | $ python main.py 140 | 141 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 142 | INFO: Started reloader process [28720] 143 | INFO: Started server process [28722] 144 | INFO: Waiting for application startup. 145 | INFO: Application startup complete. 146 | ``` 147 | 148 |
149 | 150 | ### Send it 151 | 152 | Open your browser at http://127.0.0.1:8000. 153 | 154 | You will see the web form: 155 | 156 | ![Example form](https://reforms-boardpack.netlify.app/img/index/index-01-web-form.png) 157 | 158 | Add some information like this and click "Send" button: 159 | 160 | ![Passed example form](https://reforms-boardpack.netlify.app/img/index/index-02-web-form-passed.png) 161 | 162 | ### Check it 163 | 164 | Finally, you can see a printed validated model object in your console: 165 | 166 | ```bash hl_lines="8" 167 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 168 | INFO: Started reloader process [28720] 169 | INFO: Started server process [28722] 170 | INFO: Waiting for application startup. 171 | INFO: Application startup complete. 172 | 173 | INFO: 127.0.0.1:33612 - "GET / HTTP/1.1" 200 OK 174 | first_name='Roman' last_name='Dukkee' email='example@example.com' has_github=True 175 | INFO: 127.0.0.1:33872 - "POST / HTTP/1.1" 302 Found 176 | INFO: 127.0.0.1:33872 - "GET / HTTP/1.1" 200 OK 177 | ``` 178 | 179 | ## Acknowledgments 180 | 181 | Special thanks to: 182 | 183 | * [Sebastián Ramírez](https://github.com/tiangolo) and his [FastAPI](https://github.com/tiangolo/fastapi) project, some scripts and documentation structure and parts were used from there. 184 | 185 | * [Samuel Colvin](https://github.com/samuelcolvin) and his [Pydantic](https://github.com/samuelcolvin/pydantic/) project. 186 | 187 | ## License 188 | 189 | This project is licensed under the terms of the MIT license. 190 | -------------------------------------------------------------------------------- /docs/ru/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Reforms 2 | site_description: Reforms is a modern pydantic-based forms validation and rendering library for Python 3.6+. 3 | site_url: https://reforms.boardpack.org/ru/ 4 | theme: 5 | name: material 6 | palette: 7 | - scheme: default 8 | primary: deep purple 9 | accent: amber 10 | toggle: 11 | icon: material/lightbulb-outline 12 | name: Перейти к темной версии 13 | - scheme: slate 14 | primary: deep purple 15 | accent: amber 16 | toggle: 17 | icon: material/lightbulb 18 | name: Перейти к cветлой версии 19 | features: 20 | - search.suggest 21 | - search.highlight 22 | icon: 23 | repo: fontawesome/brands/github-alt 24 | logo: https://reforms.boardpack.org/img/icon-white.png 25 | favicon: https://reforms.boardpack.org/img/favicon.png 26 | language: ru 27 | repo_name: boardpack/reforms 28 | repo_url: https://github.com/boardpack/reforms 29 | edit_uri: '' 30 | copyright: Copyright © 2021 Roman Sadzhenytsia 31 | plugins: 32 | - search 33 | - markdownextradata: 34 | data: data 35 | nav: 36 | - Reforms: index.md 37 | - Languages: 38 | - English: / 39 | - Русский: /ru/ 40 | - features.md 41 | - Usage: 42 | - usage/fields.md 43 | - usage/validators.md 44 | - help-reforms.md 45 | - contributing.md 46 | - release-notes.md 47 | markdown_extensions: 48 | - toc: 49 | permalink: true 50 | - markdown.extensions.codehilite: 51 | guess_lang: false 52 | - markdown_include.include: 53 | base_path: docs 54 | - admonition 55 | - codehilite 56 | - extra 57 | - pymdownx.superfences: 58 | custom_fences: 59 | - name: mermaid 60 | class: mermaid 61 | format: !!python/name:pymdownx.superfences.fence_div_format '' 62 | - pymdownx.tabbed 63 | extra: 64 | social: 65 | - icon: fontawesome/brands/github-alt 66 | link: https://github.com/boardpack/reforms 67 | - icon: fontawesome/brands/telegram 68 | link: https://t.me/dukkee 69 | - icon: fontawesome/solid/globe 70 | link: https://boardpack.org 71 | alternate: 72 | - link: / 73 | name: English 74 | - link: /ru/ 75 | name: Русский 76 | extra_css: 77 | - https://reforms.boardpack.org/css/termynal.css 78 | - https://reforms.boardpack.org/css/custom.css 79 | extra_javascript: 80 | - https://unpkg.com/mermaid@8.4.6/dist/mermaid.min.js 81 | - https://reforms.boardpack.org/js/termynal.js 82 | - https://reforms.boardpack.org/js/custom.js 83 | -------------------------------------------------------------------------------- /docs/ru/overrides/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/docs/ru/overrides/.gitignore -------------------------------------------------------------------------------- /docs_src/first-steps/main_fastapi.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import Depends, FastAPI, Request 3 | from fastapi.responses import HTMLResponse, RedirectResponse 4 | from fastapi.templating import Jinja2Templates 5 | from starlette.status import HTTP_302_FOUND 6 | from reforms import Reforms 7 | from reforms.contrib.fastapi import from_model 8 | 9 | from models import UserModel 10 | 11 | app = FastAPI() 12 | 13 | forms = Reforms(package="reforms") 14 | 15 | templates = Jinja2Templates(directory="templates") 16 | 17 | 18 | @app.get("/", response_class=HTMLResponse) 19 | async def index(request: Request): 20 | user_form = forms.Form(UserModel) 21 | 22 | return templates.TemplateResponse( 23 | "index.html", 24 | {"request": request, "form": user_form}, 25 | ) 26 | 27 | 28 | @app.post("/", response_class=RedirectResponse) 29 | async def handle_form(form: from_model(UserModel) = Depends()): 30 | print(form) 31 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 32 | 33 | 34 | if __name__ == "__main__": 35 | uvicorn.run(app) 36 | -------------------------------------------------------------------------------- /docs_src/first-steps/main_starlette.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from starlette.applications import Starlette 3 | from starlette.routing import Route 4 | from starlette.requests import Request 5 | from starlette.responses import RedirectResponse 6 | from starlette.templating import Jinja2Templates 7 | from starlette.status import HTTP_302_FOUND 8 | from reforms import Reforms 9 | 10 | from models import UserModel 11 | 12 | forms = Reforms(package="reforms") 13 | 14 | templates = Jinja2Templates(directory="templates") 15 | 16 | 17 | async def index(request: Request): 18 | user_form = forms.Form(UserModel) 19 | 20 | return templates.TemplateResponse( 21 | "index.html", 22 | {"request": request, "form": user_form}, 23 | ) 24 | 25 | 26 | async def handle_form(request: Request): 27 | raw_form = await request.form() 28 | form = UserModel(**raw_form) 29 | 30 | print(form) 31 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 32 | 33 | 34 | if __name__ == "__main__": 35 | app = Starlette( 36 | routes=[ 37 | Route('/', endpoint=index), 38 | Route('/', endpoint=handle_form, methods=["POST"]), 39 | ], 40 | ) 41 | uvicorn.run(app) 42 | 43 | -------------------------------------------------------------------------------- /docs_src/first-steps/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from reforms import BooleanField, EmailField, StringField 3 | from reforms.validators import Length 4 | 5 | 6 | class UserModel(BaseModel): 7 | first_name: StringField( 8 | label="First Name", 9 | field_id="firstName", 10 | placeholder="John", 11 | validators=[Length(min=5)], 12 | ) 13 | last_name: StringField( 14 | label="Last Name", 15 | field_id="lastName", 16 | placeholder="Doe", 17 | validators=[Length(min=5)], 18 | ) 19 | email: EmailField( 20 | label="Email", 21 | field_id="email", 22 | placeholder="john.doe@example.com", 23 | ) 24 | has_github: BooleanField(label="Has Github account?", field_id="hasGithub") = False 25 | -------------------------------------------------------------------------------- /docs_src/first-steps/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example reforms page 6 | 7 | 8 |
9 | {{ form }} 10 | {#{{ form.first_name }}#} 11 |
12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /docs_src/validators/tutorial001.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ValidationError, validator 2 | from reforms import BooleanField, StringField 3 | 4 | 5 | class UserModel(BaseModel): 6 | name: StringField() 7 | is_admin: BooleanField() 8 | username: StringField() 9 | password1: StringField() 10 | password2: StringField() 11 | 12 | @validator("name") 13 | def name_must_contain_space(cls, v): 14 | if " " not in v: 15 | raise ValueError("must contain a space") 16 | return v.title() 17 | 18 | @validator("password2") 19 | def passwords_match(cls, v, values): 20 | if "password1" in values and v != values["password1"]: 21 | raise ValueError("passwords do not match") 22 | return v 23 | 24 | @validator("username") 25 | def username_alphanumeric(cls, v): 26 | assert v.isalnum(), "must be alphanumeric" 27 | return v 28 | 29 | @validator("is_admin") 30 | def contains_admin_name(cls, v, values): 31 | if v and ("name" not in values or "admin" not in values["name"].lower()): 32 | raise ValueError('if user is admin, his name must contain "admin" part') 33 | 34 | return v 35 | 36 | 37 | user = UserModel( 38 | name="samuel colvin admin", 39 | username="scolvin", 40 | password1="zxcvbn", 41 | password2="zxcvbn", 42 | is_admin=True, 43 | ) 44 | print(user) 45 | 46 | try: 47 | UserModel( 48 | name="samuel", 49 | username="scolvin", 50 | password1="zxcvbn", 51 | password2="zxcvbn2", 52 | is_admin=True, 53 | ) 54 | except ValidationError as e: 55 | print(e) 56 | -------------------------------------------------------------------------------- /docs_src/validators/tutorial002.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ValidationError 2 | from reforms import StringField 3 | from reforms.validators import Length 4 | 5 | 6 | class ContactUsModel(BaseModel): 7 | name: StringField(validators=[Length(min=5)]) 8 | message: StringField() 9 | 10 | 11 | contact = ContactUsModel(name="Roman", message="Some message") 12 | print(contact) 13 | 14 | try: 15 | ContactUsModel( 16 | message="Temp", 17 | ) 18 | except ValidationError as e: 19 | print(e) 20 | 21 | try: 22 | ContactUsModel( 23 | name="Dan", 24 | ) 25 | except ValidationError as e: 26 | print(e) 27 | -------------------------------------------------------------------------------- /docs_src/validators/tutorial003.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from pydantic import BaseModel, ValidationError 4 | from reforms import StringField 5 | 6 | 7 | def has_lines(n: int = 2) -> Callable: 8 | def _has_lines(value: str): 9 | if len(value.split("\n")) < n: 10 | raise ValueError(f"Value doesn't contain minimum {n} lines") 11 | 12 | return value 13 | 14 | return _has_lines 15 | 16 | 17 | class MessageModel(BaseModel): 18 | content: StringField(validators=[has_lines(n=3)]) 19 | 20 | 21 | contact = MessageModel( 22 | content=""" 23 | First sentence. 24 | Second sentence. 25 | Third sentence""" 26 | ) 27 | 28 | print(contact) 29 | 30 | try: 31 | MessageModel(content="One line") 32 | except ValidationError as e: 33 | print(e) 34 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | # --strict 4 | disallow_any_generics = True 5 | disallow_subclassing_any = True 6 | disallow_untyped_calls = True 7 | disallow_untyped_defs = True 8 | disallow_incomplete_defs = True 9 | check_untyped_defs = True 10 | disallow_untyped_decorators = True 11 | no_implicit_optional = True 12 | warn_redundant_casts = True 13 | warn_unused_ignores = True 14 | warn_return_any = True 15 | strict_equality = True 16 | # --strict end 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit"] 3 | build-backend = "flit.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "reforms" 7 | dist-name = "reforms" 8 | author = "Roman Sadzhenytsia" 9 | author-email = "urchin.dukkee@gmail.com" 10 | home-page = "https://github.com/boardpack/reforms" 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: System Administrators", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python", 17 | "Topic :: Internet", 18 | "Topic :: Software Development :: Libraries :: Python Modules", 19 | "Topic :: Software Development :: Libraries", 20 | "Topic :: Software Development", 21 | "Typing :: Typed", 22 | "Environment :: Web Environment", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.6", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | ] 31 | description-file = "README.md" 32 | requires-python = ">=3.6" 33 | requires = [ 34 | "pydantic[email] >=1.6.2,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<2.0.0", 35 | "jinja2 >=3.0.0", 36 | ] 37 | 38 | [tool.flit.metadata.urls] 39 | Documentation = "https://reforms.boardpack.org" 40 | 41 | [tool.flit.metadata.requires-extra] 42 | test = [ 43 | "pytest ==5.4.3", 44 | "pytest-cov ==2.10.0", 45 | "pytest-lazy-fixture ==0.6.3", 46 | "mypy ==0.812", 47 | "flake8 >=3.8.3,<4.0.0", 48 | "black ==20.8b1", 49 | "isort >=5.0.6,<6.0.0", 50 | "starlette ==0.14.2", 51 | "fastapi ==0.65.2", 52 | "requests ==2.25.1", 53 | "python-multipart ==0.0.5", 54 | ] 55 | dev = [ 56 | "autoflake >=1.3.1,<2.0.0", 57 | "flake8 >=3.8.3,<4.0.0", 58 | "pre-commit", 59 | "tox >=3.23.1", 60 | ] 61 | doc = [ 62 | "mkdocs ==1.1.2", 63 | "mkdocs-material ==7.1.4", 64 | "markdown-include ==0.5.1", 65 | "mkdocs-markdownextradata-plugin ==0.1.9", 66 | "typer-cli ==0.0.9", 67 | "pyyaml ==5.4.1" 68 | ] 69 | 70 | [tool.isort] 71 | profile = "black" 72 | known_first_party = ["reforms", "pydantic", "jinja2", "markupsafe", "starlette", 73 | "pytest", "fastapi"] 74 | -------------------------------------------------------------------------------- /reforms/__init__.py: -------------------------------------------------------------------------------- 1 | """Reforms is a modern pydantic-based web forms library for Python 3.6+. """ 2 | 3 | __author__ = """Roman Sadzhenytsia""" 4 | __email__ = "urchin.dukkee@gmail.com" 5 | __version__ = "0.1.0" 6 | 7 | from .fields import BooleanField, EmailField, HiddenField, StringField 8 | from .main import Reforms 9 | 10 | __all__ = ("Reforms", "StringField", "BooleanField", "EmailField", "HiddenField") 11 | -------------------------------------------------------------------------------- /reforms/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/reforms/contrib/__init__.py -------------------------------------------------------------------------------- /reforms/contrib/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Type 2 | 3 | from fastapi.requests import Request 4 | from pydantic import BaseModel, StrictBool 5 | from pydantic.fields import ModelField 6 | 7 | __all__ = ["from_model"] 8 | 9 | 10 | def from_model(model: Type[BaseModel]) -> Callable[[Request], Any]: 11 | """This is a helper to convert raw form data into Pydantic model with help of the 12 | FastAPI Dependency Injection system (Depends function): 13 | 14 | class UserModel(pydantic.BaseModel): 15 | name = reforms.StringField(...) 16 | email = reforms.EmailField(...) 17 | 18 | # ... 19 | 20 | @app.post("/", response_class=RedirectResponse) 21 | async def handle_form(form: from_model(UserModel) = Depends()): 22 | print(form) 23 | return RedirectResponse("/", status_code=HTTP_302_FOUND) 24 | 25 | """ 26 | 27 | def _convert_bool_value(field: ModelField, form: Dict[str, Any]) -> bool: 28 | if field.required or field.name in form: 29 | return field.name in form 30 | 31 | return bool(field.default) 32 | 33 | async def _from_model(request: Request) -> Any: 34 | form = dict(await request.form()) 35 | 36 | for field in model.__fields__.values(): 37 | if issubclass(field.type_, StrictBool): 38 | form[field.name] = _convert_bool_value(field, form) # type: ignore 39 | 40 | return model.parse_obj(form) 41 | 42 | return _from_model 43 | -------------------------------------------------------------------------------- /reforms/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseField 2 | from .bool_field import BooleanField 3 | from .email_field import EmailField 4 | from .hidden import HiddenField 5 | from .str_field import StringField 6 | -------------------------------------------------------------------------------- /reforms/fields/base.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Iterable, List 3 | 4 | import jinja2 5 | from pydantic import BaseModel 6 | from pydantic.fields import ModelField 7 | from pydantic.utils import Representation 8 | 9 | from ..validators import BaseValidator 10 | from ..widgets import BaseWidget 11 | 12 | __all__ = ("BaseField", "RenderField") 13 | 14 | 15 | class BaseField(Representation): 16 | widget: BaseWidget 17 | _validators: List[BaseValidator] = [] 18 | 19 | @classmethod 20 | def __get_validators__(cls) -> Iterable[BaseValidator]: 21 | return itertools.chain(cls._validators) 22 | 23 | 24 | class RenderField(BaseModel): 25 | env: jinja2.Environment 26 | data: ModelField 27 | 28 | class Config: 29 | arbitrary_types_allowed = True 30 | 31 | def __html__(self) -> str: 32 | return str(self) 33 | 34 | def __repr__(self) -> str: 35 | return repr(self.data) 36 | 37 | def __str__(self) -> str: 38 | field_type: BaseField = self.data.type_ 39 | 40 | if self.data.required and field_type.widget.settings.get("disabled"): 41 | raise ValueError( 42 | f"You can't render {self.data.name} because of it has disabled option " 43 | "and doesn't have a default value" 44 | ) 45 | 46 | return field_type.widget.render( 47 | self.env, 48 | name=self.data.name, 49 | required=self.data.required, 50 | default=self.data.default, 51 | ) 52 | -------------------------------------------------------------------------------- /reforms/fields/bool_field.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from pydantic import StrictBool 4 | 5 | from ..validators import BaseValidator 6 | from ..widgets import BaseWidget, Checkbox 7 | from .base import BaseField 8 | 9 | __all__ = ["BooleanField"] 10 | 11 | 12 | def BooleanField( 13 | *, 14 | widget: Type[BaseWidget] = Checkbox, 15 | field_id: str = "", 16 | field_class: str = "", 17 | label: str = "", 18 | disabled: bool = False, 19 | render_kw: Optional[Dict[str, Any]] = None, 20 | validators: Optional[List[BaseValidator]] = None, 21 | ) -> type: 22 | namespace = dict( 23 | widget=widget( 24 | field_id=field_id, 25 | field_class=field_class, 26 | label=label, 27 | disabled=disabled, 28 | **(render_kw or {}), 29 | ), 30 | _validators=validators or [], 31 | ) 32 | 33 | return type("BooleanField", (StrictBool, BaseField), namespace) 34 | -------------------------------------------------------------------------------- /reforms/fields/email_field.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from pydantic import EmailStr 4 | 5 | from ..validators import BaseValidator 6 | from ..widgets import BaseWidget, EmailInput 7 | from .base import BaseField 8 | 9 | __all__ = ["EmailField"] 10 | 11 | 12 | def EmailField( 13 | *, 14 | widget: Type[BaseWidget] = EmailInput, 15 | field_id: str = "", 16 | field_class: str = "", 17 | label: str = "", 18 | placeholder: str = "", 19 | disabled: bool = False, 20 | render_kw: Optional[Dict[str, Any]] = None, 21 | validators: Optional[List[BaseValidator]] = None, 22 | ) -> type: 23 | namespace = dict( 24 | widget=widget( 25 | field_id=field_id, 26 | field_class=field_class, 27 | label=label, 28 | placeholder=placeholder, 29 | disabled=disabled, 30 | **(render_kw or {}), 31 | ), 32 | _validators=validators or [], 33 | ) 34 | 35 | return type("EmailField", (EmailStr, BaseField), namespace) 36 | -------------------------------------------------------------------------------- /reforms/fields/hidden.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from ..validators import BaseValidator 4 | from ..widgets import BaseWidget, HiddenInput 5 | from .base import BaseField 6 | 7 | __all__ = ["HiddenField"] 8 | 9 | 10 | def HiddenField( 11 | *, 12 | widget: Type[BaseWidget] = HiddenInput, 13 | field_id: str = "", 14 | field_class: str = "", 15 | render_kw: Optional[Dict[str, Any]] = None, 16 | validators: Optional[List[BaseValidator]] = None, 17 | ) -> type: 18 | namespace = dict( 19 | widget=widget( 20 | field_id=field_id, 21 | field_class=field_class, 22 | **(render_kw or {}), 23 | ), 24 | _validators=validators or [], 25 | ) 26 | 27 | return type("HiddenField", (str, BaseField), namespace) 28 | -------------------------------------------------------------------------------- /reforms/fields/str_field.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Type 2 | 3 | from ..validators import BaseValidator 4 | from ..widgets import BaseWidget, TextInput 5 | from .base import BaseField 6 | 7 | __all__ = ["StringField"] 8 | 9 | 10 | def StringField( 11 | *, 12 | widget: Type[BaseWidget] = TextInput, 13 | field_id: str = "", 14 | field_class: str = "", 15 | label: str = "", 16 | placeholder: str = "", 17 | disabled: bool = False, 18 | render_kw: Optional[Dict[str, Any]] = None, 19 | validators: Optional[List[BaseValidator]] = None, 20 | ) -> type: 21 | namespace = dict( 22 | widget=widget( 23 | field_id=field_id, 24 | field_class=field_class, 25 | label=label, 26 | placeholder=placeholder, 27 | disabled=disabled, 28 | **(render_kw or {}), 29 | ), 30 | _validators=validators or [], 31 | ) 32 | 33 | return type("StringField", (str, BaseField), namespace) 34 | -------------------------------------------------------------------------------- /reforms/forms.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Iterable, Mapping, Sequence, Type, Union 3 | 4 | import jinja2 5 | from markupsafe import Markup 6 | from pydantic import BaseModel, Protocol, StrBytes 7 | from pydantic.fields import ModelField 8 | 9 | from .fields.base import RenderField 10 | 11 | __all__ = ["Form"] 12 | 13 | 14 | class Form(BaseModel): 15 | env: jinja2.Environment 16 | model: Type[BaseModel] 17 | 18 | class Config: 19 | arbitrary_types_allowed = True 20 | 21 | def __str__(self) -> str: 22 | return self.render() 23 | 24 | def __html__(self) -> str: 25 | return self.render() 26 | 27 | def __getattr__(self, item: str) -> Any: 28 | if item in self.model.__fields__: 29 | field: ModelField = self.model.__fields__[item] 30 | return RenderField(env=self.env, data=field) 31 | 32 | return super().__getattr__(item) # type: ignore 33 | 34 | def __call__(self, *args: Sequence[Any], **kwargs: Mapping[Any, Any]) -> BaseModel: 35 | return self.model(*args, **kwargs) 36 | 37 | def render(self) -> str: 38 | fields: Iterable[ModelField] = self.model.__fields__.values() 39 | return Markup( 40 | "
".join(str(RenderField(env=self.env, data=field)) for field in fields) 41 | ) 42 | 43 | def parse_obj(self, obj: Any) -> BaseModel: # type: ignore 44 | return self.model.parse_obj(obj) 45 | 46 | def parse_raw( # type: ignore 47 | self, 48 | b: StrBytes, 49 | *, 50 | content_type: str = None, # type: ignore 51 | encoding: str = "utf8", 52 | proto: Protocol = None, # type: ignore 53 | allow_pickle: bool = False, 54 | ) -> BaseModel: 55 | return self.model.parse_raw( 56 | b=b, 57 | content_type=content_type, 58 | encoding=encoding, 59 | proto=proto, 60 | allow_pickle=allow_pickle, 61 | ) 62 | 63 | def parse_file( # type: ignore 64 | self, 65 | path: Union[str, Path], 66 | *, 67 | content_type: str = None, # type: ignore 68 | encoding: str = "utf8", 69 | proto: Protocol = None, # type: ignore 70 | allow_pickle: bool = False, 71 | ) -> BaseModel: 72 | return self.model.parse_file( 73 | path=path, 74 | content_type=content_type, 75 | encoding=encoding, 76 | proto=proto, 77 | allow_pickle=allow_pickle, 78 | ) 79 | -------------------------------------------------------------------------------- /reforms/main.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Type 2 | 3 | import jinja2 4 | from pydantic import BaseModel 5 | 6 | from .forms import Form 7 | 8 | __all__ = ["Reforms"] 9 | 10 | 11 | class Reforms: 12 | default_package_path: str = "templates/forms" 13 | default_template_package: str = "reforms" 14 | 15 | def __init__( 16 | self, *, directory: Optional[str] = None, package: Optional[str] = None 17 | ) -> None: 18 | self.env = self.load_template_env(directory=directory, package=package) 19 | 20 | def load_template_env( 21 | self, *, directory: Optional[str] = None, package: Optional[str] = None 22 | ) -> "jinja2.Environment": 23 | loaders: List[jinja2.BaseLoader] = [ 24 | jinja2.PackageLoader( 25 | self.default_template_package, self.default_package_path 26 | ) 27 | ] 28 | 29 | if package and package != self.default_template_package: 30 | loaders.append(jinja2.PackageLoader(package, self.default_package_path)) 31 | if directory: 32 | loaders.append(jinja2.FileSystemLoader(directory)) 33 | 34 | loaders.reverse() 35 | return jinja2.Environment(loader=jinja2.ChoiceLoader(loaders), autoescape=True) 36 | 37 | def Form(self, model: Type[BaseModel]) -> Form: 38 | return Form(env=self.env, model=model) 39 | -------------------------------------------------------------------------------- /reforms/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/reforms/py.typed -------------------------------------------------------------------------------- /reforms/templates/forms/checkbox.html: -------------------------------------------------------------------------------- 1 | {% if label %}{{ label }}{% endif -%} 2 | 3 | -------------------------------------------------------------------------------- /reforms/templates/forms/email.html: -------------------------------------------------------------------------------- 1 | {% include "input.html" %} 2 | -------------------------------------------------------------------------------- /reforms/templates/forms/hidden.html: -------------------------------------------------------------------------------- 1 | {% include "input.html" %} 2 | -------------------------------------------------------------------------------- /reforms/templates/forms/input.html: -------------------------------------------------------------------------------- 1 | {% if label %}{{ label }}{% endif -%} 2 | 3 | -------------------------------------------------------------------------------- /reforms/templates/forms/text.html: -------------------------------------------------------------------------------- 1 | {% include "input.html" %} 2 | -------------------------------------------------------------------------------- /reforms/validators/__init__.py: -------------------------------------------------------------------------------- 1 | from .any_of import AnyOf 2 | from .base import BaseValidator 3 | from .length import Length 4 | from .none_of import NoneOf 5 | -------------------------------------------------------------------------------- /reforms/validators/any_of.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional, Sequence 2 | 3 | from pydantic.fields import ModelField 4 | 5 | from .base import BaseValidator 6 | 7 | __all__ = ["AnyOf"] 8 | 9 | 10 | class AnyOf(BaseValidator): 11 | values: Sequence[Any] 12 | message: str = "Invalid value, must be one of: {values}." 13 | values_formatter: Optional[Callable[[Sequence[Any]], str]] = None 14 | 15 | def __call__(self, value: Any, field: ModelField) -> Any: 16 | if value in self.values: 17 | return value 18 | 19 | if not self.values_formatter: 20 | self.values_formatter = self.default_values_formatter 21 | 22 | raise ValueError(self.message.format(values=self.values_formatter(self.values))) 23 | 24 | @staticmethod 25 | def default_values_formatter(values: Sequence[Any]) -> str: 26 | return ", ".join(str(x) for x in values) 27 | -------------------------------------------------------------------------------- /reforms/validators/base.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from pydantic import BaseModel 4 | from pydantic.fields import ModelField 5 | 6 | __all__ = ["BaseValidator"] 7 | 8 | 9 | class BaseValidator(BaseModel): 10 | def __call__(self, value: typing.Any, field: ModelField) -> typing.Any: 11 | return NotImplemented 12 | -------------------------------------------------------------------------------- /reforms/validators/length.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from pydantic import validator 4 | from pydantic.fields import ModelField 5 | 6 | from .base import BaseValidator 7 | 8 | __all__ = ["Length"] 9 | 10 | 11 | class Length(BaseValidator): 12 | max: int = -1 13 | min: int = -1 14 | message: str = "" 15 | 16 | @validator("min") 17 | def min_or_max(cls, v: int, values: typing.Any) -> int: 18 | if v == -1 and values["max"] == -1: 19 | raise ValueError("At least one of `min` or `max` must be specified.") 20 | if not (values["max"] == -1 or v <= values["max"]): 21 | raise ValueError("`min` cannot be more than `max`.") 22 | 23 | return v 24 | 25 | def __call__(self, value: typing.Any, field: ModelField) -> typing.Any: 26 | length = value and len(value) or 0 27 | if length >= self.min and (self.max == -1 or length <= self.max): 28 | return value 29 | 30 | if self.message: 31 | message = self.message 32 | elif self.max == -1: 33 | message = "Field must be at least {min} character long." 34 | elif self.min == -1: 35 | message = "Field cannot be longer than {max} character." 36 | elif self.min == self.max: 37 | message = "Field must be exactly {max} character long." 38 | else: 39 | message = "Field must be between {min} and {max} characters long." 40 | 41 | message_params = { 42 | name: value 43 | for name, value in ( 44 | ("min", self.min), 45 | ("max", self.max), 46 | ("length", length), 47 | ) 48 | if "{" + name + "}" in message 49 | } 50 | 51 | raise ValueError(message.format(**message_params)) 52 | -------------------------------------------------------------------------------- /reforms/validators/none_of.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional, Sequence 2 | 3 | from pydantic.fields import ModelField 4 | 5 | from .base import BaseValidator 6 | 7 | __all__ = ["NoneOf"] 8 | 9 | 10 | class NoneOf(BaseValidator): 11 | values: Sequence[Any] 12 | message: str = "Invalid value, can't be any of: {values}." 13 | values_formatter: Optional[Callable[[Sequence[Any]], str]] = None 14 | 15 | def __call__(self, value: Any, field: ModelField) -> Any: 16 | if value not in self.values: 17 | return value 18 | 19 | if not self.values_formatter: 20 | self.values_formatter = self.default_values_formatter 21 | 22 | raise ValueError(self.message.format(values=self.values_formatter(self.values))) 23 | 24 | @staticmethod 25 | def default_values_formatter(values: Sequence[Any]) -> str: 26 | return ", ".join(str(x) for x in values) 27 | -------------------------------------------------------------------------------- /reforms/widgets.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Any, Dict 3 | 4 | import jinja2 5 | from markupsafe import Markup 6 | 7 | __all__ = ("BaseWidget", "Input", "TextInput", "EmailInput", "Checkbox", "HiddenInput") 8 | 9 | 10 | class BaseWidget: 11 | template: str = "" 12 | 13 | def __init__(self, **render_settings: Any) -> None: 14 | self._render_settings: Dict[str, Any] = render_settings 15 | 16 | @property 17 | def settings(self) -> Dict[str, Any]: 18 | return deepcopy(self._render_settings) 19 | 20 | def render(self, env: jinja2.Environment, **kwargs: Any) -> str: 21 | if not env or not isinstance(env, jinja2.Environment): 22 | raise ValueError( 23 | "You can't render field outside of any form. Please define Form class " 24 | "first." 25 | ) 26 | if not self.template: 27 | raise ValueError( 28 | "You can't render the field without the template reference. Please " 29 | "define 'template' attribute in your field definition." 30 | ) 31 | 32 | self._render_settings.update(kwargs) 33 | 34 | if "name" not in self._render_settings: 35 | raise ValueError( 36 | "The widget must contain the 'name' attribute for the rendering." 37 | ) 38 | 39 | template: jinja2.Template = env.get_template(self.template) 40 | return Markup(template.render(self.settings)) 41 | 42 | def __str__(self) -> str: 43 | return "{class_name}(template='{template}', {attrs})".format( 44 | class_name=self.__class__.__name__, 45 | template=self.template, 46 | attrs=", ".join( 47 | "{}={!r}".format(k, v) for k, v in self._render_settings.items() 48 | ), 49 | ) 50 | 51 | 52 | class Input(BaseWidget): 53 | input_type: str = "" 54 | template: str = "input.html" 55 | 56 | @property 57 | def settings(self) -> Dict[str, Any]: 58 | base_settings = super().settings 59 | 60 | if hasattr(self, "input_type"): 61 | base_settings["type"] = self.input_type 62 | 63 | return base_settings 64 | 65 | def render(self, env: jinja2.Environment, **kwargs: Any) -> str: 66 | if not self.input_type: 67 | raise AttributeError( 68 | "Input is a base class, please use one of its children." 69 | ) 70 | 71 | return super().render(env, **kwargs) 72 | 73 | 74 | class TextInput(Input): 75 | input_type: str = "text" 76 | template: str = "text.html" 77 | 78 | 79 | class EmailInput(Input): 80 | input_type: str = "email" 81 | template: str = "email.html" 82 | 83 | 84 | class Checkbox(Input): 85 | input_type: str = "checkbox" 86 | template: str = "checkbox.html" 87 | 88 | 89 | class HiddenInput(Input): 90 | input_type: str = "hidden" 91 | template: str = "hidden.html" 92 | -------------------------------------------------------------------------------- /scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | python ./scripts/docs.py build-all 7 | -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ -d 'dist' ] ; then 4 | rm -r dist 5 | fi 6 | if [ -d 'site' ] ; then 7 | rm -r site 8 | fi 9 | -------------------------------------------------------------------------------- /scripts/docs-live.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | mkdocs serve --dev-addr 0.0.0.0:8008 6 | -------------------------------------------------------------------------------- /scripts/docs.py: -------------------------------------------------------------------------------- 1 | """Lightweight version of docs.py script for Reforms package 2 | 3 | You can find original here: https://github.com/tiangolo/fastapi/blob/master/scripts/docs.py 4 | 5 | """ 6 | 7 | import os 8 | import re 9 | import shutil 10 | from http.server import HTTPServer, SimpleHTTPRequestHandler 11 | from multiprocessing import Pool 12 | from pathlib import Path 13 | from typing import Dict, List, Optional, Tuple 14 | 15 | import mkdocs.commands.build 16 | import mkdocs.commands.serve 17 | import mkdocs.config 18 | import mkdocs.utils 19 | import typer 20 | import yaml 21 | 22 | app = typer.Typer() 23 | 24 | mkdocs_name = "mkdocs.yml" 25 | 26 | missing_translation_snippet = """ 27 | {!../../../docs/missing-translation.md!} 28 | """ 29 | 30 | docs_path = Path("docs") 31 | en_docs_path = Path("docs/en") 32 | en_config_path: Path = en_docs_path / mkdocs_name 33 | 34 | 35 | def get_en_config() -> dict: 36 | return mkdocs.utils.yaml_load(en_config_path.read_text(encoding="utf-8")) 37 | 38 | 39 | def get_lang_paths(): 40 | return sorted(docs_path.iterdir()) 41 | 42 | 43 | def lang_callback(lang: Optional[str]): 44 | if lang is None: 45 | return 46 | if not lang.isalpha() or len(lang) != 2: 47 | typer.echo("Use a 2 letter language code, like: es") 48 | raise typer.Abort() 49 | lang = lang.lower() 50 | return lang 51 | 52 | 53 | def complete_existing_lang(incomplete: str): 54 | lang_path: Path 55 | for lang_path in get_lang_paths(): 56 | if lang_path.is_dir() and lang_path.name.startswith(incomplete): 57 | yield lang_path.name 58 | 59 | 60 | def get_base_lang_config(lang: str): 61 | en_config = get_en_config() 62 | new_config = en_config.copy() 63 | new_config["site_url"] = en_config["site_url"] + f"{lang}/" 64 | new_config["theme"]["logo"] = en_config["site_url"] + en_config["theme"]["logo"] 65 | new_config["theme"]["favicon"] = ( 66 | en_config["site_url"] + en_config["theme"]["favicon"] 67 | ) 68 | new_config["theme"]["language"] = lang 69 | new_config["nav"] = en_config["nav"][:2] 70 | extra_css = [] 71 | css: str 72 | for css in en_config["extra_css"]: 73 | if css.startswith("http"): 74 | extra_css.append(css) 75 | else: 76 | extra_css.append(en_config["site_url"] + css) 77 | new_config["extra_css"] = extra_css 78 | 79 | extra_js = [] 80 | js: str 81 | for js in en_config["extra_javascript"]: 82 | if js.startswith("http"): 83 | extra_js.append(js) 84 | else: 85 | extra_js.append(en_config["site_url"] + js) 86 | new_config["extra_javascript"] = extra_js 87 | return new_config 88 | 89 | 90 | @app.command() 91 | def new_lang(lang: str = typer.Argument(..., callback=lang_callback)): 92 | """ 93 | Generate a new docs translation directory for the language LANG. 94 | 95 | LANG should be a 2-letter language code, like: en, es, de, pt, etc. 96 | """ 97 | new_path: Path = Path("docs") / lang 98 | if new_path.exists(): 99 | typer.echo(f"The language was already created: {lang}") 100 | raise typer.Abort() 101 | new_path.mkdir() 102 | new_config = get_base_lang_config(lang) 103 | new_config_path: Path = Path(new_path) / mkdocs_name 104 | new_config_path.write_text( 105 | yaml.dump(new_config, sort_keys=False, width=200, allow_unicode=True), 106 | encoding="utf-8", 107 | ) 108 | new_config_docs_path: Path = new_path / "docs" 109 | new_config_docs_path.mkdir() 110 | en_index_path: Path = en_docs_path / "docs" / "index.md" 111 | new_index_path: Path = new_config_docs_path / "index.md" 112 | en_index_content = en_index_path.read_text(encoding="utf-8") 113 | new_index_content = f"{missing_translation_snippet}\n\n{en_index_content}" 114 | new_index_path.write_text(new_index_content, encoding="utf-8") 115 | typer.secho(f"Successfully initialized: {new_path}", color=typer.colors.GREEN) 116 | update_languages(lang=None) 117 | 118 | 119 | @app.command() 120 | def build_lang( 121 | lang: str = typer.Argument( 122 | ..., callback=lang_callback, autocompletion=complete_existing_lang 123 | ) 124 | ): 125 | """ 126 | Build the docs for a language, filling missing pages with translation notifications. 127 | """ 128 | lang_path: Path = Path("docs") / lang 129 | if not lang_path.is_dir(): 130 | typer.echo(f"The language translation doesn't seem to exist yet: {lang}") 131 | raise typer.Abort() 132 | typer.echo(f"Building docs for: {lang}") 133 | build_dir_path = Path("docs_build") 134 | build_dir_path.mkdir(exist_ok=True) 135 | build_lang_path = build_dir_path / lang 136 | en_lang_path = Path("docs/en") 137 | site_path = Path("site").absolute() 138 | if lang == "en": 139 | dist_path = site_path 140 | else: 141 | dist_path: Path = site_path / lang 142 | shutil.rmtree(build_lang_path, ignore_errors=True) 143 | shutil.copytree(lang_path, build_lang_path) 144 | shutil.copytree(en_docs_path / "data", build_lang_path / "data") 145 | overrides_src = en_docs_path / "overrides" 146 | overrides_dest = build_lang_path / "overrides" 147 | for path in overrides_src.iterdir(): 148 | dest_path = overrides_dest / path.name 149 | if not dest_path.exists(): 150 | shutil.copy(path, dest_path) 151 | en_config_path: Path = en_lang_path / mkdocs_name 152 | en_config: dict = mkdocs.utils.yaml_load(en_config_path.read_text(encoding="utf-8")) 153 | nav = en_config["nav"] 154 | lang_config_path: Path = lang_path / mkdocs_name 155 | lang_config: dict = mkdocs.utils.yaml_load( 156 | lang_config_path.read_text(encoding="utf-8") 157 | ) 158 | lang_nav = lang_config["nav"] 159 | # Exclude first 2 entries Reforms and Languages, for custom handling 160 | use_nav = nav[2:] 161 | lang_use_nav = lang_nav[2:] 162 | file_to_nav = get_file_to_nav_map(use_nav) 163 | sections = get_sections(use_nav) 164 | lang_file_to_nav = get_file_to_nav_map(lang_use_nav) 165 | use_lang_file_to_nav = get_file_to_nav_map(lang_use_nav) 166 | for file in file_to_nav: 167 | file_path = Path(file) 168 | lang_file_path: Path = build_lang_path / "docs" / file_path 169 | en_file_path: Path = en_lang_path / "docs" / file_path 170 | lang_file_path.parent.mkdir(parents=True, exist_ok=True) 171 | if not lang_file_path.is_file(): 172 | en_text = en_file_path.read_text(encoding="utf-8") 173 | lang_text = get_text_with_translate_missing(en_text) 174 | lang_file_path.write_text(lang_text, encoding="utf-8") 175 | file_key = file_to_nav[file] 176 | use_lang_file_to_nav[file] = file_key 177 | if file_key: 178 | composite_key = () 179 | new_key = () 180 | for key_part in file_key: 181 | composite_key += (key_part,) 182 | key_first_file = sections[composite_key] 183 | if key_first_file in lang_file_to_nav: 184 | new_key = lang_file_to_nav[key_first_file] 185 | else: 186 | new_key += (key_part,) 187 | use_lang_file_to_nav[file] = new_key 188 | key_to_section = {(): []} 189 | for file, orig_file_key in file_to_nav.items(): 190 | if file in use_lang_file_to_nav: 191 | file_key = use_lang_file_to_nav[file] 192 | else: 193 | file_key = orig_file_key 194 | section = get_key_section(key_to_section=key_to_section, key=file_key) 195 | section.append(file) 196 | new_nav = key_to_section[()] 197 | export_lang_nav = [lang_nav[0], nav[1]] + new_nav 198 | lang_config["nav"] = export_lang_nav 199 | build_lang_config_path: Path = build_lang_path / mkdocs_name 200 | build_lang_config_path.write_text( 201 | yaml.dump(lang_config, sort_keys=False, width=200, allow_unicode=True), 202 | encoding="utf-8", 203 | ) 204 | current_dir = os.getcwd() 205 | os.chdir(build_lang_path) 206 | mkdocs.commands.build.build(mkdocs.config.load_config(site_dir=str(dist_path))) 207 | os.chdir(current_dir) 208 | typer.secho(f"Successfully built docs for: {lang}", color=typer.colors.GREEN) 209 | 210 | 211 | @app.command() 212 | def build_all(): 213 | """ 214 | Build mkdocs site for en, and then build each language inside, end result is located 215 | at directory ./site/ with each language inside. 216 | """ 217 | site_path = Path("site").absolute() 218 | update_languages(lang=None) 219 | current_dir = os.getcwd() 220 | os.chdir(en_docs_path) 221 | typer.echo("Building docs for: en") 222 | mkdocs.commands.build.build(mkdocs.config.load_config(site_dir=str(site_path))) 223 | os.chdir(current_dir) 224 | langs = [] 225 | for lang in get_lang_paths(): 226 | if lang == en_docs_path or not lang.is_dir(): 227 | continue 228 | langs.append(lang.name) 229 | cpu_count = os.cpu_count() or 1 230 | with Pool(cpu_count * 2) as p: 231 | p.map(build_lang, langs) 232 | 233 | 234 | def update_single_lang(lang: str): 235 | lang_path = docs_path / lang 236 | typer.echo(f"Updating {lang_path.name}") 237 | update_config(lang_path.name) 238 | 239 | 240 | @app.command() 241 | def update_languages( 242 | lang: str = typer.Argument( 243 | None, callback=lang_callback, autocompletion=complete_existing_lang 244 | ) 245 | ): 246 | """ 247 | Update the mkdocs.yml file Languages section including all the available languages. 248 | 249 | The LANG argument is a 2-letter language code. If it's not provided, update all the 250 | mkdocs.yml files (for all the languages). 251 | """ 252 | if lang is None: 253 | for lang_path in get_lang_paths(): 254 | if lang_path.is_dir(): 255 | update_single_lang(lang_path.name) 256 | else: 257 | update_single_lang(lang) 258 | 259 | 260 | @app.command() 261 | def serve(): 262 | """ 263 | A quick server to preview a built site with translations. 264 | 265 | For development, prefer the command live (or just mkdocs serve). 266 | 267 | This is here only to preview a site with translations already built. 268 | 269 | Make sure you run the build-all command first. 270 | """ 271 | typer.echo("Warning: this is a very simple server.") 272 | typer.echo("For development, use the command live instead.") 273 | typer.echo("This is here only to preview a site with translations already built.") 274 | typer.echo("Make sure you run the build-all command first.") 275 | os.chdir("site") 276 | server_address = ("", 8008) 277 | server = HTTPServer(server_address, SimpleHTTPRequestHandler) 278 | typer.echo(f"Serving at: http://127.0.0.1:8008") 279 | server.serve_forever() 280 | 281 | 282 | @app.command() 283 | def live( 284 | lang: str = typer.Argument( 285 | None, callback=lang_callback, autocompletion=complete_existing_lang 286 | ) 287 | ): 288 | """ 289 | Serve with livereload a docs site for a specific language. 290 | 291 | This only shows the actual translated files, not the placeholders created with 292 | build-all. 293 | 294 | Takes an optional LANG argument with the name of the language to serve, by default 295 | en. 296 | """ 297 | if lang is None: 298 | lang = "en" 299 | lang_path: Path = docs_path / lang 300 | os.chdir(lang_path) 301 | mkdocs.commands.serve.serve(dev_addr="127.0.0.1:8008") 302 | 303 | 304 | def update_config(lang: str): 305 | lang_path: Path = docs_path / lang 306 | config_path = lang_path / mkdocs_name 307 | current_config: dict = mkdocs.utils.yaml_load( 308 | config_path.read_text(encoding="utf-8") 309 | ) 310 | if lang == "en": 311 | config = get_en_config() 312 | else: 313 | config = get_base_lang_config(lang) 314 | config["nav"] = current_config["nav"] 315 | config["theme"]["language"] = current_config["theme"]["language"] 316 | config["theme"]["palette"] = current_config["theme"]["palette"] 317 | 318 | original_languages: Dict[str, str] = { 319 | re.sub("[^a-z]", "", v) or "en": k 320 | for lang in config["nav"][1]["Languages"] 321 | for k, v in lang.items() 322 | } 323 | languages: List[Dict[str, str]] = [] 324 | 325 | alternate: List[Dict[str, str]] = config["extra"].get("alternate", []) 326 | alternate_dict = {alt["link"]: alt["name"] for alt in alternate} 327 | new_alternate: List[Dict[str, str]] = [] 328 | for lang_path in get_lang_paths(): 329 | if not lang_path.is_dir(): 330 | continue 331 | 332 | name = path_part = lang_path.name 333 | if name in original_languages: 334 | name = original_languages[name] 335 | 336 | languages.append({name: "/" if path_part == "en" else f"/{path_part}/"}) 337 | for lang_dict in languages: 338 | name = list(lang_dict.keys())[0] 339 | url = lang_dict[name] 340 | if url not in alternate_dict: 341 | new_alternate.append({"link": url, "name": name}) 342 | else: 343 | use_name = alternate_dict[url] 344 | new_alternate.append({"link": url, "name": use_name}) 345 | config["nav"][1] = {"Languages": languages} 346 | config["extra"]["alternate"] = new_alternate 347 | config_path.write_text( 348 | yaml.dump(config, sort_keys=False, width=200, allow_unicode=True), 349 | encoding="utf-8", 350 | ) 351 | 352 | 353 | def get_key_section( 354 | *, key_to_section: Dict[Tuple[str, ...], list], key: Tuple[str, ...] 355 | ) -> list: 356 | if key in key_to_section: 357 | return key_to_section[key] 358 | super_key = key[:-1] 359 | title = key[-1] 360 | super_section = get_key_section(key_to_section=key_to_section, key=super_key) 361 | new_section = [] 362 | super_section.append({title: new_section}) 363 | key_to_section[key] = new_section 364 | return new_section 365 | 366 | 367 | def get_text_with_translate_missing(text: str) -> str: 368 | lines = text.splitlines() 369 | lines.insert(1, missing_translation_snippet) 370 | new_text = "\n".join(lines) 371 | return new_text 372 | 373 | 374 | def get_file_to_nav_map(nav: list) -> Dict[str, Tuple[str, ...]]: 375 | file_to_nav = {} 376 | for item in nav: 377 | if type(item) is str: 378 | file_to_nav[item] = tuple() 379 | elif type(item) is dict: 380 | item_key = list(item.keys())[0] 381 | sub_nav = item[item_key] 382 | sub_file_to_nav = get_file_to_nav_map(sub_nav) 383 | for k, v in sub_file_to_nav.items(): 384 | file_to_nav[k] = (item_key,) + v 385 | return file_to_nav 386 | 387 | 388 | def get_sections(nav: list) -> Dict[Tuple[str, ...], str]: 389 | sections = {} 390 | for item in nav: 391 | if type(item) is str: 392 | continue 393 | elif type(item) is dict: 394 | item_key = list(item.keys())[0] 395 | sub_nav = item[item_key] 396 | sections[(item_key,)] = sub_nav[0] 397 | sub_sections = get_sections(sub_nav) 398 | for k, v in sub_sections.items(): 399 | new_key = (item_key,) + k 400 | sections[new_key] = v 401 | return sections 402 | 403 | 404 | if __name__ == "__main__": 405 | app() 406 | -------------------------------------------------------------------------------- /scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | source venv/bin/activate 3 | 4 | set -x 5 | 6 | # Sort imports one per line, so autoflake can remove unused imports 7 | isort reforms tests scripts --force-single-line-imports 8 | sh ./scripts/format.sh 9 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place reforms tests scripts --exclude=__init__.py 5 | black reforms tests scripts 6 | isort reforms tests scripts 7 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | mypy reforms 7 | flake8 reforms tests 8 | black reforms tests --check 9 | isort reforms tests scripts --check-only 10 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | flit publish 6 | -------------------------------------------------------------------------------- /scripts/test-cov-html.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash scripts/test.sh --cov-report=html ${@} 7 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash ./scripts/lint.sh 7 | pytest --cov=reforms --cov=tests --cov-report=term-missing --cov-report=xml tests ${@} 8 | -------------------------------------------------------------------------------- /scripts/zip-docs.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | 6 | if [ -f docs.zip ]; then 7 | rm -rf docs.zip 8 | fi 9 | zip -r docs.zip ./site 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4 6 | exclude = docs, __init__.py 7 | 8 | [isort] 9 | multi_line_output=3 10 | include_trailing_comma=True 11 | force_grid_wrap=0 12 | use_parentheses=True 13 | line_length=88 14 | 15 | [mypy] 16 | files=reforms,tests 17 | ignore_missing_imports=true 18 | 19 | [aliases] 20 | test = pytest 21 | 22 | [tool:pytest] 23 | testpaths=tests/ 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from reforms import Reforms 3 | 4 | 5 | @pytest.fixture() 6 | def forms() -> Reforms: 7 | default_forms = Reforms(package="reforms") 8 | return default_forms 9 | -------------------------------------------------------------------------------- /tests/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/tests/contrib/__init__.py -------------------------------------------------------------------------------- /tests/contrib/fastapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/tests/contrib/fastapi/__init__.py -------------------------------------------------------------------------------- /tests/contrib/fastapi/test_from_model.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, Type 2 | 3 | import pytest 4 | from fastapi import Depends, FastAPI 5 | from fastapi.testclient import TestClient 6 | from pydantic import BaseModel 7 | from reforms import BooleanField, EmailField, StringField 8 | from reforms.contrib.fastapi import from_model 9 | 10 | 11 | class UserModel(BaseModel): 12 | name: StringField() 13 | email: EmailField() 14 | has_github: BooleanField() 15 | 16 | 17 | class UserModelWithBoolDefault(BaseModel): 18 | name: StringField() = "John" 19 | email: EmailField() 20 | has_github: BooleanField() = True 21 | 22 | 23 | @pytest.fixture 24 | def create_app() -> Callable: 25 | def _create_app(model: Type[BaseModel]) -> FastAPI: 26 | test_app = FastAPI() 27 | 28 | @test_app.post("/") 29 | async def index(form: from_model(model) = Depends()): 30 | return form 31 | 32 | return test_app 33 | 34 | return _create_app 35 | 36 | 37 | @pytest.fixture 38 | def client( 39 | create_app: Callable[[Type[BaseModel]], FastAPI], model: Type[BaseModel] 40 | ) -> TestClient: 41 | app = create_app(model) 42 | return TestClient(app) 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "model, input_data, expected_data", 47 | [ 48 | ( 49 | UserModel, 50 | {"name": "name", "email": "email@e.com"}, 51 | {"name": "name", "email": "email@e.com", "has_github": False}, 52 | ), 53 | ( 54 | UserModel, 55 | {"name": "name", "email": "email@e.com", "has_github": "on"}, 56 | {"name": "name", "email": "email@e.com", "has_github": True}, 57 | ), 58 | ( 59 | UserModelWithBoolDefault, 60 | {"name": "name", "email": "email@e.com"}, 61 | {"name": "name", "email": "email@e.com", "has_github": True}, 62 | ), 63 | ( 64 | UserModelWithBoolDefault, 65 | {"email": "email@e.com"}, 66 | {"name": "John", "email": "email@e.com", "has_github": True}, 67 | ), 68 | ], 69 | ) 70 | def test_on_model( 71 | client: TestClient, 72 | input_data: Dict[str, str], 73 | expected_data: Dict[str, str], 74 | ): 75 | response = client.post("/", data=input_data) 76 | assert response.json() == expected_data 77 | -------------------------------------------------------------------------------- /tests/test_default_rendering.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Any, Callable, Mapping, Sequence 3 | 4 | import pytest 5 | from pydantic import BaseModel 6 | from reforms import BooleanField, EmailField, HiddenField, Reforms, StringField 7 | from reforms.fields import BaseField 8 | 9 | 10 | @pytest.fixture 11 | def create_form(forms: Reforms) -> Callable: 12 | def wrapped( 13 | field_factory: Callable, default_value: Any = None, **kwargs: Mapping 14 | ) -> Reforms.Form: 15 | if default_value is None: 16 | 17 | class MyModel(BaseModel): 18 | field: field_factory(**kwargs) 19 | 20 | else: 21 | 22 | class MyModel(BaseModel): 23 | field: field_factory(**kwargs) = default_value 24 | 25 | return forms.Form(MyModel) 26 | 27 | return wrapped 28 | 29 | 30 | field_settings = [ 31 | ( 32 | StringField, 33 | ( 34 | ("field_id", "id", "exampleId"), 35 | ("field_class", "class", "example-class"), 36 | ("placeholder", "placeholder", "Example placeholder"), 37 | ("label", None, "example label text"), 38 | ), 39 | ), 40 | ( 41 | BooleanField, 42 | ( 43 | ("field_id", "id", "exampleId"), 44 | ("field_class", "class", "example-class"), 45 | ("label", None, "example label text"), 46 | ), 47 | ), 48 | ( 49 | EmailField, 50 | ( 51 | ("field_id", "id", "exampleId"), 52 | ("field_class", "class", "example-class"), 53 | ("placeholder", "placeholder", "Example placeholder"), 54 | ("label", None, "example label text"), 55 | ), 56 | ), 57 | ( 58 | HiddenField, 59 | ( 60 | ("field_id", "id", "exampleId"), 61 | ("field_class", "class", "example-class"), 62 | ), 63 | ), 64 | ] 65 | 66 | fields_with_attrs_combinations = [ 67 | pytest.param( 68 | field, variant, id=f"{field.__name__}-{'-'.join(i[0] for i in variant)}" 69 | ) 70 | for field, settings in field_settings 71 | for i in range(len(settings) + 1) 72 | for variant in itertools.combinations(settings, i) 73 | ] 74 | 75 | 76 | @pytest.mark.parametrize("field_factory, args", fields_with_attrs_combinations) 77 | def test_render(field_factory: Callable, args: Sequence, create_form: Callable): 78 | field_kwargs = {name: value for name, _, value in args} 79 | form = create_form(field_factory, **field_kwargs) 80 | rendered_layout = str(form.field) 81 | 82 | for field_name, rendered_name, value in args: 83 | if field_name == "label": 84 | assert f"{value}" in rendered_layout 85 | if "field_id" in field_kwargs: 86 | assert 'for="{}"'.format(field_kwargs["field_id"]) in rendered_layout 87 | 88 | continue 89 | 90 | assert f'{rendered_name}="{value}"' in rendered_layout 91 | 92 | 93 | @pytest.mark.parametrize("field_factory, args", fields_with_attrs_combinations) 94 | def test_rendered_spaces( 95 | field_factory: Callable, args: Sequence, create_form: Callable 96 | ): 97 | field_kwargs = {name: value for name, _, value in args} 98 | form = create_form(field_factory, **field_kwargs) 99 | rendered_layout = str(form.field) 100 | 101 | for bad_variant in (" ", " >"): 102 | assert bad_variant not in rendered_layout 103 | 104 | 105 | @pytest.mark.parametrize( 106 | "field_factory, default_value, html_part", 107 | [ 108 | (StringField, "value", 'value="value"'), 109 | (BooleanField, False, ""), 110 | (BooleanField, True, "checked"), 111 | (EmailField, "example@example.com", 'value="example@example.com"'), 112 | (HiddenField, "example@example.com", 'value="example@example.com"'), 113 | ], 114 | ) 115 | def test_field_default( 116 | field_factory: Callable[[], BaseField], 117 | default_value: Any, 118 | html_part: str, 119 | create_form: Callable, 120 | ): 121 | form = create_form(field_factory, default_value=default_value) 122 | rendered_layout = str(form.field) 123 | 124 | assert html_part in rendered_layout 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "field_factory, html_part", 129 | [ 130 | (StringField, 'value="value"'), 131 | (BooleanField, "checked"), 132 | (EmailField, 'value="example@example.com"'), 133 | (HiddenField, 'value="..."'), 134 | ], 135 | ) 136 | def test_field_without_default( 137 | field_factory: Callable[[], BaseField], html_part: str, create_form: Callable 138 | ): 139 | exclude_fields = (BooleanField,) 140 | form = create_form(field_factory) 141 | rendered_layout = str(form.field) 142 | 143 | assert html_part not in rendered_layout 144 | if field_factory not in exclude_fields: 145 | assert " required" in rendered_layout 146 | 147 | 148 | @pytest.mark.parametrize( 149 | "field_factory, default_value, disabled, expected", 150 | [ 151 | (field_factory, default_value, disabled, disabled) 152 | for disabled in (True, False) 153 | for field_factory, default_value in ( 154 | (StringField, ""), 155 | (BooleanField, False), 156 | (EmailField, "e@e.com"), 157 | ) 158 | ], 159 | ) 160 | def test_field_disabled( 161 | field_factory: Callable[[], BaseField], 162 | default_value: Any, 163 | disabled: bool, 164 | expected: bool, 165 | create_form: Callable, 166 | ): 167 | form = create_form(field_factory, default_value=default_value, disabled=disabled) 168 | rendered_layout = str(form.field) 169 | actual = " disabled" in rendered_layout 170 | 171 | assert actual is expected 172 | 173 | 174 | @pytest.mark.parametrize( 175 | "field_factory, default_value, disabled", 176 | [(field, None, True) for field in (BooleanField, EmailField, StringField)], 177 | ) 178 | def test_disabled_default_value_conflict( 179 | field_factory: Callable[[], BaseField], 180 | default_value: Any, 181 | disabled: bool, 182 | create_form: Callable, 183 | ): 184 | form = create_form(field_factory, default_value=default_value, disabled=disabled) 185 | 186 | with pytest.raises(ValueError): 187 | str(form.field) 188 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from pydantic import BaseModel, Field, ValidationError 5 | from reforms import BooleanField, EmailField, StringField 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "field_type, value", 10 | [ 11 | (StringField, "value"), 12 | (BooleanField, False), 13 | (EmailField, "example@example.com"), 14 | ], 15 | ) 16 | def test_field(field_type: Field, value: Any): 17 | class MyForm(BaseModel): 18 | my_field: field_type() 19 | 20 | form = MyForm(my_field=value) 21 | assert form.my_field == value 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "field_type, value", 26 | [(StringField, None), (BooleanField, "False"), (EmailField, "example@.com")], 27 | ) 28 | def test_field_fail(field_type: Field, value: Any): 29 | class MyForm(BaseModel): 30 | my_field: field_type() 31 | 32 | with pytest.raises(ValidationError): 33 | MyForm(my_field=value) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "field_type, default_value", 38 | [ 39 | (StringField, "value"), 40 | (BooleanField, False), 41 | (EmailField, "example@example.com"), 42 | ], 43 | ) 44 | def test_field_default(field_type: Field, default_value: Any): 45 | class MyForm(BaseModel): 46 | my_field: field_type() = default_value 47 | 48 | form = MyForm() 49 | assert form.my_field == default_value 50 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Union 3 | 4 | import jinja2 5 | import pytest 6 | from reforms import Reforms 7 | 8 | 9 | @pytest.fixture 10 | def default_package() -> str: 11 | return "reforms" 12 | 13 | 14 | @pytest.fixture 15 | def template_package(tmp_path) -> str: 16 | package_name = "template_package" 17 | 18 | package = tmp_path / package_name 19 | package.mkdir() 20 | (package / "templates").mkdir() 21 | (package / "templates" / "forms").mkdir() 22 | (package / "__init__.py").touch() 23 | 24 | sys.path.append(str(tmp_path)) 25 | 26 | yield package_name 27 | 28 | sys.path.remove(str(tmp_path)) 29 | 30 | 31 | def get_loader( 32 | forms: Reforms, index: int 33 | ) -> Union[jinja2.PackageLoader, jinja2.FileSystemLoader]: 34 | return forms.env.loader.loaders[index] 35 | 36 | 37 | def check_package_loader( 38 | forms: Reforms, expected_package_name: str, index: int 39 | ) -> None: 40 | assert isinstance(get_loader(forms, index), jinja2.PackageLoader) 41 | assert get_loader(forms, index).package_name == expected_package_name 42 | 43 | 44 | def check_directory_loader(forms: Reforms, expected_path: str, index: int) -> None: 45 | assert isinstance(get_loader(forms, index), jinja2.FileSystemLoader) 46 | assert expected_path in get_loader(forms, index).searchpath 47 | 48 | 49 | @pytest.mark.parametrize( 50 | ("directory", "package"), 51 | [ 52 | (None, None), 53 | (pytest.lazy_fixture("tmpdir"), None), 54 | (None, pytest.lazy_fixture("template_package")), 55 | (pytest.lazy_fixture("tmpdir"), pytest.lazy_fixture("template_package")), 56 | ], 57 | ) 58 | def test_default_loader(directory, package, default_package): 59 | forms = Reforms(directory=directory, package=package) 60 | 61 | assert isinstance(forms.env.loader, jinja2.ChoiceLoader) 62 | assert len(forms.env.loader.loaders) > 0 63 | 64 | check_package_loader(forms, default_package, index=-1) 65 | 66 | 67 | def test_directory_loader(tmpdir): 68 | forms = Reforms(directory=str(tmpdir)) 69 | check_directory_loader(forms, str(tmpdir), index=0) 70 | 71 | 72 | def test_package_loader(template_package: str): 73 | forms = Reforms(package=template_package) 74 | check_package_loader(forms, template_package, index=0) 75 | 76 | 77 | def test_package_and_directory_loader(tmpdir, template_package: str): 78 | forms = Reforms(directory=str(tmpdir), package=template_package) 79 | 80 | check_directory_loader(forms, str(tmpdir), index=0) 81 | check_package_loader(forms, template_package, index=1) 82 | -------------------------------------------------------------------------------- /tests/test_validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardpack/reforms/34121cf4d140ed5753e6b2f5b4a4086587d06c81/tests/test_validators/__init__.py -------------------------------------------------------------------------------- /tests/test_validators/test_any_of.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List 2 | 3 | import pytest 4 | from pydantic import BaseModel, ValidationError 5 | from reforms import Reforms, StringField 6 | from reforms.validators import AnyOf 7 | 8 | 9 | @pytest.fixture 10 | def create_form(forms: Reforms) -> Callable: 11 | def wrapped( 12 | values: List[Any], message: str = "", values_formatter: Callable = None 13 | ) -> Reforms.Form: 14 | args = {"values": values, "values_formatter": values_formatter} 15 | if message: 16 | args["message"] = message 17 | 18 | class MyForm(BaseModel): 19 | field: StringField(validators=[AnyOf(**args)]) 20 | 21 | return forms.Form(MyForm) 22 | 23 | return wrapped 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "test_value, test_list", [("b", ["a", "b", "c"]), (2, [1, 2, 3])] 28 | ) 29 | def test_any_of_passes(test_value, test_list, create_form): 30 | form = create_form(values=test_list) 31 | form(field=test_value) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "test_value, test_list", [("d", ["a", "b", "c"]), (6, [1, 2, 3])] 36 | ) 37 | def test_any_of_raises(test_value, test_list, create_form): 38 | form = create_form(values=test_list) 39 | with pytest.raises(ValidationError): 40 | form(field=test_value) 41 | 42 | 43 | def test_any_of_values_formatter(create_form): 44 | def formatter(values): 45 | return "::".join(str(x) for x in reversed(values)) 46 | 47 | form = create_form( 48 | values=[7, 8, 9], message="test {values}", values_formatter=formatter 49 | ) 50 | 51 | with pytest.raises(ValidationError) as e: 52 | form(field=4) 53 | 54 | assert e.value.errors()[0]["msg"] == "test 9::8::7" 55 | -------------------------------------------------------------------------------- /tests/test_validators/test_length.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import pytest 4 | from pydantic import BaseModel, ValidationError 5 | from reforms import Reforms, StringField 6 | from reforms.validators import Length 7 | 8 | 9 | @pytest.fixture 10 | def create_form(forms: Reforms) -> Callable: 11 | def wrapped( 12 | min_value: int = -1, max_value: int = -1, message: str = "" 13 | ) -> Reforms.Form: 14 | class MyForm(BaseModel): 15 | field: StringField( 16 | validators=[Length(min=min_value, max=max_value, message=message)] 17 | ) 18 | 19 | return forms.Form(MyForm) 20 | 21 | return wrapped 22 | 23 | 24 | @pytest.mark.parametrize("min_value, max_value", [(2, 6), (6, -1), (-1, 6), (6, 6)]) 25 | def test_correct_length_passes(min_value, max_value, create_form): 26 | form = create_form(min_value=min_value, max_value=max_value) 27 | form(field="foobar") 28 | 29 | 30 | @pytest.mark.parametrize("min_value, max_value", [(7, -1), (-1, 5)]) 31 | def test_bad_length_raises(min_value, max_value, create_form): 32 | form = create_form(min_value=min_value, max_value=max_value) 33 | 34 | with pytest.raises(ValidationError): 35 | form(field="foobar") 36 | 37 | 38 | @pytest.mark.parametrize("min_value, max_value", [(-1, -1), (5, 2)]) 39 | def test_bad_length_init_raises(min_value, max_value, create_form): 40 | with pytest.raises(ValidationError): 41 | create_form(min_value=min_value, max_value=max_value) 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ("kwargs", "message"), 46 | ( 47 | ({"min_value": 2, "max_value": 5, "message": "{min} and {max}"}, "2 and 5"), 48 | ({"min_value": 8, "max_value": -1}, "at least 8"), 49 | ({"min_value": -1, "max_value": 5}, "longer than 5"), 50 | ({"min_value": 2, "max_value": 5}, "between 2 and 5"), 51 | ({"min_value": 5, "max_value": 5}, "exactly 5"), 52 | ), 53 | ) 54 | def test_length_messages(kwargs, message, create_form): 55 | form = create_form(**kwargs) 56 | 57 | with pytest.raises(ValidationError) as e: 58 | form(field="foobar") 59 | 60 | assert message in e.value.errors()[0]["msg"] 61 | -------------------------------------------------------------------------------- /tests/test_validators/test_multiply_validators.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import pytest 4 | from pydantic import BaseModel 5 | from reforms import Reforms, StringField 6 | from reforms.validators import AnyOf, BaseValidator, Length, NoneOf 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "test_value, validator1, validator2", 11 | [ 12 | (test_value, validator1, validator2) 13 | for test_value, validator1, validator2 in itertools.chain( 14 | ( 15 | ("a", validator1, validator2) 16 | for validator1, validator2 in itertools.combinations( 17 | (AnyOf(values=["a", "b", "c"]), Length(min=1)), 2 18 | ) 19 | ), 20 | ( 21 | ("d", validator1, validator2) 22 | for validator1, validator2 in itertools.combinations( 23 | (NoneOf(values=["a", "b", "c"]), Length(min=1)), 2 24 | ) 25 | ), 26 | ) 27 | ], 28 | ) 29 | def test_multiply_validators_passes( 30 | test_value: str, 31 | validator1: BaseValidator, 32 | validator2: BaseValidator, 33 | forms: Reforms, 34 | ): 35 | class MyForm(BaseModel): 36 | field: StringField(validators=[validator1, validator2]) 37 | 38 | form = forms.Form(MyForm) 39 | form(field=test_value) 40 | 41 | 42 | # TODO: add more tests for multiply validators usage 43 | -------------------------------------------------------------------------------- /tests/test_validators/test_none_of.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List 2 | 3 | import pytest 4 | from pydantic import BaseModel, ValidationError 5 | from reforms import Reforms, StringField 6 | from reforms.validators import NoneOf 7 | 8 | 9 | @pytest.fixture 10 | def create_form(forms: Reforms) -> Callable: 11 | def wrapped( 12 | values: List[Any], message: str = "", values_formatter: Callable = None 13 | ) -> Reforms.Form: 14 | args = {"values": values, "values_formatter": values_formatter} 15 | if message: 16 | args["message"] = message 17 | 18 | class MyForm(BaseModel): 19 | field: StringField(validators=[NoneOf(**args)]) 20 | 21 | return forms.Form(MyForm) 22 | 23 | return wrapped 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "test_value, test_list", [("d", ["a", "b", "c"]), (6, [1, 2, 3])] 28 | ) 29 | def test_none_of_passes(test_value, test_list, create_form): 30 | form = create_form(values=test_list) 31 | form(field=test_value) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "test_value, test_list", [("b", ["a", "b", "c"]), (2, [1, 2, 3])] 36 | ) 37 | def test_none_of_raises(test_value, test_list, create_form): 38 | form = create_form(values=test_list) 39 | with pytest.raises(ValidationError): 40 | form(field=test_value) 41 | 42 | 43 | def test_none_of_values_formatter(create_form): 44 | def formatter(values): 45 | return "::".join(str(x) for x in reversed(values)) 46 | 47 | form = create_form( 48 | values=[7, 8, 9], message="test {values}", values_formatter=formatter 49 | ) 50 | 51 | with pytest.raises(ValidationError) as e: 52 | form(field=7) 53 | 54 | assert e.value.errors()[0]["msg"] == "test 9::8::7" 55 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Type 2 | 3 | import jinja2 4 | import pytest 5 | from reforms import Reforms 6 | from reforms.widgets import ( 7 | BaseWidget, 8 | Checkbox, 9 | EmailInput, 10 | HiddenInput, 11 | Input, 12 | TextInput, 13 | ) 14 | 15 | all_widgets = ( 16 | Checkbox, 17 | EmailInput, 18 | TextInput, 19 | HiddenInput, 20 | ) 21 | 22 | input_widgets = ( 23 | Checkbox, 24 | EmailInput, 25 | TextInput, 26 | HiddenInput, 27 | ) 28 | 29 | widgets_with_placeholder = [w for w in input_widgets if w is not Checkbox] 30 | widgets_with_value = [w for w in input_widgets if w is not Checkbox] 31 | 32 | 33 | @pytest.fixture 34 | def env() -> jinja2.Environment: 35 | reforms = Reforms() 36 | return reforms.env 37 | 38 | 39 | @pytest.fixture 40 | def render_settings() -> Dict[str, Any]: 41 | return {"name": "example", "field_id": "example", "field_class": "example"} 42 | 43 | 44 | def test_widget_rendering_without_name(env: jinja2.Environment): 45 | widget = BaseWidget() 46 | widget.template = "input.html" 47 | 48 | with pytest.raises(ValueError): 49 | widget.render(env) 50 | 51 | 52 | @pytest.mark.parametrize("widget_class", all_widgets) 53 | def test_non_empty_template(widget_class: Type[BaseWidget]): 54 | assert widget_class.template 55 | 56 | 57 | @pytest.mark.parametrize("widget_class", input_widgets) 58 | def test_non_empty_input_type(widget_class: Type[Input]): 59 | assert widget_class.input_type 60 | 61 | 62 | @pytest.mark.parametrize("widget_class", input_widgets) 63 | def test_input_type_rendering(widget_class: Type[Input], env: jinja2.Environment): 64 | widget = widget_class(name="type") 65 | assert f'type="{widget_class.input_type}"' in str(widget.render(env)) 66 | 67 | 68 | @pytest.mark.parametrize( 69 | ("widget_class", "settings", "expected_html_part"), 70 | [ 71 | (widget_class, settings, expected_html_part) 72 | for widget_class in all_widgets 73 | for settings, expected_html_part in ( 74 | ({"name": "e"}, ' name="e"'), 75 | ({"field_id": "example"}, ' id="example"'), 76 | ({"field_class": "example"}, ' class="example"'), 77 | ({"disabled": True}, " disabled"), 78 | ) 79 | ], 80 | ) 81 | def test_widget_base_attrs_rendering( 82 | widget_class: Type[BaseWidget], 83 | settings: Dict[str, Any], 84 | expected_html_part: str, 85 | env: jinja2.Environment, 86 | ): 87 | if "name" not in settings: 88 | settings["name"] = "example" 89 | 90 | widget = widget_class(**settings) 91 | assert expected_html_part in str(widget.render(env)) 92 | 93 | 94 | def test_checkbox_widget_checked(env: jinja2.Environment): 95 | widget = Checkbox(name="temp", required=False, default=True) 96 | assert "checked" in str(widget.render(env)) 97 | 98 | 99 | @pytest.mark.parametrize( 100 | ("required", "default"), 101 | [ 102 | pytest.param(required, default, id=f"required={required}, default={default}") 103 | for required, default in ( 104 | (True, False), 105 | (True, True), 106 | (False, False), 107 | ) 108 | ], 109 | ) 110 | def test_checkbox_widget_not_checked( 111 | env: jinja2.Environment, required: bool, default: bool 112 | ): 113 | widget = Checkbox(name="temp", required=required, default=default) 114 | assert "checked" not in str(widget.render(env)) 115 | 116 | 117 | @pytest.mark.parametrize("widget_class", all_widgets) 118 | def test_label_rendering( 119 | widget_class: Type[BaseWidget], 120 | env: jinja2.Environment, 121 | ): 122 | settings = { 123 | "name": "temp", 124 | "field_id": "example", 125 | "label": "example label", 126 | } 127 | expected_html = f'' 128 | widget = widget_class(**settings) 129 | 130 | assert expected_html in str(widget.render(env)) 131 | 132 | 133 | @pytest.mark.parametrize("widget_class", widgets_with_placeholder) 134 | def test_placeholder_rendering(widget_class: Type[BaseWidget], env: jinja2.Environment): 135 | placeholder_settings = {"placeholder": "example text"} 136 | widget = widget_class(name="temp", **placeholder_settings) 137 | assert 'placeholder="example text"' in str(widget.render(env)) 138 | 139 | 140 | @pytest.mark.parametrize("widget_class", widgets_with_placeholder) 141 | def test_placeholder_no_rendering( 142 | widget_class: Type[BaseWidget], env: jinja2.Environment 143 | ): 144 | widget = widget_class(name="temp") 145 | assert "placeholder=" not in str(widget.render(env)) 146 | 147 | 148 | @pytest.mark.parametrize( 149 | ("widget_class", "settings", "expected_html_part"), 150 | [ 151 | (widget_class, settings, expected_html_part) 152 | for widget_class in widgets_with_value 153 | for settings, expected_html_part in ( 154 | ({"default": "example", "required": False}, 'value="example"'), 155 | ) 156 | ], 157 | ) 158 | def test_value_rendering( 159 | widget_class: Type[BaseWidget], 160 | settings: Dict[str, Any], 161 | expected_html_part: str, 162 | env: jinja2.Environment, 163 | ): 164 | widget = widget_class(name="temp", **settings) 165 | assert expected_html_part in str(widget.render(env)) 166 | 167 | 168 | @pytest.mark.parametrize( 169 | ("widget_class", "settings"), 170 | [ 171 | (widget_class, settings) 172 | for widget_class in widgets_with_value 173 | for settings in ( 174 | {"required": True}, 175 | {"default": "example", "required": True}, 176 | ) 177 | ], 178 | ) 179 | def test_value_no_rendering( 180 | widget_class: Type[BaseWidget], 181 | settings: Dict[str, Any], 182 | env: jinja2.Environment, 183 | ): 184 | widget = widget_class(name="temp", **settings) 185 | assert "value=" not in str(widget.render(env)) 186 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{39,38,37,36} 3 | isolated_build = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.6: py36 8 | 3.7: py37 9 | 3.8: py38 10 | 3.9: py39 11 | 12 | [testenv] 13 | deps = 14 | pytest ==5.4.3 15 | pytest-cov ==2.10.0 16 | pytest-lazy-fixture ==0.6.3 17 | mypy ==0.812 18 | flake8 >=3.8.3,<4.0.0 19 | black ==20.8b1 20 | isort >=5.0.6,<6.0.0 21 | starlette ==0.14.2 22 | fastapi ==0.65.2 23 | requests ==2.25.1 24 | python-multipart ==0.0.5 25 | 26 | commands = 27 | bash scripts/test.sh 28 | --------------------------------------------------------------------------------