├── .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 |
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 "