├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── lint-test.yml │ ├── publish.yml │ └── status-embed.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.rst ├── baguette ├── __init__.py ├── app.py ├── config.py ├── converters.py ├── forms.py ├── headers.py ├── httpexceptions.py ├── json.py ├── middleware.py ├── middlewares │ ├── __init__.py │ ├── default_headers.py │ └── errors.py ├── rendering.py ├── request.py ├── responses.py ├── router.py ├── testing.py ├── types.py ├── utils.py └── view.py ├── dev-requirements.txt ├── docs ├── .readthedocs.yml ├── Makefile ├── _static │ ├── images │ │ ├── banner.png │ │ ├── banner_black.png │ │ ├── banner_white.png │ │ ├── logo.png │ │ ├── logo_black.png │ │ └── logo_white.png │ ├── small_logo.png │ ├── small_logo_black.png │ └── small_logo_white.png ├── api.rst ├── conf.py ├── extensions │ └── resourcelinks.py ├── index.rst ├── make.bat ├── requirements.txt └── user_guide │ ├── intro.rst │ ├── middlewares.rst │ ├── quickstart.rst │ ├── request.rst │ ├── responses.rst │ ├── routing.rst │ ├── testing.rst │ └── view.rst ├── examples ├── api.py ├── forms.py ├── middleware.py └── minimal.py ├── requirements.txt ├── scripts ├── format.sh ├── lint-code.sh ├── lint-docs.sh ├── lint.sh └── test.sh ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── configs ├── __init__.py ├── bad_config.json ├── config.json ├── config.py └── config_module.py ├── conftest.py ├── middlewares ├── __init__.py ├── test_default_headers_middleware.py └── test_error_middleware.py ├── static ├── banner.png ├── css │ └── style.css └── js │ └── script.js ├── templates ├── base.html └── index.html ├── test_app.py ├── test_config.py ├── test_converters.py ├── test_forms.py ├── test_headers.py ├── test_httpexceptions.py ├── test_rendering.py ├── test_request.py ├── test_responses.py ├── test_router.py ├── test_testing.py ├── test_utils.py └── test_view.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: takos22 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | Code: 21 | ```py 22 | # include code here 23 | ``` 24 | 25 | ## Expected behavior 26 | A clear and concise description of what you expected to happen. 27 | 28 | ## Desktop (please complete the following information): 29 | - OS: [e.g. Windows 10] 30 | - Python version: [e.g. 3.7.6] 31 | - Baguette version [e.g. 0.1.1] 32 | 33 | ## Additional context 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Security bug report 4 | url: https://mailxto.com/nqzky8 5 | about: To avoid leaking the security issues, send me a mail at takos2210@gmail.com. 6 | - name: Need help? 7 | url: https://discord.gg/PGC3eAznJ6 8 | about: Join the discord support server 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: takos22 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint and test 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | python-version: [3.6, 3.7, 3.8, 3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | pip install uvicorn[standard] # for app.run() testing 30 | pip install -r dev-requirements.txt 31 | 32 | - name: Run linting script 33 | run: | 34 | ./scripts/lint.sh 35 | 36 | - name: Run testing script 37 | run: | 38 | ./scripts/test.sh --no-lint 39 | 40 | - name: Upload coverage to Codecov 41 | uses: codecov/codecov-action@v1 42 | with: 43 | fail_ci_if_error: true 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish to PyPI 5 | 6 | on: 7 | release: 8 | types: [created, edited] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine check dist/* 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /.github/workflows/status-embed.yml: -------------------------------------------------------------------------------- 1 | name: Status Embeds 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Lint and test 7 | - Publish to PyPI 8 | types: 9 | - completed 10 | 11 | jobs: 12 | embed: 13 | name: Send Status Embed 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Github Actions Embed 17 | uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 18 | with: 19 | webhook_id: '838134168805310505' 20 | webhook_token: ${{ secrets.WEBHOOK_TOKEN }} 21 | 22 | workflow_name: ${{ github.event.workflow_run.name }} 23 | run_id: ${{ github.event.workflow_run.id }} 24 | run_number: ${{ github.event.workflow_run.run_number }} 25 | status: ${{ github.event.workflow_run.conclusion }} 26 | actor: ${{ github.actor }} 27 | repository: ${{ github.repository }} 28 | ref: ${{ github.ref }} 29 | sha: ${{ github.event.workflow_run.head_sha }} 30 | -------------------------------------------------------------------------------- /.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.save 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | .env.local 114 | 115 | # Databases 116 | database.db 117 | 118 | # VSCode project settings 119 | .vscode/ 120 | 121 | # Pycharm project settings 122 | .idea 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: lint-code 5 | name: Lint python code 6 | description: Use scripts/lint-code.sh to lint the python code. 7 | entry: bash ./scripts/lint-code.sh 8 | language: system 9 | types: [python] 10 | pass_filenames: false 11 | always_run: true 12 | 13 | - id: lint-docs 14 | name: Lint docs 15 | description: Use scripts/lint-docs.sh to lint the docs. 16 | entry: bash ./scripts/lint-docs.sh 17 | language: system 18 | types: [text] 19 | pass_filenames: false 20 | always_run: true 21 | 22 | # - id: test-code 23 | # name: Test python code 24 | # description: Use scripts/test.sh to test the python code. 25 | # entry: bash ./scripts/test.sh --no-lint 26 | # language: system 27 | # types: [python] 28 | # pass_filenames: false 29 | # always_run: true 30 | # exclude: '.coverage' 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | takos2210@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Takos 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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | baguette - asynchronous web framework 2 | ===================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/baguette?color=blue 5 | :target: https://pypi.python.org/pypi/baguette 6 | :alt: PyPI version info 7 | .. image:: https://img.shields.io/pypi/pyversions/baguette?color=orange 8 | :target: https://pypi.python.org/pypi/baguette 9 | :alt: Supported Python versions 10 | .. image:: https://img.shields.io/github/checks-status/takos22/baguette/master?label=tests 11 | :target: https://github.com/takos22/baguette/actions/workflows/lint-test.yml 12 | :alt: Lint and test workflow status 13 | .. image:: https://readthedocs.org/projects/baguette/badge/?version=latest 14 | :target: https://baguette.readthedocs.io/en/latest/ 15 | :alt: Documentation build status 16 | .. image:: https://codecov.io/gh/takos22/baguette/branch/master/graph/badge.svg?token=0P3BV8D3AJ 17 | :target: https://codecov.io/gh/takos22/baguette 18 | :alt: Code coverage 19 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 20 | :target: https://github.com/psf/black 21 | :alt: Code style: Black 22 | .. image:: https://img.shields.io/discord/831992562986123376.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2 23 | :target: https://discord.gg/PGC3eAznJ6 24 | :alt: Discord support server 25 | 26 | ``baguette`` is an asynchronous web framework for ASGI servers. 27 | 28 | Installation 29 | ------------ 30 | 31 | **Python 3.6 or higher is required.** 32 | 33 | Install ``baguette`` with pip: 34 | 35 | .. code:: sh 36 | 37 | pip install baguette 38 | 39 | You also need an ASGI server to run your app like `uvicorn `_ or `hypercorn `_. 40 | To install `uvicorn `_ directly with baguette, you can add the ``uvicorn`` argument: 41 | 42 | .. code:: sh 43 | 44 | pip install baguette[uvicorn] 45 | 46 | Quickstart 47 | ---------- 48 | 49 | Create an application, in ``example.py``: 50 | 51 | .. code:: python 52 | 53 | from baguette import Baguette 54 | 55 | app = Baguette() 56 | 57 | @app.route("/") 58 | async def index(request): 59 | return "

Hello world

" 60 | 61 | Run the server with `uvicorn `_: 62 | 63 | .. code:: sh 64 | 65 | uvicorn example:app 66 | 67 | See `uvicorn's deployment guide `_ for more deployment options. 68 | 69 | Contribute 70 | ---------- 71 | 72 | - `Source Code `_ 73 | - `Issue Tracker `_ 74 | 75 | 76 | Support 77 | ------- 78 | 79 | If you are having issues, please let me know by joining the discord support server at https://discord.gg/8HgtN6E 80 | 81 | License 82 | ------- 83 | 84 | The project is licensed under the MIT license. 85 | 86 | Links 87 | ------ 88 | 89 | - `PyPi `_ 90 | - `Documentation `_ 91 | - `Discord support server `_ 92 | -------------------------------------------------------------------------------- /baguette/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Baguette 3 | ======== 4 | 5 | Asynchronous web framework. 6 | """ 7 | 8 | from typing import NamedTuple 9 | 10 | VersionInfo = NamedTuple( 11 | "VersionInfo", major=int, minor=int, micro=int, releaselevel=str, serial=int 12 | ) 13 | 14 | version_info = VersionInfo(major=0, minor=3, micro=0, releaselevel="", serial=1) 15 | 16 | __title__ = "baguette" 17 | __author__ = "takos22" 18 | __version__ = "0.3.1" 19 | 20 | __all__ = [ 21 | "Baguette", 22 | "Config", 23 | "Headers", 24 | "make_headers", 25 | "Middleware", 26 | "render", 27 | "Response", 28 | "HTMLResponse", 29 | "PlainTextResponse", 30 | "JSONResponse", 31 | "EmptyResponse", 32 | "RedirectResponse", 33 | "FileResponse", 34 | "make_response", 35 | "make_error_response", 36 | "redirect", 37 | "Request", 38 | "TestClient", 39 | "View", 40 | ] 41 | 42 | from .app import Baguette 43 | from .config import Config 44 | from .headers import Headers, make_headers 45 | from .middleware import Middleware 46 | from .rendering import render 47 | from .request import Request 48 | from .responses import ( 49 | EmptyResponse, 50 | FileResponse, 51 | HTMLResponse, 52 | JSONResponse, 53 | PlainTextResponse, 54 | RedirectResponse, 55 | Response, 56 | make_error_response, 57 | make_response, 58 | redirect, 59 | ) 60 | from .testing import TestClient 61 | from .view import View 62 | -------------------------------------------------------------------------------- /baguette/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .headers import Headers, make_headers 4 | from .types import HeadersType 5 | from .utils import import_from_string 6 | 7 | 8 | class Config: 9 | """Baguette application configuration. 10 | 11 | Keyword Arguments 12 | ----------------- 13 | debug : :class:`bool` 14 | Whether to run the application in debug mode. 15 | Default: ``False``. 16 | 17 | default_headers : :class:`list` of ``(str, str)`` tuples, \ 18 | :class:`dict` or :class:`Headers` 19 | Default headers to include in every response. 20 | Default: No headers. 21 | 22 | static_url_path : :class:`str` 23 | URL path for the static file handler. 24 | Default: ``"static"``. 25 | 26 | static_directory : :class:`str` 27 | Path to the folder containing static files. 28 | Default: ``"static"``. 29 | 30 | templates_directory : :class:`str` 31 | Path to the folder containing the HTML templates. 32 | Default: ``"templates"``. 33 | 34 | error_response_type : :class:`str` 35 | Type of response to use in case of error. 36 | One of: ``"plain"``, ``"json"``, ``"html"``. 37 | Default: ``"plain"``. 38 | 39 | error_include_description : :class:`bool` 40 | Whether to include the error description in the response 41 | in case of error. 42 | If debug is ``True``, this will also be ``True``. 43 | Default: ``True``. 44 | 45 | 46 | Attributes 47 | ---------- 48 | debug : :class:`bool` 49 | Whether the application is running in debug mode. 50 | 51 | default_headers : :class:`Headers` 52 | Default headers included in every response. 53 | 54 | static_url_path : :class:`str` 55 | URL path for the static file handler. 56 | 57 | static_directory : :class:`str` 58 | Path to the folder containing static files. 59 | 60 | templates_directory : :class:`str` 61 | Path to the folder containing the HTML templates. 62 | 63 | error_response_type : :class:`str` 64 | Type of response to use in case of error. 65 | One of: ``"plain"``, ``"json"``, ``"html"`` 66 | 67 | error_include_description : :class:`bool` 68 | Whether the error description is included in the response 69 | in case of error. 70 | If debug is ``True``, this will also be ``True``. 71 | """ 72 | 73 | def __init__( 74 | self, 75 | *, 76 | debug: bool = False, 77 | default_headers: HeadersType = None, 78 | static_url_path: str = "static", 79 | static_directory: str = "static", 80 | templates_directory: str = "static", 81 | error_response_type: str = "plain", 82 | error_include_description: bool = True, 83 | ): 84 | self.debug = debug 85 | self.default_headers: Headers = make_headers(default_headers) 86 | 87 | self.static_url_path = static_url_path 88 | self.static_directory = static_directory 89 | self.templates_directory = templates_directory 90 | 91 | if error_response_type not in ("plain", "json", "html"): 92 | raise ValueError( 93 | "Bad response type. Must be one of: 'plain', 'json', 'html'" 94 | ) 95 | self.error_response_type = error_response_type 96 | self.error_include_description = error_include_description or self.debug 97 | 98 | @classmethod 99 | def from_json(cls, filename: str) -> "Config": 100 | """Loads the configuration from a JSON file. 101 | 102 | Arguments 103 | --------- 104 | filename : :class:`str` 105 | The file name of the JSON config file. 106 | 107 | Returns 108 | ------- 109 | :class:`Config` 110 | The loaded configuration. 111 | """ 112 | 113 | with open(filename) as config_file: 114 | config = json.load(config_file) 115 | 116 | if not isinstance(config, dict): 117 | raise ValueError( 118 | f"Configuration must be a dictionary. Got: {type(config)}" 119 | ) 120 | 121 | return cls(**config) 122 | 123 | @classmethod 124 | def from_class(cls, class_or_module_name) -> "Config": 125 | """Loads the configuration from a python class. 126 | 127 | Arguments 128 | --------- 129 | class_or_module_name : class or :class:`str` 130 | The class to load the configuration. 131 | 132 | Returns 133 | ------- 134 | :class:`Config` 135 | The loaded configuration. 136 | """ 137 | 138 | if isinstance(class_or_module_name, str): 139 | config_class = import_from_string(class_or_module_name) 140 | else: 141 | config_class = class_or_module_name 142 | 143 | config = {} 144 | for attribute in ( 145 | "debug", 146 | "default_headers", 147 | "static_url_path", 148 | "static_directory", 149 | "templates_directory", 150 | "error_response_type", 151 | "error_include_description", 152 | ): 153 | if hasattr(config_class, attribute): 154 | config[attribute] = getattr(config_class, attribute) 155 | elif hasattr(config_class, attribute.upper()): 156 | config[attribute] = getattr(config_class, attribute.upper()) 157 | 158 | return cls(**config) 159 | -------------------------------------------------------------------------------- /baguette/converters.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import math 3 | import typing 4 | 5 | 6 | class Converter(abc.ABC): 7 | @abc.abstractproperty 8 | def REGEX(self) -> str: 9 | ... 10 | 11 | @abc.abstractmethod 12 | def convert(self, string: str): 13 | ... 14 | 15 | 16 | class StringConverter(Converter): 17 | """Converter for string URL parameters. 18 | 19 | Arguments 20 | --------- 21 | length : Optional :class:`int` 22 | Required length of the string. 23 | Default: ``None`` 24 | 25 | allow_slash : Optional :class:`bool` 26 | Allow slashes in the string. 27 | Default: ``False`` 28 | 29 | Attributes 30 | ---------- 31 | length : Optional :class:`int` 32 | Required length of the string. 33 | 34 | allow_slash : :class:`bool` 35 | Allow slashes in the string. 36 | 37 | REGEX : :class:`str` 38 | Regex for the route :meth:`~baguette.router.Route.build_regex`. 39 | """ 40 | 41 | REGEX = r"[^\/]+" 42 | 43 | def __init__( 44 | self, 45 | length: typing.Optional[int] = None, 46 | allow_slash: bool = False, 47 | ): 48 | self.length = length 49 | self.allow_slash = allow_slash 50 | if self.allow_slash: 51 | self.REGEX = r".+" 52 | 53 | def convert(self, string: str): 54 | """Converts the string of the URL parameter and validates the value. 55 | 56 | Arguments 57 | --------- 58 | string : :class:`str` 59 | URL parameter to convert. 60 | 61 | Returns 62 | ------- 63 | :class:`str` 64 | Converted URL parameter. 65 | 66 | Raises 67 | ------ 68 | ValueError 69 | :attr:`length` is specified and the URL parameter has a 70 | different length than :attr:`length`. 71 | 72 | ValueError 73 | :attr:`allow_slash` is ``False`` and the URL parameter 74 | contains slashes. 75 | """ 76 | 77 | if self.length is not None and len(string) != self.length: 78 | raise ValueError( 79 | f"Expected string of length {self.length}. Got {len(string)}" 80 | ) 81 | 82 | if not self.allow_slash and "/" in string: 83 | raise ValueError(f"Expected string without '/'. Got {string!r}") 84 | 85 | return str(string) 86 | 87 | 88 | class PathConverter(Converter): 89 | """Converter for string URL parameters. 90 | 91 | Arguments 92 | --------- 93 | allow_empty : Optional :class:`bool` 94 | Whether to allow empty paths. 95 | Default: ``False`` 96 | 97 | Attributes 98 | ---------- 99 | allow_empty : :class:`bool` 100 | Whether to allow empty paths. 101 | 102 | REGEX : :class:`str` 103 | Regex for the route :meth:`~baguette.router.Route.build_regex`. 104 | """ 105 | 106 | REGEX = r".+" 107 | 108 | def __init__(self, allow_empty: bool = False): 109 | self.allow_empty = allow_empty 110 | if self.allow_empty: 111 | self.REGEX = r".*" 112 | 113 | def convert(self, string: str): 114 | """Converts the string of the URL parameter and validates the value. 115 | 116 | Arguments 117 | --------- 118 | string : :class:`str` 119 | URL parameter to convert. 120 | 121 | Returns 122 | ------- 123 | :class:`str` 124 | Converted URL parameter. 125 | 126 | Raises 127 | ------ 128 | ValueError 129 | :attr:`allow_empty` is ``True`` and the path is empty. 130 | """ 131 | 132 | if not self.allow_empty and string == "": 133 | raise ValueError("Path cannot be empty") 134 | 135 | return string.strip("/") 136 | 137 | 138 | class IntegerConverter(Converter): 139 | """Converter for integer URL parameters. 140 | 141 | Arguments 142 | --------- 143 | signed : Optional :class:`bool` 144 | Whether to accept integers starting with ``+`` or ``-``. 145 | Default: ``False`` 146 | 147 | min : Optional :class:`int` 148 | Minimum value of the integer. 149 | Default: ``None`` 150 | 151 | max : Optional :class:`int` 152 | Maximum value of the integer. 153 | Default: ``None`` 154 | 155 | Attributes 156 | ---------- 157 | signed : :class:`bool` 158 | Whether to accept integers starting with ``+`` or ``-``. 159 | 160 | min : Optional :class:`int` 161 | Minimum value of the integer. 162 | 163 | max : Optional :class:`int` 164 | Maximum value of the integer. 165 | 166 | REGEX : :class:`str` 167 | Regex for the route :meth:`~baguette.router.Route.build_regex`. 168 | """ 169 | 170 | REGEX = r"[\+-]?\d+" 171 | 172 | def __init__( 173 | self, 174 | signed: bool = False, 175 | min: typing.Optional[int] = None, 176 | max: typing.Optional[int] = None, 177 | ): 178 | self.signed = signed 179 | self.min = min 180 | self.max = max 181 | 182 | def convert(self, string: str): 183 | """Converts the string of the URL parameter and validates the value. 184 | 185 | Arguments 186 | --------- 187 | string : :class:`str` 188 | URL parameter to convert. 189 | 190 | Returns 191 | ------- 192 | :class:`int` 193 | Converted URL parameter. 194 | 195 | Raises 196 | ------ 197 | ValueError 198 | :attr:`signed` is ``False`` and the URL parameter starts 199 | with ``+`` or ``-``. 200 | 201 | ValueError 202 | Couldn't convert the URL parameter to an integer. 203 | 204 | ValueError 205 | :attr:`min` is specified and the URL parameter 206 | is lower then :attr:`min`. 207 | 208 | ValueError 209 | :attr:`max` is specified and the URL parameter 210 | is higher then :attr:`max`. 211 | """ 212 | 213 | if not self.signed and (string.strip()[0] in "+-"): 214 | raise ValueError( 215 | "Expected unsigned integer. Got integer starting with " 216 | + string.strip()[0] 217 | ) 218 | 219 | integer = int(string) 220 | 221 | if self.min is not None and integer < self.min: 222 | raise ValueError(f"Expected integer higher than {self.min}") 223 | 224 | if self.max is not None and integer > self.max: 225 | raise ValueError(f"Expected integer lower than {self.max}") 226 | 227 | return integer 228 | 229 | 230 | class FloatConverter(Converter): 231 | """Converter for float URL parameters. 232 | 233 | Arguments 234 | --------- 235 | signed : Optional :class:`bool` 236 | Whether to accept floats starting with ``+`` or ``-``. 237 | Default: ``False`` 238 | 239 | min : Optional :class:`float` 240 | Minimum value of the float. 241 | Default: ``None`` 242 | 243 | max : Optional :class:`float` 244 | Maximum value of the float. 245 | Default: ``None`` 246 | 247 | allow_infinity : Optional :class:`bool` 248 | Whether to accept floats that are ``inf`` or ``-inf``. 249 | Default: ``False`` 250 | 251 | allow_nan : Optional :class:`bool` 252 | Whether to accept floats that are ``NaN``. 253 | Default: ``False`` 254 | 255 | Attributes 256 | ---------- 257 | signed : :class:`bool` 258 | Whether to accept floats starting with ``+`` or ``-``. 259 | 260 | min : Optional :class:`float` 261 | Minimum value of the float. 262 | 263 | max : Optional :class:`float` 264 | Maximum value of the float. 265 | 266 | allow_infinity : :class:`bool` 267 | Whether to accept floats that are ``inf`` or ``-inf``. 268 | 269 | allow_nan : :class:`bool` 270 | Whether to accept floats that are ``NaN``. 271 | 272 | REGEX : :class:`str` 273 | Regex for the route :meth:`~baguette.router.Route.build_regex`. 274 | """ 275 | 276 | REGEX = r"[\+-]?\d*\.?\d*" 277 | 278 | def __init__( 279 | self, 280 | signed: bool = False, 281 | min: typing.Optional[float] = None, 282 | max: typing.Optional[float] = None, 283 | allow_infinity: bool = False, 284 | allow_nan: bool = False, 285 | ): 286 | self.signed = signed 287 | self.min = min 288 | self.max = max 289 | self.allow_infinity = allow_infinity 290 | self.allow_nan = allow_nan 291 | 292 | def convert(self, string: str): 293 | """Converts the string of the URL parameter and validates the value. 294 | 295 | Arguments 296 | --------- 297 | string : :class:`str` 298 | URL parameter to convert. 299 | 300 | Returns 301 | ------- 302 | :class:`float` 303 | Converted URL parameter. 304 | 305 | Raises 306 | ------ 307 | ValueError 308 | :attr:`signed` is ``False`` and the URL parameter starts 309 | with ``+`` or ``-``. 310 | 311 | ValueError 312 | Couldn't convert the URL parameter to an float. 313 | 314 | ValueError 315 | :attr:`min` is specified and the URL parameter 316 | is lower then :attr:`min`. 317 | 318 | ValueError 319 | :attr:`max` is specified and the URL parameter 320 | is higher then :attr:`max`. 321 | 322 | ValueError 323 | :attr:`allow_infinity` is ``False`` and the URL parameter 324 | is ``inf`` or ``-inf``. 325 | 326 | ValueError 327 | :attr:`allow_nan` is ``False`` and the URL parameter 328 | is ``nan``. 329 | """ 330 | 331 | if not self.signed and (string.strip()[0] in "+-"): 332 | raise ValueError( 333 | "Expected unsigned float. Got float starting with " 334 | + string.strip()[0] 335 | ) 336 | 337 | number = float(string) 338 | 339 | if self.min is not None and number < self.min: 340 | raise ValueError(f"Expected float higher than {self.min}") 341 | 342 | if self.max is not None and number > self.max: 343 | raise ValueError(f"Expected float lower than {self.max}") 344 | 345 | if not self.allow_infinity and math.isinf(number): 346 | raise ValueError(f"Expected a non-infinity value. Got {number}") 347 | 348 | if not self.allow_nan and math.isnan(number): 349 | raise ValueError(f"Expected a non-NaN value. Got {number}") 350 | 351 | return number 352 | -------------------------------------------------------------------------------- /baguette/forms.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import mimetypes 3 | import typing 4 | from cgi import parse_header 5 | from urllib.parse import parse_qs 6 | 7 | from .headers import make_headers 8 | from .types import StrOrBytes 9 | from .utils import split_on_first, to_str 10 | 11 | 12 | class Field: 13 | def __init__( 14 | self, 15 | name: str, 16 | values: typing.List[StrOrBytes], 17 | encoding: str = "utf-8", 18 | ): 19 | self.name = name 20 | self.values = [to_str(value) for value in values] 21 | if len(self.values) > 0: 22 | self.value = self.values[0] 23 | else: 24 | self.value = None 25 | 26 | self.is_file = False 27 | 28 | def __str__(self) -> str: 29 | return self.value 30 | 31 | def copy(self) -> "Field": 32 | return Field(name=self.name, values=self.values.copy()) 33 | 34 | def __eq__(self, other: "Field") -> bool: 35 | return all( 36 | getattr(self, name) == getattr(other, name) 37 | for name in ("name", "values") 38 | ) 39 | 40 | 41 | class FileField(Field): 42 | def __init__( 43 | self, 44 | name: str, 45 | content: StrOrBytes, 46 | filename: typing.Optional[str] = "", 47 | content_type: typing.Optional[str] = None, 48 | encoding: str = "utf-8", 49 | ): 50 | self.name = name 51 | 52 | self.is_file = True 53 | self.filename = filename 54 | self.content = content 55 | self.content_type = content_type 56 | self.encoding = encoding 57 | 58 | if not self.content_type: 59 | self.content_type = ( 60 | mimetypes.guess_type(self.filename)[0] 61 | or "application/octet-stream" 62 | ) 63 | 64 | @property 65 | def text(self) -> StrOrBytes: 66 | if isinstance(self.content, bytes): 67 | try: 68 | return self.content.decode(self.encoding) 69 | except UnicodeDecodeError: 70 | return self.content 71 | 72 | return self.content 73 | 74 | def __str__(self) -> str: 75 | return self.text 76 | 77 | def copy(self) -> "FileField": 78 | return FileField( 79 | name=self.name, 80 | content=self.content, 81 | filename=self.filename, 82 | content_type=self.content_type, 83 | encoding=self.encoding, 84 | ) 85 | 86 | def __eq__(self, other: "FileField") -> bool: 87 | return all( 88 | getattr(self, name) == getattr(other, name) 89 | for name in ( 90 | "name", 91 | "content", 92 | "filename", 93 | "content_type", 94 | "encoding", 95 | ) 96 | ) 97 | 98 | 99 | class Form(collections.abc.Mapping): 100 | def __init__( 101 | self, 102 | fields: typing.Optional[typing.Dict[str, Field]] = None, 103 | files: typing.Optional[typing.Dict[str, FileField]] = None, 104 | ): 105 | self.fields: typing.Dict[str, Field] = fields or {} 106 | self.files: typing.Dict[str, FileField] = files or { 107 | name: field for name, field in self.fields.items() if field.is_file 108 | } 109 | 110 | def __getitem__(self, name: str) -> Field: 111 | return self.fields[name] 112 | 113 | def __iter__(self) -> typing.Iterator: 114 | return iter(self.fields) 115 | 116 | def __len__(self) -> int: 117 | return len(self.fields) 118 | 119 | @classmethod 120 | def parse(cls, body: bytes, encoding: str = "utf-8") -> "Form": 121 | raise NotImplementedError() 122 | 123 | def copy(self) -> "Form": 124 | return self.__class__( 125 | fields={name: field.copy() for name, field in self.fields.items()}, 126 | files={name: file.copy() for name, file in self.files.items()}, 127 | ) 128 | 129 | def __eq__(self, other: "Form") -> bool: 130 | return self.fields == other.fields 131 | 132 | 133 | class URLEncodedForm(Form): 134 | @classmethod 135 | def parse(cls, body: bytes, encoding: str = "utf-8") -> "URLEncodedForm": 136 | raw_fields: typing.Dict[str, typing.List[str]] = parse_qs( 137 | body.decode(encoding), encoding=encoding 138 | ) 139 | fields: typing.Dict[str, Field] = {} 140 | for name, values in raw_fields.items(): 141 | fields[name] = Field(name, values, encoding=encoding) 142 | return cls(fields) 143 | 144 | 145 | class MultipartForm(Form): 146 | @classmethod 147 | def parse( 148 | cls, body: bytes, boundary: bytes, encoding: str = "utf-8" 149 | ) -> "MultipartForm": 150 | fields: typing.Dict[str, Field] = {} 151 | files: typing.Dict[str, FileField] = {} 152 | for part in body.strip(b"\r\n").split(b"".join((b"--", boundary))): 153 | part = part.strip(b"\r\n") 154 | if part in (b"", b"--"): # ignore start and end parts 155 | continue 156 | 157 | headers, value = split_on_first(part, b"\r\n\r\n") 158 | headers = make_headers(headers) 159 | kwargs = parse_header(headers["content-disposition"])[1] 160 | 161 | name = kwargs["name"] 162 | is_file = "filename" in kwargs 163 | filename = kwargs.get("filename", None) 164 | 165 | if name in fields and not fields[name].is_file: 166 | fields[name].values.append(value.decode(encoding)) 167 | else: 168 | if is_file: 169 | fields[name] = files[name] = FileField( 170 | name, 171 | value, 172 | filename=filename, 173 | content_type=parse_header(headers["content-type"])[0], 174 | encoding=encoding, 175 | ) 176 | else: 177 | fields[name] = Field( 178 | name, 179 | [value], 180 | ) 181 | return cls(fields, files) 182 | -------------------------------------------------------------------------------- /baguette/headers.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import typing 3 | from collections.abc import Mapping, Sequence 4 | 5 | from .types import HeadersType 6 | from .utils import to_str 7 | 8 | 9 | class Headers: 10 | """Headers implementation for handling :class:`str` or :class:`bytes` names 11 | and values. 12 | 13 | .. tip:: 14 | Use :func:`make_headers` to easily make a header from a :class:`list` or 15 | a :class:`dict`. 16 | """ 17 | 18 | def __init__(self, *args, **kwargs): 19 | self._headers: typing.Dict[str, str] = {} 20 | 21 | for name, value in itertools.chain(args, kwargs.items()): 22 | self[name] = value 23 | 24 | def get(self, name, default=None): 25 | """Gets a header from its name. If not found, returns ``default``. 26 | 27 | Arguments 28 | --------- 29 | name: :class:`str` or :class:`bytes` 30 | Name of the header to get. 31 | default: Optional anything 32 | Value to return if header not found. 33 | Default: ``None`` 34 | 35 | Returns 36 | ------- 37 | :class:`str` 38 | Header value if found. 39 | Anything 40 | ``default``'s value. 41 | """ 42 | 43 | name = to_str(name, encoding="ascii") 44 | return self._headers.get(name.lower().strip(), default) 45 | 46 | def keys(self): 47 | """Returns an iterator over the headers names. 48 | 49 | Returns 50 | ------- 51 | Iterator of :class:`str` 52 | Iterator over the headers names. 53 | """ 54 | 55 | return self._headers.keys() 56 | 57 | def items(self): 58 | """Returns an iterator over the headers names and values. 59 | 60 | Returns 61 | ------- 62 | Iterator of ``(name, value)`` :class:`tuple` 63 | Iterator over the headers names and values. 64 | """ 65 | 66 | return self._headers.items() 67 | 68 | def raw(self): 69 | """Returns the raw headers, a :class:`list` of ``[name, value]`` where 70 | ``name`` and ``value`` are :class:`bytes`. 71 | 72 | Returns 73 | ------- 74 | :class:`list` of ``[name, value]`` where ``name`` and ``value`` are\ 75 | :class:`bytes` 76 | Raw headers for the ASGI response. 77 | """ 78 | 79 | return [ 80 | [name.encode("ascii"), value.encode("ascii")] 81 | for name, value in self 82 | ] 83 | 84 | def __str__(self) -> str: 85 | return "\n".join(name + ": " + value for name, value in self) 86 | 87 | def __iter__(self): 88 | return iter(self.items()) 89 | 90 | def __len__(self): 91 | return len(self._headers) 92 | 93 | def __getitem__(self, name): 94 | name = to_str(name, encoding="ascii") 95 | return self._headers[name.lower().strip()] 96 | 97 | def __setitem__(self, name, value): 98 | name = to_str(name, encoding="ascii") 99 | value = to_str(value, encoding="ascii") 100 | self._headers[name.lower().strip()] = value.strip() 101 | 102 | def __delitem__(self, name): 103 | name = to_str(name, encoding="ascii") 104 | del self._headers[name.lower().strip()] 105 | 106 | def __contains__(self, name): 107 | name = to_str(name, encoding="ascii") 108 | return name.lower().strip() in self._headers 109 | 110 | def __add__(self, other: HeadersType): 111 | new = Headers(**self) 112 | new += other 113 | return new 114 | 115 | def __iadd__(self, other: HeadersType): 116 | other = make_headers(other) 117 | for name, value in other: 118 | self._headers[name] = value 119 | return self 120 | 121 | def __eq__(self, other: HeadersType) -> bool: 122 | other = make_headers(other) 123 | if len(self) != len(other): 124 | return False 125 | for name, value in self: 126 | if other[name] != value: 127 | return False 128 | return True 129 | 130 | 131 | def make_headers(headers: HeadersType = None) -> Headers: 132 | """Makes a :class:`Headers` object from a :class:`list` of ``(str, str)`` 133 | tuples, a :class:`dict`, or a :class:`Headers` instance. 134 | 135 | Arguments 136 | --------- 137 | headers: :class:`list` of ``(str, str)`` tuples, \ 138 | :class:`dict` or :class:`Headers` 139 | The raw headers to convert. 140 | 141 | Raises 142 | ------ 143 | :exc:`TypeError` 144 | ``headers`` isn't of type :class:`str`, :class:`list`, 145 | :class:`dict`, :class:`Headers` or :obj:`None` 146 | 147 | Returns 148 | ------- 149 | :class:`Headers` 150 | The converted headers. 151 | """ 152 | 153 | if headers is None: 154 | headers = Headers() 155 | elif isinstance(headers, (str, bytes)): 156 | headers = to_str(headers, encoding="ascii") 157 | headers = Headers( 158 | *[header.split(":") for header in headers.splitlines()] 159 | ) 160 | elif isinstance(headers, Sequence): 161 | headers = Headers(*headers) 162 | elif isinstance(headers, (Mapping, Headers)): 163 | new_headers = Headers() 164 | for name, value in headers.items(): 165 | new_headers[name] = value 166 | headers = new_headers 167 | else: 168 | raise TypeError( 169 | "headers must be a str, a list, a dict, a Headers instance or None" 170 | ) 171 | 172 | return headers 173 | -------------------------------------------------------------------------------- /baguette/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import ujson 4 | 5 | 6 | class UJSONEncoder(json.JSONEncoder): 7 | encode = ujson.encode 8 | 9 | 10 | class UJSONDecoder(json.JSONDecoder): 11 | decode = ujson.decode 12 | -------------------------------------------------------------------------------- /baguette/middleware.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | from .request import Request 3 | from .responses import Response 4 | 5 | 6 | class Middleware: 7 | """Base class for middlewares. 8 | 9 | Arguments 10 | --------- 11 | next_middleware : :class:`Middleware` 12 | The next middleware to call. 13 | 14 | config : :class:`Config` 15 | The application configuration. 16 | 17 | Attributes 18 | ---------- 19 | next_middleware : :class:`Middleware` 20 | The next middleware to call. 21 | 22 | nexte : :class:`Middleware` 23 | The next middleware to call. 24 | (Alias for :attr:`next_middleware`) 25 | 26 | config : :class:`Config` 27 | The application configuration. 28 | """ 29 | 30 | def __init__(self, next_middleware: "Middleware", config: Config): 31 | self.next_middleware = self.next = next_middleware 32 | self.config = config 33 | 34 | async def __call__(self, request: Request) -> Response: 35 | """Call the middleware, executed at every request. 36 | 37 | Arguments 38 | --------- 39 | request: :class:`Request` 40 | The request to handle. 41 | 42 | Returns 43 | ------- 44 | :class:`Response` 45 | The handled response. 46 | """ 47 | 48 | return await self.next(request) # pragma: no cover 49 | -------------------------------------------------------------------------------- /baguette/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "DefaultHeadersMiddleware", 3 | "ErrorMiddleware", 4 | ] 5 | 6 | from .default_headers import DefaultHeadersMiddleware 7 | from .errors import ErrorMiddleware 8 | -------------------------------------------------------------------------------- /baguette/middlewares/default_headers.py: -------------------------------------------------------------------------------- 1 | from ..middleware import Middleware 2 | from ..request import Request 3 | from ..responses import Response 4 | 5 | 6 | class DefaultHeadersMiddleware(Middleware): 7 | """Middleware to add the :attr:`app.config.default_headers 8 | ` to every response.""" 9 | 10 | async def __call__(self, request: Request) -> Response: 11 | response = await self.next(request) 12 | response.headers = self.config.default_headers + response.headers 13 | return response 14 | -------------------------------------------------------------------------------- /baguette/middlewares/errors.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from ..httpexceptions import HTTPException, InternalServerError 4 | from ..middleware import Middleware 5 | from ..request import Request 6 | from ..responses import Response, make_error_response 7 | 8 | 9 | class ErrorMiddleware(Middleware): 10 | """Middleware to handle errors in request handling. Can be 11 | :exc:`~baguette.httpexceptions.HTTPException` or other exceptions. 12 | 13 | If :attr:`app.config.debug ` and the HTTP 14 | status code is higher than 500, then the error traceback is included. 15 | """ 16 | 17 | async def __call__(self, request: Request) -> Response: 18 | try: 19 | return await self.next(request) 20 | 21 | except HTTPException as http_exception: 22 | return make_error_response( 23 | http_exception, 24 | type_=self.config.error_response_type, 25 | include_description=self.config.error_include_description, 26 | traceback="".join( 27 | traceback.format_tb(http_exception.__traceback__) 28 | ) 29 | if self.config.debug and http_exception.status_code >= 500 30 | else None, 31 | ) 32 | 33 | except Exception as exception: 34 | traceback.print_exc() 35 | http_exception = InternalServerError() 36 | return make_error_response( 37 | http_exception, 38 | type_=self.config.error_response_type, 39 | include_description=self.config.error_include_description, 40 | traceback="".join(traceback.format_tb(exception.__traceback__)) 41 | if self.config.debug 42 | else None, 43 | ) 44 | -------------------------------------------------------------------------------- /baguette/rendering.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import jinja2 4 | 5 | from .types import FilePath 6 | 7 | _renderer = None 8 | 9 | 10 | class Renderer: 11 | def __init__( 12 | self, 13 | templates_directory: typing.Union[ 14 | FilePath, typing.List[FilePath] 15 | ] = "templates", 16 | ): 17 | self.env = jinja2.Environment( 18 | loader=jinja2.FileSystemLoader(templates_directory), 19 | enable_async=True, 20 | ) 21 | 22 | async def render(self, template_name, *args, **kwargs): 23 | template: jinja2.Template = self.env.get_template(template_name) 24 | return await template.render_async(*args, **kwargs) 25 | 26 | 27 | def init( 28 | templates_directory: typing.Union[ 29 | FilePath, typing.List[FilePath] 30 | ] = "templates" 31 | ): 32 | global _renderer 33 | _renderer = Renderer(templates_directory) 34 | return _renderer 35 | 36 | 37 | async def render(template_name, *args, **kwargs): 38 | if _renderer is None: 39 | init(kwargs.pop("templates_directory", "templates")) 40 | 41 | return await _renderer.render(template_name, *args, **kwargs) 42 | -------------------------------------------------------------------------------- /baguette/request.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import typing 4 | from cgi import parse_header 5 | from urllib.parse import parse_qs 6 | 7 | from .forms import Field, Form, MultipartForm, URLEncodedForm 8 | from .headers import Headers 9 | from .httpexceptions import BadRequest 10 | from .json import UJSONDecoder, UJSONEncoder 11 | from .types import ASGIApp, JSONType, Receive, Scope, StrOrBytes 12 | from .utils import get_encoding_from_headers, to_bytes, to_str 13 | 14 | FORM_CONTENT_TYPE = ["application/x-www-form-urlencoded", "multipart/form-data"] 15 | 16 | 17 | class Request: 18 | """Request class that is passed to the view functions. 19 | 20 | Arguments 21 | --------- 22 | app: ASGI App 23 | The application that handles the request. 24 | 25 | scope: :class:`dict` 26 | ASGI scope of the request. 27 | See `HTTP scope ASGI specifications `_. 29 | 30 | receive: Asynchronous callable 31 | Awaitable callable that will yield a 32 | new event dictionary when one is available. 33 | See `applications ASGI specifications `_. 35 | 36 | Attributes 37 | ---------- 38 | app: ASGI App 39 | The application that handles the request. 40 | 41 | http_version: :class:`str` 42 | The HTTP version used. 43 | One of ``"1.0"``, ``"1.1"`` or ``"2"``. 44 | 45 | asgi_version: :class:`str` 46 | The ASGI specification version used. 47 | 48 | headers: :class:`Headers` 49 | The HTTP headers included in the request. 50 | 51 | method: :class:`str` 52 | The HTTP method name, uppercased. 53 | 54 | scheme: :class:`str` 55 | URL scheme portion (likely ``"http"`` or ``"https"``). 56 | 57 | path: :class:`str` 58 | HTTP request target excluding any query string, 59 | with percent-encoded sequences and UTF-8 byte sequences 60 | decoded into characters. 61 | ``"/"`` at the end of the path is striped. 62 | 63 | querystring: :class:`dict` with :class:`str` keys and \ 64 | :class:`list` of :class:`str` values 65 | URL querystring decoded by :func:`urllib.parse.parse_qs`. 66 | 67 | server: :class:`tuple` of (:class:`str`, :class:`int`) 68 | Adress and port of the server. 69 | The first element can be the path to the UNIX socket running 70 | the application, in that case the second element is ``None``. 71 | 72 | client: :class:`tuple` of (:class:`str`, :class:`int`) 73 | Adress and port of the client. 74 | The adress can be either IPv4 or IPv6. 75 | 76 | content_type: :class:`str` 77 | Content type of the response body. 78 | 79 | encoding: :class:`str` 80 | Encoding of the response body. 81 | """ 82 | 83 | def __init__(self, app: ASGIApp, scope: Scope, receive: Receive): 84 | self.app = app 85 | self._scope = scope 86 | self._receive = receive 87 | 88 | self.http_version: str = scope["http_version"] 89 | self.asgi_version: str = scope["asgi"]["version"] 90 | 91 | self.headers: Headers = Headers(*scope["headers"]) 92 | self.method: str = scope["method"].upper() 93 | self.scheme: str = scope.get("scheme", "http") 94 | self.root_path: str = scope.get("root_path", "") 95 | self.path: str = scope["path"].rstrip("/") or "/" 96 | self.querystring: typing.Dict[str, typing.List[str]] = parse_qs( 97 | scope["query_string"].decode("ascii") 98 | ) 99 | 100 | self.server: typing.Tuple[str, int] = scope["server"] 101 | self.client: typing.Tuple[str, int] = scope["client"] 102 | 103 | # common headers 104 | self.content_type: str = parse_header( 105 | self.headers.get("content-type", "") 106 | )[0] 107 | self.encoding: str = get_encoding_from_headers(self.headers) or "utf-8" 108 | 109 | # cached 110 | self._raw_body: bytes = None 111 | self._body: str = None 112 | self._json: JSONType = None 113 | self._form: Form = None 114 | 115 | # -------------------------------------------------------------------------- 116 | # Body methods 117 | 118 | async def raw_body(self) -> bytes: 119 | """Gets the raw request body in :class:`bytes`. 120 | 121 | Returns 122 | ------- 123 | :class:`bytes` 124 | Raw request body. 125 | """ 126 | 127 | # caching 128 | if self._raw_body is not None: 129 | return self._raw_body 130 | 131 | body = b"" 132 | more_body = True 133 | 134 | while more_body: 135 | message = await self._receive() 136 | body += message.get("body", b"") 137 | more_body = message.get("more_body", False) 138 | 139 | self._raw_body = body 140 | return self._raw_body 141 | 142 | async def body(self) -> str: 143 | """Gets the request body in :class:`str`. 144 | 145 | Returns 146 | ------- 147 | :class:`str` 148 | Request body. 149 | """ 150 | 151 | # caching 152 | if self._body is not None: 153 | return self._body 154 | 155 | raw_body = await self.raw_body() 156 | self._body = raw_body.decode(self.encoding) 157 | return self._body 158 | 159 | async def json(self) -> JSONType: 160 | """Parses the request body to JSON. 161 | 162 | Returns 163 | ------- 164 | Anything that can be decoded from JSON 165 | Parsed body. 166 | 167 | Raises 168 | ------ 169 | ~baguette.httpexceptions.BadRequest 170 | If the JSON body is not JSON. 171 | You can usually not handle this error as it will be handled by 172 | the app and converted to a response with a ``400`` status code. 173 | """ 174 | 175 | # caching 176 | if self._json is not None: 177 | return self._json 178 | 179 | body = await self.body() 180 | try: 181 | self._json = json.loads(body, cls=UJSONDecoder) 182 | except (json.JSONDecodeError, ValueError): 183 | raise BadRequest(description="Can't decode body as JSON") 184 | return self._json 185 | 186 | async def form(self, include_querystring: bool = False) -> Form: 187 | """Parses the request body as form data. 188 | 189 | Arguments 190 | --------- 191 | include_querystring : Optional :class:`bool` 192 | Whether to include the querystrings in the form fields. 193 | 194 | Returns 195 | ------- 196 | :class:`~baguette.forms.Form` 197 | Parsed form. 198 | """ 199 | 200 | if self._form is None: 201 | body = await self.raw_body() 202 | if self.content_type not in FORM_CONTENT_TYPE: 203 | raise ValueError( 204 | "Content-type '{}' isn't one of: {}".format( 205 | self.content_type, ", ".join(FORM_CONTENT_TYPE) 206 | ) 207 | ) 208 | 209 | if self.content_type == "application/x-www-form-urlencoded": 210 | form = URLEncodedForm.parse(body, encoding=self.encoding) 211 | elif self.content_type == "multipart/form-data": 212 | params = parse_header(self.headers.get("content-type", ""))[1] 213 | form = MultipartForm.parse( 214 | body, 215 | boundary=params["boundary"].encode(self.encoding), 216 | encoding=self.encoding, 217 | ) 218 | 219 | self._form = form 220 | else: 221 | form = self._form.copy() 222 | 223 | if include_querystring: 224 | for name, values in self.querystring.items(): 225 | if name in form.fields: 226 | form.fields[name].values.extend(values) 227 | else: 228 | form.fields[name] = Field(name, values) 229 | 230 | return form 231 | 232 | # -------------------------------------------------------------------------- 233 | # Setters 234 | 235 | def set_raw_body(self, raw_body: bytes): 236 | """Sets the raw body of the request. 237 | 238 | Arguments 239 | --------- 240 | raw_body : :class:`bytes` 241 | The new request raw body 242 | 243 | Raises 244 | ------ 245 | TypeError 246 | The raw body isn't of type :class:`bytes` 247 | """ 248 | 249 | if not isinstance(raw_body, bytes): 250 | raise TypeError( 251 | "Argument raw_body most be of type bytes. Got " 252 | + raw_body.__class__.__name__ 253 | ) 254 | self._raw_body = raw_body 255 | 256 | def set_body(self, body: StrOrBytes): 257 | """Sets the request body. 258 | 259 | Parameters 260 | ---------- 261 | body : :class:`str` or :class:`bytes` 262 | The new request body 263 | 264 | Raises 265 | ------ 266 | TypeError 267 | The body isn't of type :class:`str` or :class:`bytes` 268 | """ 269 | 270 | self._body = to_str(body) 271 | self.set_raw_body(to_bytes(body)) 272 | 273 | def set_json(self, data: JSONType): 274 | """Sets the request JSON data. 275 | 276 | Parameters 277 | ---------- 278 | data : Anything JSON serializable 279 | The data to put in the request body. 280 | 281 | Raises 282 | ------ 283 | TypeError 284 | The data isn't JSON serializable. 285 | """ 286 | 287 | self.set_body(json.dumps(data, cls=UJSONEncoder)) 288 | self._json = copy.deepcopy(data) 289 | 290 | def set_form(self, form: Form): 291 | """Sets the request form. 292 | 293 | Parameters 294 | ---------- 295 | form : :class:`~baguette.forms.Form` 296 | The form to add to the request. 297 | 298 | Raises 299 | ------ 300 | TypeError 301 | The form isn't a :class:`~baguette.forms.Form`. 302 | """ 303 | 304 | if not isinstance(form, Form): 305 | raise TypeError( 306 | "Argument form most be of type Form. Got " 307 | + form.__class__.__name__ 308 | ) 309 | self._form = form.copy() 310 | -------------------------------------------------------------------------------- /baguette/router.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | import typing 4 | 5 | import cachetools 6 | 7 | from .converters import ( 8 | Converter, 9 | FloatConverter, 10 | IntegerConverter, 11 | PathConverter, 12 | StringConverter, 13 | ) 14 | from .httpexceptions import MethodNotAllowed, NotFound 15 | from .types import Handler 16 | from .view import View 17 | 18 | 19 | class Route: 20 | PARAM_REGEX = re.compile( 21 | r"<(?P\w+)(?::(?P\w+)(?:\((?P(?:\w+=(?:\w|\+|-|\.)+,?\s*)*)\))?)?>" # noqa: E501 22 | ) 23 | PARAM_ARGS_REGEX = re.compile(r"(\w+)=((?:\w|\+|-|\.)+)") 24 | PARAM_CONVERTERS = { 25 | "str": StringConverter, 26 | "path": PathConverter, 27 | "int": IntegerConverter, 28 | "float": FloatConverter, 29 | } 30 | 31 | def __init__( 32 | self, 33 | path: str, 34 | name: str, 35 | handler: Handler, 36 | methods: typing.List[str], 37 | defaults: typing.Dict[str, typing.Any] = None, 38 | ): 39 | self.path = path 40 | self.name = name 41 | self.handler = handler 42 | self.methods = methods 43 | self.defaults = defaults or {} 44 | 45 | handler_signature = inspect.signature(self.handler) 46 | self.handler_kwargs = [ 47 | param.name 48 | for param in handler_signature.parameters.values() 49 | if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) 50 | ] 51 | self.handler_is_class = isinstance(self.handler, View) 52 | 53 | if self.name is None: 54 | self.name = ( 55 | self.handler.__class__.__name__ 56 | if self.handler_is_class 57 | else self.handler.__name__ 58 | ) 59 | 60 | self.converters = {} # name: converter 61 | self.index_converters = {} # index: (name, converter) 62 | self.build_converters() 63 | 64 | self.regex = re.compile("") 65 | self.build_regex() 66 | 67 | def build_converters(self): 68 | segments = self.path.strip("/").split("/") 69 | for index, segment in enumerate(segments): 70 | param = self.PARAM_REGEX.fullmatch(segment) 71 | if param is None: 72 | continue 73 | 74 | groups = param.groupdict() 75 | if groups["type"] is None: 76 | groups["type"] = "str" 77 | 78 | if groups["type"] not in self.PARAM_CONVERTERS: 79 | raise ValueError( 80 | "Expected type to be one of: {}. Got {}".format( 81 | ", ".join(self.PARAM_CONVERTERS), groups["type"] 82 | ) 83 | ) 84 | converter: Converter = self.PARAM_CONVERTERS[groups["type"]] 85 | 86 | kwargs = {} 87 | if "args" in groups and groups["args"] is not None: 88 | args = self.PARAM_ARGS_REGEX.findall(groups["args"]) 89 | for name, value in args: 90 | if value in ["True", "False"]: 91 | value = value == "True" 92 | elif value.lstrip("+-").isdecimal(): 93 | value = int(value) 94 | elif value.lstrip("+-").replace(".", "", 1).isdecimal(): 95 | value = float(value) 96 | else: 97 | value = value.strip("'\"") 98 | kwargs[name] = value 99 | 100 | self.converters[groups["name"]] = converter(**kwargs) 101 | self.index_converters[index] = (groups["name"], converter(**kwargs)) 102 | 103 | def build_regex(self): 104 | segments = self.path.strip("/").split("/") 105 | regex = "" 106 | 107 | for index, segment in enumerate(segments): 108 | regex += r"\/" 109 | if index in self.index_converters: 110 | name, converter = self.index_converters[index] 111 | regex += "(?P<{}>{})".format(name, converter.REGEX) 112 | 113 | if index == len(segments) - 1 and name in self.defaults: 114 | regex += "?" 115 | 116 | else: 117 | regex += re.escape(segment) 118 | 119 | regex += r"\/?" 120 | 121 | self.regex = re.compile(regex) 122 | 123 | def match(self, path: str) -> bool: 124 | return self.regex.fullmatch(path if path.endswith("/") else path + "/") 125 | 126 | def convert(self, path: str) -> typing.Dict[str, typing.Any]: 127 | kwargs = self.defaults.copy() 128 | match = self.regex.fullmatch(path if path.endswith("/") else path + "/") 129 | 130 | if match is None: 131 | raise ValueError("Path doesn't match router path") 132 | 133 | parameters = match.groupdict() 134 | 135 | for name, value in parameters.items(): 136 | if value is None: 137 | if name in kwargs: 138 | continue 139 | 140 | converter = self.converters[name] 141 | try: 142 | kwargs[name] = converter.convert(value) 143 | except ValueError as conversion_error: 144 | raise ValueError( 145 | f"Failed to convert {name} argument: " 146 | + str(conversion_error) 147 | ) from conversion_error 148 | 149 | return kwargs 150 | 151 | 152 | class Router: 153 | def __init__(self, routes: typing.Optional[typing.List[Route]] = None): 154 | self.routes: typing.List[Route] = routes or [] 155 | self._cache: typing.Mapping[str, Route] = cachetools.LFUCache(256) 156 | 157 | def add_route( 158 | self, 159 | handler: Handler, 160 | path: str, 161 | methods: typing.List[str] = None, 162 | name: str = None, 163 | defaults: dict = None, 164 | ) -> Route: 165 | route = Route( 166 | path=path, 167 | name=name, 168 | handler=handler, 169 | methods=methods, 170 | defaults=defaults or {}, 171 | ) 172 | self.routes.append(route) 173 | return route 174 | 175 | def get(self, path: str, method: str) -> Route: 176 | route = self._cache.get(method + " " + path) 177 | if route is None: 178 | for possible_route in self.routes: 179 | if possible_route.match(path): 180 | route = possible_route 181 | if method not in route.methods: 182 | continue 183 | break 184 | 185 | if route is None: 186 | raise NotFound() 187 | 188 | if method not in route.methods: 189 | raise MethodNotAllowed() 190 | 191 | self._cache[method + " " + path] = route 192 | 193 | return route 194 | -------------------------------------------------------------------------------- /baguette/testing.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from collections.abc import Mapping, Sequence 3 | from urllib.parse import urlencode 4 | 5 | from .app import Baguette 6 | from .headers import Headers, make_headers 7 | from .request import Request 8 | from .responses import Response 9 | from .types import BodyType, HeadersType, JSONType, ParamsType 10 | 11 | METHOD_DOCS = """Sends a {method} request to :attr:`app`. 12 | 13 | Arguments 14 | --------- 15 | path : :class:`str` 16 | The path of the request. 17 | 18 | Keyword Arguments 19 | ----------------- 20 | params : :class:`str` or :class:`dict` with :class:`str` keys and \ 21 | :class:`str` or :class:`list` values 22 | The parameters to send in the querystring. 23 | 24 | body : :class:`str` or :class:`bytes` 25 | The data to send in the request body. 26 | 27 | json : Anything JSON serializable 28 | The JSON data to send in the request body. 29 | 30 | headers : :class:`list` of ``(str, str)`` tuples, \ 31 | :class:`dict` or :class:`Headers` 32 | The headers to send in the request. 33 | """ 34 | 35 | 36 | class TestClient: 37 | """Test client for a :class:`Baguette` application. 38 | 39 | This class works like a :class:`req:requests.Session`. 40 | 41 | Arguments 42 | --------- 43 | app : :class:`Baguette` 44 | Application tho send the test requests to. 45 | 46 | default_headers : :class:`list` of ``(str, str)`` tuples, \ 47 | :class:`dict` or :class:`Headers` 48 | Default headers to include in every request. 49 | Default: No headers. 50 | 51 | Attributes 52 | ---------- 53 | app : :class:`Baguette` 54 | Application tho send the test requests to. 55 | 56 | default_headers : :class:`list` of ``(str, str)`` tuples, \ 57 | :class:`dict` or :class:`Headers` 58 | Default headers included in every request. 59 | """ 60 | 61 | DEFAULT_SCOPE = { 62 | "type": "http", 63 | "asgi": {"version": "3.0", "spec_version": "2.1"}, 64 | "http_version": "1.1", 65 | "server": ("127.0.0.1", 8000), 66 | "client": ("127.0.0.1", 9000), 67 | "scheme": "http", 68 | "root_path": "", 69 | } 70 | 71 | def __init__( 72 | self, 73 | app: Baguette, 74 | default_headers: typing.Optional[HeadersType] = None, 75 | ): 76 | self.app = app 77 | self.default_headers: Headers = make_headers(default_headers) 78 | 79 | async def request( 80 | self, 81 | method: str, 82 | path: str, 83 | *, 84 | params: typing.Optional[ParamsType] = None, 85 | body: typing.Optional[BodyType] = None, 86 | json: typing.Optional[JSONType] = None, 87 | headers: typing.Optional[HeadersType] = None, 88 | ) -> Response: 89 | """Creates and sends a request to :attr:`app`. 90 | 91 | Arguments 92 | --------- 93 | method : :class:`str` 94 | The HTTP method for the request. 95 | 96 | path : :class:`str` 97 | The path of the request. 98 | 99 | Keyword Arguments 100 | ----------------- 101 | params : :class:`str` or :class:`dict` with :class:`str` keys and \ 102 | :class:`str` or :class:`list` values 103 | The parameters to send in the querystring. 104 | 105 | body : :class:`str` or :class:`bytes` 106 | The data to send in the request body. 107 | 108 | json : Anything JSON serializable 109 | The JSON data to send in the request body. 110 | 111 | headers : :class:`list` of ``(str, str)`` tuples, \ 112 | :class:`dict` or :class:`Headers` 113 | The headers to send in the request. 114 | """ 115 | 116 | request = self._prepare_request( 117 | method=method, 118 | path=path, 119 | params=params, 120 | body=body, 121 | json=json, 122 | headers=headers, 123 | ) 124 | response = await self.app.handle_request(request) 125 | return response 126 | 127 | async def get( 128 | self, 129 | path: str, 130 | *, 131 | params: typing.Optional[ParamsType] = None, 132 | body: typing.Optional[BodyType] = None, 133 | json: typing.Optional[JSONType] = None, 134 | headers: typing.Optional[HeadersType] = None, 135 | ) -> Response: 136 | return await self.request( 137 | method="GET", 138 | path=path, 139 | params=params, 140 | body=body, 141 | json=json, 142 | headers=headers, 143 | ) 144 | 145 | async def head( 146 | self, 147 | path: str, 148 | *, 149 | params: typing.Optional[ParamsType] = None, 150 | body: typing.Optional[BodyType] = None, 151 | json: typing.Optional[JSONType] = None, 152 | headers: typing.Optional[HeadersType] = None, 153 | ) -> Response: 154 | return await self.request( 155 | method="HEAD", 156 | path=path, 157 | params=params, 158 | body=body, 159 | json=json, 160 | headers=headers, 161 | ) 162 | 163 | async def post( 164 | self, 165 | path: str, 166 | *, 167 | params: typing.Optional[ParamsType] = None, 168 | body: typing.Optional[BodyType] = None, 169 | json: typing.Optional[JSONType] = None, 170 | headers: typing.Optional[HeadersType] = None, 171 | ) -> Response: 172 | return await self.request( 173 | method="POST", 174 | path=path, 175 | params=params, 176 | body=body, 177 | json=json, 178 | headers=headers, 179 | ) 180 | 181 | async def put( 182 | self, 183 | path: str, 184 | *, 185 | params: typing.Optional[ParamsType] = None, 186 | body: typing.Optional[BodyType] = None, 187 | json: typing.Optional[JSONType] = None, 188 | headers: typing.Optional[HeadersType] = None, 189 | ) -> Response: 190 | return await self.request( 191 | method="PUT", 192 | path=path, 193 | params=params, 194 | body=body, 195 | json=json, 196 | headers=headers, 197 | ) 198 | 199 | async def delete( 200 | self, 201 | path: str, 202 | *, 203 | params: typing.Optional[ParamsType] = None, 204 | body: typing.Optional[BodyType] = None, 205 | json: typing.Optional[JSONType] = None, 206 | headers: typing.Optional[HeadersType] = None, 207 | ) -> Response: 208 | return await self.request( 209 | method="DELETE", 210 | path=path, 211 | params=params, 212 | body=body, 213 | json=json, 214 | headers=headers, 215 | ) 216 | 217 | async def connect( 218 | self, 219 | path: str, 220 | *, 221 | params: typing.Optional[ParamsType] = None, 222 | body: typing.Optional[BodyType] = None, 223 | json: typing.Optional[JSONType] = None, 224 | headers: typing.Optional[HeadersType] = None, 225 | ) -> Response: 226 | return await self.request( 227 | method="CONNECT", 228 | path=path, 229 | params=params, 230 | body=body, 231 | json=json, 232 | headers=headers, 233 | ) 234 | 235 | async def options( 236 | self, 237 | path: str, 238 | *, 239 | params: typing.Optional[ParamsType] = None, 240 | body: typing.Optional[BodyType] = None, 241 | json: typing.Optional[JSONType] = None, 242 | headers: typing.Optional[HeadersType] = None, 243 | ) -> Response: 244 | return await self.request( 245 | method="OPTIONS", 246 | path=path, 247 | params=params, 248 | body=body, 249 | json=json, 250 | headers=headers, 251 | ) 252 | 253 | async def trace( 254 | self, 255 | path: str, 256 | *, 257 | params: typing.Optional[ParamsType] = None, 258 | body: typing.Optional[BodyType] = None, 259 | json: typing.Optional[JSONType] = None, 260 | headers: typing.Optional[HeadersType] = None, 261 | ) -> Response: 262 | return await self.request( 263 | method="TRACE", 264 | path=path, 265 | params=params, 266 | body=body, 267 | json=json, 268 | headers=headers, 269 | ) 270 | 271 | async def patch( 272 | self, 273 | path: str, 274 | *, 275 | params: typing.Optional[ParamsType] = None, 276 | body: typing.Optional[BodyType] = None, 277 | json: typing.Optional[JSONType] = None, 278 | headers: typing.Optional[HeadersType] = None, 279 | ) -> Response: 280 | return await self.request( 281 | method="PATCH", 282 | path=path, 283 | params=params, 284 | body=body, 285 | json=json, 286 | headers=headers, 287 | ) 288 | 289 | def _prepare_request( 290 | self, 291 | method: str, 292 | path: str, 293 | *, 294 | params: typing.Optional[ParamsType] = None, 295 | body: typing.Optional[BodyType] = None, 296 | json: typing.Optional[JSONType] = None, 297 | headers: typing.Optional[HeadersType] = None, 298 | ) -> Request: 299 | headers: Headers = self._prepare_headers(headers) 300 | querystring: str = self._prepare_querystring(params) 301 | scope = { 302 | **self.DEFAULT_SCOPE, 303 | **{ 304 | "method": method.upper(), 305 | "path": path, 306 | "headers": headers.raw(), 307 | "query_string": querystring.encode("ascii"), 308 | }, 309 | } 310 | 311 | request = Request(self.app, scope, None) 312 | request.set_body(body or "") 313 | if json is not None: 314 | request.set_json(json) 315 | 316 | return request 317 | 318 | def _prepare_headers( 319 | self, headers: typing.Optional[HeadersType] = None 320 | ) -> Headers: 321 | headers = make_headers(headers) 322 | headers = self.default_headers + headers 323 | 324 | return headers 325 | 326 | def _prepare_querystring( 327 | self, params: typing.Optional[ParamsType] = None 328 | ) -> str: 329 | query = {} 330 | 331 | if params is None or isinstance(params, str): 332 | return params or "" 333 | 334 | if isinstance(params, Mapping): 335 | params = list(params.items()) 336 | 337 | if isinstance(params, Sequence): 338 | if any(len(param) != 2 for param in params): 339 | raise ValueError("Incorrect param type") 340 | 341 | for name, value in params: 342 | if isinstance(value, str): 343 | values = [value] 344 | elif isinstance(value, Sequence): 345 | if not all(isinstance(v, str) for v in value): 346 | raise ValueError("Incorrect param type") 347 | values = list(value) 348 | else: 349 | raise ValueError("Incorrect param type") 350 | 351 | if name in query: 352 | query[name].extend(values) 353 | else: 354 | query[name] = values 355 | 356 | else: 357 | raise ValueError("Incorrect param type") 358 | 359 | querystring = urlencode(query, doseq=True) 360 | 361 | return querystring 362 | 363 | for method in [ 364 | "GET", 365 | "HEAD", 366 | "POST", 367 | "PUT", 368 | "DELETE", 369 | "CONNECT", 370 | "OPTIONS", 371 | "TRACE", 372 | "PATCH", 373 | ]: 374 | locals().get(method.lower()).__doc__ = METHOD_DOCS.format(method=method) 375 | -------------------------------------------------------------------------------- /baguette/types.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | 4 | if typing.TYPE_CHECKING: 5 | from .headers import Headers 6 | from .request import Request 7 | from .responses import Response 8 | 9 | Scope = typing.MutableMapping[str, typing.Any] 10 | Message = typing.MutableMapping[str, typing.Any] 11 | 12 | Receive = typing.Callable[[], typing.Awaitable[Message]] 13 | Send = typing.Callable[[Message], typing.Awaitable[None]] 14 | 15 | ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] 16 | 17 | StrOrBytes = typing.Union[str, bytes] 18 | 19 | HeadersType = typing.Union[ 20 | "Headers", 21 | StrOrBytes, 22 | typing.Mapping[StrOrBytes, StrOrBytes], 23 | typing.Sequence[typing.Tuple[StrOrBytes, StrOrBytes]], 24 | ] 25 | 26 | Result = typing.Union[ 27 | "Response", 28 | typing.Tuple[ 29 | typing.Any, 30 | typing.Optional[int], 31 | typing.Optional[HeadersType], 32 | ], 33 | ] 34 | 35 | Handler = typing.Callable[["Request"], typing.Awaitable[Result]] 36 | 37 | ParamsType = typing.Union[ 38 | str, 39 | typing.Mapping[str, typing.Union[str, typing.Sequence[str]]], 40 | typing.Sequence[typing.Tuple[str, typing.Union[str, typing.Sequence[str]]]], 41 | ] 42 | BodyType = typing.Union[str, bytes] 43 | JSONType = typing.Union[dict, list, tuple, str, int, float, bool] 44 | 45 | FilePath = typing.Union[bytes, str, os.PathLike] 46 | 47 | MiddlewareCallable = typing.Callable[["Request"], typing.Awaitable["Response"]] 48 | -------------------------------------------------------------------------------- /baguette/utils.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import os 3 | import pathlib 4 | import typing 5 | 6 | from .httpexceptions import NotFound 7 | from .types import FilePath, StrOrBytes 8 | 9 | 10 | def get_encoding_from_content_type(content_type): 11 | """Returns encodings from given Content-Type Header.""" 12 | 13 | if not content_type: 14 | return None 15 | 16 | content_type, params = cgi.parse_header(content_type) 17 | 18 | if "charset" in params: 19 | return params["charset"].strip("'\"") 20 | 21 | if "text" in content_type: 22 | return "ISO-8859-1" 23 | 24 | 25 | def get_encoding_from_headers(headers): 26 | """Returns encodings from given HTTP Headers.""" 27 | 28 | content_type = headers.get("content-type") 29 | 30 | return get_encoding_from_content_type(content_type) 31 | 32 | 33 | def file_path_to_path(*paths: FilePath) -> pathlib.Path: 34 | """Convert a list of paths into a pathlib.Path.""" 35 | 36 | safe_paths: typing.List[typing.Union[str, os.PathLike]] = [] 37 | for path in paths: 38 | if isinstance(path, bytes): 39 | safe_paths.append(path.decode()) 40 | else: 41 | safe_paths.append(path) 42 | 43 | return pathlib.Path(*safe_paths) 44 | 45 | 46 | def safe_join(directory: FilePath, *paths: FilePath) -> pathlib.Path: 47 | """Safely join the paths to the known directory to return a full path. 48 | 49 | Raises 50 | ------ 51 | ~baguette.httpexceptions.NotFound 52 | If the full path does not share a commonprefix with the directory. 53 | """ 54 | 55 | try: 56 | safe_path = file_path_to_path(directory).resolve(strict=True) 57 | full_path = file_path_to_path(directory, *paths).resolve(strict=True) 58 | except FileNotFoundError: 59 | raise NotFound() 60 | try: 61 | full_path.relative_to(safe_path) 62 | except ValueError: 63 | raise NotFound() 64 | return full_path 65 | 66 | 67 | def split_on_first(text: str, sep: str) -> typing.Tuple[str, str]: 68 | point = text.find(sep) 69 | if point == -1: 70 | return text, text[:0] # return bytes or str 71 | return text[:point], text[point + len(sep) :] # noqa: E203 72 | 73 | 74 | def import_from_string(string: str): 75 | module = __import__(string.split(".")[0]) 76 | for name in string.split(".")[1:]: 77 | module = getattr(module, name) 78 | return module 79 | 80 | 81 | def address_to_str(address: typing.Tuple[str, int]) -> str: 82 | """Converts a ``(host, port)`` tuple into a ``host:port`` string.""" 83 | 84 | return "{}:{}".format(*address) 85 | 86 | 87 | def to_bytes(str_or_bytes: StrOrBytes, encoding="utf-8") -> bytes: 88 | """Makes sure that the argument is a :class:`bytes`. 89 | 90 | Arguments 91 | --------- 92 | str_or_bytes : :class:`str` or :class:`bytes` 93 | The argument to convert to a :class:`bytes`. 94 | 95 | encoding : Optional :class:`str` 96 | The string encoding. 97 | Default: ``"utf-8"`` 98 | 99 | Returns 100 | ------- 101 | :class:`bytes` 102 | The converted argument. 103 | 104 | Raises 105 | ------ 106 | TypeError 107 | The argument isn't a :class:`str` or a :class:`bytes`. 108 | """ 109 | 110 | if not isinstance(str_or_bytes, (str, bytes)): 111 | raise TypeError( 112 | "str_or_bytes must be of type str or bytes. Got: " 113 | + str_or_bytes.__class__.__name__ 114 | ) 115 | return ( 116 | str_or_bytes.encode(encoding=encoding) 117 | if isinstance(str_or_bytes, str) 118 | else str_or_bytes 119 | ) 120 | 121 | 122 | def to_str(str_or_bytes: StrOrBytes, encoding="utf-8") -> str: 123 | """Makes sure that the argument is a :class:`str`. 124 | 125 | Arguments 126 | --------- 127 | str_or_bytes : :class:`str` or :class:`bytes` 128 | The argument to convert to a :class:`bytes`. 129 | 130 | encoding : Optional :class:`str` 131 | The bytes encoding. 132 | Default: ``"utf-8"`` 133 | 134 | Returns 135 | ------- 136 | :class:`str` 137 | The converted argument. 138 | 139 | Raises 140 | ------ 141 | TypeError 142 | The argument isn't a :class:`str` or a :class:`bytes`. 143 | """ 144 | 145 | if not isinstance(str_or_bytes, (str, bytes)): 146 | raise TypeError( 147 | "str_or_bytes must be of type str or bytes. Got: " 148 | + str_or_bytes.__class__.__name__ 149 | ) 150 | return ( 151 | str_or_bytes.decode(encoding=encoding) 152 | if isinstance(str_or_bytes, bytes) 153 | else str_or_bytes 154 | ) 155 | -------------------------------------------------------------------------------- /baguette/view.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | 4 | from .httpexceptions import MethodNotAllowed 5 | from .request import Request 6 | from .responses import Response 7 | from .types import Handler 8 | 9 | if typing.TYPE_CHECKING: 10 | from .app import Baguette 11 | 12 | 13 | class View: 14 | """Base view class. 15 | 16 | Arguments 17 | --------- 18 | app : :class:`Baguette` 19 | The app that the view was added to. 20 | 21 | Attributes 22 | ---------- 23 | app : :class:`Baguette` 24 | The app that the view was added to. 25 | 26 | methods : :class:`list` of :class:`str` 27 | The methods that this view can handle. 28 | """ 29 | 30 | METHODS = [ 31 | "GET", 32 | "HEAD", 33 | "POST", 34 | "PUT", 35 | "DELETE", 36 | "CONNECT", 37 | "OPTIONS", 38 | "TRACE", 39 | "PATCH", 40 | ] 41 | 42 | def __init__(self, app: "Baguette"): 43 | self.app: "Baguette" = app 44 | self.methods: typing.List[str] = [] 45 | for method in self.METHODS: 46 | if hasattr(self, method.lower()): 47 | self.methods.append(method) 48 | 49 | self._methods_kwargs = {} 50 | for method in self.methods: 51 | handler_signature = inspect.signature(getattr(self, method.lower())) 52 | self._methods_kwargs[method] = [ 53 | param.name 54 | for param in handler_signature.parameters.values() 55 | if param.kind 56 | in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) 57 | ] 58 | 59 | async def __call__(self, request: Request, **kwargs) -> Response: 60 | return await self.dispatch(request, **kwargs) 61 | 62 | async def dispatch(self, request: Request, **kwargs) -> Response: 63 | """Dispatch the request to the right method handler.""" 64 | 65 | if request.method not in self.methods: 66 | raise MethodNotAllowed() 67 | handler: Handler = getattr(self, request.method.lower()) 68 | 69 | kwargs["request"] = request 70 | kwargs = { 71 | k: v 72 | for k, v in kwargs.items() 73 | if k in self._methods_kwargs[request.method] 74 | } 75 | 76 | return await handler(**kwargs) 77 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | autoflake 2 | black 3 | flake8 4 | isort 5 | pre-commit 6 | pytest 7 | pytest-asyncio 8 | pytest-cov 9 | requests 10 | twine 11 | git+https://github.com/PyCQA/doc8 12 | -------------------------------------------------------------------------------- /docs/.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # Build documentation with MkDocs 12 | # mkdocs: 13 | # configuration: mkdocs.yml 14 | 15 | # Optionally build your docs in additional formats such as PDF 16 | # formats: 17 | # - pdf 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.7 22 | install: 23 | - requirements: docs/requirements.txt 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/banner.png -------------------------------------------------------------------------------- /docs/_static/images/banner_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/banner_black.png -------------------------------------------------------------------------------- /docs/_static/images/banner_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/banner_white.png -------------------------------------------------------------------------------- /docs/_static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/logo.png -------------------------------------------------------------------------------- /docs/_static/images/logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/logo_black.png -------------------------------------------------------------------------------- /docs/_static/images/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/images/logo_white.png -------------------------------------------------------------------------------- /docs/_static/small_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/small_logo.png -------------------------------------------------------------------------------- /docs/_static/small_logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/small_logo_black.png -------------------------------------------------------------------------------- /docs/_static/small_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/docs/_static/small_logo_white.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | .. currentmodule:: baguette 4 | 5 | API Reference 6 | ============= 7 | 8 | The following section documents every class and function in the baguette module. 9 | 10 | Version Related Info 11 | -------------------- 12 | 13 | There are two main ways to query version information about the library. 14 | 15 | .. data:: version_info 16 | 17 | A named tuple that is similar to :obj:`py:sys.version_info`. 18 | 19 | Just like :obj:`py:sys.version_info` the valid values for ``releaselevel`` 20 | are 'alpha', 'beta', 'candidate' and 'final'. 21 | 22 | .. data:: __version__ 23 | 24 | A string representation of the version. e.g. ``'1.0.0rc1'``. This is based 25 | off of :pep:`440`. 26 | 27 | Application 28 | ----------- 29 | 30 | .. autoclass:: Baguette() 31 | :exclude-members: handle_static_file 32 | 33 | Configuration 34 | ------------- 35 | 36 | .. autoclass:: Config() 37 | 38 | View 39 | ---- 40 | 41 | .. autoclass:: View() 42 | :exclude-members: METHODS 43 | 44 | Request 45 | ------- 46 | 47 | .. autoclass:: Request() 48 | 49 | Responses 50 | --------- 51 | 52 | Base Response 53 | ************* 54 | 55 | .. autoclass:: Response() 56 | :exclude-members: CHARSET 57 | 58 | 59 | Plain Text Response 60 | ******************* 61 | 62 | .. autoclass:: PlainTextResponse() 63 | :inherited-members: 64 | :exclude-members: CHARSET 65 | 66 | HTML Response 67 | ************* 68 | 69 | .. autoclass:: HTMLResponse() 70 | :inherited-members: 71 | :exclude-members: CHARSET 72 | 73 | JSON Response 74 | ************* 75 | 76 | .. autoclass:: JSONResponse() 77 | :inherited-members: 78 | :exclude-members: CHARSET, JSON_ENCODER 79 | 80 | Empty Response 81 | ************** 82 | 83 | .. autoclass:: EmptyResponse() 84 | :inherited-members: 85 | :exclude-members: CHARSET 86 | 87 | Redirect Response 88 | ***************** 89 | 90 | .. autoclass:: RedirectResponse() 91 | :inherited-members: 92 | :exclude-members: CHARSET 93 | 94 | There's an alias to create this class, it's the :func:`redirect` function. 95 | 96 | .. autofunction:: redirect() 97 | 98 | File Response 99 | ************** 100 | 101 | .. autoclass:: FileResponse() 102 | :inherited-members: 103 | :exclude-members: CHARSET, body, raw_body 104 | 105 | Make Response 106 | ************* 107 | 108 | .. autofunction:: make_response() 109 | 110 | .. autofunction:: make_error_response() 111 | 112 | Headers 113 | ------- 114 | 115 | Headers class 116 | ************* 117 | 118 | .. autoclass:: Headers() 119 | 120 | Make Headers 121 | ************ 122 | 123 | .. autofunction:: make_headers() 124 | 125 | Forms 126 | ----- 127 | 128 | Form parsers 129 | ************ 130 | 131 | .. autoclass:: baguette.forms.Form() 132 | 133 | .. autoclass:: baguette.forms.URLEncodedForm() 134 | 135 | .. autoclass:: baguette.forms.MultipartForm() 136 | 137 | Fields 138 | ****** 139 | 140 | .. autoclass:: baguette.forms.Field() 141 | 142 | .. autoclass:: baguette.forms.FileField() 143 | 144 | Rendering 145 | --------- 146 | 147 | Renderer 148 | ******** 149 | 150 | .. autoclass:: baguette.rendering.Renderer() 151 | 152 | Render 153 | ****** 154 | 155 | .. autofunction:: baguette.rendering.init() 156 | 157 | .. autofunction:: render() 158 | 159 | Routing 160 | ------- 161 | 162 | Router 163 | ****** 164 | 165 | .. autoclass:: baguette.router.Router() 166 | 167 | Route 168 | ***** 169 | 170 | .. autoclass:: baguette.router.Route() 171 | :exclude-members: PARAM_ARGS_REGEX, PARAM_CONVERTERS, PARAM_REGEX 172 | 173 | Path parameters converters 174 | ************************** 175 | 176 | .. autoclass:: baguette.converters.StringConverter() 177 | :exclude-members: REGEX 178 | :inherited-members: 179 | 180 | .. autoclass:: baguette.converters.PathConverter() 181 | :exclude-members: REGEX 182 | :inherited-members: 183 | 184 | .. autoclass:: baguette.converters.IntegerConverter() 185 | :exclude-members: REGEX 186 | :inherited-members: 187 | 188 | .. autoclass:: baguette.converters.FloatConverter() 189 | :exclude-members: REGEX 190 | :inherited-members: 191 | 192 | 193 | HTTP Exceptions 194 | --------------- 195 | 196 | .. automodule:: baguette.httpexceptions 197 | 198 | .. currentmodule:: baguette 199 | 200 | Middlewares 201 | ----------- 202 | 203 | Base middleware 204 | *************** 205 | 206 | .. autoclass:: Middleware() 207 | :special-members: __call__ 208 | 209 | Included middlewares 210 | ******************** 211 | 212 | .. automodule:: baguette.middlewares 213 | 214 | .. currentmodule:: baguette 215 | 216 | Testing 217 | ------- 218 | 219 | .. autoclass:: TestClient() 220 | :exclude-members: DEFAULT_SCOPE 221 | 222 | Utils 223 | ----- 224 | 225 | .. automodule:: baguette.utils 226 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import re 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("..")) 19 | sys.path.append(os.path.abspath("extensions")) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "baguette" 24 | copyright = "2021, takos22" 25 | author = "takos22" 26 | 27 | # The version info for the project you're documenting, acts as replacement for 28 | # |version| and |release|, also used in various other places throughout the 29 | # built documents. 30 | # 31 | # The short X.Y version. 32 | 33 | version = "" 34 | with open("../baguette/__init__.py") as f: 35 | version = re.search( 36 | r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", f.read(), re.MULTILINE 37 | ).group(1) 38 | 39 | # The full version, including alpha/beta/rc tags 40 | release = version 41 | 42 | branch = "master" if version.endswith("a") else "v" + version 43 | 44 | # -- General configuration --------------------------------------------------- 45 | 46 | # If your documentation needs a minimal Sphinx version, state it here. 47 | needs_sphinx = "3.0" 48 | 49 | # Add any Sphinx extension module names here, as strings. They can be 50 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 51 | # ones. 52 | extensions = [ 53 | "sphinx.ext.autodoc", 54 | "sphinx.ext.autosectionlabel", 55 | "sphinx.ext.coverage", 56 | "sphinx.ext.extlinks", 57 | "sphinx.ext.intersphinx", 58 | "sphinx.ext.napoleon", 59 | "sphinx.ext.todo", 60 | "sphinx_inline_tabs", 61 | "sphinx_copybutton", 62 | "resourcelinks", 63 | ] 64 | 65 | # Links used for cross-referencing stuff in other documentation 66 | intersphinx_mapping = { 67 | "py": ("https://docs.python.org/3", None), 68 | "req": ("https://requests.readthedocs.io/en/latest/", None) 69 | } 70 | 71 | # Add any paths that contain templates here, relative to this directory. 72 | templates_path = ["_templates"] 73 | 74 | # The master toctree document. 75 | master_doc = "index" 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | # This pattern also affects html_static_path and html_extra_path. 80 | exclude_patterns = ["_build"] 81 | 82 | 83 | # -- Options for HTML output ------------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_title = "Baguette" 89 | html_theme = "furo" 90 | html_theme_options = { 91 | "navigation_with_keys": True, 92 | "light_logo": "small_logo_black.png", 93 | "dark_logo": "small_logo.png", 94 | "dark_css_variables": { 95 | "color-inline-code-background": "#292d2d", 96 | }, 97 | } 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ["_static"] 103 | 104 | resource_links = { 105 | "discord": "https://discord.gg/PGC3eAznJ6", 106 | "issues": "https://github.com/takos22/baguette/issues", 107 | "examples": f"https://github.com/takos22/baguette/tree/{branch}/examples", 108 | "uvicorn": "https://www.uvicorn.org/", 109 | } 110 | 111 | # remove type hints in docs 112 | autodoc_typehints = "none" 113 | 114 | # display TODOs in docs 115 | todo_include_todos = True 116 | 117 | # avoid confusion between section references 118 | autosectionlabel_prefix_document = True 119 | 120 | # pygments styles 121 | pygments_style = "sphinx" 122 | pygments_dark_style = "monokai" 123 | 124 | # autodoc defaults 125 | autodoc_default_options = { 126 | "members": True, 127 | "undoc-members": True, 128 | } 129 | autodoc_member_order = "bysource" 130 | -------------------------------------------------------------------------------- /docs/extensions/resourcelinks.py: -------------------------------------------------------------------------------- 1 | # https://raw.githubusercontent.com/Rapptz/discord.py/master/docs/extensions/resourcelinks.py 2 | 3 | from typing import Any, Dict, List, Tuple 4 | 5 | import sphinx 6 | from docutils import nodes, utils 7 | from docutils.nodes import Node, system_message 8 | from docutils.parsers.rst.states import Inliner 9 | from sphinx.application import Sphinx 10 | from sphinx.util.nodes import split_explicit_title 11 | from sphinx.util.typing import RoleFunction 12 | 13 | 14 | def make_link_role(resource_links: Dict[str, str]) -> RoleFunction: 15 | def role( 16 | typ: str, 17 | rawtext: str, 18 | text: str, 19 | lineno: int, 20 | inliner: Inliner, 21 | options: Dict = {}, 22 | content: List[str] = [], 23 | ) -> Tuple[List[Node], List[system_message]]: 24 | 25 | text = utils.unescape(text) 26 | has_explicit_title, title, key = split_explicit_title(text) 27 | full_url = resource_links[key] 28 | if not has_explicit_title: 29 | title = full_url 30 | pnode = nodes.reference(title, title, internal=False, refuri=full_url) 31 | return [pnode], [] 32 | 33 | return role 34 | 35 | 36 | def add_link_role(app: Sphinx) -> None: 37 | app.add_role("resource", make_link_role(app.config.resource_links)) 38 | 39 | 40 | def setup(app: Sphinx) -> Dict[str, Any]: 41 | app.add_config_value("resource_links", {}, "env") 42 | app.connect("builder-inited", add_link_role) 43 | return {"version": sphinx.__display_version__, "parallel_read_safe": True} 44 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Baguette 2 | ======== 3 | 4 | .. image:: /_static/images/banner.png 5 | 6 | Baguette is an asynchronous web framework for ASGI servers. 7 | 8 | .. image:: https://img.shields.io/pypi/v/baguette?color=blue 9 | :target: https://pypi.python.org/pypi/baguette 10 | :alt: PyPI version info 11 | .. image:: https://img.shields.io/pypi/pyversions/baguette?color=orange 12 | :target: https://pypi.python.org/pypi/baguette 13 | :alt: PyPI supported Python versions 14 | .. image:: https://img.shields.io/pypi/dm/baguette 15 | :target: https://pypi.python.org/pypi/baguette 16 | :alt: PyPI downloads 17 | .. image:: https://readthedocs.org/projects/baguette/badge/?version=latest 18 | :target: https://baguette.readthedocs.io/en/latest/ 19 | :alt: Documentation Status 20 | .. image:: https://img.shields.io/github/license/takos22/baguette?color=brightgreen 21 | :target: https://github.com/takos22/baguette/blob/master/LICENSE 22 | :alt: License: MIT 23 | .. image:: https://img.shields.io/discord/831992562986123376.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2 24 | :target: https://discord.gg/PGC3eAznJ6 25 | :alt: Discord support server 26 | 27 | **Features:** 28 | 29 | - Easy to develop 30 | - High performance framework, especially when using the uvicorn server 31 | 32 | Getting started 33 | --------------- 34 | 35 | Is this your first time using the framweork? This is the place to get started! 36 | 37 | - **First steps:** :ref:`intro` | :ref:`quickstart` 38 | - **Examples:** Many examples are available in the 39 | :resource:`repository `. 40 | 41 | Getting help 42 | ------------ 43 | 44 | If you're having trouble with something, these resources might help. 45 | 46 | - Ask us and hang out with us in our :resource:`Discord ` server. 47 | - If you're looking for something specific, try the :ref:`index ` 48 | or :ref:`searching `. 49 | - Report bugs in the :resource:`issue tracker `. 50 | 51 | 52 | User Guide 53 | ---------- 54 | 55 | .. toctree:: 56 | :maxdepth: 1 57 | 58 | user_guide/intro 59 | user_guide/quickstart 60 | user_guide/view 61 | user_guide/routing 62 | user_guide/request 63 | user_guide/responses 64 | user_guide/middlewares 65 | user_guide/testing 66 | 67 | 68 | API Reference 69 | ------------- 70 | 71 | .. toctree:: 72 | :maxdepth: 3 73 | 74 | api 75 | 76 | 77 | Indices and tables 78 | ================== 79 | 80 | * :ref:`genindex` 81 | * :ref:`search` 82 | 83 | .. toctree:: 84 | :caption: References 85 | :hidden: 86 | 87 | GitHub Repository 88 | Examples 89 | Issue Tracker 90 | Discord support server 91 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # requirements for building the docs 2 | sphinx==3.1 3 | furo 4 | sphinx-inline-tabs 5 | sphinx-copybutton 6 | sphinxext-opengraph 7 | -------------------------------------------------------------------------------- /docs/user_guide/intro.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: baguette 2 | 3 | .. _intro: 4 | 5 | Introduction 6 | ============ 7 | 8 | This is the documentation for ``baguette``, a micro web 9 | framework for ASGI servers. 10 | 11 | Prerequisites 12 | ------------- 13 | 14 | **Python 3.6 or higher is required.** 15 | 16 | 17 | .. _installing: 18 | 19 | Installing 20 | ---------- 21 | 22 | Install ``baguette`` with pip: 23 | 24 | .. tab:: Linux or MacOS 25 | 26 | .. code:: sh 27 | 28 | python3 -m pip install -U baguette 29 | 30 | .. tab:: Windows 31 | 32 | .. code:: sh 33 | 34 | py -3 -m pip install -U baguette 35 | 36 | .. _installing_asgi_server: 37 | 38 | Installing ASGI server 39 | ~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | You also need an ASGI server to run your app like `uvicorn `_ 42 | or `hypercorn `_. To install `uvicorn `_ 43 | directly with baguette, you can add the ``uvicorn`` argument: 44 | 45 | .. tab:: Linux or MacOS 46 | 47 | .. code:: sh 48 | 49 | python3 -m pip install -U baguette[uvicorn] 50 | 51 | .. tab:: Windows 52 | 53 | .. code:: sh 54 | 55 | py -3 -m pip install -U baguette[uvicorn] 56 | 57 | You can also install it with pip: 58 | 59 | .. tab:: Linux or MacOS 60 | 61 | .. code:: sh 62 | 63 | python3 -m pip install -U uvicorn[standard] 64 | 65 | .. tab:: Windows 66 | 67 | .. code:: sh 68 | 69 | py -3 -m pip install -U uvicorn[standard] 70 | 71 | .. _venv: 72 | 73 | Virtual Environments 74 | ~~~~~~~~~~~~~~~~~~~~ 75 | 76 | Sometimes you want to keep libraries from polluting system installs or use a 77 | different version of libraries than the ones installed on the system. You might 78 | also not have permissions to install libraries system-wide. 79 | For this purpose, the standard library as of Python 3.3 comes with a concept 80 | called "Virtual Environment"s to help maintain these separate versions. 81 | 82 | A more in-depth tutorial is found on :doc:`py:tutorial/venv`. 83 | 84 | However, for the quick and dirty: 85 | 86 | 1. Go to your project's working directory: 87 | 88 | .. tab:: Linux or MacOS 89 | 90 | .. code:: sh 91 | 92 | cd your-website-dir 93 | 94 | .. tab:: Windows 95 | 96 | .. code:: sh 97 | 98 | cd your-website-dir 99 | 100 | 2. Create a virtual environment: 101 | 102 | .. tab:: Linux or MacOS 103 | 104 | .. code:: sh 105 | 106 | python3 -m venv venv 107 | 108 | .. tab:: Windows 109 | 110 | .. code:: sh 111 | 112 | py -3 -m venv venv 113 | 114 | 3. Activate the virtual environment: 115 | 116 | .. tab:: Linux or MacOS 117 | 118 | .. code:: sh 119 | 120 | source venv/bin/activate 121 | 122 | .. tab:: Windows 123 | 124 | .. code:: sh 125 | 126 | venv\Scripts\activate.bat 127 | 128 | 4. Use pip like usual: 129 | 130 | .. tab:: Linux or MacOS 131 | 132 | .. code:: sh 133 | 134 | pip install -U baguette 135 | 136 | .. tab:: Windows 137 | 138 | .. code:: sh 139 | 140 | pip install -U baguette 141 | 142 | Congratulations. You now have a virtual environment all set up. 143 | You can start to code, learn more in the :doc:`quickstart`. 144 | -------------------------------------------------------------------------------- /docs/user_guide/middlewares.rst: -------------------------------------------------------------------------------- 1 | .. _middlewares: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Middlewares 6 | =========== 7 | 8 | A middleware adds a behaviour to every request, they go between the app and 9 | the handler. You can use the included middlewares or create your own. 10 | 11 | Create your own middleware 12 | -------------------------- 13 | 14 | Class based middleware 15 | ********************** 16 | 17 | To make a middleware, you can subclass the :class:`Middleware` and define the 18 | :meth:`__call__ ` method. 19 | This method must be an asynchronous method that takes a :class:`Request` 20 | argument and returns a :class:`Response`. 21 | To call the next middleware, you can use :attr:`self.next ` 22 | with ``await self.next(request)``. 23 | 24 | For example, a middleware that would time the request: 25 | 26 | .. code-block:: python 27 | :linenos: 28 | 29 | import time 30 | from baguette import Middleware 31 | 32 | class TimingMiddleware(Middleware): 33 | async def __call__(self, request: Request): 34 | start_time = time.perf_counter() 35 | response = await self.next(request) 36 | print( 37 | "{0.method} {0.path} took {1} seconds to be handled".format( 38 | request, time.perf_counter() - start_time 39 | ) 40 | ) 41 | return response 42 | 43 | To add that middleware to the application you have 3 ways to do it: 44 | 45 | 1. With the :meth:`@app.middleware ` decorator: 46 | 47 | .. code-block:: python 48 | 49 | app = Baguette() 50 | 51 | @app.middleware() 52 | class TimingMiddleware: 53 | ... 54 | 55 | 2. With the ``middleware`` parameter in :class:`Baguette`: 56 | 57 | .. code-block:: python 58 | 59 | app = Baguette(middlewares=[TimingMiddleware]) 60 | 61 | 3. With the :meth:`app.add_middleware ` method: 62 | 63 | .. code-block:: python 64 | 65 | app = Baguette() 66 | app.add_middleware(TimingMiddleware) 67 | 68 | Function based middleware 69 | ************************* 70 | 71 | You can also write middlewares in functions for simpler middlewares. The 72 | function must have 2 parameters: the next middleware to call and the request. 73 | 74 | For example, the same timing middleware with a function would look like this: 75 | 76 | .. code-block:: python 77 | :linenos: 78 | 79 | import time 80 | 81 | @app.middleware() 82 | async def timing_middleware(next_middleware, request): 83 | start_time = time.perf_counter() 84 | response = await next_middleware(request) 85 | print( 86 | "{0.method} {0.path} took {1} seconds to be handled".format( 87 | request, time.perf_counter() - start_time 88 | ) 89 | ) 90 | return response 91 | 92 | .. _default_middlewares: 93 | 94 | Default middlewares 95 | ------------------- 96 | 97 | There are some middlewares that are in the middleware stack by default. 98 | The middleware stack will be like this: 99 | 100 | - :class:`~baguette.middlewares.ErrorMiddleware` 101 | - Your custom middlewares 102 | - :class:`~baguette.middlewares.DefaultHeadersMiddleware` 103 | - :meth:`app.dispatch(request) ` 104 | 105 | Editing requests and reponses 106 | ----------------------------- 107 | 108 | Middlewares usually edit the request and reponse. 109 | 110 | In the :class:`Request`, you can't edit the :meth:`Request.body` method and the 111 | other methods of the request body because they are methods and not attributes. 112 | However there are some methods to remedy to this issue: 113 | :meth:`Request.set_raw_body`, :meth:`Request.set_body`, :meth:`Request.set_json` 114 | and :meth:`Request.set_form`. 115 | 116 | For :class:`Response`, it is easier: you can just set the :attr:`Response.body` 117 | to the new response body, or for a :class:`JSONResponse` you can edit the 118 | :attr:`JSONResponse.json`. The only exception is for :class:`FileResponse`, if 119 | you want to change the file, you need to create a new :class:`FileResponse`. 120 | -------------------------------------------------------------------------------- /docs/user_guide/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Quickstart 6 | ========== 7 | 8 | This page gives a brief introduction to the baguette module. 9 | It assumes you have baguette installed, if you don't check the 10 | :ref:`installing` portion. 11 | 12 | Minimal website 13 | --------------- 14 | 15 | Let's see how a very simple app that returns a small HTML response looks. 16 | 17 | .. code-block:: python 18 | :linenos: 19 | :caption: ``hello-world.py`` 20 | 21 | from baguette import Baguette 22 | 23 | app = Baguette() 24 | 25 | @app.route("/") 26 | async def hello_world(): 27 | return "

Hello world

" 28 | 29 | Just save it as ``hello-world.py`` or something similar. Make sure to not call 30 | your file ``baguette.py`` because this would conflict with Baguette itself. 31 | 32 | Make sure to have an :ref:`ASGI server ` installed. 33 | Then simply run the server via: 34 | 35 | .. code:: sh 36 | 37 | uvicorn hello-world:app 38 | 39 | You can also add the following code to the end of your program: 40 | 41 | .. code-block:: python 42 | :linenos: 43 | :lineno-start: 9 44 | 45 | if __name__ == "__main__": 46 | app.run() 47 | 48 | This will run your app on http://127.0.0.1:8000 by default, you can change the 49 | host and the port with ``app.run(host="1.2.3.4", port=1234)`` if you want to 50 | run it on ``http://1.2.3.4:1234``. For more options, see 51 | :meth:`app.run `. 52 | -------------------------------------------------------------------------------- /docs/user_guide/request.rst: -------------------------------------------------------------------------------- 1 | .. _request: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Request 6 | ======= 7 | 8 | In order to manipulate the :class:`Request`, you will need to include a 9 | parameter named ``request`` in your handler: 10 | 11 | .. code-block:: python 12 | :linenos: 13 | 14 | @app.route("/") 15 | async def index(request): 16 | # do something with the request 17 | return ... 18 | 19 | Common attributes 20 | ----------------- 21 | 22 | The :class:`Request` object has many useful attributes, 23 | for example :attr:`Request.method` for the HTTP method used in the request, 24 | :attr:`Request.headers` for the HTTP headers included in the request, 25 | :attr:`Request.path` for the full path requested (without the domain name), 26 | :attr:`Request.querystring` for a :class:`dict` of the querystrings included 27 | in the URL, :attr:`Request.content_type` for the request Content-Type. 28 | 29 | .. note:: 30 | For a full list of attributes, see :class:`Request`. 31 | 32 | .. _body: 33 | 34 | Request body 35 | ------------ 36 | 37 | You can get the request body with :meth:`Request.body` which will return a 38 | :class:`str` of the full body, decoded with :attr:`Request.encoding`. If you 39 | want to work with a :class:`bytes` body instead of a :class:`str` body, you can 40 | use :meth:`Request.raw_body` which will return a :class:`bytes` instead of a 41 | :class:`str` of the full body. 42 | 43 | .. note:: 44 | :meth:`Request.body` and :meth:`Request.raw_body` are coroutines so you need 45 | to ``await`` them: ``await request.body()`` 46 | 47 | Here's an example on how to use these: 48 | 49 | .. code-block:: python 50 | :linenos: 51 | 52 | @app.route("/") 53 | async def index(request): 54 | info = ( 55 | "{0.method} {0.path} HTTP/{0.http_version} {0.content_type}" 56 | "Headers:\n{0.headers}\n\n{1}" 57 | ).format(request, await request.body()) 58 | return info 59 | 60 | This handler will be called for every path and will return information about 61 | the request: the method, the path, the HTTP version, the content type, the 62 | headers and the body. 63 | 64 | .. _json_body: 65 | 66 | JSON body 67 | --------- 68 | 69 | If you want to get the body decoded to JSON, you can use :meth:`Request.json`. 70 | It will raise a :exc:`~baguette.httpexceptions.BadRequest` if the request body 71 | can't be decoded as JSON, you can usually not handle this error as it will be 72 | handled by the app and converted to a response with a ``400`` status code. 73 | 74 | Here's an example for a user creation endpoint, it gets the username and email 75 | from the JSON body: 76 | 77 | .. code-block:: python 78 | :linenos: 79 | 80 | @app.route("/users", methods=["POST"]) 81 | async def user_create(request): 82 | user = await request.json() 83 | 84 | if not isinstance(user, dict): 85 | raise BadRequest(description="JSON body must be a dictionnary") 86 | 87 | try: 88 | username, email = user["username"], user["email"] 89 | except KeyError: 90 | raise BadRequest(description="JSON body must include username and email") 91 | 92 | # add the user to database 93 | ... 94 | 95 | return {"username": username, "email": email} 96 | 97 | .. _form_body: 98 | 99 | Form body 100 | --------- 101 | 102 | If you want to use forms, the easiest way to parse them from the request body is 103 | with :meth:`Request.form` which will give you a :class:`~baguette.forms.Form`. 104 | It will raise a :exc:`ValueError` if the :attr:`Request.content_type` isn't one 105 | of ``application/x-www-form-urlencoded`` or ``multipart/form-data``. You can 106 | also include the querystring arguments as form fields if some of your data is in 107 | the querystring. 108 | 109 | The easiest way to use forms is with :class:`View`: 110 | 111 | .. code-block:: python 112 | :linenos: 113 | :caption: From ``examples/forms.py`` 114 | 115 | FORM_HTML = """ 116 |
117 |
118 | 119 | 120 |
121 |
122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 | """ 130 | 131 | @app.route("/") 132 | class Form(View): 133 | async def get(self): 134 | return FORM_HTML 135 | 136 | async def post(self, request): 137 | form = await request.form() 138 | return '

Said "{}" to {}

'.format(form["say"], form["to"]) 139 | -------------------------------------------------------------------------------- /docs/user_guide/responses.rst: -------------------------------------------------------------------------------- 1 | .. _responses: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Responses 6 | ========= 7 | 8 | Normal responses 9 | ---------------- 10 | 11 | Handler functions can return many types that will be interpreted by 12 | :func:`make_response` and converted to a :class:`Response`. Here are some 13 | examples of accepted return values: 14 | 15 | .. code-block:: python 16 | 17 | return body 18 | return body, status_code 19 | return body, headers 20 | return body, status_code, headers 21 | return Response(body, status_code, headers) # not recommended 22 | 23 | In these examples, ``body`` can be anything described in the table below, 24 | ``status_code`` is an :class:`int` for the HTTP status code and ``headers`` is a 25 | :class:`dict` or a :class:`Headers`. 26 | 27 | .. note:: 28 | The :attr:`app.default_headers ` will be added to 29 | every response headers. 30 | 31 | .. note:: 32 | The default status code is ``200``, except for :class:`EmptyResponse` which 33 | defaults to ``204`` 34 | 35 | .. csv-table:: 36 | :header: "Body", "Response class" 37 | :widths: 10, 51 38 | 39 | ":class:`str` or :class:`bytes`", ":class:`HTMLResponse` if there are any 40 | HTML tags in the string, else :class:`PlainTextResponse`" 41 | ":class:`list` or :class:`dict`", ":class:`JSONResponse`" 42 | ":obj:`None`", ":class:`EmptyResponse`" 43 | "Anything else", ":class:`PlainTextResponse` with ``body = str(value)``" 44 | 45 | Error responses 46 | --------------- 47 | 48 | HTTP Exceptions 49 | *************** 50 | 51 | If there are :mod:`HTTP exceptions ` raised in your 52 | handler, a :class:`Response` will be made with :func:`make_error_response` and 53 | will be sent like a normal response. 54 | 55 | For example: 56 | 57 | .. code-block:: python 58 | :linenos: 59 | 60 | from baguette.httpexceptions import NotFound 61 | 62 | @app.route("/profile/{username}") 63 | async def profile(username): 64 | if username not in users: # lets assume users is a list of usernames 65 | raise NotFound() 66 | ... 67 | 68 | This will send a response with a ``Not Found`` body and a ``404`` status code. 69 | 70 | .. seealso:: 71 | For a full list of available HTTP exceptions, see 72 | :mod:`~baguette.httpexceptions`. 73 | 74 | Server errors 75 | ************* 76 | 77 | If you have a python error somewhere in your handler, Baguette will transform it 78 | in a :exc:`~baguette.httpexceptions.InternalServerError` for you. The error 79 | traceback will be included if :attr:`app.debug ` is ``True``. 80 | 81 | .. code-block:: python 82 | :linenos: 83 | 84 | app = Baguette(debug=True) 85 | 86 | @app.route("/error") 87 | async def eror(): 88 | raise Exception 89 | 90 | This will send a response with an ``Internal Server Error`` body along with the 91 | error traceback and a ``500`` status code. 92 | -------------------------------------------------------------------------------- /docs/user_guide/routing.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: baguette 2 | 3 | .. _routing: 4 | 5 | Routing 6 | ======= 7 | 8 | Each handler can handle one path, the path of a request is the last part of the 9 | URL starting from the first ``/``. 10 | In ``https://example.com/home``, the path is ``/home``. 11 | 12 | .. _dynamic_routing: 13 | 14 | Dynamic Routing 15 | --------------- 16 | 17 | Dynamic routing is useful to pass variables in URLs. 18 | For example, if you want to show a user's profile from their username, 19 | you won't hardcode every username, instead you will pass the username in the 20 | URL: ``/profile/{username}``. 21 | 22 | To do this with baguette, it's easy: 23 | 24 | .. code-block:: python 25 | :linenos: 26 | 27 | @app.route("/profile/") 28 | async def profile(username): 29 | return f"

{username}'s profile

" 30 | 31 | .. _converters: 32 | 33 | Converters 34 | ********** 35 | 36 | Use converters if you want the path parameters to be of a certain type and 37 | reject the request otherwise. For example, if you work with user IDs, you can 38 | make sure that the provided user ID is an integer: 39 | 40 | .. code-block:: python 41 | :linenos: 42 | 43 | @app.route("/profile/") 44 | async def profile(user_id): 45 | # let's assume we have a database from where we can query the user from their ID 46 | user = User.fetch(id=user_id) 47 | return f"

{user.name}'s profile

" 48 | 49 | You can also pass arguments to the converters to customize how they convert and 50 | what they can convert. 51 | For example if you only want strings that are 4 characters long: 52 | 53 | .. code-block:: python 54 | :linenos: 55 | 56 | @app.route("/text/") 57 | async def profile(text): 58 | return f"{text} is 4 characters long" 59 | 60 | .. seealso:: 61 | :ref:`api:Path parameters converters` 62 | -------------------------------------------------------------------------------- /docs/user_guide/testing.rst: -------------------------------------------------------------------------------- 1 | .. _testing: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Testing 6 | ======= 7 | 8 | .. todo:: 9 | Add information about testing an application with 10 | :class:`~baguette.testing.TestClient` objects 11 | -------------------------------------------------------------------------------- /docs/user_guide/view.rst: -------------------------------------------------------------------------------- 1 | .. _view: 2 | 3 | .. currentmodule:: baguette 4 | 5 | Views 6 | ===== 7 | 8 | Views are functions or classes that handle a request made to a route. 9 | 10 | .. note:: 11 | Views are also called handlers or endpoints 12 | 13 | 14 | .. _view_func: 15 | 16 | View functions 17 | -------------- 18 | 19 | View functions must be coroutines (functions defined with ``async def``) 20 | and the ``request`` parameter is optional. 21 | 22 | There are multiple ways to add them to your app, most notably 23 | the :meth:`@app.route ` decorator: 24 | 25 | .. code-block:: python 26 | :linenos: 27 | 28 | from baguette import Baguette 29 | 30 | app = Baguette() 31 | 32 | @app.route("/") 33 | async def hello_world(): 34 | return "

Hello world

" 35 | 36 | You can specify which methods your function can handle by 37 | adding the ``methods`` parameter: 38 | 39 | .. code-block:: python 40 | :linenos: 41 | 42 | from baguette import Baguette 43 | 44 | app = Baguette() 45 | 46 | @app.route("/", methods=["GET", "POST"]) 47 | async def index(request): 48 | if request.method == "GET": 49 | return "

Hello from GET

" 50 | elif request.method == "POST": 51 | return "

Hello from POST

" 52 | 53 | If other methods are requested, the application will respond with a 54 | Method Not Allowed response and a 405 status code. 55 | 56 | .. seealso:: 57 | For easier handling of multiple methods, see :ref:`view_class`. 58 | 59 | .. _view_class: 60 | 61 | View classes 62 | ------------ 63 | 64 | :class:`View` classes allow you to handle a request made to a route with 65 | a function for each method, this is useful when you have multiple methods 66 | for the same route and need to handle each method with a different logic. 67 | The most common use case is in an API: 68 | 69 | .. code-block:: python 70 | :linenos: 71 | :caption: From ``examples/api.py`` 72 | 73 | @app.route("/users/") 74 | class UserDetail(View): 75 | async def get(self, user_id: int): 76 | if user_id not in users: 77 | raise NotFound(description=f"No user with ID {user_id}") 78 | 79 | return users[user_id] 80 | 81 | async def delete(self, user_id: int): 82 | if user_id not in users: 83 | raise NotFound(description=f"No user with ID {user_id}") 84 | 85 | del users[user_id] 86 | return EmptyResponse() 87 | -------------------------------------------------------------------------------- /examples/api.py: -------------------------------------------------------------------------------- 1 | from baguette import Baguette, View 2 | from baguette.httpexceptions import BadRequest, NotFound 3 | from baguette.responses import EmptyResponse 4 | 5 | app = Baguette(debug=True, error_response_type="json") 6 | 7 | API_VERSION = "1.0" 8 | REQUIRED_FIELDS = {"name", "email"} 9 | 10 | users = {} # TODO: use DB 11 | # user: {"id": int, "name": str, "email": str} 12 | 13 | 14 | @app.route("/", methods=["GET"]) 15 | async def index(): 16 | return {"version": API_VERSION} 17 | 18 | 19 | @app.route("/users") 20 | class UserList(View): 21 | id = 1 22 | 23 | async def get(self): 24 | return list(users.values()) 25 | 26 | async def post(self, request): 27 | try: 28 | user = await request.json() 29 | except ValueError: 30 | raise BadRequest(description="Can't decode body as JSON") 31 | 32 | if not isinstance(user, dict): 33 | raise BadRequest(description="Dict required") 34 | 35 | if REQUIRED_FIELDS - set(user.keys()) != set(): 36 | raise BadRequest( 37 | description="Must include: " + ", ".join(self.REQUIRED_FIELDS) 38 | ) 39 | 40 | if set(user.keys()) - REQUIRED_FIELDS != set(): 41 | raise BadRequest( 42 | description="Must only include: " + ", ".join(REQUIRED_FIELDS) 43 | ) 44 | 45 | user["id"] = self.id 46 | users[user["id"]] = user 47 | self.id += 1 48 | 49 | return user 50 | 51 | 52 | @app.route("/users/") 53 | class UserDetail(View): 54 | async def get(self, user_id: int): 55 | if user_id not in users: 56 | raise NotFound(description=f"No user with ID {user_id}") 57 | 58 | return users[user_id] 59 | 60 | async def delete(self, user_id: int): 61 | if user_id not in users: 62 | raise NotFound(description=f"No user with ID {user_id}") 63 | 64 | del users[user_id] 65 | return EmptyResponse() 66 | 67 | 68 | if __name__ == "__main__": 69 | app.run() 70 | -------------------------------------------------------------------------------- /examples/forms.py: -------------------------------------------------------------------------------- 1 | from baguette import Baguette, View 2 | from baguette.httpexceptions import BadRequest 3 | 4 | app = Baguette(error_response_type="html") 5 | 6 | 7 | FORM_HTML = """ 8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 |
20 |
21 | """ 22 | 23 | FILE_FORM_HTML = """ 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 |
32 |
33 | """ 34 | 35 | 36 | @app.route("/") 37 | class Form(View): 38 | async def get(self): 39 | return FORM_HTML 40 | 41 | async def post(self, request): 42 | form = await request.form() 43 | return '

Said "{}" to {}

'.format(form["say"], form["to"]) 44 | 45 | 46 | @app.route("/file") 47 | class FileForm(View): 48 | async def get(self): 49 | return FILE_FORM_HTML 50 | 51 | async def post(self, request): 52 | form = await request.form() 53 | if not form["file"].is_file: 54 | raise BadRequest("Form value 'file' isn't a file") 55 | return "

Uploaded {}

\nIt's content is:
{}
".format( 56 | form["file"].filename, form["file"].text 57 | ) 58 | 59 | 60 | if __name__ == "__main__": 61 | app.run() 62 | -------------------------------------------------------------------------------- /examples/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from baguette import Baguette, Middleware 5 | 6 | app = Baguette() 7 | logger = logging.getLogger("uvicorn.time") 8 | 9 | 10 | @app.middleware(index=0) 11 | class TimingMiddleware(Middleware): 12 | async def __call__(self, request): 13 | start_time = time.perf_counter() 14 | response = await self.next(request) 15 | process_time = time.perf_counter() - start_time 16 | logger.info( 17 | "{0.method} {0.path}: {1}ms".format( 18 | request, round(process_time * 1000, 2) 19 | ) 20 | ) 21 | return response 22 | 23 | 24 | @app.route("/") 25 | async def index(): 26 | return "Hello, World!" 27 | 28 | 29 | if __name__ == "__main__": 30 | app.run() 31 | -------------------------------------------------------------------------------- /examples/minimal.py: -------------------------------------------------------------------------------- 1 | from baguette import Baguette, View 2 | 3 | app = Baguette(error_response_type="html") 4 | 5 | 6 | @app.route("/") 7 | async def index(request): 8 | return '

Hello world

\nHome' 9 | 10 | 11 | @app.route("/home") 12 | class Home(View): 13 | home_text = "

Home

" 14 | 15 | async def get(self): 16 | return self.home_text 17 | 18 | async def post(self, request): 19 | self.home_text = await request.body() 20 | return self.home_text 21 | 22 | 23 | if __name__ == "__main__": 24 | app.run() 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles 2 | cachetools 3 | jinja2 4 | ujson 5 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="baguette tests examples" 5 | 6 | set -x 7 | 8 | # put every import on one line for autoflake remove unused imports 9 | isort $folders --force-single-line-imports 10 | # remove unused imports and variables 11 | autoflake $folders --remove-all-unused-imports --recursive --remove-unused-variables --in-place --exclude=__init__.py 12 | 13 | # format code 14 | black $folders --line-length 80 15 | 16 | # resort imports 17 | isort $folders 18 | -------------------------------------------------------------------------------- /scripts/lint-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="baguette tests examples" 5 | 6 | set -x 7 | 8 | # stop the build if there are Python syntax errors or undefined names 9 | flake8 $folders --count --select=E9,F63,F7,F82 --show-source --statistics 10 | # exit-zero treats all errors as warnings 11 | flake8 $folders --count --exit-zero --statistics 12 | 13 | # check formatting with black 14 | black $folders --check --line-length 80 15 | 16 | # check import ordering with isort 17 | isort $folders --check-only 18 | -------------------------------------------------------------------------------- /scripts/lint-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="docs" 5 | 6 | set -x 7 | 8 | # check the docs with doc8 9 | doc8 $folders --quiet 10 | 11 | # check package build for README.rst 12 | python3 setup.py --quiet sdist 13 | twine check dist/* 14 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # lint code 5 | ./scripts/lint-code.sh 6 | 7 | # lint docs and README 8 | ./scripts/lint-docs.sh 9 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | lint=true 5 | 6 | # no linitng if -n or --no-lint flag 7 | for arg in "$@" 8 | do 9 | if [ "$arg" == "-n" ] || [ "$arg" == "--no-lint" ]; then 10 | lint=false 11 | fi 12 | done 13 | 14 | if [ "$lint" = true ]; then 15 | # lint 16 | ./scripts/lint.sh 17 | fi 18 | 19 | set -x 20 | 21 | # run tests 22 | pytest 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E402 3 | max-complexity = 10 4 | max-line-length = 80 5 | per-file-ignores = 6 | # imported but unused 7 | __init__.py: F401 8 | # long docstring at start 9 | baguette/httpexceptions.py: E501 10 | 11 | [isort] 12 | multi_line_output = 3 13 | include_trailing_comma = True 14 | force_grid_wrap = 0 15 | use_parentheses = True 16 | ensure_newline_before_comments = True 17 | line_length = 80 18 | 19 | [tool:pytest] 20 | testpaths = tests 21 | python_classes = !TestClient 22 | addopts = --cov 23 | 24 | [coverage:run] 25 | source = baguette 26 | 27 | [coverage:report] 28 | show_missing = True 29 | exclude_lines = 30 | pragma: no cover 31 | def __repr__ 32 | def __str__ 33 | raise AssertionError 34 | raise NotImplementedError 35 | if 0: 36 | if __name__ == .__main__.: 37 | if typing.TYPE_CHECKING: 38 | 39 | [doc8] 40 | max-line-length = 80 41 | ignore = D002,D004 42 | ignore-path = docs/_build 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from setuptools import setup 5 | 6 | 7 | def get_version(package): 8 | """Return package version as listed in `__version__` in `init.py`.""" 9 | 10 | path = os.path.join(package, "__init__.py") 11 | version = "" 12 | with open(path, "r", encoding="utf8") as init_py: 13 | version = re.search( 14 | r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", 15 | init_py.read(), 16 | re.MULTILINE, 17 | ).group(1) 18 | 19 | if not version: 20 | raise RuntimeError(f"__version__ is not set in {path}") 21 | 22 | return version 23 | 24 | 25 | def get_packages(package): 26 | """Return root package and all sub-packages.""" 27 | 28 | return [ 29 | dirpath 30 | for dirpath, *_ in os.walk(package) 31 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 32 | ] 33 | 34 | 35 | def get_long_description(filename: str = "README.rst"): 36 | """Return the README.""" 37 | 38 | with open(filename, "r", encoding="utf8") as readme: 39 | long_description = readme.read() 40 | return long_description 41 | 42 | 43 | def get_requirements(filename: str = "requirements.txt"): 44 | """Return the requirements.""" 45 | 46 | requirements = [] 47 | with open(filename, "r", encoding="utf8") as requirements_txt: 48 | requirements = requirements_txt.read().splitlines() 49 | return requirements 50 | 51 | 52 | extra_requires = { 53 | "uvicorn": ["uvicorn[standard]"], 54 | } 55 | 56 | setup( 57 | name="baguette", 58 | version=get_version("baguette"), 59 | url="https://github.com/takos22/baguette", 60 | license="MIT", 61 | description="Asynchronous web framework.", 62 | long_description=get_long_description(), 63 | long_description_content_type="text/x-rst", 64 | author="takos22", 65 | author_email="takos2210@gmail.com", 66 | packages=get_packages("baguette"), 67 | python_requires=">=3.6", 68 | install_requires=get_requirements(), 69 | extras_require=extra_requires, 70 | project_urls={ 71 | "Documentation": "https://baguette.readthedocs.io/", 72 | "Issue tracker": "https://github.com/takos22/baguette/issues", 73 | }, 74 | classifiers=[ 75 | "Development Status :: 3 - Alpha", 76 | "Environment :: Web Environment", 77 | "Intended Audience :: Developers", 78 | "License :: OSI Approved :: MIT License", 79 | "Operating System :: OS Independent", 80 | "Topic :: Internet :: WWW/HTTP", 81 | "Programming Language :: Python", 82 | "Programming Language :: Python :: 3", 83 | "Programming Language :: Python :: 3 :: Only", 84 | "Programming Language :: Python :: 3.6", 85 | "Programming Language :: Python :: 3.7", 86 | "Programming Language :: Python :: 3.8", 87 | "Programming Language :: Python :: 3.9", 88 | ], 89 | ) 90 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import configs 2 | -------------------------------------------------------------------------------- /tests/configs/__init__.py: -------------------------------------------------------------------------------- 1 | from . import config, config_module 2 | -------------------------------------------------------------------------------- /tests/configs/bad_config.json: -------------------------------------------------------------------------------- 1 | ["this", "is", "a", "bad", "config"] 2 | -------------------------------------------------------------------------------- /tests/configs/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": true, 3 | "default_headers": {"server": "baguette"}, 4 | "static_directory": "tests/static", 5 | "templates_directory": "tests/templates", 6 | "error_response_type": "html" 7 | } 8 | -------------------------------------------------------------------------------- /tests/configs/config.py: -------------------------------------------------------------------------------- 1 | class Configuration: 2 | DEBUG = True 3 | 4 | static_directory = "tests/static" 5 | templates_directory = "tests/templates" 6 | 7 | error_response_type = "html" 8 | -------------------------------------------------------------------------------- /tests/configs/config_module.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | static_directory = "tests/static" 4 | templates_directory = "tests/templates" 5 | 6 | error_response_type = "html" 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.app import Baguette 4 | from baguette.headers import make_headers 5 | from baguette.request import Request 6 | 7 | 8 | class Send: 9 | def __init__(self): 10 | self.values = [] 11 | 12 | async def __call__(self, message): 13 | self.values.append(message) 14 | 15 | 16 | class Receive: 17 | def __init__(self, values: list = None): 18 | self.values = values or [] 19 | 20 | async def __call__(self): 21 | return self.values.pop(0) 22 | 23 | 24 | def create_http_scope( 25 | path: str = "/", 26 | method: str = "GET", 27 | headers={"server": "baguette", "content-type": "text/plain; charset=utf-8"}, 28 | querystring: str = "a=b", 29 | ): 30 | return { 31 | "type": "http", 32 | "asgi": {"version": "3.0", "spec_version": "2.1"}, 33 | "http_version": "1.1", 34 | "server": ("127.0.0.1", 8000), 35 | "client": ("127.0.0.1", 9000), 36 | "scheme": "http", 37 | "root_path": "", 38 | "method": method.upper(), 39 | "path": path, 40 | "headers": make_headers(headers).raw(), 41 | "query_string": querystring.encode("ascii"), 42 | } 43 | 44 | 45 | @pytest.fixture(name="http_scope") 46 | def create_http_scope_fixture(): 47 | return create_http_scope() 48 | 49 | 50 | def create_test_request( 51 | path: str = "/", 52 | method: str = "GET", 53 | headers={"server": "baguette", "content-type": "text/plain; charset=utf-8"}, 54 | querystring: str = "a=b", 55 | body: str = "", 56 | json=None, 57 | ): 58 | request = Request( 59 | Baguette(), 60 | create_http_scope( 61 | path=path, 62 | method=method, 63 | headers=headers, 64 | querystring=querystring, 65 | ), 66 | Receive(), 67 | ) 68 | request._body = body 69 | if json is not None: 70 | request._json = json 71 | 72 | return request 73 | 74 | 75 | @pytest.fixture(name="test_request") 76 | def create_test_request_fixture(): 77 | return create_test_request() 78 | 79 | 80 | # modified verison of https://stackoverflow.com/a/9759329/12815996 81 | def concreter(abcls): 82 | """Create a concrete class for testing from an ABC. 83 | >>> import abc 84 | >>> class Abstract(abc.ABC): 85 | ... @abc.abstractmethod 86 | ... def bar(self): 87 | ... ... 88 | 89 | >>> c = concreter(Abstract) 90 | >>> c.__name__ 91 | 'dummy_concrete_Abstract' 92 | >>> c().bar() 93 | """ 94 | 95 | if not hasattr(abcls, "__abstractmethods__"): 96 | return abcls 97 | 98 | new_dict = abcls.__dict__.copy() 99 | del new_dict["__abstractmethods__"] 100 | 101 | for abstractmethod in abcls.__abstractmethods__: 102 | method = abcls.__dict__[abstractmethod] 103 | method.__isabstractmethod__ = False 104 | new_dict[abstractmethod] = method 105 | 106 | # creates a new class, with the overriden ABCs: 107 | return type("dummy_concrete_" + abcls.__name__, (abcls,), new_dict) 108 | 109 | 110 | def strip(text: str) -> str: 111 | return text.replace(" ", "").replace("\n", "") 112 | -------------------------------------------------------------------------------- /tests/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/tests/middlewares/__init__.py -------------------------------------------------------------------------------- /tests/middlewares/test_default_headers_middleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.app import Baguette 4 | from baguette.responses import Response 5 | 6 | from ..conftest import create_test_request 7 | 8 | 9 | @pytest.mark.asyncio 10 | @pytest.mark.parametrize( 11 | [ 12 | "headers", 13 | "expected_headers", 14 | ], 15 | [ 16 | [{}, {"server": "baguette"}], 17 | [ 18 | {"content-type": "text/plain"}, 19 | {"content-type": "text/plain", "server": "baguette"}, 20 | ], 21 | ], 22 | ) 23 | async def test_default_headers_middleware(headers, expected_headers): 24 | app = Baguette(default_headers={"server": "baguette"}) 25 | 26 | @app.route("/") 27 | async def index(request): 28 | return Response(body="", headers=request.headers) 29 | 30 | request = create_test_request(headers=headers) 31 | response = await app.handle_request(request) 32 | assert response.headers == expected_headers 33 | -------------------------------------------------------------------------------- /tests/middlewares/test_error_middleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.app import Baguette 4 | from baguette.httpexceptions import NotImplemented 5 | 6 | from ..conftest import create_test_request 7 | 8 | 9 | @pytest.mark.asyncio 10 | @pytest.mark.parametrize( 11 | [ 12 | "path", 13 | "method", 14 | "body", 15 | "expected_response_body", 16 | "expected_response_status_code", 17 | ], 18 | [ 19 | ["/", "POST", "", "Method Not Allowed", 405], 20 | ["/user/-1", "GET", "", "Bad Request", 400], 21 | ["/user/text", "GET", "", "Not Found", 404], 22 | ["/notimplemented", "GET", "", "Not Implemented", 501], 23 | ["/error", "GET", "", "Internal Server Error", 500], 24 | ["/nonexistent", "GET", "", "Not Found", 404], 25 | ], 26 | ) 27 | async def test_error_middleware( 28 | path: str, 29 | method: str, 30 | body: str, 31 | expected_response_body: str, 32 | expected_response_status_code: int, 33 | ): 34 | app = Baguette(error_include_description=False) 35 | 36 | @app.route("/") 37 | async def index(request): 38 | return await request.body() 39 | 40 | @app.route("/user/") 41 | async def user(user_id: int): 42 | return str(user_id) 43 | 44 | @app.route("/notimplemented") 45 | async def notimplemented(): 46 | raise NotImplemented() # noqa: F901 47 | 48 | @app.route("/error") 49 | async def error(): 50 | raise Exception() 51 | 52 | request = create_test_request(path=path, method=method, body=body) 53 | response = await app.handle_request(request) 54 | assert response.body == expected_response_body 55 | assert response.status_code == expected_response_status_code 56 | -------------------------------------------------------------------------------- /tests/static/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/baguette/36c6cafa793ff4be057ca2f8a5c7129baf8a5ab8/tests/static/banner.png -------------------------------------------------------------------------------- /tests/static/css/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /tests/static/js/script.js: -------------------------------------------------------------------------------- 1 | console.log('10'+1) 2 | console.log('10'-1) 3 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | 6 | {% block title %}{% endblock %} - My Webpage 7 | {% endblock %} 8 | 9 | 10 |
{% block content %}{% endblock %}
11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Index{% endblock %} 3 | {% block head %} 4 | {{ super() }} 5 | 10 | {% endblock %} 11 | {% block content %} 12 |

Index

13 |

Welcome to my awesome homepage.

14 | {% for item in paragraphs %} 15 |

{{ item }}

16 | {% endfor %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.config import Config 4 | 5 | 6 | def test_config_create(): 7 | config = Config( 8 | debug=True, 9 | static_directory="tests/static", 10 | templates_directory="tests/templates", 11 | error_response_type="html", 12 | error_include_description=False, 13 | ) 14 | assert config.debug 15 | assert config.static_directory == "tests/static" 16 | assert config.templates_directory == "tests/templates" 17 | assert config.error_response_type == "html" 18 | assert config.error_include_description 19 | 20 | 21 | def test_config_create_error(): 22 | with pytest.raises(ValueError): 23 | Config(error_response_type="bad type") 24 | 25 | 26 | def test_config_from_json(): 27 | config = Config.from_json("tests/configs/config.json") 28 | assert config.debug 29 | assert config.static_directory == "tests/static" 30 | assert config.templates_directory == "tests/templates" 31 | assert config.error_response_type == "html" 32 | assert config.error_include_description 33 | 34 | 35 | def test_config_from_json_error(): 36 | with pytest.raises(ValueError): 37 | Config.from_json("tests/configs/bad_config.json") 38 | 39 | 40 | def test_config_from_class(): 41 | from tests.configs.config import Configuration 42 | 43 | config = Config.from_class(Configuration) 44 | assert config.debug 45 | assert config.static_directory == "tests/static" 46 | assert config.templates_directory == "tests/templates" 47 | assert config.error_response_type == "html" 48 | assert config.error_include_description 49 | 50 | 51 | def test_config_from_class_string(): 52 | config = Config.from_class("tests.configs.config.Configuration") 53 | assert config.debug 54 | assert config.static_directory == "tests/static" 55 | assert config.templates_directory == "tests/templates" 56 | assert config.error_response_type == "html" 57 | assert config.error_include_description 58 | 59 | 60 | def test_config_from_module(): 61 | from tests.configs import config_module 62 | 63 | config = Config.from_class(config_module) 64 | assert config.debug 65 | assert config.static_directory == "tests/static" 66 | assert config.templates_directory == "tests/templates" 67 | assert config.error_response_type == "html" 68 | assert config.error_include_description 69 | 70 | 71 | def test_config_from_module_string(): 72 | config = Config.from_class("tests.configs.config_module") 73 | assert config.debug 74 | assert config.static_directory == "tests/static" 75 | assert config.templates_directory == "tests/templates" 76 | assert config.error_response_type == "html" 77 | assert config.error_include_description 78 | -------------------------------------------------------------------------------- /tests/test_converters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.converters import ( 4 | Converter, 5 | FloatConverter, 6 | IntegerConverter, 7 | PathConverter, 8 | StringConverter, 9 | ) 10 | 11 | from .conftest import concreter 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ["converter", "string", "expected"], 16 | [ 17 | # string converters 18 | [StringConverter(), "test", "test"], 19 | [StringConverter(length=4), "test", "test"], 20 | [StringConverter(allow_slash=True), "test/test", "test/test"], 21 | # path converters 22 | [PathConverter(), "test", "test"], 23 | [PathConverter(), "test/test", "test/test"], 24 | [PathConverter(allow_empty=True), "test", "test"], 25 | [PathConverter(allow_empty=True), "test/test", "test/test"], 26 | [PathConverter(allow_empty=True), "", ""], 27 | # integer converters 28 | [IntegerConverter(), "1", 1], 29 | [IntegerConverter(signed=True), "+1", 1], 30 | [IntegerConverter(signed=True), "-1", -1], 31 | [IntegerConverter(min=0), "1", 1], 32 | [IntegerConverter(min=1), "1", 1], 33 | [IntegerConverter(max=1), "1", 1], 34 | [IntegerConverter(max=2), "1", 1], 35 | # float converters 36 | [FloatConverter(), "1", 1.0], 37 | [FloatConverter(), "1.", 1.0], 38 | [FloatConverter(), "1.0", 1.0], 39 | [FloatConverter(), ".1", 0.1], 40 | [FloatConverter(), "0.1", 0.1], 41 | [FloatConverter(signed=True), "+1.0", 1.0], 42 | [FloatConverter(signed=True), "-1.0", -1.0], 43 | [FloatConverter(min=0), "1.0", 1.0], 44 | [FloatConverter(min=0.0), "1.0", 1.0], 45 | [FloatConverter(min=1), "1.0", 1.0], 46 | [FloatConverter(min=1.0), "1.0", 1.0], 47 | [FloatConverter(max=1), "1.0", 1.0], 48 | [FloatConverter(max=1.0), "1.0", 1.0], 49 | [FloatConverter(max=2), "1.0", 1.0], 50 | [FloatConverter(max=2.0), "1.0", 1.0], 51 | [FloatConverter(allow_infinity=True), "inf", float("inf")], 52 | [ 53 | FloatConverter(signed=True, allow_infinity=True), 54 | "+inf", 55 | float("inf"), 56 | ], 57 | [ 58 | FloatConverter(signed=True, allow_infinity=True), 59 | "-inf", 60 | float("-inf"), 61 | ], 62 | [FloatConverter(allow_nan=True), "nan", float("nan")], 63 | ], 64 | ) 65 | def test_converter(converter: Converter, string: str, expected): 66 | assert converter.convert(string) == pytest.approx(expected, nan_ok=True) 67 | 68 | 69 | @pytest.mark.parametrize( 70 | ["converter", "string"], 71 | [ 72 | # string converters 73 | [StringConverter(), "test/test"], 74 | [StringConverter(length=1), "test"], 75 | # path converters 76 | [PathConverter(), ""], 77 | # integer converters 78 | [IntegerConverter(), "text"], 79 | [IntegerConverter(), "+1"], 80 | [IntegerConverter(), "-1"], 81 | [IntegerConverter(min=1), "0"], 82 | [IntegerConverter(max=1), "2"], 83 | # float converters 84 | [FloatConverter(), "text"], 85 | [FloatConverter(), "+1.0"], 86 | [FloatConverter(), "-1.0"], 87 | [FloatConverter(), "inf"], 88 | [FloatConverter(), "nan"], 89 | [FloatConverter(min=1.0), "0.0"], 90 | [FloatConverter(max=1.0), "2.0"], 91 | ], 92 | ) 93 | def test_converter_error(converter, string): 94 | with pytest.raises(ValueError): 95 | converter.convert(string) 96 | 97 | 98 | def test_abstract_converter(): 99 | converter = concreter(Converter)() 100 | # run the code for coverage 101 | converter.REGEX 102 | converter.convert("test") 103 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.forms import Field, FileField, Form, MultipartForm, URLEncodedForm 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["values", "expected_value"], 8 | [ 9 | [[], None], 10 | [["test"], "test"], 11 | [[b"test"], "test"], 12 | [["test", "test2"], "test"], 13 | [[b"test", "test2"], "test"], 14 | [["test", b"test2"], "test"], 15 | [[b"test", b"test2"], "test"], 16 | ], 17 | ) 18 | def test_field(values, expected_value): 19 | field = Field("test", values) 20 | assert field.value == expected_value 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ["content", "expected_text"], 25 | [ 26 | ["", ""], 27 | [b"", ""], 28 | ["test", "test"], 29 | [b"test", "test"], 30 | [chr(129), chr(129)], 31 | # ASCII has 128 values so 129 will be an ASCII decode error 32 | # so FileField.text will return bytes 33 | [chr(129).encode("utf-8"), chr(129).encode("utf-8")], 34 | ], 35 | ) 36 | def test_file_field(content, expected_text): 37 | field = FileField("test", content, encoding="ascii") 38 | assert field.text == expected_text 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ["filename", "content_type", "expected_content_type"], 43 | [ 44 | ["", None, "application/octet-stream"], 45 | ["", "", "application/octet-stream"], 46 | ["text.txt", "", "text/plain"], 47 | ["text.txt", "text/html", "text/html"], 48 | ["index.html", "", "text/html"], 49 | ["", "text/plain", "text/plain"], 50 | ], 51 | ) 52 | def test_file_field_content_type(filename, content_type, expected_content_type): 53 | field = FileField( 54 | "test", b"test", filename=filename, content_type=content_type 55 | ) 56 | assert field.content_type == expected_content_type 57 | 58 | 59 | def test_form(): 60 | field = Field("test", ["test"]) 61 | form = Form({"test": field}) 62 | assert form["test"] == field 63 | assert len(form) == 1 64 | for name in form: 65 | assert name == "test" 66 | assert form[name].values == ["test"] 67 | 68 | assert form == Form({"test": Field("test", ["test"])}) 69 | 70 | 71 | def test_urlencoded_form_parse(): 72 | form = URLEncodedForm.parse(b"a=b&b=test%20test&a=c") 73 | assert len(form) == 2 74 | assert "a" in form 75 | assert "b" in form 76 | assert form["a"].values == ["b", "c"] 77 | assert form["b"].values == ["test test"] 78 | 79 | 80 | multipart_body = ( 81 | b"--abcd1234\r\n" 82 | b'Content-Disposition: form-data; name="a"\r\n\r\n' 83 | b"b\r\n" 84 | b"--abcd1234\r\n" 85 | b'Content-Disposition: form-data; name="b"\r\n\r\n' 86 | b"test test\r\n" 87 | b"--abcd1234\r\n" 88 | b'Content-Disposition: form-data; name="a"\r\n\r\n' 89 | b"c\r\n" 90 | b"--abcd1234--\r\n" 91 | ) 92 | 93 | 94 | def test_multipart_form_parse(): 95 | form = MultipartForm.parse(multipart_body, boundary=b"abcd1234") 96 | assert len(form) == 2 97 | assert "a" in form 98 | assert "b" in form 99 | assert form["a"].values == ["b", "c"] 100 | assert form["b"].values == ["test test"] 101 | 102 | 103 | file_multipart_body = ( 104 | b"--abcd1234\r\n" 105 | b'Content-Disposition: form-data; name="file"; filename="script.js"\r\n' 106 | b"Content-Type: application/javascript\r\n\r\n" 107 | b'console.log("Hello, World!")\r\n' 108 | b"--abcd1234--\r\n" 109 | ) 110 | 111 | 112 | def test_multipart_form_parse_file(): 113 | form = MultipartForm.parse(file_multipart_body, boundary=b"abcd1234") 114 | assert len(form) == 1 115 | assert "file" in form 116 | assert form["file"].filename == "script.js" 117 | assert form["file"].content == b'console.log("Hello, World!")' 118 | assert form["file"].text == 'console.log("Hello, World!")' 119 | assert form["file"].content_type == "application/javascript" 120 | -------------------------------------------------------------------------------- /tests/test_headers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.headers import Headers, make_headers 4 | 5 | 6 | def test_create_headers(): 7 | Headers() 8 | Headers(("Content-Type", "text/html"), (b"Server", b"baguette")) 9 | Headers(*[("Content-Type", "text/html"), (b"Server", b"baguette")]) 10 | Headers(server="baguette") 11 | Headers(**{"Content-Type": "text/html", "Server": b"baguette"}) 12 | 13 | 14 | @pytest.fixture(name="headers") 15 | def create_headers(): 16 | return Headers(**{"Content-Type": "text/html", "Server": "baguette"}) 17 | 18 | 19 | def test_headers_get(headers: Headers): 20 | assert ( 21 | headers.get("server") 22 | == headers.get("SERVER") 23 | == headers.get(b"server") 24 | == "baguette" 25 | ) 26 | assert headers.get("nonexistent") is None 27 | assert headers.get("nonexistent", default="baguette") == "baguette" 28 | 29 | 30 | def test_headers_raw(headers: Headers): 31 | assert isinstance(headers.raw(), list) 32 | for header in headers.raw(): 33 | assert isinstance(header, list) 34 | assert len(header) == 2 35 | name, value = header 36 | assert isinstance(name, bytes) 37 | assert isinstance(value, bytes) 38 | 39 | 40 | def test_headers_str(headers: Headers): 41 | assert len(str(headers).splitlines()) == len(headers) 42 | for line in str(headers).splitlines(): 43 | assert len(line.split(": ")) == 2 44 | name, value = line.split(": ") 45 | assert headers[name] == value 46 | 47 | 48 | def test_headers_getitem(headers: Headers): 49 | assert ( 50 | headers["server"] 51 | == headers["SERVER"] 52 | == headers[b"server"] 53 | == "baguette" 54 | ) 55 | 56 | with pytest.raises(KeyError): 57 | headers["nonexistent"] 58 | 59 | 60 | @pytest.mark.parametrize("name", ["connection", "CONNECTION", b"connection"]) 61 | def test_headers_setitem(headers: Headers, name: str): 62 | headers[name] = ( 63 | "Keep-Alive".encode("ascii") 64 | if isinstance(name, bytes) 65 | else "Keep-Alive" 66 | ) 67 | assert headers["connection"] == "Keep-Alive" 68 | 69 | 70 | @pytest.mark.parametrize("name", ["server", "SERVER", b"server"]) 71 | def test_headers_delitem(headers: Headers, name: str): 72 | del headers[name] 73 | assert "server" not in headers 74 | 75 | 76 | @pytest.mark.parametrize("name", ["server", "SERVER", b"server"]) 77 | def test_headers_contains(headers: Headers, name: str): 78 | assert name in headers 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "other", 83 | [ 84 | {"server": "uvicorn"}, 85 | {"connection": "Keep-Alive"}, 86 | {"CONNECTION": "Keep-Alive"}, 87 | {b"connection": b"Keep-Alive"}, 88 | Headers(connection="Keep-Alive"), 89 | ], 90 | ) 91 | def test_headers_add(headers: Headers, other): 92 | new_headers = headers + other 93 | assert isinstance(new_headers, Headers) 94 | 95 | for name, value in headers.items(): 96 | if name in other: 97 | continue 98 | assert new_headers[name] == value 99 | 100 | for name, value in other.items(): 101 | if isinstance(value, bytes): 102 | value = value.decode("ascii") 103 | assert new_headers[name] == value 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "other", 108 | [ 109 | {"server": "uvicorn"}, 110 | {"connection": "Keep-Alive"}, 111 | {"CONNECTION": "Keep-Alive"}, 112 | {b"connection": b"Keep-Alive"}, 113 | Headers(connection="Keep-Alive"), 114 | ], 115 | ) 116 | def test_headers_iadd(headers: Headers, other): 117 | old_headers = Headers(**headers) 118 | headers += other 119 | assert isinstance(headers, Headers) 120 | 121 | for name, value in old_headers.items(): 122 | if name in other: 123 | continue 124 | assert headers[name] == value 125 | 126 | for name, value in other.items(): 127 | if isinstance(value, bytes): 128 | value = value.decode("ascii") 129 | assert headers[name] == value 130 | 131 | 132 | @pytest.mark.parametrize( 133 | ["other", "eq"], 134 | [ 135 | [{"content-type": "text/html", "server": "baguette"}, True], 136 | [{"content-type": "text/html"}, False], 137 | [{"content-type": "text/plain", "server": "baguette"}, False], 138 | ], 139 | ) 140 | def test_headers_eq(headers: Headers, other, eq): 141 | assert (headers == other) == eq 142 | 143 | 144 | @pytest.mark.parametrize( 145 | ["headers", "expected_headers"], 146 | [ 147 | [None, Headers()], 148 | ["server: baguette", Headers(server="baguette")], 149 | [b"server: baguette", Headers(server="baguette")], 150 | [[["server", "baguette"]], Headers(server="baguette")], 151 | [[[b"server", b"baguette"]], Headers(server="baguette")], 152 | [{"server": "baguette"}, Headers(server="baguette")], 153 | [{b"server": b"baguette"}, Headers(server="baguette")], 154 | [Headers(server="baguette"), Headers(server="baguette")], 155 | ], 156 | ) 157 | def test_make_headers(headers, expected_headers): 158 | headers = make_headers(headers) 159 | assert headers == expected_headers 160 | 161 | 162 | def test_make_headers_error(): 163 | with pytest.raises(TypeError): 164 | make_headers(1) 165 | -------------------------------------------------------------------------------- /tests/test_httpexceptions.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from baguette import responses 6 | from baguette.httpexceptions import ( 7 | BadGateway, 8 | BadRequest, 9 | Conflict, 10 | ExpectationFailed, 11 | FailedDependency, 12 | Forbidden, 13 | GatewayTimeout, 14 | Gone, 15 | HTTPException, 16 | HTTPVersionNotSupported, 17 | IMATeapot, 18 | InsufficientStorage, 19 | InternalServerError, 20 | LengthRequired, 21 | Locked, 22 | LoopDetected, 23 | MethodNotAllowed, 24 | MisdirectedRequest, 25 | NetworkAuthenticationRequired, 26 | NotAcceptable, 27 | NotExtended, 28 | NotFound, 29 | NotImplemented, 30 | PaymentRequired, 31 | PreconditionFailed, 32 | PreconditionRequired, 33 | ProxyAuthenticationRequired, 34 | RequestedRangeNotSatisfiable, 35 | RequestEntityTooLarge, 36 | RequestHeaderFieldsTooLarge, 37 | RequestTimeout, 38 | RequestURITooLong, 39 | ServiceUnavailable, 40 | TooEarly, 41 | TooManyRequests, 42 | Unauthorized, 43 | UnavailableForLegalReasons, 44 | UnprocessableEntity, 45 | UnsupportedMediaType, 46 | UpgradeRequired, 47 | VariantAlsoNegotiates, 48 | ) 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ["cls", "status_code", "name", "description"], 53 | [ 54 | [ 55 | BadRequest, 56 | 400, 57 | "Bad Request", 58 | "Bad request syntax or unsupported method", 59 | ], 60 | [ 61 | Unauthorized, 62 | 401, 63 | "Unauthorized", 64 | "No permission -- see authorization schemes", 65 | ], 66 | [ 67 | PaymentRequired, 68 | 402, 69 | "Payment Required", 70 | "No payment -- see charging schemes", 71 | ], 72 | [ 73 | Forbidden, 74 | 403, 75 | "Forbidden", 76 | "Request forbidden -- authorization will not help", 77 | ], 78 | [NotFound, 404, "Not Found", "Nothing matches the given URI"], 79 | [ 80 | MethodNotAllowed, 81 | 405, 82 | "Method Not Allowed", 83 | "Specified method is invalid for this resource", 84 | ], 85 | [ 86 | NotAcceptable, 87 | 406, 88 | "Not Acceptable", 89 | "URI not available in preferred format", 90 | ], 91 | [ 92 | ProxyAuthenticationRequired, 93 | 407, 94 | "Proxy Authentication Required", 95 | "You must authenticate with this proxy before proceeding", 96 | ], 97 | [ 98 | RequestTimeout, 99 | 408, 100 | "Request Timeout", 101 | "Request timed out; try again later", 102 | ], 103 | [Conflict, 409, "Conflict", "Request conflict"], 104 | [ 105 | Gone, 106 | 410, 107 | "Gone", 108 | "URI no longer exists and has been permanently removed", 109 | ], 110 | [ 111 | LengthRequired, 112 | 411, 113 | "Length Required", 114 | "Client must specify Content-Length", 115 | ], 116 | [ 117 | PreconditionFailed, 118 | 412, 119 | "Precondition Failed", 120 | "Precondition in headers is false", 121 | ], 122 | [ 123 | RequestEntityTooLarge, 124 | 413, 125 | "Request Entity Too Large", 126 | "Entity is too large", 127 | ], 128 | [RequestURITooLong, 414, "Request-URI Too Long", "URI is too long"], 129 | [ 130 | UnsupportedMediaType, 131 | 415, 132 | "Unsupported Media Type", 133 | "Entity body in unsupported format", 134 | ], 135 | [ 136 | RequestedRangeNotSatisfiable, 137 | 416, 138 | "Requested Range Not Satisfiable", 139 | "Cannot satisfy request range", 140 | ], 141 | [ 142 | ExpectationFailed, 143 | 417, 144 | "Expectation Failed", 145 | "Expect condition could not be satisfied", 146 | ], 147 | [ 148 | IMATeapot, 149 | 418, 150 | "I'm a Teapot", 151 | "Server refuses to brew coffee because it is a teapot.", 152 | ], 153 | [ 154 | MisdirectedRequest, 155 | 421, 156 | "Misdirected Request", 157 | "Server is not able to produce a response", 158 | ], 159 | [UnprocessableEntity, 422, "Unprocessable Entity", ""], 160 | [Locked, 423, "Locked", ""], 161 | [FailedDependency, 424, "Failed Dependency", ""], 162 | [ 163 | TooEarly, 164 | 425, 165 | "Too Early", 166 | ( 167 | "Server is unwilling to risk processing a request " 168 | "that might be replayed" 169 | ), 170 | ], 171 | [UpgradeRequired, 426, "Upgrade Required", ""], 172 | [ 173 | PreconditionRequired, 174 | 428, 175 | "Precondition Required", 176 | "The origin server requires the request to be conditional", 177 | ], 178 | [ 179 | TooManyRequests, 180 | 429, 181 | "Too Many Requests", 182 | ( 183 | "The user has sent too many requests in a " 184 | 'given amount of time ("rate limiting")' 185 | ), 186 | ], 187 | [ 188 | RequestHeaderFieldsTooLarge, 189 | 431, 190 | "Request Header Fields Too Large", 191 | ( 192 | "The server is unwilling to process the request " 193 | "because its header fields are too large" 194 | ), 195 | ], 196 | [ 197 | UnavailableForLegalReasons, 198 | 451, 199 | "Unavailable For Legal Reasons", 200 | ( 201 | "The server is denying access to the resource " 202 | "as a consequence of a legal demand" 203 | ), 204 | ], 205 | [ 206 | InternalServerError, 207 | 500, 208 | "Internal Server Error", 209 | "Server got itself in trouble", 210 | ], 211 | [ 212 | NotImplemented, 213 | 501, 214 | "Not Implemented", 215 | "Server does not support this operation", 216 | ], 217 | [ 218 | BadGateway, 219 | 502, 220 | "Bad Gateway", 221 | "Invalid responses from another server/proxy", 222 | ], 223 | [ 224 | ServiceUnavailable, 225 | 503, 226 | "Service Unavailable", 227 | "The server cannot process the request due to a high load", 228 | ], 229 | [ 230 | GatewayTimeout, 231 | 504, 232 | "Gateway Timeout", 233 | "The gateway server did not receive a timely response", 234 | ], 235 | [ 236 | HTTPVersionNotSupported, 237 | 505, 238 | "HTTP Version Not Supported", 239 | "Cannot fulfill request", 240 | ], 241 | [VariantAlsoNegotiates, 506, "Variant Also Negotiates", ""], 242 | [InsufficientStorage, 507, "Insufficient Storage", ""], 243 | [LoopDetected, 508, "Loop Detected", ""], 244 | [NotExtended, 510, "Not Extended", ""], 245 | [ 246 | NetworkAuthenticationRequired, 247 | 511, 248 | "Network Authentication Required", 249 | "The client needs to authenticate to gain network access", 250 | ], 251 | ], 252 | ) 253 | def test_errors( 254 | cls: typing.Type[HTTPException], 255 | status_code: int, 256 | name: str, 257 | description: str, 258 | ): 259 | error = cls() 260 | assert error.status_code == status_code 261 | assert error.name == name 262 | assert error.description == description 263 | 264 | 265 | @pytest.mark.parametrize( 266 | ["type_", "response_type"], 267 | [ 268 | ["plain", responses.PlainTextResponse], 269 | ["json", responses.JSONResponse], 270 | ["html", responses.HTMLResponse], 271 | ], 272 | ) 273 | def test_error_response( 274 | type_: str, response_type: typing.Type[responses.Response] 275 | ): 276 | error = HTTPException(400) 277 | response = responses.make_error_response( 278 | error, type_=type_, traceback="Traceback (most recent call last):\n..." 279 | ) 280 | assert isinstance(response, response_type) 281 | 282 | 283 | def test_error_response_error(): 284 | error = HTTPException(400) 285 | with pytest.raises(ValueError): 286 | responses.make_error_response(error, type_="nonexistent") 287 | -------------------------------------------------------------------------------- /tests/test_rendering.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette import rendering 4 | from baguette.rendering import Renderer, init, render 5 | 6 | from .conftest import strip 7 | 8 | expected_html = """ 9 | 10 | 11 | 12 | 13 | Index - My Webpage 14 | 19 | 20 | 21 |
22 |

Index

23 |

Welcome to my awesome homepage.

24 |

1st paragraph

25 |

2nd paragraph

26 |
27 | 30 | 31 | """ 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_renderer(): 36 | renderer = Renderer("tests/templates") 37 | html = await renderer.render( 38 | "index.html", 39 | paragraphs=["1st paragraph", "2nd paragraph"], 40 | ) 41 | assert strip(html) == strip(expected_html) 42 | 43 | 44 | def test_init(): 45 | renderer = init("tests/templates") 46 | assert isinstance(renderer, Renderer) 47 | assert rendering._renderer == renderer 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_render(): 52 | rendering._renderer = None 53 | html = await render( 54 | "index.html", 55 | paragraphs=["1st paragraph", "2nd paragraph"], 56 | templates_directory="tests/templates", 57 | ) 58 | assert strip(html) == strip(expected_html) 59 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode 3 | 4 | import pytest 5 | 6 | from baguette.app import Baguette 7 | from baguette.forms import Field, FileField, Form 8 | from baguette.httpexceptions import BadRequest 9 | from baguette.request import Request 10 | 11 | from .conftest import Receive, create_http_scope 12 | 13 | 14 | def test_request_create(http_scope): 15 | request = Request(Baguette(), http_scope, Receive()) 16 | assert request.http_version == "1.1" 17 | assert request.asgi_version == "3.0" 18 | assert request.headers["server"] == "baguette" 19 | assert request.headers["content-type"] == "text/plain; charset=utf-8" 20 | assert request.content_type == "text/plain" 21 | assert request.encoding == "utf-8" 22 | assert request.method == "GET" 23 | assert request.scheme == "http" 24 | assert request.root_path == "" 25 | assert request.path == "/" 26 | assert request.querystring == {"a": ["b"]} 27 | assert request.server == ("127.0.0.1", 8000) 28 | assert request.client == ("127.0.0.1", 9000) 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_request_raw_body(http_scope): 33 | receive = Receive( 34 | [ 35 | { 36 | "type": "http.request.body", 37 | "body": b"Hello, ", 38 | "more_body": True, 39 | }, 40 | { 41 | "type": "http.request.body", 42 | "body": b"World!", 43 | }, 44 | ] 45 | ) 46 | request = Request(Baguette(), http_scope, receive) 47 | assert await request.raw_body() == b"Hello, World!" 48 | assert len(receive.values) == 0 49 | # caching 50 | assert hasattr(request, "_raw_body") 51 | assert await request.raw_body() == b"Hello, World!" 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_request_body(http_scope): 56 | receive = Receive( 57 | [ 58 | { 59 | "type": "http.request.body", 60 | "body": b"Hello, ", 61 | "more_body": True, 62 | }, 63 | { 64 | "type": "http.request.body", 65 | "body": b"World!", 66 | }, 67 | ] 68 | ) 69 | request = Request(Baguette(), http_scope, receive) 70 | assert await request.body() == "Hello, World!" 71 | assert len(receive.values) == 0 72 | # caching 73 | assert hasattr(request, "_raw_body") 74 | assert hasattr(request, "_body") 75 | assert await request.body() == "Hello, World!" 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_request_json(http_scope): 80 | receive = Receive( 81 | [ 82 | { 83 | "type": "http.request.body", 84 | "body": json.dumps({"message": "Hello, World!"}).encode( 85 | "utf-8" 86 | ), 87 | } 88 | ] 89 | ) 90 | request = Request(Baguette(), http_scope, receive) 91 | assert await request.json() == {"message": "Hello, World!"} 92 | assert len(receive.values) == 0 93 | # caching 94 | assert hasattr(request, "_raw_body") 95 | assert hasattr(request, "_body") 96 | assert hasattr(request, "_json") 97 | assert await request.json() == {"message": "Hello, World!"} 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_request_json_error(http_scope): 102 | receive = Receive( 103 | [ 104 | { 105 | "type": "http.request.body", 106 | "body": b"no json", 107 | } 108 | ] 109 | ) 110 | request = Request(Baguette(), http_scope, receive) 111 | with pytest.raises(BadRequest): 112 | await request.json() 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_request_form_url_encoded(): 117 | http_scope = create_http_scope( 118 | headers="content-type: application/x-www-form-urlencoded", 119 | querystring=urlencode({"test2": "test test test"}), 120 | ) 121 | receive = Receive( 122 | [ 123 | { 124 | "type": "http.request.body", 125 | "body": urlencode({"test": "test test"}).encode("utf-8"), 126 | } 127 | ] 128 | ) 129 | request = Request(Baguette(), http_scope, receive) 130 | assert { 131 | field.name: field.value 132 | for field in (await request.form()).fields.values() 133 | } == {"test": "test test"} 134 | assert len(receive.values) == 0 135 | # caching 136 | assert hasattr(request, "_raw_body") 137 | assert { 138 | field.name: field.value 139 | for field in (await request.form()).fields.values() 140 | } == {"test": "test test"} 141 | 142 | # include querystring 143 | assert { 144 | field.name: field.value 145 | for field in ( 146 | await request.form(include_querystring=True) 147 | ).fields.values() 148 | } == {"test": "test test", "test2": "test test test"} 149 | 150 | 151 | multipart_body = ( 152 | b"--abcd1234\r\n" 153 | b'Content-Disposition: form-data; name="test"\r\n\r\n' 154 | b"test test\r\n" 155 | b"--abcd1234\r\n" 156 | b'Content-Disposition: form-data; name="file"; filename="script.js"\r\n' 157 | b"Content-Type: application/javascript\r\n\r\n" 158 | b'console.log("Hello, World!")\r\n' 159 | b"--abcd1234\r\n" 160 | b'Content-Disposition: form-data; name="another test"\r\n\r\n' 161 | b"another test test\r\n" 162 | b"--abcd1234--\r\n" 163 | ) 164 | 165 | 166 | @pytest.mark.asyncio 167 | async def test_request_form_multipart(): 168 | http_scope = create_http_scope( 169 | headers="content-type: multipart/form-data; boundary=abcd1234", 170 | querystring=urlencode({"test": "test test test"}), 171 | ) 172 | receive = Receive( 173 | [ 174 | { 175 | "type": "http.request.body", 176 | "body": multipart_body, 177 | } 178 | ] 179 | ) 180 | request = Request(Baguette(), http_scope, receive) 181 | form = await request.form() 182 | assert { 183 | field.name: field.value 184 | for field in form.fields.values() 185 | if not field.is_file 186 | } == {"test": "test test", "another test": "another test test"} 187 | assert {file.name: file.content for file in form.files.values()} == { 188 | "file": b'console.log("Hello, World!")' 189 | } 190 | assert len(receive.values) == 0 191 | # caching 192 | assert hasattr(request, "_raw_body") 193 | form = await request.form() 194 | assert { 195 | field.name: field.value 196 | for field in form.fields.values() 197 | if not field.is_file 198 | } == {"test": "test test", "another test": "another test test"} 199 | assert {file.name: file.content for file in form.files.values()} == { 200 | "file": b'console.log("Hello, World!")' 201 | } 202 | 203 | # include querystring 204 | form = await request.form(include_querystring=True) 205 | assert { 206 | field.name: field.values 207 | for field in form.fields.values() 208 | if not field.is_file 209 | } == { 210 | "test": ["test test", "test test test"], 211 | "another test": ["another test test"], 212 | } 213 | 214 | 215 | @pytest.mark.asyncio 216 | async def test_request_form_error(): 217 | http_scope = create_http_scope( 218 | headers="content-type: text/plain", 219 | ) 220 | receive = Receive( 221 | [ 222 | { 223 | "type": "http.request.body", 224 | "body": b"", 225 | } 226 | ] 227 | ) 228 | request = Request(Baguette(), http_scope, receive) 229 | with pytest.raises(ValueError): 230 | await request.form() 231 | 232 | 233 | @pytest.mark.asyncio 234 | async def test_request_set_raw_body(test_request: Request): 235 | test_request.set_raw_body(b"Hello, World!") 236 | assert test_request._raw_body == b"Hello, World!" 237 | assert (await test_request.raw_body()) == b"Hello, World!" 238 | 239 | 240 | def test_request_set_raw_body_error(test_request: Request): 241 | with pytest.raises(TypeError): 242 | test_request.set_raw_body("Hello, World!") 243 | 244 | 245 | @pytest.mark.asyncio 246 | @pytest.mark.parametrize( 247 | ["body", "expected_body", "expected_raw_body"], 248 | [ 249 | ["Hello, World!", "Hello, World!", b"Hello, World!"], 250 | [b"Hello, World!", "Hello, World!", b"Hello, World!"], 251 | ], 252 | ) 253 | async def test_request_set_body( 254 | test_request: Request, body, expected_body, expected_raw_body 255 | ): 256 | test_request.set_body(body) 257 | assert test_request._raw_body == expected_raw_body 258 | assert (await test_request.raw_body()) == expected_raw_body 259 | assert test_request._body == expected_body 260 | assert (await test_request.body()) == expected_body 261 | 262 | 263 | def test_request_set_body_error(test_request: Request): 264 | with pytest.raises(TypeError): 265 | test_request.set_body(1) 266 | 267 | 268 | @pytest.mark.asyncio 269 | @pytest.mark.parametrize( 270 | ["json", "expected_json", "expected_body", "expected_raw_body"], 271 | [ 272 | [ 273 | "Hello, World!", 274 | "Hello, World!", 275 | '"Hello, World!"', 276 | b'"Hello, World!"', 277 | ], 278 | [ 279 | {"Hello": "World!"}, 280 | {"Hello": "World!"}, 281 | '{"Hello":"World!"}', 282 | b'{"Hello":"World!"}', 283 | ], 284 | ], 285 | ) 286 | async def test_request_set_json( 287 | test_request: Request, json, expected_json, expected_body, expected_raw_body 288 | ): 289 | test_request.set_json(json) 290 | assert test_request._raw_body == expected_raw_body 291 | assert (await test_request.raw_body()) == expected_raw_body 292 | assert test_request._body == expected_body 293 | assert (await test_request.body()) == expected_body 294 | assert test_request._json == expected_json 295 | assert (await test_request.json()) == expected_json 296 | 297 | 298 | def test_request_set_json_error(test_request: Request): 299 | with pytest.raises(TypeError): 300 | test_request.set_json(set()) 301 | 302 | 303 | @pytest.mark.asyncio 304 | async def test_request_set_form(test_request: Request): 305 | form = Form( 306 | fields={ 307 | "test": Field(name="test", values=["test", b"test2"]), 308 | "file": FileField( 309 | name="file", 310 | content=b'console.log("Hello, World!")', 311 | filename="script.js", 312 | ), 313 | } 314 | ) 315 | test_request.set_form(form) 316 | assert test_request._form == form 317 | assert (await test_request.form()) == form 318 | 319 | 320 | def test_request_set_form_error(test_request: Request): 321 | with pytest.raises(TypeError): 322 | test_request.set_form("Hello, World!") 323 | -------------------------------------------------------------------------------- /tests/test_responses.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import re 4 | 5 | import aiofiles 6 | import pytest 7 | 8 | from baguette.headers import Headers 9 | from baguette.httpexceptions import NotFound 10 | from baguette.responses import ( 11 | EmptyResponse, 12 | FileResponse, 13 | HTMLResponse, 14 | JSONResponse, 15 | PlainTextResponse, 16 | RedirectResponse, 17 | Response, 18 | make_response, 19 | redirect, 20 | ) 21 | 22 | from .conftest import Send 23 | 24 | 25 | @pytest.mark.parametrize("body", ["Hello, World!", b"Hello, World!"]) 26 | def test_response_create(body): 27 | response = Response( 28 | body, 29 | headers={"content_type": "text/plain"}, 30 | ) 31 | assert isinstance(response.body, str) 32 | assert isinstance(response.raw_body, bytes) 33 | assert isinstance(response.status_code, int) 34 | assert isinstance(response.headers, Headers) 35 | 36 | assert response.body == "Hello, World!" 37 | assert response.raw_body == b"Hello, World!" 38 | assert response.headers == {"content_type": "text/plain"} 39 | 40 | 41 | def test_response_create_error(): 42 | with pytest.raises(TypeError): 43 | Response(1) 44 | 45 | 46 | def test_response_body(): 47 | response = Response( 48 | "Hello, World!", 49 | headers={"content_type": "text/plain"}, 50 | ) 51 | 52 | assert response.body == "Hello, World!" 53 | assert response.raw_body == b"Hello, World!" 54 | assert response.headers == {"content_type": "text/plain"} 55 | 56 | response.body = "Bye, World!" 57 | assert response.body == "Bye, World!" 58 | assert response.raw_body == b"Bye, World!" 59 | 60 | response.body = b"Hello again, World!" 61 | assert response.body == "Hello again, World!" 62 | assert response.raw_body == b"Hello again, World!" 63 | 64 | response.raw_body = "Bye, World!" 65 | assert response.body == "Bye, World!" 66 | assert response.raw_body == b"Bye, World!" 67 | 68 | response.raw_body = b"Hello again, World!" 69 | assert response.body == "Hello again, World!" 70 | assert response.raw_body == b"Hello again, World!" 71 | 72 | 73 | def test_response_body_error(): 74 | response = Response( 75 | "Hello, World!", 76 | headers={"content_type": "text/plain"}, 77 | ) 78 | with pytest.raises(TypeError): 79 | response.body = 1 80 | with pytest.raises(TypeError): 81 | response.raw_body = 1 82 | 83 | 84 | def test_json_response_create(): 85 | response = JSONResponse({"message": "Hello, World!"}) 86 | assert isinstance(response.json, dict) 87 | assert isinstance(response.body, str) 88 | assert isinstance(response.raw_body, bytes) 89 | assert isinstance(response.status_code, int) 90 | assert isinstance(response.headers, Headers) 91 | 92 | assert response.headers["content-type"] == "application/json" 93 | assert json.loads(response.body) == {"message": "Hello, World!"} 94 | 95 | 96 | def test_plain_text_response_create(): 97 | response = PlainTextResponse("Hello, World!") 98 | assert isinstance(response.body, str) 99 | assert isinstance(response.raw_body, bytes) 100 | assert isinstance(response.status_code, int) 101 | assert isinstance(response.headers, Headers) 102 | 103 | assert response.body == "Hello, World!" 104 | assert response.raw_body == b"Hello, World!" 105 | assert response.headers["content-type"].startswith("text/plain") 106 | 107 | 108 | def test_html_response_create(): 109 | response = HTMLResponse("

Hello, World!

") 110 | assert isinstance(response.body, str) 111 | assert isinstance(response.raw_body, bytes) 112 | assert isinstance(response.status_code, int) 113 | assert isinstance(response.headers, Headers) 114 | 115 | assert response.body == "

Hello, World!

" 116 | assert response.raw_body == b"

Hello, World!

" 117 | assert response.headers["content-type"].startswith("text/html") 118 | 119 | 120 | def test_empty_response_create(): 121 | response = EmptyResponse() 122 | assert isinstance(response.body, str) 123 | assert isinstance(response.raw_body, bytes) 124 | assert isinstance(response.status_code, int) 125 | assert isinstance(response.headers, Headers) 126 | 127 | assert response.body == "" 128 | assert response.raw_body == b"" 129 | assert response.status_code == 204 130 | 131 | 132 | def test_redirect_response_create(): 133 | response = RedirectResponse("/home") 134 | assert isinstance(response.body, str) 135 | assert isinstance(response.raw_body, bytes) 136 | assert isinstance(response.status_code, int) 137 | assert isinstance(response.headers, Headers) 138 | 139 | assert response.body == "" 140 | assert response.raw_body == b"" 141 | assert response.status_code == 301 142 | assert response.location == "/home" 143 | 144 | 145 | def test_redirect(): 146 | response = redirect( 147 | "/home", status_code=302, headers={"server": "baguette"} 148 | ) 149 | assert isinstance(response.body, str) 150 | assert isinstance(response.raw_body, bytes) 151 | assert isinstance(response.status_code, int) 152 | assert isinstance(response.headers, Headers) 153 | 154 | assert response.body == "" 155 | assert response.raw_body == b"" 156 | assert response.status_code == 302 157 | assert response.location == "/home" 158 | 159 | 160 | @pytest.mark.parametrize( 161 | ["file_path", "mimetype", "kwargs"], 162 | [ 163 | [ 164 | "tests/static/banner.png", 165 | "image/png", 166 | dict(as_attachment=True, attachment_filename="baguette.png"), 167 | ], 168 | ["tests/static/css/style.css", "text/css", dict(add_etags=False)], 169 | ["tests/static/js/script.js", "application/javascript", {}], 170 | ], 171 | ) 172 | def test_file_response_create(file_path, mimetype, kwargs): 173 | response = FileResponse(file_path, **kwargs) 174 | 175 | path = pathlib.Path(file_path).resolve(strict=True) 176 | with open(path, "rb") as f: 177 | content_length = len(f.read()) 178 | 179 | assert response.file_path == path 180 | assert response.mimetype == mimetype 181 | assert response.headers["content-type"] == mimetype 182 | assert response.file_size == content_length 183 | assert int(response.headers["content-length"]) == content_length 184 | 185 | if kwargs.get("as_attachment", False): 186 | filename = kwargs.get("attachment_filename", path.name) 187 | assert ( 188 | response.headers["content-disposition"] 189 | == "attachment; filename=" + filename 190 | ) 191 | 192 | if kwargs.get("add_etags", True): 193 | assert re.fullmatch(r"(\d|\.)+-\d+-\d+", response.headers["etag"]) 194 | 195 | 196 | def test_file_response_create_error(): 197 | with pytest.raises(NotFound): 198 | FileResponse("nonexistent") 199 | with pytest.raises(NotFound): 200 | FileResponse("tests/static") 201 | 202 | 203 | @pytest.mark.asyncio 204 | async def test_response_send(): 205 | send = Send() 206 | response = Response( 207 | "Hello, World!", 208 | status_code=200, 209 | headers={"content-type": "text/plain"}, 210 | ) 211 | await response._send(send) 212 | assert send.values.pop(0) == { 213 | "type": "http.response.start", 214 | "status": 200, 215 | "headers": [[b"content-type", b"text/plain"]], 216 | } 217 | assert send.values.pop(0) == { 218 | "type": "http.response.body", 219 | "body": b"Hello, World!", 220 | } 221 | 222 | 223 | @pytest.mark.asyncio 224 | async def test_file_response_send(): 225 | send = Send() 226 | response = FileResponse("tests/static/css/style.css", add_etags=False) 227 | async with aiofiles.open("tests/static/css/style.css", "rb") as f: 228 | content = await f.read() 229 | 230 | await response._send(send) 231 | assert send.values.pop(0) == { 232 | "type": "http.response.start", 233 | "status": 200, 234 | "headers": [ 235 | [b"content-length", str(len(content)).encode()], 236 | [b"content-type", b"text/css"], 237 | ], 238 | } 239 | assert send.values.pop(0) == { 240 | "type": "http.response.body", 241 | "body": content, 242 | } 243 | 244 | 245 | @pytest.mark.parametrize( 246 | ["result", "expected_response"], 247 | [ 248 | # result is already a response 249 | [Response("test"), Response("test")], 250 | [PlainTextResponse("test"), PlainTextResponse("test")], 251 | # only the response body is provided 252 | ["test", PlainTextResponse("test")], 253 | [b"test", PlainTextResponse("test")], 254 | ["

test

", HTMLResponse("

test

")], 255 | [["test", "test2"], JSONResponse(["test", "test2"])], 256 | [ 257 | {"test": "a", "test2": "b"}, 258 | JSONResponse({"test": "a", "test2": "b"}), 259 | ], 260 | [None, EmptyResponse()], 261 | [2, PlainTextResponse("2")], 262 | # body and status code are provided 263 | [("test", 201), PlainTextResponse("test", status_code=201)], 264 | # body and headers are provided 265 | [ 266 | ("test", {"server": "baguette"}), 267 | PlainTextResponse("test", headers=Headers(server="baguette")), 268 | ], 269 | # body, status code and headers are provided 270 | [ 271 | ("test", 201, {"server": "baguette"}), 272 | PlainTextResponse( 273 | "test", status_code=201, headers=Headers(server="baguette") 274 | ), 275 | ], 276 | ], 277 | ) 278 | def test_make_response(result, expected_response: Response): 279 | response = make_response(result) 280 | assert type(response) == type(expected_response) 281 | assert response.body == expected_response.body 282 | assert response.raw_body == expected_response.raw_body 283 | assert response.status_code == expected_response.status_code 284 | assert response.headers == expected_response.headers 285 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from baguette.converters import ( 6 | FloatConverter, 7 | IntegerConverter, 8 | PathConverter, 9 | StringConverter, 10 | ) 11 | from baguette.httpexceptions import MethodNotAllowed, NotFound 12 | from baguette.router import Route, Router 13 | 14 | 15 | def test_route(): 16 | async def handler(request, id: int): 17 | pass 18 | 19 | route = Route( 20 | path="/test//test", 21 | name="test", 22 | handler=handler, 23 | methods=["GET"], 24 | ) 25 | assert route.handler_kwargs == ["request", "id"] 26 | assert route.handler_is_class is False 27 | 28 | id_converter = route.converters["id"] 29 | assert isinstance(id_converter, IntegerConverter) 30 | 31 | assert route.regex == re.compile(r"\/test\/(?P[\+-]?\d+)\/test\/?") 32 | assert route.match("/test/1/test") 33 | assert not route.match("/test") 34 | assert not route.match("/test/1") 35 | assert not route.match("/test//test") 36 | assert not route.match("/test/test/test") 37 | 38 | assert route.convert("/test/1/test") == {"id": 1} 39 | with pytest.raises(ValueError): 40 | route.convert("/test") 41 | with pytest.raises(ValueError): 42 | route.convert("/test/test/test") 43 | 44 | route.path = "/test/" 45 | route.defaults["id"] = 1 46 | route.build_regex() 47 | assert route.convert("/test") == {"id": 1} 48 | 49 | 50 | def test_route2(): 51 | async def handler(request): 52 | pass 53 | 54 | with pytest.raises(ValueError): 55 | Route( 56 | path="/test//test", 57 | name="test", 58 | handler=handler, 59 | methods=["GET"], 60 | ) 61 | 62 | with pytest.raises(TypeError): 63 | Route( 64 | path="/test//test", 65 | name="test", 66 | handler=handler, 67 | methods=["GET"], 68 | ) 69 | 70 | route = Route( 71 | path="/test//test", 72 | name="test", 73 | handler=handler, 74 | methods=["GET"], 75 | ) 76 | assert route.handler_kwargs == ["request"] 77 | assert route.handler_is_class is False 78 | 79 | id_converter = route.converters["id"] 80 | assert isinstance(id_converter, FloatConverter) 81 | assert id_converter.signed is True 82 | assert id_converter.min == -10 83 | assert id_converter.max == pytest.approx(10.0) 84 | 85 | 86 | def test_route3(): 87 | async def handler(request, test: str): 88 | pass 89 | 90 | route = Route( 91 | path="/test//test", 92 | name="test", 93 | handler=handler, 94 | methods=["GET"], 95 | ) 96 | assert route.handler_kwargs == ["request", "test"] 97 | assert route.handler_is_class is False 98 | 99 | id_converter = route.converters["test"] 100 | assert isinstance(id_converter, StringConverter) 101 | 102 | assert route.regex == re.compile(r"\/test\/(?P[^\/]+)\/test\/?") 103 | assert route.match("/test/1/test") 104 | assert route.match("/test/test/test") 105 | assert not route.match("/test") 106 | assert not route.match("/test/1") 107 | assert not route.match("/test//test") 108 | 109 | assert route.convert("/test/test/test") == {"test": "test"} 110 | assert route.convert("/test/1/test") == {"test": "1"} 111 | with pytest.raises(ValueError): 112 | route.convert("/test") 113 | 114 | route.path = "/test/" 115 | route.defaults["test"] = "test" 116 | route.build_regex() 117 | assert route.convert("/test") == {"test": "test"} 118 | 119 | 120 | def test_route4(): 121 | async def handler(path: str): 122 | pass 123 | 124 | route = Route( 125 | path="/test//test", 126 | name="path", 127 | handler=handler, 128 | methods=["GET"], 129 | ) 130 | assert route.handler_kwargs == ["path"] 131 | assert route.handler_is_class is False 132 | 133 | path_converter = route.converters["path"] 134 | assert isinstance(path_converter, PathConverter) 135 | 136 | assert route.regex == re.compile(r"\/test\/(?P.+)\/test\/?") 137 | assert route.match("/test/1/test") 138 | assert route.match("/test/test/test") 139 | assert route.match("/test/test/test/test") 140 | assert not route.match("/test") 141 | assert not route.match("/test/1") 142 | assert not route.match("/test//test") 143 | 144 | assert route.convert("/test/test/test") == {"path": "test"} 145 | assert route.convert("/test/test/test/test") == {"path": "test/test"} 146 | assert route.convert("/test/1/test") == {"path": "1"} 147 | with pytest.raises(ValueError): 148 | route.convert("/test") 149 | 150 | route.path = "/test/" 151 | route.defaults["path"] = "test/test" 152 | route.build_regex() 153 | assert route.convert("/test") == {"path": "test/test"} 154 | 155 | 156 | def test_router(): 157 | async def handler(request): 158 | pass 159 | 160 | index = Route( 161 | path="/", 162 | name="index", 163 | handler=handler, 164 | methods=["GET"], 165 | ) 166 | router = Router(routes=[index]) 167 | assert len(router.routes) == 1 168 | 169 | home = router.add_route( 170 | path="/home", 171 | name="home", 172 | handler=handler, 173 | methods=["GET", "HEAD"], 174 | ) 175 | assert len(router.routes) == 2 176 | 177 | user_get = router.add_route( 178 | path="/user/", 179 | name="home", 180 | handler=handler, 181 | methods=["GET"], 182 | ) 183 | assert len(router.routes) == 3 184 | 185 | user_delete = router.add_route( 186 | path="/user/", 187 | name="home", 188 | handler=handler, 189 | methods=["DELETE"], 190 | ) 191 | assert len(router.routes) == 4 192 | 193 | with pytest.raises(NotFound): 194 | router.get("/nonexistent", "GET") 195 | 196 | assert router.get("/", "GET") == index 197 | with pytest.raises(MethodNotAllowed): 198 | router.get("/", "POST") 199 | 200 | assert router.get("/home", "GET") == home 201 | assert router.get("/home", "HEAD") == home 202 | with pytest.raises(MethodNotAllowed): 203 | router.get("/home", "POST") 204 | 205 | assert router.get("/user/1", "GET") == user_get 206 | assert router.get("/user/1", "DELETE") == user_delete 207 | with pytest.raises(MethodNotAllowed): 208 | router.get("/user/1", "POST") 209 | -------------------------------------------------------------------------------- /tests/test_testing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.app import Baguette 4 | from baguette.responses import PlainTextResponse 5 | 6 | # need to change name to make sure pytest doesn't think this is a test class 7 | from baguette.testing import TestClient as Client 8 | 9 | from .conftest import create_test_request 10 | 11 | 12 | @pytest.fixture(name="test_client") 13 | def create_test_client(): 14 | app = Baguette() 15 | 16 | @app.route( 17 | "/any/method", 18 | methods=[ 19 | "GET", 20 | "HEAD", 21 | "POST", 22 | "PUT", 23 | "DELETE", 24 | "CONNECT", 25 | "OPTIONS", 26 | "TRACE", 27 | "PATCH", 28 | ], 29 | ) 30 | async def any_method(request): 31 | return request.method 32 | 33 | return Client(app, default_headers={"server": "baguette"}) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ["params", "expected_querystring"], 38 | [ 39 | [None, ""], 40 | ["", ""], 41 | ["a=b", "a=b"], 42 | [[("a", "b")], "a=b"], 43 | [[("a", "b"), ("a", "c"), ("b", "d")], "a=b&a=c&b=d"], 44 | [[("a", ["b", "c"]), ("b", "d")], "a=b&a=c&b=d"], 45 | [{"a": ["b", "c"], "b": "d"}, "a=b&a=c&b=d"], 46 | [{"a": [], "b": "d"}, "b=d"], 47 | ], 48 | ) 49 | def test_test_client_prepare_querystring( 50 | test_client: Client, params, expected_querystring 51 | ): 52 | querystring = test_client._prepare_querystring(params) 53 | assert querystring == expected_querystring 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "params", 58 | [ 59 | 1, 60 | [["a", "b", "c"]], 61 | {"a": 1}, 62 | {"a": [1, 2]}, 63 | ], 64 | ) 65 | def test_test_client_prepare_querystring_error(test_client: Client, params): 66 | with pytest.raises(ValueError): 67 | test_client._prepare_querystring(params) 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_test_client_prepare_request(test_client: Client): 72 | request = test_client._prepare_request( 73 | method="GET", 74 | path="/", 75 | params={"a": "b"}, 76 | json={"b": "c"}, 77 | headers={"content-type": "text/plain; charset=utf-8"}, 78 | ) 79 | expected_request = create_test_request(body="Hello, World!") 80 | 81 | for attr in [ 82 | "http_version", 83 | "asgi_version", 84 | "encoding", 85 | "method", 86 | "scheme", 87 | "root_path", 88 | "path", 89 | "querystring", 90 | "server", 91 | "client", 92 | ]: 93 | assert getattr(request, attr) == getattr(expected_request, attr) 94 | 95 | assert (await request.json()) == {"b": "c"} 96 | 97 | assert request.headers == expected_request.headers 98 | 99 | 100 | @pytest.mark.asyncio 101 | @pytest.mark.parametrize( 102 | "method", 103 | [ 104 | "GET", 105 | "HEAD", 106 | "POST", 107 | "PUT", 108 | "DELETE", 109 | "CONNECT", 110 | "OPTIONS", 111 | "TRACE", 112 | "PATCH", 113 | ], 114 | ) 115 | async def test_test_client_request(test_client: Client, method: str): 116 | response = await test_client.request(method, "/any/method") 117 | assert isinstance(response, PlainTextResponse) 118 | assert response.body == method 119 | 120 | 121 | @pytest.mark.asyncio 122 | @pytest.mark.parametrize( 123 | "method", 124 | [ 125 | "GET", 126 | "HEAD", 127 | "POST", 128 | "PUT", 129 | "DELETE", 130 | "CONNECT", 131 | "OPTIONS", 132 | "TRACE", 133 | "PATCH", 134 | ], 135 | ) 136 | async def test_test_client_request_method(test_client: Client, method: str): 137 | response = await getattr(test_client, method.lower())("/any/method") 138 | assert isinstance(response, PlainTextResponse) 139 | assert response.body == method 140 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pytest 4 | 5 | from baguette.headers import Headers 6 | from baguette.httpexceptions import NotFound 7 | from baguette.utils import ( 8 | address_to_str, 9 | file_path_to_path, 10 | get_encoding_from_headers, 11 | safe_join, 12 | split_on_first, 13 | ) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | ["headers", "encoding"], 18 | [ 19 | [Headers(), None], 20 | [Headers(("content-type", "text/plain")), "ISO-8859-1"], 21 | [Headers(("content-type", "text/plain; charset=utf-8")), "utf-8"], 22 | [Headers(("content-type", "text/plain; charset='utf-8'")), "utf-8"], 23 | [Headers(("content-type", 'text/plain; charset="utf-8"')), "utf-8"], 24 | ], 25 | ) 26 | def test_get_encoding_from_headers(headers, encoding): 27 | assert get_encoding_from_headers(headers) == encoding 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "paths", 32 | [ 33 | ["test/test/test"], 34 | ["test", "test/test"], 35 | [b"test/test/test"], 36 | ["test", b"test/test"], 37 | [b"test", b"test/test"], 38 | [pathlib.Path("test/test/test")], 39 | [pathlib.Path("test"), pathlib.Path("test/test")], 40 | ], 41 | ) 42 | def test_file_path_to_path(paths): 43 | path = file_path_to_path(*paths) 44 | assert path == pathlib.Path("test/test/test") 45 | 46 | 47 | @pytest.mark.parametrize( 48 | ["directory", "paths"], 49 | [ 50 | ["tests", ["static", "css/style.css"]], 51 | ["tests/static", ["css", "style.css"]], 52 | ], 53 | ) 54 | def test_safe_join(directory, paths): 55 | path = safe_join(directory, *paths) 56 | assert path == pathlib.Path("tests/static/css/style.css").resolve() 57 | 58 | 59 | def test_safe_join_error(): 60 | with pytest.raises(NotFound): 61 | safe_join("nonexistent", "css/style.css") 62 | with pytest.raises(NotFound): 63 | safe_join("tests/static", "nonexistent") 64 | with pytest.raises(NotFound): 65 | safe_join("tests/static", "..") 66 | 67 | 68 | @pytest.mark.parametrize( 69 | ["text", "sep", "expected"], 70 | [ 71 | ["test", ":", ("test", "")], 72 | ["test:test", ":", ("test", "test")], 73 | ["test:test:test", ":", ("test", "test:test")], 74 | [b"test", b":", (b"test", b"")], 75 | [b"test:test", b":", (b"test", b"test")], 76 | [b"test:test:test", b":", (b"test", b"test:test")], 77 | ], 78 | ) 79 | def test_split_on_first(text, sep, expected): 80 | assert split_on_first(text, sep) == expected 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ["address", "expected"], 85 | [ 86 | [("", 0), ":0"], 87 | [("1.2.3.4", 1234), "1.2.3.4:1234"], 88 | ], 89 | ) 90 | def test_address_to_str(address, expected): 91 | assert address_to_str(address) == expected 92 | -------------------------------------------------------------------------------- /tests/test_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from baguette.app import Baguette 4 | from baguette.httpexceptions import MethodNotAllowed 5 | from baguette.responses import make_response 6 | from baguette.view import View 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_view_create(): 11 | class TestView(View): 12 | async def get(self, request): 13 | return "GET" 14 | 15 | async def post(self, request): 16 | return "POST" 17 | 18 | async def put(self, request): 19 | return "PUT" 20 | 21 | async def delete(self, request): 22 | return "DELETE" 23 | 24 | async def nonexistent_method(self, request): 25 | return "NONEXISTENT" 26 | 27 | view = TestView(Baguette()) 28 | assert view.methods == ["GET", "POST", "PUT", "DELETE"] 29 | assert await view.get(None) == "GET" 30 | assert await view.post(None) == "POST" 31 | assert await view.put(None) == "PUT" 32 | assert await view.delete(None) == "DELETE" 33 | assert await view.nonexistent_method(None) == "NONEXISTENT" 34 | 35 | 36 | @pytest.fixture(name="view") 37 | def create_view(): 38 | class TestView(View): 39 | async def get(self, request): 40 | return "GET" 41 | 42 | async def post(self, request): 43 | return "POST" 44 | 45 | async def put(self, request): 46 | return "PUT" 47 | 48 | async def delete(self, request): 49 | return "DELETE" 50 | 51 | return TestView(Baguette()) 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_view_call(view, test_request): 56 | result = await view(test_request) 57 | response = make_response(result) 58 | assert response.status_code == 200 59 | assert response.body == "GET" 60 | 61 | 62 | @pytest.mark.asyncio 63 | @pytest.mark.parametrize( 64 | ["method", "method_allowed"], 65 | [ 66 | ["GET", True], 67 | ["POST", True], 68 | ["PUT", True], 69 | ["DELETE", True], 70 | ["PATCH", False], 71 | ["NONEXISTENT", False], 72 | ], 73 | ) 74 | async def test_view_dispatch(view, test_request, method, method_allowed): 75 | test_request.method = method 76 | 77 | if method_allowed: 78 | result = await view.dispatch(test_request) 79 | response = make_response(result) 80 | 81 | assert response.status_code == 200 82 | assert response.body == method 83 | else: 84 | with pytest.raises(MethodNotAllowed): 85 | await view.dispatch(test_request) 86 | --------------------------------------------------------------------------------