├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build-manylinux-wheels.sh │ ├── main.yml │ ├── perf.yml │ └── perfhistory.yml ├── .gitignore ├── .isort.cfg ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── blacksheep.svg ├── blacksheep ├── __init__.py ├── asgi.pyi ├── baseapp.pxd ├── baseapp.py ├── baseapp.pyi ├── baseapp.pyx ├── client │ ├── __init__.py │ ├── connection.py │ ├── cookies.py │ ├── exceptions.py │ ├── parser.py │ ├── pool.py │ └── session.py ├── common │ ├── __init__.py │ ├── files │ │ ├── __init__.py │ │ ├── asyncfs.py │ │ ├── info.py │ │ └── pathsutils.py │ └── types.py ├── contents.pxd ├── contents.py ├── contents.pyi ├── contents.pyx ├── cookies.pxd ├── cookies.py ├── cookies.pyi ├── cookies.pyx ├── exceptions.pxd ├── exceptions.py ├── exceptions.pyi ├── exceptions.pyx ├── headers.pxd ├── headers.py ├── headers.pyi ├── headers.pyx ├── messages.pxd ├── messages.py ├── messages.pyi ├── messages.pyx ├── middlewares.py ├── multipart.py ├── normalization.py ├── py.typed ├── ranges.py ├── scribe.pxd ├── scribe.py ├── scribe.pyi ├── scribe.pyx ├── server │ ├── __init__.py │ ├── application.py │ ├── asgi.py │ ├── authentication │ │ ├── __init__.py │ │ ├── cookie.py │ │ ├── jwt.py │ │ └── oidc.py │ ├── authorization │ │ └── __init__.py │ ├── bindings.py │ ├── compression.py │ ├── controllers.py │ ├── cors.py │ ├── csrf.py │ ├── dataprotection.py │ ├── di.py │ ├── diagnostics.py │ ├── env.py │ ├── errors.py │ ├── files │ │ ├── __init__.py │ │ ├── dynamic.py │ │ └── static.py │ ├── headers │ │ ├── __init__.py │ │ └── cache.py │ ├── normalization.py │ ├── openapi │ │ ├── __init__.py │ │ ├── common.py │ │ ├── docstrings.py │ │ ├── exceptions.py │ │ ├── ui.py │ │ └── v3.py │ ├── process.py │ ├── redirects.py │ ├── remotes │ │ ├── __init__.py │ │ ├── forwarding.py │ │ └── hosts.py │ ├── rendering │ │ ├── __init__.py │ │ ├── abc.py │ │ ├── jinja2.py │ │ └── models.py │ ├── res │ │ ├── __init__.py │ │ ├── error.css │ │ ├── error.html │ │ ├── fileslist.html │ │ ├── redoc-ui.html │ │ ├── scalar-ui.html │ │ └── swagger-ui.html │ ├── resources.py │ ├── responses.py │ ├── routing.py │ ├── security │ │ ├── __init__.py │ │ └── hsts.py │ ├── sse.py │ └── websocket.py ├── sessions │ └── __init__.py ├── settings │ ├── __init__.py │ ├── di.py │ ├── html.py │ └── json.py ├── testing │ ├── __init__.py │ ├── client.py │ ├── helpers.py │ ├── messages.py │ ├── simulator.py │ └── websocket.py ├── url.pxd ├── url.py ├── url.pyi ├── url.pyx └── utils │ ├── __init__.py │ ├── aio.py │ ├── meta.py │ └── time.py ├── itests ├── README.md ├── __init__.py ├── app_1.py ├── app_2.py ├── app_3.py ├── app_4.py ├── client_fixtures.py ├── conftest.py ├── example.html ├── example.jpg ├── flask_app.py ├── logs.py ├── lorem.py ├── requirements.txt ├── server_fixtures.py ├── static │ ├── README.md │ ├── example-com.html │ ├── example.html │ ├── example.xml │ ├── pexels-photo-126407.jpeg │ └── pexels-photo-923360.jpeg ├── test_auth_oidc.py ├── test_client.py ├── test_server.py ├── test_utils_aio.py └── utils.py ├── perf ├── README.md ├── benchmarks │ ├── __init__.py │ ├── app.py │ ├── res │ │ └── lorem.txt │ ├── url.py │ └── writeresponse.py ├── genreport.py ├── historyrun.py ├── main.py ├── req.txt └── utils │ └── md5.py ├── pyproject.toml ├── pytest.ini ├── requirements.pypy.txt ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── client ├── __init__.py ├── test_client.py ├── test_connection.py ├── test_cookiejar.py ├── test_headers.py ├── test_middlewares.py ├── test_pool.py ├── test_query.py ├── test_redirects.py └── test_timeouts.py ├── conftest.py ├── examples ├── __init__.py └── multipart.py ├── files ├── README.md ├── example.txt ├── lorem-ipsum.txt ├── pexels-photo-126407.jpeg ├── pexels-photo-302280.jpeg ├── pexels-photo-730896.jpeg └── pexels-photo-923360.jpeg ├── files2 ├── example.config ├── example.xml ├── index.html ├── scripts │ └── main.js └── styles │ ├── fonts │ └── foo.txt │ └── main.css ├── files3 └── lorem-ipsum.txt ├── mock_protocol.py ├── res ├── 0.pem ├── foreign.pem ├── jwks.json └── multipart-mix.dat ├── test_application.py ├── test_auth.py ├── test_auth_cookie.py ├── test_bindings.py ├── test_caching.py ├── test_contents.py ├── test_controllers.py ├── test_cookies.py ├── test_cors.py ├── test_csrf.py ├── test_dataprotection.py ├── test_env.py ├── test_files_handler.py ├── test_files_serving.py ├── test_forwarding.py ├── test_gzip.py ├── test_headers.py ├── test_multipart.py ├── test_normalization.py ├── test_openapi_docstrings.py ├── test_openapi_v3.py ├── test_pathutils.py ├── test_ranges.py ├── test_requests.py ├── test_responses.py ├── test_router.py ├── test_sessions.py ├── test_templating.py ├── test_testing.py ├── test_url.py ├── test_utils.py ├── test_websocket.py ├── testapp ├── __init__.py └── templates │ ├── form_1.jinja │ ├── form_2.jinja │ ├── home.jinja │ └── lorem │ ├── form_1.jinja │ ├── hello.jinja │ ├── index.jinja │ ├── nomodel.jinja │ └── specific.jinja └── utils ├── __init__.py ├── application.py └── folder.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, W503, E704, E701, F824 3 | max-line-length = 88 4 | max-complexity = 18 5 | per-file-ignores = 6 | blacksheep/__init__.py:F401 7 | blacksheep/client/__init__.py:F401 8 | blacksheep/server/__init__.py:F401 9 | blacksheep/url.py:E501 10 | tests/*:E501 11 | itests/*:E501 12 | itests/conftest.py:F401,F403 13 | 14 | exclude = 15 | env 16 | .env 17 | venv 18 | venv311 19 | build 20 | .eggs 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: "🐛 Bug Report" 12 | about: "If something isn't working as expected." 13 | title: '' 14 | --- 15 | 16 | **Describe the bug** 17 | A description of what the bug is, possibly including how to reproduce it. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🚀 Feature Request" 3 | about: "I have a suggestion (and may want to implement it)!" 4 | title: '' 5 | 6 | --- 7 | 8 | ##### _Note: consider using [Discussions](https://github.com/Neoteroi/BlackSheep/discussions) to open a conversation about new features…_ 9 | 10 | **🚀 Feature Request** 11 | -------------------------------------------------------------------------------- /.github/workflows/build-manylinux-wheels.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -x 4 | 5 | PY_MAJOR=${PYTHON_VERSION%%.*} 6 | PY_MINOR=${PYTHON_VERSION#*.} 7 | 8 | ML_PYTHON_VERSION="cp${PY_MAJOR}${PY_MINOR}-cp${PY_MAJOR}${PY_MINOR}" 9 | if [ "${PY_MAJOR}" -lt "4" -a "${PY_MINOR}" -lt "8" ]; then 10 | ML_PYTHON_VERSION+="m" 11 | fi 12 | 13 | # Compile wheels 14 | PYTHON="/opt/python/${ML_PYTHON_VERSION}/bin/python" 15 | PIP="/opt/python/${ML_PYTHON_VERSION}/bin/pip" 16 | "${PIP}" install --upgrade build 17 | cd "${GITHUB_WORKSPACE}" 18 | 19 | rm -rf dist/ 20 | "${PYTHON}" -m build 21 | 22 | # Bundle external shared libraries into the wheels. 23 | for whl in "${GITHUB_WORKSPACE}"/dist/*.whl; do 24 | auditwheel repair $whl -w "${GITHUB_WORKSPACE}"/dist/ 25 | rm "${GITHUB_WORKSPACE}"/dist/*-linux_*.whl 26 | done 27 | -------------------------------------------------------------------------------- /.github/workflows/perf.yml: -------------------------------------------------------------------------------- 1 | #################################################################################### 2 | # Runs benchmarks for BlackSheep source code for various versions of Python 3 | # and Operating System and publishes the results. 4 | # See the perf folder for more information. 5 | #################################################################################### 6 | name: Benchmark 7 | 8 | on: 9 | # push: 10 | # paths: 11 | # - '.github/**' 12 | # - 'perf/**' 13 | workflow_dispatch: 14 | 15 | jobs: 16 | perf-tests: 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.11", "3.12", "3.13"] 21 | os: [ubuntu-latest, macos-latest, windows-latest] 22 | runs-on: ${{ matrix.os }} 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 9 28 | submodules: false 29 | 30 | - name: Use Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | pip install -r requirements.txt 38 | pip install flake8 39 | 40 | - name: Compile Cython extensions 41 | run: | 42 | cython blacksheep/url.pyx 43 | cython blacksheep/exceptions.pyx 44 | cython blacksheep/headers.pyx 45 | cython blacksheep/cookies.pyx 46 | cython blacksheep/contents.pyx 47 | cython blacksheep/messages.pyx 48 | cython blacksheep/scribe.pyx 49 | cython blacksheep/baseapp.pyx 50 | python setup.py build_ext --inplace 51 | 52 | - name: Install dependencies for benchmark 53 | run: | 54 | pip install memory-profiler==0.61.0 psutil==7.0.0 55 | 56 | - name: Run benchmark 57 | shell: bash 58 | run: | 59 | export PYTHONPATH="." 60 | python perf/main.py --times 5 61 | 62 | - name: Upload results 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }} 66 | path: benchmark_results 67 | 68 | perf-tests-pypy: 69 | strategy: 70 | fail-fast: false 71 | matrix: 72 | python-version: ["pypy-3.11"] 73 | os: [ubuntu-latest, macos-latest, windows-latest] 74 | runs-on: ${{ matrix.os }} 75 | 76 | steps: 77 | - uses: actions/checkout@v4 78 | with: 79 | fetch-depth: 9 80 | submodules: false 81 | 82 | - name: Use Python ${{ matrix.python-version }} 83 | uses: actions/setup-python@v5 84 | with: 85 | python-version: ${{ matrix.python-version }} 86 | 87 | - name: Install dependencies 88 | run: | 89 | pip install -r requirements.pypy.txt 90 | 91 | - name: Install dependencies for benchmark 92 | run: | 93 | pip install memory-profiler==0.61.0 psutil==7.0.0 94 | 95 | - name: Run benchmark 96 | shell: bash 97 | run: | 98 | export PYTHONPATH="." 99 | python perf/main.py --times 5 100 | 101 | - name: Upload results 102 | uses: actions/upload-artifact@v4 103 | with: 104 | name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }} 105 | path: benchmark_results 106 | 107 | genreport: 108 | runs-on: ubuntu-latest 109 | needs: [perf-tests, perf-tests-pypy] 110 | steps: 111 | - uses: actions/checkout@v4 112 | with: 113 | fetch-depth: 9 114 | submodules: false 115 | 116 | - name: Download a distribution artifact 117 | uses: actions/download-artifact@v4 118 | with: 119 | pattern: benchmark-results-* 120 | merge-multiple: true 121 | path: benchmark_results 122 | 123 | - name: Use Python 3.13 124 | uses: actions/setup-python@v5 125 | with: 126 | python-version: '3.13' 127 | 128 | - name: Install dependencies 129 | run: | 130 | cd perf 131 | pip install -r req.txt 132 | 133 | - name: Generate report 134 | shell: bash 135 | run: | 136 | ls -R benchmark_results 137 | chmod -R 755 benchmark_results 138 | 139 | export PYTHONPATH="." 140 | python perf/genreport.py 141 | python perf/genreport.py --output windows-results.xlsx --platform Windows 142 | python perf/genreport.py --output linux-results.xlsx --platform Linux 143 | python perf/genreport.py --output macos-results.xlsx --platform macOS 144 | 145 | - name: Upload reports 146 | uses: actions/upload-artifact@v4 147 | with: 148 | name: benchmark-reports 149 | path: "**/*.xlsx" # Upload all .xlsx files 150 | -------------------------------------------------------------------------------- /.github/workflows/perfhistory.yml: -------------------------------------------------------------------------------- 1 | #################################################################################### 2 | # Runs benchmarks for BlackSheep source code at various points of the commit history, 3 | # on Ubuntu and Windows for a single version of Python. 4 | # 5 | # This workflow supports both manual and automatic triggers. 6 | # If triggered manually, it is possible to select commits hashes or tags to checkout. 7 | # 8 | # The minimum supported BlackSheep version by the benchmarks is v2.0.1! 9 | #################################################################################### 10 | name: HistoryBenchmark 11 | 12 | on: 13 | # push: 14 | # paths: 15 | # - '.github/**' 16 | # - 'perf/**' 17 | workflow_dispatch: 18 | inputs: 19 | commits: 20 | description: "List of commits or tags to benchmark (space-separated)" 21 | required: true 22 | default: "v2.0.1 v2.2.0 v2.3.0 current" 23 | memory: 24 | description: "Include memory benchmark (Y|N). Time consuming." 25 | required: true 26 | default: "N" 27 | 28 | env: 29 | DEFAULT_MEMORY: "N" 30 | DEFAULT_COMMITS: "v2.0.1 v2.2.0 v2.3.0 current" 31 | 32 | jobs: 33 | perf-tests: 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | python-version: ["3.13"] 38 | os: [ubuntu-latest, windows-latest] 39 | runs-on: ${{ matrix.os }} 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 0 45 | submodules: false 46 | 47 | - name: Use Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | 52 | - name: Install dependencies 53 | run: | 54 | pip install -r requirements.txt 55 | cd perf 56 | pip install -r req.txt 57 | 58 | - name: Run benchmark 59 | shell: bash 60 | env: 61 | MEMORY: ${{ github.event.inputs.memory || env.DEFAULT_MEMORY }} 62 | COMMITS: ${{ github.event.inputs.commits || env.DEFAULT_COMMITS }} 63 | run: | 64 | 65 | echo "Running benchmarks for commits: $COMMITS" 66 | export PYTHONPATH="." 67 | 68 | if [ $MEMORY == "Y" ]; then 69 | echo "➔ Including memory benchmarks 🟢" 70 | python perf/historyrun.py --commits $COMMITS --times 3 --memory 71 | else 72 | echo "➔ Excluding memory benchmarks 🔴" 73 | python perf/historyrun.py --commits $COMMITS --times 3 --no-memory 74 | fi 75 | 76 | - name: Upload results 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }} 80 | path: benchmark_results 81 | 82 | genreport: 83 | runs-on: ubuntu-latest 84 | needs: [perf-tests] 85 | steps: 86 | - uses: actions/checkout@v4 87 | with: 88 | fetch-depth: 0 89 | submodules: false 90 | 91 | - name: Download a distribution artifact 92 | uses: actions/download-artifact@v4 93 | with: 94 | pattern: benchmark-results-* 95 | merge-multiple: true 96 | path: benchmark_results 97 | 98 | - name: Use Python 3.13 99 | uses: actions/setup-python@v5 100 | with: 101 | python-version: '3.13' 102 | 103 | - name: Install dependencies 104 | run: | 105 | cd perf 106 | pip install -r req.txt 107 | 108 | - name: Generate report 109 | shell: bash 110 | run: | 111 | ls -R benchmark_results 112 | chmod -R 755 benchmark_results 113 | 114 | export PYTHONPATH="." 115 | python perf/genreport.py 116 | python perf/genreport.py --output windows-results.xlsx --platform Windows 117 | python perf/genreport.py --output linux-results.xlsx --platform Linux 118 | 119 | - name: Upload reports 120 | uses: actions/upload-artifact@v4 121 | with: 122 | name: benchmark-reports 123 | path: "**/*.xlsx" # Upload all .xlsx files 124 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | *.orig 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | py37venv 94 | py38venv 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | .idea/ 107 | .vscode/ 108 | 109 | tests/out/ 110 | 111 | # mypy 112 | .mypy_cache/ 113 | 114 | *,cover 115 | 116 | blacksheep/*.c 117 | blacksheep/*.html 118 | blacksheep/includes/*.html 119 | out/* 120 | 121 | # Output of integration tests 122 | itests/out/* 123 | /itests/nice-cat.jpg 124 | 125 | example.html 126 | example.jpg 127 | nice-cat.jpg 128 | venv* 129 | 130 | .local/* 131 | benchmark_results/ 132 | *.xlsx 133 | .~lock* 134 | .cython-hash 135 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | multi_line_output = 3 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `roberto.prevato@gmail.com`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Roberto Prevato roberto.prevato@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include blacksheep/server/res/*.html 4 | include blacksheep/server/res/*.css 5 | include build_info.txt 6 | recursive-include blacksheep *.pyx *.pxd *.pxi *.pyi *.py *.c *.h 7 | 8 | # Stubs 9 | include blacksheep/py.typed 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: compile release test annotate buildext check-isort check-black 2 | 3 | 4 | cyt: 5 | cython blacksheep/url.pyx 6 | cython blacksheep/exceptions.pyx 7 | cython blacksheep/headers.pyx 8 | cython blacksheep/cookies.pyx 9 | cython blacksheep/contents.pyx 10 | cython blacksheep/messages.pyx 11 | cython blacksheep/scribe.pyx 12 | cython blacksheep/baseapp.pyx 13 | 14 | compile: cyt 15 | python3 setup.py build_ext --inplace 16 | 17 | 18 | clean: 19 | rm -rf dist/ 20 | rm -rf build/ 21 | rm -f blacksheep/*.c 22 | rm -f blacksheep/*.so 23 | 24 | 25 | buildext: 26 | python3 setup.py build_ext --inplace 27 | 28 | 29 | annotate: 30 | cython blacksheep/url.pyx -a 31 | cython blacksheep/exceptions.pyx -a 32 | cython blacksheep/headers.pyx -a 33 | cython blacksheep/cookies.pyx -a 34 | cython blacksheep/contents.pyx -a 35 | cython blacksheep/messages.pyx -a 36 | cython blacksheep/scribe.pyx -a 37 | cython blacksheep/baseapp.pyx -a 38 | 39 | 40 | build: test 41 | python -m build 42 | 43 | 44 | prepforbuild: 45 | pip install --upgrade build 46 | 47 | 48 | testrelease: 49 | twine upload -r testpypi dist/* 50 | 51 | 52 | release: clean compile artifacts 53 | twine upload -r pypi dist/* 54 | 55 | 56 | test: 57 | pytest tests/ 58 | 59 | 60 | itest: 61 | APP_DEFAULT_ROUTER=false pytest itests/ 62 | 63 | 64 | init: 65 | pip install -r requirements.txt 66 | 67 | 68 | test-v: 69 | pytest -v 70 | 71 | 72 | test-cov-unit: 73 | pytest --cov-report html --cov=blacksheep tests 74 | 75 | 76 | test-cov: 77 | pytest --cov-report html --cov=blacksheep --disable-warnings 78 | 79 | 80 | lint: check-flake8 check-isort check-black 81 | 82 | format: 83 | @isort blacksheep 2>&1 84 | @isort tests 2>&1 85 | @isort itests 2>&1 86 | @black blacksheep 2>&1 87 | @black tests 2>&1 88 | @black itests 2>&1 89 | 90 | check-flake8: 91 | @echo "$(BOLD)Checking flake8$(RESET)" 92 | @flake8 blacksheep 2>&1 93 | @flake8 itests 2>&1 94 | @flake8 tests 2>&1 95 | 96 | 97 | check-isort: 98 | @echo "$(BOLD)Checking isort$(RESET)" 99 | @isort --check-only blacksheep 2>&1 100 | @isort --check-only tests 2>&1 101 | @isort --check-only itests 2>&1 102 | 103 | 104 | check-black: ## Run the black tool in check mode only (won't modify files) 105 | @echo "$(BOLD)Checking black$(RESET)" 106 | @black --check blacksheep 2>&1 107 | @black --check tests 2>&1 108 | @black --check itests 2>&1 109 | -------------------------------------------------------------------------------- /blacksheep/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Root module of the framework. This module re-exports the most commonly 3 | used types to reduce the verbosity of the imports statements. 4 | """ 5 | 6 | __author__ = "Roberto Prevato " 7 | __version__ = "2.3.1" 8 | 9 | from .contents import Content as Content 10 | from .contents import FormContent as FormContent 11 | from .contents import FormPart as FormPart 12 | from .contents import HTMLContent as HTMLContent 13 | from .contents import JSONContent as JSONContent 14 | from .contents import MultiPartFormData as MultiPartFormData 15 | from .contents import StreamedContent as StreamedContent 16 | from .contents import TextContent as TextContent 17 | from .contents import parse_www_form as parse_www_form 18 | from .cookies import Cookie as Cookie 19 | from .cookies import CookieSameSiteMode as CookieSameSiteMode 20 | from .cookies import datetime_from_cookie_format as datetime_from_cookie_format 21 | from .cookies import datetime_to_cookie_format as datetime_to_cookie_format 22 | from .cookies import parse_cookie as parse_cookie 23 | from .exceptions import HTTPException as HTTPException 24 | from .headers import Header as Header 25 | from .headers import Headers as Headers 26 | from .messages import Message as Message 27 | from .messages import Request as Request 28 | from .messages import Response as Response 29 | from .server.application import Application as Application 30 | from .server.authorization import allow_anonymous as allow_anonymous 31 | from .server.authorization import auth as auth 32 | from .server.bindings import ClientInfo as ClientInfo 33 | from .server.bindings import FromBytes as FromBytes 34 | from .server.bindings import FromCookie as FromCookie 35 | from .server.bindings import FromFiles as FromFiles 36 | from .server.bindings import FromForm as FromForm 37 | from .server.bindings import FromHeader as FromHeader 38 | from .server.bindings import FromJSON as FromJSON 39 | from .server.bindings import FromQuery as FromQuery 40 | from .server.bindings import FromRoute as FromRoute 41 | from .server.bindings import FromServices as FromServices 42 | from .server.bindings import FromText as FromText 43 | from .server.bindings import ServerInfo as ServerInfo 44 | from .server.responses import ContentDispositionType as ContentDispositionType 45 | from .server.responses import FileInput as FileInput 46 | from .server.responses import accepted as accepted 47 | from .server.responses import bad_request as bad_request 48 | from .server.responses import created as created 49 | from .server.responses import file as file 50 | from .server.responses import forbidden as forbidden 51 | from .server.responses import html as html 52 | from .server.responses import json as json 53 | from .server.responses import moved_permanently as moved_permanently 54 | from .server.responses import no_content as no_content 55 | from .server.responses import not_found as not_found 56 | from .server.responses import not_modified as not_modified 57 | from .server.responses import ok as ok 58 | from .server.responses import permanent_redirect as permanent_redirect 59 | from .server.responses import pretty_json as pretty_json 60 | from .server.responses import redirect as redirect 61 | from .server.responses import see_other as see_other 62 | from .server.responses import status_code as status_code 63 | from .server.responses import temporary_redirect as temporary_redirect 64 | from .server.responses import text as text 65 | from .server.responses import unauthorized as unauthorized 66 | from .server.routing import Route as Route 67 | from .server.routing import RouteException as RouteException 68 | from .server.routing import Router as Router 69 | from .server.routing import RoutesRegistry as RoutesRegistry 70 | from .server.routing import connect as connect 71 | from .server.routing import delete as delete 72 | from .server.routing import get as get 73 | from .server.routing import head as head 74 | from .server.routing import options as options 75 | from .server.routing import patch as patch 76 | from .server.routing import post as post 77 | from .server.routing import put as put 78 | from .server.routing import route as route 79 | from .server.routing import trace as trace 80 | from .server.routing import ws as ws 81 | from .server.websocket import WebSocket as WebSocket 82 | from .server.websocket import WebSocketDisconnectError as WebSocketDisconnectError 83 | from .server.websocket import WebSocketError as WebSocketError 84 | from .server.websocket import WebSocketState as WebSocketState 85 | from .url import URL as URL 86 | from .url import InvalidURL as InvalidURL 87 | -------------------------------------------------------------------------------- /blacksheep/asgi.pyi: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, TypedDict 2 | 3 | class ASGIScopeInterface(TypedDict): 4 | type: str 5 | http_version: str 6 | server: Tuple[str, int] 7 | client: Tuple[str, int] 8 | scheme: str 9 | method: str 10 | path: str 11 | root_path: str 12 | raw_path: bytes 13 | query_string: str 14 | headers: List[Tuple[bytes, bytes]] 15 | -------------------------------------------------------------------------------- /blacksheep/baseapp.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3, embedsignature=True 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | from .exceptions cimport HTTPException 8 | 9 | 10 | cdef class BaseApplication: 11 | 12 | cdef public bint show_error_details 13 | cdef readonly object router 14 | cdef readonly object logger 15 | cdef public dict exceptions_handlers 16 | cpdef object get_http_exception_handler(self, HTTPException http_exception) 17 | cdef object get_exception_handler(self, Exception exception, type stop_at) 18 | cdef bint is_handled_exception(self, Exception exception) 19 | -------------------------------------------------------------------------------- /blacksheep/baseapp.pyi: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Awaitable, Callable, Dict, Optional, Type, TypeVar, Union 3 | 4 | from blacksheep.exceptions import HTTPException 5 | from blacksheep.messages import Request, Response 6 | from blacksheep.server.application import Application 7 | from blacksheep.server.routing import RouteMatch, Router 8 | 9 | ExcT = TypeVar("ExcT", bound=Exception) 10 | 11 | ExceptionHandlersType = Dict[ 12 | Union[int, Type[Exception]], 13 | Callable[[Application, Request, ExcT], Awaitable[Response]], 14 | ] 15 | 16 | class BaseApplication: 17 | def __init__(self, show_error_details: bool, router: Router): 18 | self.router = router 19 | self.exceptions_handlers = self.init_exceptions_handlers() 20 | self.show_error_details = show_error_details 21 | 22 | def init_exceptions_handlers(self) -> ExceptionHandlersType: ... 23 | async def handle(self, request: Request) -> Response: ... 24 | async def handle_internal_server_error( 25 | self, request: Request, exc: Exception 26 | ) -> Response: ... 27 | async def handle_http_exception( 28 | self, request: Request, http_exception: HTTPException 29 | ) -> Response: ... 30 | async def handle_exception(self, request: Request, exc: Exception) -> Response: ... 31 | async def handle_request_handler_exception( 32 | self, request: Request, exc: Exception 33 | ) -> Response: ... 34 | def get_route_match(self, request: Request) -> Optional[RouteMatch]: ... 35 | def get_http_exception_handler( 36 | self, exc: HTTPException 37 | ) -> Optional[ 38 | Callable[[Application, Request, HTTPException], Awaitable[Response]] 39 | ]: ... 40 | 41 | def get_logger() -> logging.Logger: ... 42 | def handle_not_found( 43 | app: BaseApplication, request: Request, http_exception: HTTPException 44 | ) -> Response: ... 45 | def handle_internal_server_error( 46 | app: BaseApplication, request: Request, exception: Exception 47 | ) -> Response: ... 48 | -------------------------------------------------------------------------------- /blacksheep/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import CircularRedirectError as CircularRedirectError 2 | from .session import ClientSession as ClientSession 3 | from .session import ConnectionTimeout as ConnectionTimeout 4 | from .session import MaximumRedirectsExceededError as MaximumRedirectsExceededError 5 | from .session import MissingLocationForRedirect as MissingLocationForRedirect 6 | from .session import RequestTimeout as RequestTimeout 7 | -------------------------------------------------------------------------------- /blacksheep/client/exceptions.py: -------------------------------------------------------------------------------- 1 | from blacksheep import URL 2 | 3 | 4 | class ClientException(Exception): 5 | """Base class for BlackSheep Client Exceptions.""" 6 | 7 | 8 | class InvalidResponseException(ClientException): 9 | def __init__(self, message, response): 10 | super().__init__(message) 11 | self.response = response 12 | 13 | 14 | class MissingLocationForRedirect(InvalidResponseException): 15 | def __init__(self, response): 16 | super().__init__( 17 | f"The server returned a redirect status ({response.status}) " 18 | f'but didn`t send a "Location" header', 19 | response, 20 | ) 21 | 22 | 23 | class ConnectionTimeout(TimeoutError): 24 | def __init__(self, url: URL, timeout: float): 25 | super().__init__( 26 | f"Connection attempt timed out, to {url.value.decode()}. " 27 | f"Current timeout setting: {timeout}." 28 | ) 29 | 30 | 31 | class RequestTimeout(TimeoutError): 32 | def __init__(self, url: URL, timeout: float): 33 | super().__init__( 34 | f"Request timed out, to: {url.value.decode()}. " 35 | f"Current timeout setting: {timeout}." 36 | ) 37 | 38 | 39 | class CircularRedirectError(InvalidResponseException): 40 | def __init__(self, path, response): 41 | path_string = " --> ".join(x.decode("utf8") for x in path) 42 | super().__init__( 43 | f"Circular redirects detected. " f"Requests path was: ({path_string}).", 44 | response, 45 | ) 46 | 47 | 48 | class MaximumRedirectsExceededError(InvalidResponseException): 49 | def __init__(self, path, response, maximum_redirects): 50 | path_string = ", ".join(x.decode("utf8") for x in path) 51 | super().__init__( 52 | f"Maximum Redirects Exceeded ({maximum_redirects}). " 53 | f"Requests path was: ({path_string}).", 54 | response, 55 | ) 56 | 57 | 58 | class UnsupportedRedirect(ClientException): 59 | """ 60 | Exception raised when the client cannot handle a redirect; 61 | for example if the redirect is to a URN (not a URL). In such case, 62 | we don't follow the redirect and return the response with location: the 63 | caller can handle it. 64 | """ 65 | 66 | def __init__(self, redirect_url: bytes) -> None: 67 | self.redirect_url = redirect_url 68 | -------------------------------------------------------------------------------- /blacksheep/client/parser.py: -------------------------------------------------------------------------------- 1 | try: 2 | import httptools 3 | except ImportError: 4 | httptools = None 5 | 6 | 7 | try: 8 | import h11 9 | except ImportError: 10 | h11 = None 11 | 12 | 13 | if httptools is not None: 14 | 15 | class HTTPToolsResponseParser: 16 | def __init__(self, connection) -> None: 17 | self.connection = connection 18 | self._parser = httptools.HttpResponseParser(connection) 19 | 20 | def feed_data(self, data: bytes) -> None: 21 | self._parser.feed_data(data) 22 | 23 | def get_status_code(self) -> int: 24 | return self._parser.get_status_code() 25 | 26 | def reset(self) -> None: 27 | self._parser = httptools.HttpResponseParser(self.connection) 28 | 29 | 30 | if h11 is not None: 31 | 32 | class H11ResponseParser: 33 | def __init__(self, connection) -> None: 34 | self.connection = connection 35 | self._conn = h11.Connection(h11.CLIENT) 36 | self._status_code = None 37 | self._headers = [] 38 | self._complete = False 39 | 40 | def feed_data(self, data: bytes) -> None: 41 | self._conn.receive_data(data) 42 | while True: 43 | event = self._conn.next_event() 44 | if event is h11.NEED_DATA: 45 | break 46 | if isinstance(event, h11.Response): 47 | self._status_code = event.status_code 48 | self._headers = [(k, v) for k, v in event.headers] 49 | self.connection.headers = self._headers 50 | self.connection.on_headers_complete() 51 | elif isinstance(event, h11.Data): 52 | self.connection.on_body(event.data) 53 | elif isinstance(event, h11.EndOfMessage): 54 | self._complete = True 55 | self.connection.on_message_complete() 56 | # Ignore other events 57 | 58 | def get_status_code(self) -> int: 59 | return self._status_code or 0 60 | 61 | def reset(self) -> None: 62 | self._conn = h11.Connection(h11.CLIENT) 63 | self._status_code = None 64 | self._headers = [] 65 | self._complete = False 66 | 67 | 68 | def get_default_parser(client_connection): 69 | 70 | if h11 is not None: 71 | return H11ResponseParser(client_connection) 72 | 73 | if httptools is not None: 74 | return HTTPToolsResponseParser(client_connection) 75 | 76 | raise RuntimeError( 77 | "Missing Python dependencies to provide a default HTTP Response parser. " 78 | "Install either h11 or httptools." 79 | ) 80 | -------------------------------------------------------------------------------- /blacksheep/common/__init__.py: -------------------------------------------------------------------------------- 1 | def extend(obj, cls): 2 | """ 3 | Applies a mixin to an instance of a class. 4 | 5 | This method is used in those scenarios where opting-in for a feature incurs a 6 | performance fee, so that said fee is paid only when features are used. 7 | """ 8 | base_cls = obj.__class__ 9 | 10 | # Check if the mixin is already applied 11 | if cls in base_cls.__mro__: 12 | return 13 | 14 | # Create a new class that combines the mixin and the original class 15 | base_cls_name = f"{base_cls.__name__}_{cls.__name__}" 16 | obj.__class__ = type(base_cls_name, (cls, base_cls), {}) 17 | -------------------------------------------------------------------------------- /blacksheep/common/files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/common/files/__init__.py -------------------------------------------------------------------------------- /blacksheep/common/files/asyncfs.py: -------------------------------------------------------------------------------- 1 | from asyncio import AbstractEventLoop 2 | from concurrent.futures.thread import ThreadPoolExecutor 3 | from typing import IO, Any, AnyStr, AsyncIterable, Callable, Optional, Union 4 | 5 | from blacksheep.utils.aio import get_running_loop 6 | 7 | 8 | class PoolClient: 9 | def __init__( 10 | self, 11 | loop: Optional[AbstractEventLoop] = None, 12 | executor: Optional[ThreadPoolExecutor] = None, 13 | ): 14 | self._loop = loop or get_running_loop() 15 | self._executor = executor 16 | 17 | @property 18 | def loop(self) -> AbstractEventLoop: 19 | return self._loop 20 | 21 | async def run(self, func, *args) -> Any: 22 | return await self._loop.run_in_executor(self._executor, func, *args) 23 | 24 | 25 | class FileContext(PoolClient): 26 | def __init__( 27 | self, 28 | file_path: str, 29 | *, 30 | loop: Optional[AbstractEventLoop] = None, 31 | mode: str = "rb", 32 | ): 33 | super().__init__(loop) 34 | self._file_path = file_path 35 | self._file = None 36 | self._mode = mode 37 | 38 | @property 39 | def mode(self) -> str: 40 | return self._mode 41 | 42 | @property 43 | def file(self) -> IO: 44 | if self._file is None: 45 | raise TypeError("The file is not open.") 46 | return self._file 47 | 48 | async def seek(self, offset: int, whence: int = 0) -> None: 49 | await self.run(self.file.seek, offset, whence) 50 | 51 | async def read(self, chunk_size: Optional[int] = None) -> bytes: 52 | return await self.run(self.file.read, chunk_size) 53 | 54 | async def write( 55 | self, data: Union[AnyStr, Callable[[], AsyncIterable[AnyStr]]] 56 | ) -> None: 57 | if isinstance(data, bytes) or isinstance(data, str): 58 | await self.run(self.file.write, data) 59 | else: 60 | async for chunk in data(): 61 | await self.run(self.file.write, chunk) 62 | 63 | async def chunks(self, chunk_size: int = 1024 * 64) -> AsyncIterable[AnyStr]: 64 | while True: 65 | chunk = await self.run(self.file.read, chunk_size) 66 | 67 | if not chunk: 68 | break 69 | 70 | yield chunk 71 | yield b"" 72 | 73 | async def open(self) -> IO: 74 | return await self.run(open, self._file_path, self._mode) 75 | 76 | async def __aenter__(self): 77 | self._file = await self.open() 78 | return self 79 | 80 | async def __aexit__(self, exc_type, exc_val, exc_tb): 81 | try: 82 | await self.run(self._file.close) 83 | finally: 84 | self._file = None 85 | 86 | 87 | class FilesHandler: 88 | """Provides methods to handle files asynchronously.""" 89 | 90 | def open( 91 | self, file_path: str, mode: str = "rb", loop: Optional[AbstractEventLoop] = None 92 | ) -> FileContext: 93 | return FileContext(file_path, mode=mode, loop=loop) 94 | 95 | async def read( 96 | self, file_path: str, size: Optional[int] = None, mode: str = "rb" 97 | ) -> AnyStr: 98 | async with self.open(file_path, mode=mode) as file: 99 | return await file.read(size) 100 | 101 | async def write( 102 | self, 103 | file_path: str, 104 | data: Union[AnyStr, Callable[[], AsyncIterable[AnyStr]]], 105 | mode: str = "wb", 106 | ) -> None: 107 | async with self.open(file_path, mode=mode) as file: 108 | await file.write(data) 109 | 110 | async def chunks( 111 | self, file_path: str, chunk_size: int = 1024 * 64 112 | ) -> AsyncIterable[AnyStr]: 113 | async with self.open(file_path) as file: 114 | async for chunk in file.chunks(chunk_size): 115 | yield chunk 116 | -------------------------------------------------------------------------------- /blacksheep/common/files/info.py: -------------------------------------------------------------------------------- 1 | import os 2 | from email.utils import formatdate 3 | 4 | from .pathsutils import get_mime_type_from_name 5 | 6 | 7 | class FileInfo: 8 | __slots__ = ("etag", "size", "mime", "modified_time") 9 | 10 | def __init__(self, size: int, etag: str, mime: str, modified_time: str): 11 | self.size = size 12 | self.etag = etag 13 | self.mime = mime 14 | self.modified_time = modified_time 15 | 16 | def __repr__(self): 17 | return ( 18 | f"" 21 | ) 22 | 23 | def to_dict(self): 24 | return {key: getattr(self, key, None) for key in FileInfo.__slots__} 25 | 26 | @classmethod 27 | def from_path(cls, resource_path: str): 28 | stat = os.stat(resource_path) 29 | return cls( 30 | stat.st_size, 31 | str(stat.st_mtime), 32 | get_mime_type_from_name(resource_path), 33 | formatdate(stat.st_mtime, usegmt=True), 34 | ) 35 | -------------------------------------------------------------------------------- /blacksheep/common/files/pathsutils.py: -------------------------------------------------------------------------------- 1 | from mimetypes import MimeTypes 2 | 3 | mime = MimeTypes() 4 | 5 | 6 | DEFAULT_MIME = "application/octet-stream" 7 | 8 | MIME_BY_EXTENSION = { 9 | ".ogg": "audio/ogg", 10 | ".jpg": "image/jpeg", 11 | ".jpeg": "image/jpeg", 12 | ".png": "image/png", 13 | ".woff2": "font/woff2", 14 | } 15 | 16 | 17 | def get_file_extension_from_name(name: str) -> str: 18 | if not name: 19 | return "" 20 | return name[name.rfind(".") :].lower() 21 | 22 | 23 | def get_mime_type_from_name(file_name: str) -> str: 24 | extension = get_file_extension_from_name(file_name) 25 | mime_type = mime.guess_type(file_name)[0] or DEFAULT_MIME 26 | 27 | if mime_type == DEFAULT_MIME: 28 | mime_type = MIME_BY_EXTENSION.get(extension, DEFAULT_MIME) 29 | return mime_type 30 | -------------------------------------------------------------------------------- /blacksheep/common/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common types annotations and functions. 3 | """ 4 | 5 | from typing import AnyStr, Dict, Iterable, List, Optional, Tuple, Union 6 | 7 | from blacksheep.url import URL 8 | 9 | KeyValuePair = Tuple[AnyStr, AnyStr] 10 | HeadersType = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]] 11 | ParamsType = Union[Dict[AnyStr, AnyStr], Iterable[KeyValuePair]] 12 | URLType = Union[str, bytes, URL] 13 | 14 | 15 | def _ensure_header_bytes(value: AnyStr) -> bytes: 16 | return value if isinstance(value, bytes) else value.encode("ascii") 17 | 18 | 19 | def _ensure_param_str(value: AnyStr) -> str: 20 | return value if isinstance(value, str) else value.decode("ascii") 21 | 22 | 23 | def normalize_headers( 24 | headers: Optional[HeadersType], 25 | ) -> Optional[List[Tuple[bytes, bytes]]]: 26 | if headers is None: 27 | return None 28 | if isinstance(headers, dict): 29 | return [ 30 | (_ensure_header_bytes(key), _ensure_header_bytes(value)) # type: ignore 31 | for key, value in headers.items() 32 | ] 33 | return [ 34 | (_ensure_header_bytes(key), _ensure_header_bytes(value)) 35 | for key, value in headers 36 | ] 37 | 38 | 39 | def normalize_params(params: Optional[ParamsType]) -> Optional[List[Tuple[str, str]]]: 40 | if params is None: 41 | return None 42 | if isinstance(params, dict): 43 | return [ 44 | (_ensure_param_str(key), _ensure_param_str(value)) # type: ignore 45 | for key, value in params.items() 46 | ] 47 | return [(_ensure_param_str(key), _ensure_param_str(value)) for key, value in params] 48 | -------------------------------------------------------------------------------- /blacksheep/contents.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3, embedsignature=True 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | 8 | cdef class Content: 9 | cdef readonly bytes type 10 | cdef readonly bytes body 11 | cdef readonly long long length 12 | 13 | 14 | cdef class StreamedContent(Content): 15 | cdef readonly object generator 16 | 17 | 18 | cdef class ASGIContent(Content): 19 | cdef readonly object receive 20 | cpdef void dispose(self) 21 | 22 | 23 | cdef class TextContent(Content): 24 | pass 25 | 26 | 27 | cdef class HTMLContent(Content): 28 | pass 29 | 30 | 31 | cdef class JSONContent(Content): 32 | pass 33 | 34 | 35 | cdef class FormContent(Content): 36 | pass 37 | 38 | 39 | cdef class FormPart: 40 | cdef readonly bytes name 41 | cdef readonly bytes data 42 | cdef readonly bytes content_type 43 | cdef readonly bytes file_name 44 | cdef readonly bytes charset 45 | 46 | 47 | cdef class ServerSentEvent: 48 | cdef readonly object data 49 | cdef readonly str event 50 | cdef readonly str id 51 | cdef readonly int retry 52 | cdef readonly str comment 53 | cpdef str write_data(self) 54 | 55 | 56 | cdef class TextServerSentEvent(ServerSentEvent): 57 | pass 58 | 59 | 60 | cdef class MultiPartFormData(Content): 61 | cdef readonly list parts 62 | cdef readonly bytes boundary 63 | 64 | 65 | cdef dict parse_www_form_urlencoded(str content) 66 | 67 | 68 | cdef dict multiparts_to_dictionary(list parts) 69 | -------------------------------------------------------------------------------- /blacksheep/cookies.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3, embedsignature=True 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | from cpython.datetime cimport datetime 8 | 9 | 10 | cpdef enum CookieSameSiteMode: 11 | UNDEFINED = 0 12 | LAX = 1 13 | STRICT = 2 14 | NONE = 3 15 | 16 | 17 | cdef class Cookie: 18 | cdef str _name 19 | cdef str _value 20 | cdef public datetime expires 21 | cdef public str domain 22 | cdef public str path 23 | cdef public bint http_only 24 | cdef public bint secure 25 | cdef public int max_age 26 | cdef public CookieSameSiteMode same_site 27 | cpdef Cookie clone(self) 28 | 29 | 30 | cpdef Cookie parse_cookie(bytes value) 31 | 32 | 33 | cpdef bytes datetime_to_cookie_format(datetime value) 34 | 35 | 36 | cpdef datetime datetime_from_cookie_format(bytes value) 37 | 38 | 39 | cdef bytes write_cookie_for_response(Cookie cookie) 40 | 41 | 42 | cdef tuple split_value(bytes raw_value, bytes separator) 43 | 44 | 45 | cdef CookieSameSiteMode same_site_mode_from_bytes(bytes raw_value) 46 | -------------------------------------------------------------------------------- /blacksheep/cookies.pyi: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import IntEnum 3 | from typing import Optional, Union 4 | 5 | class CookieSameSiteMode(IntEnum): 6 | UNDEFINED = 0 7 | LAX = 1 8 | STRICT = 2 9 | NONE = 3 10 | 11 | class CookieError(Exception): ... 12 | class CookieValueExceedsMaximumLength(CookieError): ... 13 | 14 | def datetime_to_cookie_format(value: datetime) -> bytes: ... 15 | def datetime_from_cookie_format(value: bytes) -> datetime: ... 16 | 17 | class Cookie: 18 | def __init__( 19 | self, 20 | name: str, 21 | value: str, 22 | expires: Optional[datetime] = None, 23 | domain: Optional[str] = None, 24 | path: Optional[str] = None, 25 | http_only: bool = False, 26 | secure: bool = False, 27 | max_age: int = -1, 28 | same_site: CookieSameSiteMode = CookieSameSiteMode.UNDEFINED, 29 | ): 30 | self.name = name 31 | self.value = value 32 | self.expires = expires 33 | self.domain = domain 34 | self.path = path 35 | self.http_only = http_only 36 | self.secure = secure 37 | self.max_age = max_age 38 | self.same_site = same_site 39 | 40 | def clone(self) -> "Cookie": ... 41 | def __eq__(self, other: Union[str, bytes, "Cookie"]) -> bool: ... 42 | def __repr__(self) -> str: 43 | return f"" 44 | 45 | def parse_cookie(value: bytes) -> Cookie: ... 46 | def write_response_cookie(cookie: Cookie) -> bytes: ... 47 | -------------------------------------------------------------------------------- /blacksheep/exceptions.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | 3 | 4 | cdef class MessageAborted(Exception): 5 | pass 6 | 7 | 8 | cdef class HTTPException(Exception): 9 | cdef public int status 10 | 11 | 12 | cdef class BadRequest(HTTPException): 13 | pass 14 | 15 | 16 | cdef class BadRequestFormat(BadRequest): 17 | cdef public object inner_exception 18 | 19 | 20 | cdef class NotFound(HTTPException): 21 | pass 22 | 23 | 24 | cdef class InternalServerError(HTTPException): 25 | cdef readonly object source_error 26 | 27 | 28 | cdef class InvalidArgument(Exception): 29 | pass 30 | 31 | 32 | cdef class InvalidOperation(Exception): 33 | pass 34 | 35 | 36 | cdef class FailedRequestError(HTTPException): 37 | cdef public str data 38 | -------------------------------------------------------------------------------- /blacksheep/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidOperation(Exception): 2 | def __init__(self, message: str, inner_exception=None): 3 | super().__init__(message) 4 | self.inner_exception = inner_exception 5 | 6 | 7 | class HTTPException(Exception): 8 | def __init__(self, status: int, message: str = "HTTP exception"): 9 | super().__init__(message) 10 | self.status = status 11 | 12 | 13 | class BadRequest(HTTPException): 14 | def __init__(self, message=None): 15 | super().__init__(400, message or "Bad request") 16 | 17 | 18 | class BadRequestFormat(BadRequest): 19 | def __init__(self, message: str, inner_exception=None): 20 | super().__init__(message) 21 | self.inner_exception = inner_exception 22 | 23 | 24 | class FailedRequestError(HTTPException): 25 | def __init__(self, status: int, data: str) -> None: 26 | super().__init__( 27 | status, 28 | f"The response status code does not indicate success: {status}. " 29 | "Response body: {data}", 30 | ) 31 | self.data = data 32 | 33 | 34 | class NotFound(HTTPException): 35 | def __init__(self, message=None): 36 | super().__init__(404, message or "Not found") 37 | 38 | 39 | class Unauthorized(HTTPException): 40 | def __init__(self, message=None): 41 | super().__init__(401, message or "Unauthorized") 42 | 43 | 44 | class Forbidden(HTTPException): 45 | def __init__(self, message=None): 46 | super().__init__(403, message or "Forbidden") 47 | 48 | 49 | class Conflict(HTTPException): 50 | def __init__(self, message=None): 51 | super().__init__(409, message or "Conflict") 52 | 53 | 54 | class RangeNotSatisfiable(HTTPException): 55 | def __init__(self): 56 | super().__init__(416, "Range not satisfiable") 57 | 58 | 59 | class InternalServerError(HTTPException): 60 | def __init__(self, source_error: Exception = None): 61 | super().__init__(500, "Internal server error") 62 | self.source_error = source_error 63 | 64 | 65 | class NotImplementedByServer(HTTPException): 66 | def __init__(self): 67 | super().__init__(501, "Not implemented by server") 68 | 69 | 70 | class InvalidArgument(Exception): 71 | def __init__(self, message: str): 72 | super().__init__(message) 73 | 74 | 75 | class MessageAborted(Exception): 76 | def __init__(self): 77 | super().__init__( 78 | "The message was aborted before the client sent its whole content." 79 | ) 80 | -------------------------------------------------------------------------------- /blacksheep/exceptions.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | class InvalidOperation(Exception): 4 | def __init__(self, message: str, inner_exception: object = None): 5 | super().__init__(message) 6 | self.inner_exception = inner_exception 7 | 8 | class HTTPException(Exception): 9 | def __init__(self, status: int, message: str = "HTTP Exception"): 10 | super().__init__(message) 11 | self.status = status 12 | 13 | class BadRequest(HTTPException): 14 | def __init__(self, message: str): 15 | super().__init__(400, message) 16 | 17 | class Unauthorized(HTTPException): 18 | def __init__(self, message: str = "Unauthorized"): 19 | super().__init__(401, message) 20 | 21 | class Forbidden(HTTPException): 22 | def __init__(self, message: str = "Forbidden"): 23 | super().__init__(403, message) 24 | 25 | class Conflict(HTTPException): 26 | def __init__(self, message: str = "Conflict"): 27 | super().__init__(409, message) 28 | 29 | class BadRequestFormat(BadRequest): 30 | def __init__(self, message: str, inner_exception: object = None): 31 | super().__init__(message) 32 | self.inner_exception = inner_exception 33 | 34 | class RangeNotSatisfiable(HTTPException): 35 | def __init__(self, message: str = "Range Not Satisfiable"): 36 | super().__init__(416, message) 37 | 38 | class NotFound(HTTPException): 39 | def __init__(self): 40 | super().__init__(404) 41 | 42 | class InvalidArgument(Exception): 43 | def __init__(self, message: str): 44 | super().__init__(message) 45 | 46 | class MessageAborted(Exception): 47 | def __init__(self): 48 | super().__init__( 49 | "The message was aborted before the client sent its whole content." 50 | ) 51 | 52 | class InternalServerError(HTTPException): 53 | def __init__( 54 | self, 55 | message: str = "Internal Server Error", 56 | source_error: Optional[Exception] = None, 57 | ): 58 | super().__init__(500, message) 59 | self.source_error = source_error 60 | 61 | class NotImplementedByServer(HTTPException): 62 | def __init__(self, message: str = "Not Implemented"): 63 | super().__init__(501, message) 64 | 65 | class FailedRequestError(HTTPException): 66 | def __init__(self, status: int, data: str) -> None: 67 | super().__init__( 68 | status, 69 | f"The response status code does not indicate success: {status}. Response body: {data}", 70 | ) 71 | self.data = data 72 | -------------------------------------------------------------------------------- /blacksheep/exceptions.pyx: -------------------------------------------------------------------------------- 1 | 2 | cdef class InvalidOperation(Exception): 3 | 4 | def __init__(self, str message, object inner_exception=None): 5 | super().__init__(message) 6 | self.inner_exception = inner_exception 7 | 8 | 9 | cdef class HTTPException(Exception): 10 | 11 | def __init__(self, int status, str message = "HTTP exception"): 12 | super().__init__(message) 13 | self.status = status 14 | 15 | 16 | cdef class BadRequest(HTTPException): 17 | 18 | def __init__(self, message=None): 19 | super().__init__(400, message or "Bad request") 20 | 21 | 22 | cdef class BadRequestFormat(BadRequest): 23 | 24 | def __init__(self, str message, object inner_exception=None): 25 | super().__init__(message) 26 | self.inner_exception = inner_exception 27 | 28 | 29 | cdef class FailedRequestError(HTTPException): 30 | def __init__(self, int status, str data) -> None: 31 | super().__init__( 32 | status, f"The response status code does not indicate success: {status}. Response body: {data}" 33 | ) 34 | self.data = data 35 | 36 | 37 | cdef class NotFound(HTTPException): 38 | 39 | def __init__(self, message=None): 40 | super().__init__(404, message or "Not found") 41 | 42 | 43 | cdef class Unauthorized(HTTPException): 44 | 45 | def __init__(self, message=None): 46 | super().__init__(401, message or "Unauthorized") 47 | 48 | 49 | cdef class Forbidden(HTTPException): 50 | 51 | def __init__(self, message=None): 52 | super().__init__(403, message or "Forbidden") 53 | 54 | 55 | cdef class Conflict(HTTPException): 56 | 57 | def __init__(self, message=None): 58 | super().__init__(409, message or "Conflict") 59 | 60 | 61 | cdef class RangeNotSatisfiable(HTTPException): 62 | 63 | def __init__(self): 64 | super().__init__(416, "Range not satisfiable") 65 | 66 | 67 | cdef class InternalServerError(HTTPException): 68 | 69 | def __init__(self, Exception source_error = None): 70 | super().__init__(500, "Internal server error") 71 | self.source_error = source_error 72 | 73 | 74 | cdef class NotImplementedByServer(HTTPException): 75 | 76 | def __init__(self): 77 | super().__init__(501, "Not implemented by server") 78 | 79 | 80 | cdef class InvalidArgument(Exception): 81 | 82 | def __init__(self, str message): 83 | super().__init__(message) 84 | 85 | 86 | cdef class MessageAborted(Exception): 87 | def __init__(self): 88 | super().__init__( 89 | "The message was aborted before the client sent its whole content." 90 | ) 91 | -------------------------------------------------------------------------------- /blacksheep/headers.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3, embedsignature=True 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | 8 | cdef class Header: 9 | cdef readonly bytes name 10 | cdef readonly bytes value 11 | 12 | 13 | cdef class Headers: 14 | cdef readonly list values 15 | 16 | cpdef tuple keys(self) 17 | 18 | cpdef Headers clone(self) 19 | 20 | cpdef tuple get(self, bytes name) 21 | 22 | cpdef list get_tuples(self, bytes name) 23 | 24 | cpdef void add(self, bytes name, bytes value) 25 | 26 | cpdef void set(self, bytes name, bytes value) 27 | 28 | cpdef bytes get_single(self, bytes name) 29 | 30 | cpdef bytes get_first(self, bytes name) 31 | 32 | cpdef void remove(self, bytes key) 33 | 34 | cpdef void merge(self, list values) 35 | 36 | cpdef bint contains(self, bytes key) 37 | -------------------------------------------------------------------------------- /blacksheep/headers.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import MutableSequence 2 | from typing import Dict, Generator, List, Optional, Tuple, Union 3 | 4 | class Header: 5 | def __init__(self, name: bytes, value: bytes): 6 | self.name = name 7 | self.value = value 8 | 9 | def __repr__(self) -> str: ... 10 | def __iter__(self) -> Generator[bytes, None, None]: ... 11 | def __eq__(self, other: object) -> bool: ... 12 | 13 | HeaderType = Tuple[bytes, bytes] 14 | 15 | class Headers: 16 | def __init__(self, values: Optional[List[HeaderType]] = None): 17 | self.values = values 18 | 19 | def get(self, name: bytes) -> Tuple[HeaderType]: ... 20 | def get_tuples(self, name: bytes) -> List[HeaderType]: ... 21 | def get_first(self, key: bytes) -> Optional[bytes]: ... 22 | def get_single(self, key: bytes) -> bytes: ... 23 | def merge(self, values: List[HeaderType]): ... 24 | def update(self, values: Dict[bytes, bytes]): ... 25 | def items(self) -> Generator[HeaderType, None, None]: ... 26 | def clone(self) -> "Headers": ... 27 | def add_many( 28 | self, values: Union[Dict[bytes, bytes], List[Tuple[bytes, bytes]]] 29 | ): ... 30 | def __add__(self, other: Union["Headers", Header, HeaderType, MutableSequence]): ... 31 | def __radd__( 32 | self, other: Union["Headers", Header, HeaderType, MutableSequence] 33 | ): ... 34 | def __iadd__( 35 | self, other: Union["Headers", Header, HeaderType, MutableSequence] 36 | ): ... 37 | def __iter__(self) -> Generator[HeaderType, None, None]: ... 38 | def __setitem__(self, key: bytes, value: bytes): ... 39 | def __getitem__(self, item: bytes): ... 40 | def keys(self) -> Tuple[bytes]: ... 41 | def add(self, name: bytes, value: bytes): ... 42 | def set(self, name: bytes, value: bytes): ... 43 | def remove(self, key: bytes): ... 44 | def contains(self, key: bytes) -> bool: ... 45 | def __delitem__(self, key: bytes): ... 46 | def __contains__(self, key: bytes) -> bool: ... 47 | def __repr__(self) -> str: ... 48 | -------------------------------------------------------------------------------- /blacksheep/messages.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3, embedsignature=True 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | from .contents cimport Content, parse_www_form_urlencoded 8 | from .cookies cimport ( 9 | Cookie, 10 | datetime_to_cookie_format, 11 | parse_cookie, 12 | write_cookie_for_response, 13 | ) 14 | from .exceptions cimport BadRequestFormat 15 | from .url cimport URL 16 | 17 | 18 | cdef class Message: 19 | cdef list _raw_headers 20 | cdef public Content content 21 | cdef object __weakref__ 22 | 23 | cpdef list get_headers(self, bytes key) 24 | cpdef bytes get_first_header(self, bytes key) 25 | cpdef bytes get_single_header(self, bytes key) 26 | cpdef void remove_header(self, bytes key) 27 | cdef bint _has_header(self, bytes key) 28 | cpdef bint has_header(self, bytes key) 29 | cdef void _add_header(self, bytes key, bytes value) 30 | cdef void _add_header_if_missing(self, bytes key, bytes value) 31 | cpdef void add_header(self, bytes key, bytes value) 32 | cpdef void set_header(self, bytes key, bytes value) 33 | cpdef bytes content_type(self) 34 | 35 | cdef void remove_headers(self, list headers) 36 | cdef list get_headers_tuples(self, bytes key) 37 | cdef void init_prop(self, str name, object value) 38 | 39 | cpdef Message with_content(self, Content content) 40 | cpdef bint has_body(self) 41 | cpdef bint declares_content_type(self, bytes type) 42 | cpdef bint declares_json(self) 43 | cpdef bint declares_xml(self) 44 | 45 | 46 | cdef class Request(Message): 47 | cdef public str method 48 | cdef public URL _url 49 | cdef public bytes _path 50 | cdef public bytes _raw_query 51 | cdef public object route_values 52 | cdef public object scope 53 | 54 | cdef dict __dict__ 55 | 56 | cpdef bint expect_100_continue(self) 57 | 58 | 59 | cdef class Response(Message): 60 | cdef public int status 61 | cdef dict __dict__ 62 | 63 | cpdef bint is_redirect(self) 64 | 65 | 66 | cpdef bint method_without_body(str method) 67 | 68 | cpdef bint is_cors_request(Request request) 69 | 70 | cpdef bint is_cors_preflight_request(Request request) 71 | 72 | cpdef URL get_request_absolute_url(Request request) 73 | 74 | cpdef URL get_absolute_url_to_path(Request request, str path) 75 | 76 | cdef bytes ensure_bytes(value) 77 | -------------------------------------------------------------------------------- /blacksheep/middlewares.py: -------------------------------------------------------------------------------- 1 | from blacksheep.normalization import copy_special_attributes 2 | 3 | 4 | def middleware_partial(handler, next_handler): 5 | async def middleware_wrapper(request): 6 | return await handler(request, next_handler) 7 | 8 | return middleware_wrapper 9 | 10 | 11 | def get_middlewares_chain(middlewares, handler): 12 | fn = handler 13 | for middleware in reversed(middlewares): 14 | if not middleware: 15 | continue 16 | wrapper_fn = middleware_partial(middleware, fn) 17 | setattr(wrapper_fn, "root_fn", handler) 18 | copy_special_attributes(fn, wrapper_fn) 19 | fn = wrapper_fn 20 | return fn 21 | -------------------------------------------------------------------------------- /blacksheep/multipart.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Generator, Iterable, Optional, Tuple 2 | 3 | from blacksheep.contents import FormPart 4 | 5 | 6 | def get_boundary_from_header(value: bytes) -> bytes: 7 | return value.split(b"=", 1)[1].split(b" ", 1)[0] 8 | 9 | 10 | def _remove_last_crlf(value: bytes) -> bytes: 11 | if value.endswith(b"\r\n"): 12 | return value[:-2] 13 | if value.endswith(b"\n"): 14 | return value[:-1] 15 | return value 16 | 17 | 18 | def split_multipart(value: bytes) -> Iterable[bytes]: 19 | """ 20 | Splits a whole multipart/form-data payload into single parts 21 | without boundary. 22 | """ 23 | value = value.strip(b" ") 24 | boundary = value[: value.index(b"\n")].rstrip(b"\r") 25 | 26 | for part in value.split(boundary): 27 | part = _remove_last_crlf(part.lstrip(b"\r\n")) 28 | if part == b"" or part == b"--": 29 | continue 30 | yield part 31 | 32 | 33 | def split_headers(value: bytes) -> Iterable[Tuple[bytes, bytes]]: 34 | """ 35 | Splits a whole portion of multipart form data representing headers 36 | into name, value pairs. 37 | """ 38 | # 39 | # Examples of whole portions: 40 | # 41 | # Content-Disposition: form-data; name="two" 42 | # Content-Disposition: form-data; name="file_example"; 43 | # filename="example-001.png"\r\nContent-Type: image/png 44 | # 45 | for raw_header in value.split(b"\r\n"): 46 | header_name, header_value = raw_header.split(b":", 1) 47 | yield header_name.lower(), header_value.lstrip(b" ") 48 | 49 | 50 | def split_content_disposition_values( 51 | value: bytes, 52 | ) -> Iterable[Tuple[bytes, Optional[bytes]]]: 53 | """ 54 | Parses a single header into key, value pairs. 55 | """ 56 | for part in value.split(b";"): 57 | if b"=" in part: 58 | name, value = part.split(b"=", 1) 59 | yield name.lower().strip(b" "), value.strip(b'" ') 60 | else: 61 | yield b"type", part 62 | 63 | 64 | def parse_content_disposition_values(value: bytes) -> Dict[bytes, Optional[bytes]]: 65 | return dict(split_content_disposition_values(value)) 66 | 67 | 68 | class CharsetPart(Exception): 69 | def __init__(self, default_charset: bytes): 70 | self.default_charset = default_charset 71 | 72 | 73 | def parse_part(value: bytes, default_charset: Optional[bytes]) -> FormPart: 74 | """Parses a single multipart/form-data part.""" 75 | raw_headers, data = value.split(b"\r\n\r\n", 1) 76 | 77 | headers = dict(split_headers(raw_headers)) 78 | 79 | content_disposition = headers.get(b"content-disposition") 80 | 81 | if not content_disposition: 82 | raise ValueError( 83 | "Missing Content-Disposition header in multipart/form-data part." 84 | ) 85 | 86 | content_disposition_values = parse_content_disposition_values(content_disposition) 87 | 88 | field_name = content_disposition_values.get(b"name") 89 | 90 | if field_name == b"_charset_": 91 | # NB: handling charset... 92 | # https://tools.ietf.org/html/rfc7578#section-4.6 93 | raise CharsetPart(data) 94 | 95 | content_type = headers.get(b"content-type", None) 96 | 97 | return FormPart( 98 | field_name or b"", 99 | data, 100 | content_type, 101 | content_disposition_values.get(b"filename"), 102 | default_charset, 103 | ) 104 | 105 | 106 | def parse_multipart(value: bytes) -> Generator[FormPart, None, None]: 107 | default_charset = None 108 | 109 | for part_bytes in split_multipart(value): 110 | try: 111 | yield parse_part(part_bytes, default_charset) 112 | except CharsetPart as charset: 113 | default_charset = charset.default_charset 114 | -------------------------------------------------------------------------------- /blacksheep/normalization.py: -------------------------------------------------------------------------------- 1 | def copy_special_attributes(source_method, wrapper) -> None: 2 | for name in { 3 | "auth", 4 | "auth_policy", 5 | "auth_schemes", 6 | "allow_anonymous", 7 | "controller_type", 8 | "route_handler", 9 | "__name__", 10 | "__doc__", 11 | "root_fn", 12 | "binders", 13 | "return_type", 14 | }: 15 | if hasattr(source_method, name): 16 | setattr(wrapper, name, getattr(source_method, name)) 17 | -------------------------------------------------------------------------------- /blacksheep/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/py.typed -------------------------------------------------------------------------------- /blacksheep/scribe.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | from .contents cimport Content, ServerSentEvent 8 | from .cookies cimport Cookie 9 | from .messages cimport Message, Request, Response 10 | 11 | 12 | cdef int MAX_RESPONSE_CHUNK_SIZE 13 | 14 | cpdef bytes get_status_line(int status) 15 | 16 | cpdef bint is_small_request(Request request) 17 | 18 | cpdef bint request_has_body(Request request) 19 | 20 | cpdef bytes write_small_request(Request request) 21 | 22 | cdef bytes write_request_method(Request request) 23 | 24 | cpdef bytes write_request_without_body(Request request) 25 | 26 | cdef bint is_small_response(Response response) 27 | 28 | cdef bytes write_small_response(Response response) 29 | 30 | cdef void set_headers_for_content(Message message) 31 | 32 | cdef void set_headers_for_response_content(Response message) 33 | 34 | cpdef bytes write_sse(ServerSentEvent event) 35 | -------------------------------------------------------------------------------- /blacksheep/scribe.pyi: -------------------------------------------------------------------------------- 1 | from typing import AsyncIterable, Callable 2 | 3 | from blacksheep.contents import Content, ServerSentEvent 4 | from blacksheep.cookies import Cookie 5 | from blacksheep.messages import Request, Response 6 | 7 | def get_status_line(status: int) -> bytes: ... 8 | def is_small_request(request: Request) -> bool: ... 9 | def request_has_body(request: Request) -> bool: ... 10 | def write_small_request(request: Request) -> bytes: ... 11 | def write_request_without_body(request: Request) -> bytes: ... 12 | def write_chunks(content: Content) -> AsyncIterable[bytes]: ... 13 | async def send_asgi_response(response: Response, send: Callable): ... 14 | def write_request(request: Request) -> AsyncIterable[bytes]: ... 15 | def write_response(response: Response) -> AsyncIterable[bytes]: ... 16 | def write_request_body_only(request: Request) -> AsyncIterable[bytes]: ... 17 | def write_response_cookie(cookie: Cookie) -> bytes: ... 18 | def write_sse(event: ServerSentEvent) -> bytes: ... 19 | -------------------------------------------------------------------------------- /blacksheep/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .application import Application as Application 2 | from .routing import Route as Route 3 | from .routing import Router as Router 4 | from .routing import RoutesRegistry as RoutesRegistry 5 | -------------------------------------------------------------------------------- /blacksheep/server/asgi.py: -------------------------------------------------------------------------------- 1 | from blacksheep.contents import ASGIContent 2 | from blacksheep.messages import Request 3 | 4 | 5 | def get_request_url_from_scope( 6 | scope, 7 | base_path: str = "", 8 | include_query: bool = True, 9 | trailing_slash: bool = False, 10 | ) -> str: 11 | """ 12 | Function used for diagnostic reasons, for example to generate URLs in pages with 13 | detailed information about internal server errors. 14 | 15 | Do not use this method for logic that must generate full request URL, since it 16 | doesn't handle Forward and X-Forwarded* headers - use instead: 17 | 18 | > from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url 19 | """ 20 | try: 21 | path = scope["path"] 22 | protocol = scope["scheme"] 23 | for key, val in scope["headers"]: 24 | if key.lower() in (b"host", b"x-forwarded-host", b"x-original-host"): 25 | host = val.decode("latin-1") 26 | port = 0 27 | break 28 | else: 29 | host, port = scope["server"] 30 | except KeyError as key_error: 31 | raise ValueError(f"Invalid scope: {key_error}") 32 | 33 | if not port: 34 | port_part = "" 35 | elif protocol == "http" and port == 80: 36 | port_part = "" 37 | elif protocol == "https" and port == 443: 38 | port_part = "" 39 | else: 40 | port_part = f":{port}" 41 | 42 | if trailing_slash: 43 | path = path + "/" 44 | 45 | query_part = ( 46 | "" 47 | if not include_query or not scope.get("query_string") 48 | else ("?" + scope.get("query_string").decode("utf8")) 49 | ) 50 | return f"{protocol}://{host}{port_part}{base_path}{path}{query_part}" 51 | 52 | 53 | def get_request_url(request: Request) -> str: 54 | """ 55 | Function used for diagnostic reasons, for example to generate URLs in pages with 56 | detailed information about internal server errors. 57 | 58 | Do not use this method for logic that must generate full request URL, since it 59 | doesn't handle Forward and X-Forwarded* headers - use instead: 60 | 61 | > from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url 62 | """ 63 | return get_request_url_from_scope(request.scope) 64 | 65 | 66 | def incoming_request(scope, receive=None) -> Request: 67 | """ 68 | Function used to simulate incoming requests from an ASGI scope. 69 | This function is intentionally not used in 70 | `blacksheep.server.application.Application`. 71 | """ 72 | request = Request.incoming( 73 | scope["method"], 74 | scope["raw_path"], 75 | scope["query_string"], 76 | list(scope["headers"]), 77 | ) 78 | request.scope = scope 79 | if receive: 80 | request.content = ASGIContent(receive) 81 | return request 82 | 83 | 84 | def get_full_path(scope) -> bytes: 85 | """ 86 | Returns the full path of the HTTP message from an ASGI scope. 87 | """ 88 | path = scope["path"].encode() 89 | query = scope["query_string"] 90 | 91 | if query: 92 | return path + b"?" + query 93 | 94 | return path 95 | -------------------------------------------------------------------------------- /blacksheep/server/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Tuple 2 | 3 | from guardpost import AuthenticationHandler, AuthenticationStrategy, AuthorizationError 4 | 5 | from blacksheep import Response, TextContent 6 | 7 | __all__ = ( 8 | "AuthenticationStrategy", 9 | "AuthenticationHandler", 10 | "AuthenticateChallenge", 11 | "get_authentication_middleware", 12 | "handle_authentication_challenge", 13 | ) 14 | 15 | 16 | def get_authentication_middleware(strategy: AuthenticationStrategy): 17 | async def authentication_middleware(request, handler): 18 | await strategy.authenticate(request, getattr(handler, "auth_schemes", None)) 19 | return await handler(request) 20 | 21 | return authentication_middleware 22 | 23 | 24 | class AuthenticateChallenge(AuthorizationError): 25 | header_name = b"WWW-Authenticate" 26 | 27 | def __init__( 28 | self, scheme: str, realm: Optional[str], parameters: Optional[Dict[str, str]] 29 | ): 30 | self.scheme = scheme 31 | self.realm = realm 32 | self.parameters = parameters 33 | 34 | def _get_header_value(self) -> bytes: 35 | if not self.realm and not self.parameters: 36 | return self.scheme.encode() 37 | 38 | parts = bytearray(self.scheme.encode()) 39 | if self.realm: 40 | parts.extend(f' realm="{self.realm}"'.encode()) 41 | 42 | if self.parameters: 43 | parts.extend(b", ") 44 | parts.extend( 45 | b", ".join( 46 | [ 47 | f'{key}="{value}"'.encode() 48 | for key, value in self.parameters.items() 49 | ] 50 | ) 51 | ) 52 | return bytes(parts) 53 | 54 | def get_header(self) -> Tuple[bytes, bytes]: 55 | return self.header_name, self._get_header_value() 56 | 57 | 58 | async def handle_authentication_challenge( 59 | app, request, exception: AuthenticateChallenge 60 | ): 61 | return Response(401, [exception.get_header()], content=TextContent("Unauthorized")) 62 | -------------------------------------------------------------------------------- /blacksheep/server/authorization/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Optional, Sequence, Tuple 2 | 3 | from guardpost.authorization import ( 4 | AuthorizationStrategy, 5 | Policy, 6 | Requirement, 7 | UnauthorizedError, 8 | ) 9 | 10 | from blacksheep import Request, Response, TextContent 11 | 12 | __all__ = ( 13 | "auth", 14 | "AuthorizationStrategy", 15 | "AuthorizationWithoutAuthenticationError", 16 | "allow_anonymous", 17 | "get_authorization_middleware", 18 | "Requirement", 19 | "handle_unauthorized", 20 | "Policy", 21 | ) 22 | 23 | 24 | def auth( 25 | policy: Optional[str] = "authenticated", 26 | *, 27 | authentication_schemes: Optional[Sequence[str]] = None, 28 | ) -> Callable[..., Any]: 29 | """ 30 | Configures authorization for a decorated request handler, optionally with a policy. 31 | If no policy is specified, the default policy to require authenticated users is 32 | used. 33 | 34 | :param policy: optional, name of the policy to use for authorization. 35 | :param authentication_schemes: optional, authentication schemes to use 36 | for this handler. If not specified, all configured authentication handlers 37 | are used. 38 | """ 39 | 40 | def decorator(f): 41 | f.auth = True 42 | f.auth_policy = policy 43 | f.auth_schemes = authentication_schemes 44 | return f 45 | 46 | return decorator 47 | 48 | 49 | def allow_anonymous(value: bool = True) -> Callable[..., Any]: 50 | """ 51 | If used without arguments, configures a decorated request handler to make it 52 | usable for all users: anonymous and authenticated users. 53 | 54 | Otherwise, enables anonymous access according to the given flag value. 55 | """ 56 | 57 | def decorator(f): 58 | f.allow_anonymous = value 59 | return f 60 | 61 | return decorator 62 | 63 | 64 | def get_authorization_middleware( 65 | strategy: AuthorizationStrategy, 66 | ) -> Callable[[Request, Callable[..., Any]], Awaitable[Response]]: 67 | async def authorization_middleware(request, handler): 68 | user = request.user 69 | 70 | if getattr(handler, "allow_anonymous", False) is True: 71 | return await handler(request) 72 | 73 | if hasattr(handler, "auth"): 74 | # function decorated by auth; 75 | await strategy.authorize(handler.auth_policy, user, request) 76 | else: 77 | # function not decorated by auth; use the default policy. 78 | # this is necessary to support configuring authorization rules globally 79 | # without configuring every single request handler 80 | await strategy.authorize(None, user, request) 81 | 82 | return await handler(request) 83 | 84 | return authorization_middleware 85 | 86 | 87 | class AuthorizationWithoutAuthenticationError(RuntimeError): 88 | def __init__(self): 89 | super().__init__( 90 | "Cannot use an authorization strategy without an authentication " 91 | "strategy. Use `use_authentication` method to configure it." 92 | ) 93 | 94 | 95 | def get_www_authenticated_header_from_generic_unauthorized_error( 96 | error: UnauthorizedError, 97 | ) -> Optional[Tuple[bytes, bytes]]: 98 | if not error.scheme: 99 | return None 100 | 101 | return b"WWW-Authenticate", error.scheme.encode() 102 | 103 | 104 | async def handle_unauthorized( 105 | app: Any, request: Request, http_exception: UnauthorizedError 106 | ) -> Response: 107 | www_authenticate = get_www_authenticated_header_from_generic_unauthorized_error( 108 | http_exception 109 | ) 110 | return Response( 111 | 401, 112 | [www_authenticate] if www_authenticate else None, 113 | content=TextContent("Unauthorized"), 114 | ) 115 | 116 | 117 | async def handle_forbidden( 118 | app: Any, request: Request, http_exception: UnauthorizedError 119 | ): 120 | return Response( 121 | 403, 122 | None, 123 | content=TextContent("Forbidden"), 124 | ) 125 | -------------------------------------------------------------------------------- /blacksheep/server/compression.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import gzip 3 | from concurrent.futures import Executor 4 | from typing import Awaitable, Callable, Iterable, List, Optional 5 | 6 | from blacksheep import Content, Request, Response 7 | from blacksheep.server.application import Application 8 | from blacksheep.server.normalization import ensure_response 9 | 10 | 11 | class GzipMiddleware: 12 | """ 13 | The gzip compression middleware for all requests with a body larger than 14 | the specified minimum size and with the "gzip" encoding in the "Accept-Encoding" 15 | header. The middleware runs compression asynchronously in a separate executor. 16 | 17 | Parameters 18 | ---------- 19 | min_size: int 20 | The minimum size of the response body to compress. 21 | comp_level: int 22 | The compression level to use. 23 | handled_types: Optional[Iterable[bytes]] 24 | The list of content types to compress. 25 | executor: Executor 26 | The executor instance to use for compression. If not specified, a 27 | default executor is used. If you specify an executor, you are responsible 28 | for shutting it down. 29 | """ 30 | 31 | handled_types: List[bytes] = [ 32 | b"json", 33 | b"xml", 34 | b"yaml", 35 | b"html", 36 | b"text/plain", 37 | b"application/javascript", 38 | b"text/css", 39 | b"text/csv", 40 | ] 41 | 42 | def __init__( 43 | self, 44 | min_size: int = 500, 45 | comp_level: int = 5, 46 | handled_types: Optional[Iterable[bytes]] = None, 47 | executor: Optional[Executor] = None, 48 | ): 49 | self.min_size = min_size 50 | self.comp_level = comp_level 51 | self._executor = executor 52 | 53 | if handled_types is not None: 54 | self.handled_types = self._normalize_types(handled_types) 55 | 56 | def _normalize_types(self, types: Iterable[bytes]) -> List[bytes]: 57 | """ 58 | Normalizes the types to bytes. 59 | """ 60 | normalized_types = [] 61 | for _type in types: 62 | if isinstance(_type, str): 63 | normalized_types.append(_type.encode("ascii")) 64 | else: 65 | normalized_types.append(_type) 66 | return normalized_types 67 | 68 | def should_handle(self, request: Request, response: Response) -> bool: 69 | """ 70 | Returns True if the response should be compressed. 71 | """ 72 | 73 | def _is_handled_type(content_type) -> bool: 74 | content_type = content_type.lower() 75 | return any(_type in content_type for _type in self.handled_types) 76 | 77 | def is_handled_encoding() -> bool: 78 | return b"gzip" in (request.get_first_header(b"accept-encoding") or b"") 79 | 80 | def is_handled_response_content() -> bool: 81 | if response is None or response.content is None: 82 | return False 83 | 84 | body_pass: bool = ( 85 | response.content.body is not None 86 | and len(response.content.body) > self.min_size 87 | ) 88 | 89 | content_type_pass: bool = ( 90 | response.content.type is not None 91 | and _is_handled_type(response.content.type) 92 | ) 93 | 94 | return body_pass and content_type_pass 95 | 96 | return is_handled_encoding() and is_handled_response_content() 97 | 98 | async def __call__( 99 | self, request: Request, handler: Callable[[Request], Awaitable[Response]] 100 | ) -> Optional[Response]: 101 | response = ensure_response(await handler(request)) 102 | 103 | if response is None or response.content is None: 104 | return response 105 | 106 | if not self.should_handle(request, response): 107 | return response 108 | 109 | loop = asyncio.get_running_loop() 110 | compressed_body = await loop.run_in_executor( 111 | self._executor, 112 | gzip.compress, 113 | response.content.body, 114 | self.comp_level, 115 | ) 116 | 117 | response.with_content( 118 | Content( 119 | content_type=response.content.type, 120 | data=compressed_body, 121 | ) 122 | ) 123 | response.add_header(b"content-encoding", b"gzip") 124 | return response 125 | 126 | 127 | def use_gzip_compression( 128 | app: Application, 129 | handler: Optional[GzipMiddleware] = None, 130 | ) -> GzipMiddleware: 131 | """ 132 | Configures the application to use gzip compression for all responses with gzip 133 | in accept-encoding header. 134 | """ 135 | if handler is None: 136 | handler = GzipMiddleware() 137 | 138 | app.middlewares.append(handler) # type: ignore 139 | 140 | return handler 141 | -------------------------------------------------------------------------------- /blacksheep/server/dataprotection.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | import string 4 | from typing import List, Optional, Sequence 5 | 6 | from itsdangerous import Serializer 7 | from itsdangerous.url_safe import URLSafeSerializer 8 | 9 | from blacksheep.baseapp import get_logger 10 | 11 | logger = get_logger() 12 | 13 | 14 | def generate_secret(length: int = 60) -> str: 15 | alphabet = string.ascii_letters + string.digits 16 | return "".join(secrets.choice(alphabet) for i in range(length)) 17 | 18 | 19 | def get_keys() -> List[str]: 20 | # if there are environmental variables with keys, use them; 21 | # by default this kind of env variables would be used: 22 | # APP_SECRET_1="***" 23 | # APP_SECRET_2="***" 24 | # APP_SECRET_3="***" 25 | app_secrets = [] 26 | env_var_key_prefix = os.environ.get("BLACKSHEEP_SECRET_PREFIX", "APP_SECRET") 27 | 28 | for key, value in os.environ.items(): 29 | if key.startswith(env_var_key_prefix) or key.startswith( 30 | env_var_key_prefix.replace("_", "") 31 | ): 32 | app_secrets.append(value) 33 | 34 | if app_secrets: 35 | return app_secrets 36 | 37 | # For best user experience, here new secrets are generated on the fly. 38 | logger.debug( 39 | "Generating secrets on the fly. Configure application secrets to support " 40 | "tokens validation across restarts and when using multiple instances of the " 41 | "application!" 42 | ) 43 | 44 | return [generate_secret() for _ in range(3)] 45 | 46 | 47 | def get_serializer( 48 | secret_keys: Optional[Sequence[str]] = None, purpose: str = "dataprotection" 49 | ) -> Serializer: 50 | if not secret_keys: 51 | secret_keys = get_keys() 52 | return URLSafeSerializer(list(secret_keys), salt=purpose.encode()) 53 | -------------------------------------------------------------------------------- /blacksheep/server/di.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Awaitable, Callable 2 | 3 | from rodi import ActivationScope, Container 4 | 5 | from blacksheep.messages import Request, Response 6 | 7 | if TYPE_CHECKING: 8 | from blacksheep.server.application import Application 9 | 10 | 11 | async def di_scope_middleware( 12 | request: Request, handler: Callable[[Request], Awaitable[Response]] 13 | ) -> Response: 14 | """ 15 | This middleware ensures that a single scope is used for Dependency Injection, 16 | across request handlers and other parts of the application that require activating 17 | services (e.g. authentication handlers). 18 | 19 | This middleware is not necessary in most cases, but in some circumstances it can be 20 | necessary. 21 | """ 22 | with ActivationScope() as scope: 23 | scope.scoped_services[Request] = request # type: ignore 24 | scope.scoped_services["__request__"] = request # type: ignore 25 | request._di_scope = scope # type: ignore 26 | return await handler(request) 27 | 28 | 29 | def request_factory(context) -> Request: 30 | # The following scoped service is set in a middleware, since in fact we are 31 | # mixing runtime data with composition data. 32 | return context.scoped_services[Request] 33 | 34 | 35 | def register_http_context(app: "Application"): 36 | """ 37 | Makes the `Request` object accessible through dependency injection for the 38 | application container. 39 | This method requires using `rodi` as solution for dependency injection, since 40 | other implementations might not support scoped services and factories using the 41 | activation scope. 42 | 43 | This is not a recommended pattern, but it might be desired in certain situations. 44 | """ 45 | assert isinstance(app.services, Container), "This method requires rodi." 46 | 47 | if di_scope_middleware not in app.middlewares: 48 | 49 | @app.on_middlewares_configuration 50 | def enable_request_accessor(_): 51 | app.middlewares.insert(0, di_scope_middleware) 52 | 53 | app.services.add_scoped_by_factory(request_factory) 54 | -------------------------------------------------------------------------------- /blacksheep/server/diagnostics.py: -------------------------------------------------------------------------------- 1 | from blacksheep import Application, Router, html 2 | from blacksheep.server.errors import ServerErrorDetailsHandler 3 | 4 | 5 | def _get_response_without_details(request, info, error_page_template: str): 6 | content = error_page_template.format_map( 7 | { 8 | "info": info, 9 | "exctype": "", 10 | "excmessage": "", 11 | "method": request.method, 12 | "path": request.url.value.decode(), 13 | "full_url": "", 14 | } 15 | ) 16 | 17 | return html(content, status=503) 18 | 19 | 20 | def get_diagnostic_app(exc: Exception, match: str = "/*") -> Application: 21 | """ 22 | Returns a fallback application, to help diagnosing start-up errors. 23 | 24 | Example: 25 | 26 | def get_app(): 27 | try: 28 | # ... your code that configures the application object 29 | return configure_application() 30 | except Exception as exc: 31 | return get_diagnostic_app(exc) 32 | 33 | 34 | app = get_app() 35 | """ 36 | router = Router() 37 | error_details_handler = ServerErrorDetailsHandler() 38 | 39 | app = Application(router=router) 40 | 41 | @router.get(match) 42 | async def diagnostic_home(request): 43 | if app.show_error_details: 44 | response = error_details_handler.produce_response(request, exc) 45 | response.status = 503 46 | return response 47 | return _get_response_without_details( 48 | request, 49 | ( 50 | "The application failed to start. Error details are hidden for security" 51 | " reasons. To display temporarily error details and investigate the " 52 | "issue, configure temporarily the environment to display error details." 53 | "APP_SHOW_ERROR_DETAILS=1" 54 | ), 55 | error_details_handler._error_page_template, 56 | ) 57 | 58 | return app 59 | -------------------------------------------------------------------------------- /blacksheep/server/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | 4 | from blacksheep.utils import truthy 5 | 6 | 7 | def get_env() -> str: 8 | return os.environ.get("APP_ENV", "production") 9 | 10 | 11 | def is_development() -> bool: 12 | """ 13 | Returns a value indicating whether the application is running for local development. 14 | This method checks if an `APP_ENV` environment variable is set and its lowercase 15 | value is either "local", "dev", or "development". 16 | """ 17 | return get_env().lower() in {"local", "dev", "development"} 18 | 19 | 20 | def is_production() -> bool: 21 | """ 22 | Returns a value indicating whether the application is running for the production 23 | environment (default is true). 24 | This method checks if an `APP_ENV` environment variable is set and its lowercase 25 | value is either "prod" or "production". 26 | """ 27 | return get_env().lower() in {"prod", "production"} 28 | 29 | 30 | def get_global_route_prefix() -> str: 31 | """ 32 | Returns the global route prefix, if any, defined by the `APP_ROUTE_PREFIX` 33 | environment variable. 34 | """ 35 | return os.environ.get("APP_ROUTE_PREFIX", "") 36 | 37 | 38 | @dataclass(init=False) 39 | class EnvironmentSettings: 40 | env: str 41 | show_error_details: bool 42 | mount_auto_events: bool 43 | use_default_router: bool 44 | add_signal_handler: bool 45 | 46 | def __init__(self) -> None: 47 | self.env = get_env() 48 | self.show_error_details = truthy(os.environ.get("APP_SHOW_ERROR_DETAILS", "")) 49 | self.mount_auto_events = truthy(os.environ.get("APP_MOUNT_AUTO_EVENTS", "1")) 50 | self.use_default_router = truthy(os.environ.get("APP_DEFAULT_ROUTER", "1")) 51 | self.add_signal_handler = truthy(os.environ.get("APP_SIGNAL_HANDLER", "")) 52 | -------------------------------------------------------------------------------- /blacksheep/server/errors.py: -------------------------------------------------------------------------------- 1 | import html 2 | import traceback 3 | 4 | from blacksheep.contents import HTMLContent 5 | from blacksheep.messages import Request, Response 6 | from blacksheep.server.asgi import get_request_url 7 | from blacksheep.server.resources import get_resource_file_content 8 | 9 | 10 | def _load_error_page_template() -> str: 11 | error_css = get_resource_file_content("error.css") 12 | error_template = get_resource_file_content("error.html") 13 | assert "/*STYLES*/" in error_template 14 | 15 | # since later it is used in format_map... 16 | error_css = error_css.replace("{", "{{").replace("}", "}}") 17 | return error_template.replace("/*STYLES*/", error_css) 18 | 19 | 20 | class ServerErrorDetailsHandler: 21 | """ 22 | This class is responsible of producing a detailed response when the Application is 23 | configured to show error details to the client, and an unhandled exception happens. 24 | """ 25 | 26 | def __init__(self) -> None: 27 | self._error_page_template = _load_error_page_template() 28 | 29 | def produce_response(self, request: Request, exc: Exception) -> Response: 30 | tb = traceback.format_exception(exc.__class__, exc, exc.__traceback__) 31 | info = "" 32 | for item in tb: 33 | info += f"
  • {html.escape(item)}
  • " 34 | 35 | content = HTMLContent( 36 | self._error_page_template.format_map( 37 | { 38 | "info": info, 39 | "exctype": exc.__class__.__name__, 40 | "excmessage": str(exc), 41 | "method": request.method, 42 | "path": request.url.value.decode(), 43 | "full_url": get_request_url(request), 44 | } 45 | ) 46 | ) 47 | 48 | return Response(500, content=content) 49 | -------------------------------------------------------------------------------- /blacksheep/server/files/static.py: -------------------------------------------------------------------------------- 1 | from email.utils import formatdate 2 | 3 | from blacksheep.contents import Content 4 | from blacksheep.messages import Request, Response 5 | 6 | 7 | def get_response_for_static_content( 8 | request: Request, 9 | content_type: bytes, 10 | contents: bytes, 11 | last_modified_time: float, 12 | cache_time: int = 10800, 13 | ) -> Response: 14 | """ 15 | Returns a response object to serve static content. 16 | """ 17 | current_etag = str(last_modified_time).encode() 18 | previous_etag = request.if_none_match 19 | 20 | headers = [ 21 | (b"Last-Modified", formatdate(last_modified_time, usegmt=True).encode()), 22 | (b"ETag", current_etag), 23 | ] 24 | 25 | if cache_time > -1: 26 | headers.append((b"Cache-Control", b"max-age=" + str(cache_time).encode())) 27 | 28 | if previous_etag and current_etag == previous_etag: 29 | # handle HTTP 304 Not Modified 30 | return Response(304, headers, None) 31 | 32 | if request.method == "HEAD": 33 | headers.append((b"Content-Type", content_type)) 34 | headers.append((b"Content-Length", str(len(contents)).encode())) 35 | return Response(200, headers, None) 36 | 37 | return Response(200, headers, Content(content_type, contents)) 38 | -------------------------------------------------------------------------------- /blacksheep/server/headers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/server/headers/__init__.py -------------------------------------------------------------------------------- /blacksheep/server/openapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/server/openapi/__init__.py -------------------------------------------------------------------------------- /blacksheep/server/openapi/exceptions.py: -------------------------------------------------------------------------------- 1 | class DocumentationException(Exception): 2 | pass 3 | 4 | 5 | class DuplicatedContentTypeDocsException(DocumentationException): 6 | def __init__(self, content_type: str) -> None: 7 | super().__init__( 8 | f"A documentation element for content type {content_type} " 9 | "has already been specified. Ensure that response content items " 10 | "have unique type." 11 | ) 12 | self.content_type = content_type 13 | -------------------------------------------------------------------------------- /blacksheep/server/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides functions related to the server process. 3 | """ 4 | 5 | import os 6 | import signal 7 | import warnings 8 | from typing import TYPE_CHECKING 9 | 10 | from blacksheep.utils import truthy 11 | 12 | if TYPE_CHECKING: 13 | from blacksheep.server.application import Application 14 | 15 | _STOPPING = False 16 | 17 | 18 | def is_stopping() -> bool: 19 | """ 20 | Returns a value indicating whether the server process received a SIGINT or a SIGTERM 21 | signal, and therefore the application is stopping. 22 | """ 23 | if not truthy(os.environ.get("APP_SIGNAL_HANDLER", "")): 24 | warnings.warn( 25 | "This function can only be used if the env variable `APP_SIGNAL_HANDLER=1`" 26 | " is set.", 27 | UserWarning, 28 | ) 29 | return False # Return a default value since the function cannot proceed 30 | return _STOPPING 31 | 32 | 33 | def use_shutdown_handler(app: "Application"): 34 | """ 35 | Configures an application start event handler that listens to SIGTERM and SIGINT 36 | to know when the process is stopping. 37 | """ 38 | 39 | @app.on_start 40 | async def configure_shutdown_handler(): 41 | # See the conversation here: 42 | # https://github.com/encode/uvicorn/issues/1579#issuecomment-1419635974 43 | for signal_type in {signal.SIGINT, signal.SIGTERM}: 44 | current_handler = signal.getsignal(signal_type) 45 | 46 | def terminate_now(signum, frame): 47 | global _STOPPING 48 | _STOPPING = True 49 | 50 | if callable(current_handler): 51 | current_handler(signum, frame) # type: ignore 52 | 53 | signal.signal(signal_type, terminate_now) 54 | -------------------------------------------------------------------------------- /blacksheep/server/redirects.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Optional 2 | 3 | from blacksheep import Response 4 | from blacksheep.server.responses import moved_permanently 5 | 6 | 7 | def default_trailing_slash_exclude(path: str) -> bool: 8 | return "/api/" in path 9 | 10 | 11 | def get_trailing_slash_middleware( 12 | exclude: Optional[Callable[[str], bool]] = None, 13 | ) -> Callable[..., Awaitable[Response]]: 14 | """ 15 | Returns a middleware that redirects requests that do not end with a trailing slash 16 | to the same URL with a trailing slash, with a HTTP 301 Moved Permanently response. 17 | This is useful for endpoints that serve HTML documents, to ensure that relative 18 | URLs in the response body are correctly resolved. 19 | To filter certain requests from being redirected, pass a callable that returns 20 | True if the request should be excluded from redirection, by path. 21 | The default exclude function excludes all requests whose path contains "/api/". 22 | """ 23 | if exclude is None: 24 | exclude = default_trailing_slash_exclude 25 | 26 | async def trailing_slash_middleware(request, handler): 27 | path = request.path 28 | 29 | if exclude and exclude(path): 30 | return await handler(request) 31 | 32 | if not path.endswith("/") and "." not in path.split("/")[-1]: 33 | return moved_permanently(f"/{path.strip('/')}/") 34 | return await handler(request) 35 | 36 | return trailing_slash_middleware 37 | -------------------------------------------------------------------------------- /blacksheep/server/remotes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/server/remotes/__init__.py -------------------------------------------------------------------------------- /blacksheep/server/remotes/hosts.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Sequence 2 | 3 | from blacksheep.exceptions import BadRequest 4 | from blacksheep.messages import Request 5 | 6 | 7 | class InvalidHostError(BadRequest): 8 | def __init__(self, host_value: str): 9 | super().__init__(f"Invalid host: {host_value}.") 10 | self.host = host_value 11 | 12 | 13 | class TrustedHostsMiddleware: 14 | def __init__( 15 | self, 16 | allowed_hosts: Optional[Sequence[str]] = None, 17 | ) -> None: 18 | self.allowed_hosts: List[str] = list(allowed_hosts) if allowed_hosts else [] 19 | 20 | def is_valid_host(self, host: str) -> bool: 21 | if not self.allowed_hosts or "*" in self.allowed_hosts: 22 | return True 23 | return host in self.allowed_hosts 24 | 25 | def validate_host(self, host: str) -> None: 26 | if not self.is_valid_host(host): 27 | raise InvalidHostError(host) 28 | 29 | async def __call__(self, request: Request, handler): 30 | self.validate_host(request.host) 31 | return await handler(request) 32 | -------------------------------------------------------------------------------- /blacksheep/server/rendering/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/server/rendering/__init__.py -------------------------------------------------------------------------------- /blacksheep/server/rendering/abc.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class ModelHandler(ABC): 6 | """Type that can handle a model object for a view template.""" 7 | 8 | @abstractmethod 9 | def model_to_view_params(self, model: Any) -> Any: 10 | """Converts a model to parameters for a template view.""" 11 | 12 | 13 | class Renderer(ABC): 14 | """Type that can render HTML views.""" 15 | 16 | @abstractmethod 17 | def render(self, template: str, model, **kwargs) -> str: 18 | """Renders a view synchronously.""" 19 | 20 | @abstractmethod 21 | async def render_async(self, template: str, model, **kwargs) -> str: 22 | """Renders a view asynchronously.""" 23 | 24 | @abstractmethod 25 | def bind_antiforgery_handler(self, handler) -> None: 26 | """Applies extensions for an antiforgery handler.""" 27 | -------------------------------------------------------------------------------- /blacksheep/server/rendering/jinja2.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | from typing import Optional 4 | 5 | from jinja2 import ( 6 | BaseLoader, 7 | Environment, 8 | PackageLoader, 9 | Template, 10 | nodes, 11 | select_autoescape, 12 | ) 13 | from jinja2.ext import Extension 14 | from jinja2.utils import pass_context 15 | 16 | from blacksheep.messages import Request 17 | from blacksheep.server.csrf import AntiForgeryHandler, MissingRequestContextError 18 | 19 | from .abc import Renderer 20 | 21 | _DEFAULT_TEMPLATES_EXTENSION = os.environ.get("APP_JINJA_EXTENSION", ".jinja") 22 | 23 | 24 | @lru_cache(1200) 25 | def get_template_name(name: str) -> str: 26 | if not name.endswith(_DEFAULT_TEMPLATES_EXTENSION): 27 | return name + _DEFAULT_TEMPLATES_EXTENSION 28 | return name 29 | 30 | 31 | def render_template(template: Template, *args, **kwargs): 32 | return template.render(*args, **kwargs) 33 | 34 | 35 | async def render_template_async(template: Template, *args, **kwargs): 36 | return await template.render_async(*args, **kwargs) 37 | 38 | 39 | class AntiForgeryBaseExtension(Extension): 40 | af_handler: Optional[AntiForgeryHandler] = None 41 | 42 | def __init__(self, environment): 43 | super().__init__(environment) 44 | 45 | if self.af_handler is None: 46 | raise TypeError("Define a subclass bound to an AntiForgeryHandler") 47 | 48 | @property 49 | def handler(self) -> AntiForgeryHandler: 50 | if self.af_handler is None: 51 | raise TypeError("Missing anti_forgery_handler") 52 | return self.af_handler 53 | 54 | def parse(self, parser): 55 | line_number = next(parser.stream).lineno 56 | return nodes.CallBlock(self.call_method("get_html"), [], [], "").set_lineno( 57 | line_number 58 | ) 59 | 60 | def get_token(self, context) -> str: 61 | try: 62 | request = context["request"] 63 | except KeyError: 64 | raise MissingRequestContextError() 65 | assert isinstance(request, Request) 66 | 67 | tokens = self.handler.get_tokens(request) 68 | return tokens[1] 69 | 70 | 71 | class AntiForgeryInputExtension(AntiForgeryBaseExtension): 72 | tags = {"csrf_input", "af_input"} 73 | 74 | @pass_context 75 | def get_html(self, context, caller): 76 | value = self.get_token(context) 77 | return ( 78 | f'' 80 | ) 81 | 82 | 83 | class AntiForgeryValueExtension(AntiForgeryBaseExtension): 84 | tags = {"csrf_token", "af_token"} 85 | 86 | @pass_context 87 | def get_html(self, context, caller): 88 | return self.get_token(context) 89 | 90 | 91 | class JinjaRenderer(Renderer): 92 | def __init__( 93 | self, 94 | loader: Optional[BaseLoader] = None, 95 | debug: bool = False, 96 | enable_async: bool = False, 97 | ) -> None: 98 | super().__init__() 99 | self.env = Environment( 100 | loader=loader 101 | or PackageLoader( 102 | os.environ.get("APP_JINJA_PACKAGE_NAME", "app"), 103 | os.environ.get("APP_JINJA_PACKAGE_PATH", "views"), 104 | ), 105 | autoescape=select_autoescape(["html", "xml", "jinja"]), 106 | auto_reload=bool(os.environ.get("APP_JINJA_DEBUG", "")) or debug, 107 | enable_async=bool(os.environ.get("APP_JINJA_ENABLE_ASYNC", "")) 108 | or enable_async, 109 | ) 110 | 111 | def render(self, template: str, model, **kwargs) -> str: 112 | if model: 113 | return render_template( 114 | self.env.get_template(get_template_name(template)), model, **kwargs 115 | ) 116 | return render_template( 117 | self.env.get_template(get_template_name(template)), **kwargs 118 | ) 119 | 120 | async def render_async(self, template: str, model, **kwargs) -> str: 121 | if model: 122 | return await self.env.get_template( 123 | get_template_name(template) 124 | ).render_async(model, **kwargs) 125 | return await self.env.get_template(get_template_name(template)).render_async( 126 | **kwargs 127 | ) 128 | 129 | def bind_antiforgery_handler(self, handler) -> None: 130 | class BoundAntiForgeryInputExtension(AntiForgeryInputExtension): 131 | af_handler = handler 132 | 133 | class BoundAntiForgeryValueExtension(AntiForgeryValueExtension): 134 | af_handler = handler 135 | 136 | self.env.add_extension(BoundAntiForgeryInputExtension) 137 | self.env.add_extension(BoundAntiForgeryValueExtension) 138 | -------------------------------------------------------------------------------- /blacksheep/server/rendering/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict, is_dataclass 2 | from typing import Any 3 | 4 | from .abc import ModelHandler 5 | 6 | 7 | class DefaultModelHandler(ModelHandler): 8 | def model_to_view_params(self, model: Any) -> Any: 9 | if isinstance(model, dict): 10 | return model 11 | if is_dataclass(model): 12 | return asdict(model) 13 | if hasattr(model, "__dict__"): 14 | return model.__dict__ 15 | return model 16 | -------------------------------------------------------------------------------- /blacksheep/server/res/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/blacksheep/server/res/__init__.py -------------------------------------------------------------------------------- /blacksheep/server/res/error.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | dl { 8 | display: flex; 9 | flex-flow: row; 10 | flex-wrap: wrap; 11 | overflow: visible; 12 | } 13 | 14 | dt { 15 | font-weight: bold; 16 | flex: 0 0 20%; 17 | text-overflow: ellipsis; 18 | overflow: hidden; 19 | border-bottom: 1px dotted #eee; 20 | padding: 10px 0; 21 | } 22 | 23 | dd { 24 | flex: 0 0 80%; 25 | margin-left: auto; 26 | text-align: left; 27 | text-overflow: ellipsis; 28 | overflow: hidden; 29 | padding: 10px 0; 30 | border-bottom: 1px dotted #eee; 31 | } 32 | 33 | dt, dd { 34 | padding: .3rem 0; 35 | } 36 | 37 | header { 38 | background-color: #1c6962; 39 | color: white; 40 | padding: .5rem 2rem; 41 | border-bottom: 1px solid #0f5046; 42 | } 43 | 44 | #content { 45 | padding: .5rem 2rem; 46 | } 47 | 48 | .stack-trace { 49 | font-family: monospace; 50 | background: aliceblue; 51 | padding: 1rem 3.5rem; 52 | border: 1px dashed #366a62; 53 | font-size: 14px; 54 | } 55 | 56 | .stack-trace pre { 57 | padding-left: 60px; 58 | word-break: break-word; 59 | white-space: break-spaces; 60 | } 61 | 62 | .custom-counter { 63 | margin: 0; 64 | padding: 0 1rem 0 0; 65 | list-style-type: none; 66 | } 67 | 68 | .custom-counter li { 69 | counter-increment: step-counter; 70 | margin-bottom: 5px; 71 | } 72 | 73 | .custom-counter li::before { 74 | content: counter(step-counter); 75 | margin-right: 20px; 76 | font-size: 80%; 77 | background-color: rgb(54 106 98); 78 | color: white; 79 | font-weight: bold; 80 | padding: 3px 8px; 81 | float: left; 82 | border-radius: 11px; 83 | margin-left: 20px; 84 | } 85 | 86 | .notes { 87 | padding: 1rem 0; 88 | font-size: small; 89 | } 90 | -------------------------------------------------------------------------------- /blacksheep/server/res/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Internal Server Error. 5 | 6 | 7 | 10 | 11 | 12 |
    13 |

    Internal Server Error.

    14 |

    While handling request: {method} {path}

    15 |
    16 |
    17 |
    18 |
    19 |
    Exception type:
    20 |
    {exctype}
    21 |
    Exception message:
    22 |
    {excmessage}
    23 |
    Method:
    24 |
    {method}
    25 |
    URL:
    26 |
    {full_url}
    27 |
    28 |
    29 |
    30 |

    Stack trace:

    31 |
      {info}
    32 |
    33 |
    34 | 35 | This error is displayed for diagnostic purpose. Error details 36 | should be hidden during normal service operation. 37 | 38 |
    39 |
    40 | 41 | 42 | -------------------------------------------------------------------------------- /blacksheep/server/res/fileslist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Folder contents. 5 | 6 | 7 | 12 | 13 | 14 |

    /{path}

    15 |
      {info}
    16 | 17 | -------------------------------------------------------------------------------- /blacksheep/server/res/redoc-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ##PAGE_TITLE## 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /blacksheep/server/res/scalar-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ##PAGE_TITLE## 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /blacksheep/server/res/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ##PAGE_TITLE## 5 | 6 | 7 | 8 | 9 | 10 | 11 |
    12 | 13 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /blacksheep/server/resources.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module offers methods to return file paths for resources. Its original purpose is 3 | to provide contents of static files stored for conveniency in the blacksheep.server.res 4 | package. 5 | """ 6 | 7 | from importlib.resources import files 8 | 9 | 10 | def get_resource_file_path(anchor, file_name: str) -> str: 11 | return str(files(anchor) / file_name) 12 | 13 | 14 | def get_resource_file_content(file_name: str) -> str: 15 | with open( 16 | get_resource_file_path("blacksheep.server.res", file_name), 17 | mode="rt", 18 | ) as source: 19 | return source.read() 20 | -------------------------------------------------------------------------------- /blacksheep/server/security/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from blacksheep.messages import Response 4 | 5 | 6 | class SecurityPolicyHandler(ABC): 7 | """ 8 | Base class used to define security rules for responses, normally defined using 9 | response headers for the client (e.g. Content-Security-Policy). 10 | """ 11 | 12 | @abstractmethod 13 | def protect(self, response: Response) -> None: 14 | """Configures security rules over a response object.""" 15 | -------------------------------------------------------------------------------- /blacksheep/server/security/hsts.py: -------------------------------------------------------------------------------- 1 | from blacksheep.messages import Request, Response 2 | 3 | 4 | def write_hsts_header_value(max_age: int, include_subdomains: bool) -> bytes: 5 | value = f"max-age={max_age};" 6 | 7 | if include_subdomains: 8 | value = value + " includeSubDomains;" 9 | 10 | return value.encode() 11 | 12 | 13 | class HSTSMiddleware: 14 | """ 15 | Middleware configuring "Strict-Transport-Security" header on responses. 16 | By default, it uses "max-age=31536000; includeSubDomains;". 17 | """ 18 | 19 | def __init__( 20 | self, 21 | max_age: int = 31536000, 22 | include_subdomains: bool = True, 23 | ) -> None: 24 | self._value = write_hsts_header_value(max_age, include_subdomains) 25 | 26 | async def __call__(self, request: Request, handler): 27 | response: Response = await handler(request) 28 | response.headers.add(b"Strict-Transport-Security", self._value) 29 | return response 30 | -------------------------------------------------------------------------------- /blacksheep/server/sse.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module offer built-in functions for Server Sent Events. 3 | """ 4 | 5 | from typing import AsyncIterable, Callable, List, Optional, Tuple 6 | 7 | from blacksheep.contents import ServerSentEvent, StreamedContent, TextServerSentEvent 8 | from blacksheep.messages import Response 9 | from blacksheep.scribe import write_sse 10 | 11 | __all__ = [ 12 | "ServerSentEvent", 13 | "TextServerSentEvent", 14 | "ServerSentEventsContent", 15 | "ServerSentEventsResponse", 16 | "EventsProvider", 17 | ] 18 | 19 | 20 | EventsProvider = Callable[[], AsyncIterable[ServerSentEvent]] 21 | 22 | 23 | class ServerSentEventsContent(StreamedContent): 24 | """ 25 | A specialized kind of StreamedContent that can be used to stream 26 | Server-Sent Events to a client. 27 | """ 28 | 29 | def __init__(self, events_provider: EventsProvider): 30 | super().__init__(b"text/event-stream", self.write_events(events_provider)) 31 | 32 | @staticmethod 33 | def write_events( 34 | events_provider: EventsProvider, 35 | ) -> Callable[[], AsyncIterable[bytes]]: 36 | async def write_events(): 37 | async for event in events_provider(): 38 | yield write_sse(event) 39 | 40 | return write_events 41 | 42 | 43 | class ServerSentEventsResponse(Response): 44 | """ 45 | An Response type that can be used to stream Server-Sent Events to a client. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | events_provider: EventsProvider, 51 | status: int = 200, 52 | headers: Optional[List[Tuple[bytes, bytes]]] = None, 53 | ) -> None: 54 | if headers is None: 55 | headers = [(b"Cache-Control", b"no-cache"), (b"Connection", b"Keep-Alive")] 56 | super().__init__(status, headers, ServerSentEventsContent(events_provider)) 57 | -------------------------------------------------------------------------------- /blacksheep/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains global settings for a BlackSheep application. 3 | """ 4 | -------------------------------------------------------------------------------- /blacksheep/settings/di.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar 2 | 3 | from rodi import Container, ContainerProtocol 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def default_container_factory() -> ContainerProtocol: 9 | return Container() 10 | 11 | 12 | class DISettings: 13 | def __init__(self): 14 | self._container_factory = default_container_factory 15 | 16 | def use(self, container_factory: Callable[[], ContainerProtocol]): 17 | self._container_factory = container_factory 18 | 19 | def get_default_container(self) -> ContainerProtocol: 20 | return self._container_factory() 21 | 22 | 23 | di_settings = DISettings() 24 | -------------------------------------------------------------------------------- /blacksheep/settings/html.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from blacksheep.server.rendering.abc import ModelHandler, Renderer 4 | from blacksheep.server.rendering.models import DefaultModelHandler 5 | 6 | 7 | def default_renderer() -> Renderer: 8 | from blacksheep.server.rendering.jinja2 import JinjaRenderer 9 | 10 | return JinjaRenderer() 11 | 12 | 13 | class HTMLSettings: 14 | def __init__(self): 15 | self._renderer: Renderer | None = None 16 | self._model_handlers: List[ModelHandler] = [DefaultModelHandler()] 17 | 18 | def use(self, renderer: Renderer): 19 | self._renderer = renderer 20 | 21 | @property 22 | def model_handlers(self) -> List[ModelHandler]: 23 | return self._model_handlers 24 | 25 | @property 26 | def renderer(self) -> Renderer: 27 | if self._renderer is None: 28 | self._renderer = default_renderer() 29 | return self._renderer 30 | 31 | def model_to_params(self, model: Any) -> Any: 32 | for handler in self.model_handlers: 33 | try: 34 | return handler.model_to_view_params(model) 35 | except NotImplementedError: 36 | continue 37 | 38 | 39 | html_settings = HTMLSettings() 40 | -------------------------------------------------------------------------------- /blacksheep/settings/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from essentials.json import dumps 5 | 6 | 7 | def default_json_dumps(obj): 8 | return dumps(obj, separators=(",", ":")) 9 | 10 | 11 | def default_pretty_json_dumps(obj): 12 | return dumps(obj, indent=4) 13 | 14 | 15 | class JSONSettings: 16 | def __init__(self): 17 | self._loads = json.loads 18 | self._dumps = default_json_dumps 19 | self._pretty_dumps = default_pretty_json_dumps 20 | 21 | def use( 22 | self, 23 | loads=json.loads, 24 | dumps=default_json_dumps, 25 | pretty_dumps=default_pretty_json_dumps, 26 | ): 27 | self._loads = loads 28 | self._dumps = dumps 29 | self._pretty_dumps = pretty_dumps 30 | 31 | def loads(self, text: str) -> Any: 32 | return self._loads(text) 33 | 34 | def dumps(self, obj: Any) -> str: 35 | return self._dumps(obj) 36 | 37 | def pretty_dumps(self, obj: Any) -> str: 38 | return self._pretty_dumps(obj) 39 | 40 | 41 | json_settings = JSONSettings() 42 | -------------------------------------------------------------------------------- /blacksheep/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from blacksheep.contents import FormContent, JSONContent, TextContent 2 | from blacksheep.testing.client import TestClient 3 | from blacksheep.testing.messages import MockReceive, MockSend 4 | from blacksheep.testing.simulator import AbstractTestSimulator 5 | 6 | __all__ = [ 7 | "TestClient", 8 | "AbstractTestSimulator", 9 | "JSONContent", 10 | "TextContent", 11 | "FormContent", 12 | "MockReceive", 13 | "MockSend", 14 | ] 15 | -------------------------------------------------------------------------------- /blacksheep/testing/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Sequence, Tuple, Union 2 | from urllib.parse import quote, urlencode 3 | 4 | _DEFAULT_AGENT = ( 5 | b"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:63.0) Gecko/20100101 Firefox/63.0" 6 | ) 7 | 8 | _DEFAULT_ACCEPT = b"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" 9 | 10 | _DEFAULT_ACCEPT_LANGUAGE = b"en-US,en;q=0.9,it-IT;q=0.8,it;q=0.7" 11 | 12 | _DEFAULT_ACCEPT_ENCODING = b"gzip, deflate" 13 | 14 | 15 | def _get_tuple(value: Union[List, Tuple[str, int]]) -> Tuple[str, int]: 16 | if isinstance(value, tuple): 17 | return value 18 | assert len(value) == 2 19 | return tuple(value) # type: ignore 20 | 21 | 22 | HeadersType = Union[None, Sequence[Tuple[bytes, bytes]], Dict[str, str]] 23 | CookiesType = Union[None, Sequence[Tuple[bytes, bytes]], Dict[str, str]] 24 | QueryType = Union[None, bytes, str, dict, list] 25 | 26 | 27 | def get_example_scope( 28 | method: str, 29 | path: str, 30 | extra_headers: HeadersType = None, 31 | *, 32 | query: QueryType = None, 33 | scheme: str = "http", 34 | server: Union[None, List, Tuple[str, int]] = None, 35 | client: Union[None, List, Tuple[str, int]] = None, 36 | user_agent: bytes = _DEFAULT_AGENT, 37 | accept: bytes = _DEFAULT_ACCEPT, 38 | accept_language: bytes = _DEFAULT_ACCEPT_LANGUAGE, 39 | accept_encoding: bytes = _DEFAULT_ACCEPT_ENCODING, 40 | cookies: CookiesType = None, 41 | ): 42 | """Returns a mocked ASGI scope""" 43 | if "?" in path: 44 | raise ValueError( 45 | "The path in ASGI messages does not contain query string, " 46 | "use the `query` parameter" 47 | ) 48 | 49 | if server is None: 50 | server = ("127.0.0.1", 8000) 51 | else: 52 | server = _get_tuple(server) 53 | 54 | if client is None: 55 | client = ("127.0.0.1", 51492) 56 | else: 57 | client = _get_tuple(client) 58 | 59 | server_port = server[1] 60 | if scheme == "http" and server_port == 80: 61 | port_part = "" 62 | elif scheme == "https" and server_port == 443: 63 | port_part = "" 64 | else: 65 | port_part = f":{server_port}" 66 | 67 | host = f"{server[0]}{port_part}" 68 | 69 | if isinstance(extra_headers, dict): 70 | extra_headers = [ 71 | (key.encode(), value.encode()) for key, value in extra_headers.items() 72 | ] 73 | 74 | query_string: bytes = b"" 75 | if query: 76 | if isinstance(query, list): 77 | query = dict(query) 78 | if isinstance(query, dict): 79 | query_string = urlencode(query).encode() 80 | if isinstance(query, str): 81 | query_string = query.encode() 82 | if isinstance(query, bytes): 83 | query_string = query 84 | 85 | cookies_headers: List[Tuple[bytes, bytes]] = [] 86 | 87 | if cookies: 88 | if isinstance(cookies, list): 89 | cookies_headers = [ 90 | (b"cookie", quote(key).encode() + b"=" + quote(value).encode()) 91 | for key, value in cookies 92 | ] 93 | elif isinstance(cookies, dict): 94 | cookies_headers = [ 95 | (b"cookie", quote(key).encode() + b"=" + quote(value).encode()) 96 | for key, value in cookies.items() 97 | ] 98 | 99 | headers = ( 100 | [ 101 | (b"host", host.encode()), 102 | (b"user-agent", user_agent), 103 | (b"accept", accept), 104 | (b"accept-language", accept_language), 105 | (b"accept-encoding", accept_encoding), 106 | (b"connection", b"keep-alive"), 107 | (b"upgrade-insecure-requests", b"1"), 108 | ] 109 | + ([tuple(header) for header in extra_headers] if extra_headers else []) 110 | + cookies_headers 111 | ) 112 | 113 | return { 114 | "type": scheme, 115 | "http_version": "1.1", 116 | "server": tuple(server), 117 | "client": client, 118 | "scheme": scheme, 119 | "method": method, 120 | "root_path": "", 121 | "path": path, 122 | "raw_path": path.encode(), 123 | "query_string": query_string, 124 | "headers": headers, 125 | } 126 | -------------------------------------------------------------------------------- /blacksheep/testing/messages.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, List, Optional, Union 3 | 4 | MessageType = Union[bytes, Dict[str, Any]] 5 | 6 | 7 | class MockReceive: 8 | """ 9 | Class used to mock the messages received by an ASGI framework and passed to the 10 | web framework. 11 | 12 | Example: 13 | 14 | MockReceive([b'{"error":"access_denied"}']) 15 | 16 | Simulates the ASGI server sending this kind of message: 17 | 18 | { 19 | "body": b'{"error":"access_denied"}', 20 | "type": "http.message", 21 | "more_body": False 22 | } 23 | """ 24 | 25 | def __init__(self, messages: Optional[List[MessageType]] = None): 26 | self.messages = messages or [] 27 | self.index = 0 28 | 29 | async def __call__(self): 30 | try: 31 | message = self.messages[self.index] 32 | except IndexError: 33 | message = b"" 34 | else: 35 | self.index += 1 36 | 37 | if isinstance(message, dict): 38 | return message 39 | 40 | await asyncio.sleep(0) 41 | return { 42 | "body": message, 43 | "type": "http.message", 44 | "more_body": ( 45 | False if (len(self.messages) == self.index or not message) else True 46 | ), 47 | } 48 | 49 | 50 | class MockSend: 51 | """ 52 | Class used to mock the `send` calls from an ASGI framework. 53 | Use this class to inspect the messages sent by the framework. 54 | """ 55 | 56 | def __init__(self): 57 | self.messages = [] 58 | 59 | async def __call__(self, message): 60 | self.messages.append(message) 61 | -------------------------------------------------------------------------------- /blacksheep/testing/websocket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import Any, AnyStr, MutableMapping 5 | 6 | from blacksheep.settings.json import json_settings 7 | 8 | 9 | class TestWebSocket: 10 | def __init__(self): 11 | self.send_queue = asyncio.Queue() 12 | self.receive_queue = asyncio.Queue() 13 | 14 | async def _send(self, data: MutableMapping[str, Any]) -> None: 15 | await self.send_queue.put(data) 16 | 17 | async def _receive(self) -> MutableMapping[str, AnyStr]: 18 | return await self.receive_queue.get() 19 | 20 | async def send(self, data: MutableMapping[str, Any]) -> None: 21 | await self.receive_queue.put(data) 22 | 23 | async def send_text(self, data: str) -> None: 24 | await self.send({"type": "websocket.send", "text": data}) 25 | 26 | async def send_bytes(self, data: bytes) -> None: 27 | await self.send({"type": "websocket.send", "bytes": data}) 28 | 29 | async def send_json(self, data: MutableMapping[Any, Any]) -> None: 30 | await self.send( 31 | { 32 | "type": "websocket.send", 33 | "text": json_settings.dumps(data), 34 | } 35 | ) 36 | 37 | async def receive(self) -> MutableMapping[str, AnyStr]: 38 | return await self.send_queue.get() 39 | 40 | async def receive_text(self) -> str: 41 | data = await self.receive() 42 | assert data["type"] == "websocket.send" 43 | return data["text"] 44 | 45 | async def receive_bytes(self) -> bytes: 46 | data = await self.receive() 47 | assert data["type"] == "websocket.send" 48 | return data["bytes"] 49 | 50 | async def receive_json(self) -> MutableMapping[str, Any]: 51 | data = await self.receive() 52 | assert data["type"] == "websocket.send" 53 | return json_settings.loads(data["text"]) 54 | 55 | async def __aenter__(self) -> TestWebSocket: 56 | await self.send({"type": "websocket.connect"}) 57 | received = await self.receive() 58 | assert received.get("type") == "websocket.accept" 59 | return self 60 | 61 | async def __aexit__(self, exc_type, exc_value, exc_tb) -> None: 62 | await self.send( 63 | { 64 | "type": "websocket.disconnect", 65 | "code": 1000, 66 | "reason": "TestWebSocket context closed", 67 | }, 68 | ) 69 | -------------------------------------------------------------------------------- /blacksheep/url.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | # Copyright (C) 2018-present Roberto Prevato 3 | # 4 | # This module is part of BlackSheep and is released under 5 | # the MIT License https://opensource.org/licenses/MIT 6 | 7 | 8 | cdef class URL: 9 | 10 | cdef readonly bytes value 11 | cdef readonly bytes schema 12 | cdef readonly bytes host 13 | cdef readonly int port 14 | cdef readonly bytes path 15 | cdef readonly bytes query 16 | cdef readonly bytes fragment 17 | cdef readonly bint is_absolute 18 | 19 | cpdef URL join(self, URL other) 20 | cpdef URL base_url(self) 21 | cpdef URL with_host(self, bytes host) 22 | cpdef URL with_scheme(self, bytes schema) 23 | cpdef URL with_query(self, bytes query) 24 | 25 | 26 | cpdef URL build_absolute_url( 27 | bytes scheme, 28 | bytes host, 29 | bytes base_path, 30 | bytes path 31 | ) 32 | 33 | 34 | cpdef str join_prefix( 35 | str prefix, 36 | str path 37 | ) 38 | -------------------------------------------------------------------------------- /blacksheep/url.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union 2 | 3 | class InvalidURL(Exception): 4 | def __init__(self, message: str) -> None: ... 5 | 6 | class URL: 7 | value: bytes 8 | schema: Optional[bytes] 9 | host: Optional[bytes] 10 | port: int 11 | path: bytes 12 | query: bytes 13 | fragment: Optional[bytes] 14 | is_absolute: Any 15 | def __init__(self, value: bytes) -> None: ... 16 | def join(self, other: "URL") -> "URL": ... 17 | def base_url(self) -> "URL": ... 18 | def with_host(self, host: bytes) -> "URL": ... 19 | def with_scheme(self, scheme: bytes) -> "URL": ... 20 | def with_query(self, query: bytes) -> "URL": ... 21 | def __add__(self, other: Union[bytes, "URL"]) -> "URL": ... 22 | def __eq__(self, other: object) -> bool: ... 23 | 24 | def join_prefix(prefix: str, path: str) -> str: 25 | """Combines a prefix and a path, ensuring a single slash between them.""" 26 | -------------------------------------------------------------------------------- /blacksheep/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import AnyStr, Type, TypeVar 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | def ensure_bytes(value: AnyStr) -> bytes: 8 | if isinstance(value, bytes): 9 | return value 10 | if isinstance(value, str): 11 | return value.encode("utf8") 12 | raise ValueError("Expected bytes or str") 13 | 14 | 15 | def ensure_str(value: AnyStr) -> str: 16 | if isinstance(value, str): 17 | return value 18 | if isinstance(value, bytes): 19 | return value.decode() 20 | raise ValueError("Expected bytes or str") 21 | 22 | 23 | def remove_duplicate_slashes(value: str) -> str: 24 | return re.sub("/{2,}", "/", value) 25 | 26 | 27 | def join_fragments(*args: AnyStr) -> str: 28 | """Joins URL fragments""" 29 | return "/" + "/".join( 30 | remove_duplicate_slashes(ensure_str(arg)).strip("/") for arg in args if arg 31 | ) 32 | 33 | 34 | def get_class_hierarchy(cls: Type[T]): 35 | return cls.__mro__ 36 | 37 | 38 | def get_class_instance_hierarchy(instance: T): 39 | return get_class_hierarchy(type(instance)) 40 | 41 | 42 | def truthy(value: str, default: bool = False) -> bool: 43 | if not value: 44 | return default 45 | return value.upper() in {"1", "TRUE"} 46 | -------------------------------------------------------------------------------- /blacksheep/utils/aio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import urllib.error 4 | import urllib.parse 5 | import urllib.request 6 | from asyncio import AbstractEventLoop 7 | from typing import Any, Optional 8 | 9 | 10 | class FailedRequestError(Exception): 11 | def __init__(self, status, data) -> None: 12 | super().__init__( 13 | f"The response status code does not indicate success: {status}." 14 | if status > -1 15 | else "The request failed." 16 | ) 17 | self.status = status 18 | self.data = data 19 | 20 | 21 | def fetch(url: str) -> Any: 22 | try: 23 | with urllib.request.urlopen(url) as response: 24 | return response.read() 25 | except urllib.error.HTTPError as http_error: 26 | content = http_error.read() 27 | raise FailedRequestError(http_error.status, _try_parse_content_as_json(content)) 28 | except urllib.error.URLError as url_error: 29 | # e.g. connection refused 30 | raise FailedRequestError(-1, str(url_error)) 31 | 32 | 33 | def _try_parse_content_as_json(content: bytes) -> Any: 34 | try: 35 | return json.loads(content.decode("utf8")) 36 | except json.JSONDecodeError: 37 | return content 38 | 39 | 40 | def post(url: str, data) -> Any: 41 | raw_data = urllib.parse.urlencode(data).encode() 42 | req = urllib.request.Request( 43 | url, 44 | method="POST", 45 | data=raw_data, 46 | ) 47 | try: 48 | response = urllib.request.urlopen(req) 49 | content = response.read() 50 | except urllib.error.HTTPError as http_error: 51 | content = http_error.read() 52 | raise FailedRequestError(http_error.status, _try_parse_content_as_json(content)) 53 | return _try_parse_content_as_json(content) 54 | 55 | 56 | class HTTPHandler: 57 | def __init__(self, loop: Optional[AbstractEventLoop] = None) -> None: 58 | self._loop = loop 59 | 60 | @property 61 | def loop(self) -> AbstractEventLoop: 62 | if self._loop is None: 63 | self._loop = asyncio.get_running_loop() 64 | return self._loop 65 | 66 | async def fetch(self, url: str) -> Any: 67 | return await self.loop.run_in_executor(None, lambda: fetch(url)) 68 | 69 | async def fetch_json(self, url: str) -> Any: 70 | data = await self.fetch(url) 71 | return json.loads(data) 72 | 73 | async def post_form(self, url: str, data: Any) -> Any: 74 | return await self.loop.run_in_executor(None, lambda: post(url, data)) 75 | 76 | 77 | def get_running_loop() -> AbstractEventLoop: 78 | try: 79 | return asyncio.get_running_loop() 80 | except RuntimeError: 81 | # TODO: fix deprecation warning happening in the test suite 82 | # DeprecationWarning: There is no current event loop 83 | return asyncio.get_event_loop() 84 | -------------------------------------------------------------------------------- /blacksheep/utils/meta.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import glob 3 | import inspect 4 | import os 5 | from pathlib import Path 6 | 7 | 8 | def get_parent_file(): 9 | """ 10 | Returns __file__ of the caller's parent module. 11 | """ 12 | try: 13 | return inspect.stack()[2][1] 14 | except IndexError: 15 | return "" 16 | 17 | 18 | def import_child_modules(root_path: Path): 19 | """ 20 | Import automatically all modules defined 21 | under a certain package path. 22 | """ 23 | path = str(root_path) 24 | modules = [ 25 | os.path.basename(f)[:-3] 26 | for f in glob.glob(path + "/*.py") 27 | if not os.path.basename(f).startswith("_") 28 | ] 29 | stripped_path = os.path.relpath(path).replace("/", ".").replace("\\", ".") 30 | for module in modules: 31 | __import__(stripped_path + "." + module) 32 | 33 | 34 | def clonefunc(func): 35 | """ 36 | Clone a function, preserving its name and docstring. 37 | """ 38 | new_func = func.__class__( 39 | func.__code__, 40 | func.__globals__, 41 | func.__name__, 42 | func.__defaults__, 43 | func.__closure__, 44 | ) 45 | new_func.__doc__ = func.__doc__ 46 | new_func.__dict__ = copy.deepcopy(func.__dict__) 47 | return new_func 48 | 49 | 50 | def all_subclasses(cls): 51 | """ 52 | Return all subclasses of a class, including those defined in other modules. 53 | """ 54 | subclasses = set() 55 | for subclass in cls.__subclasses__(): 56 | subclasses.add(subclass) 57 | subclasses.update(all_subclasses(subclass)) 58 | return subclasses 59 | -------------------------------------------------------------------------------- /blacksheep/utils/time.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import MINYEAR, datetime, timezone 3 | 4 | UTC = timezone.utc 5 | 6 | MIN_DATETIME = datetime(MINYEAR, 1, 1, tzinfo=None) 7 | 8 | 9 | def utcnow() -> datetime: 10 | if sys.version_info < (3, 12): 11 | return datetime.utcnow() 12 | return datetime.now(UTC).replace(tzinfo=None) 13 | -------------------------------------------------------------------------------- /itests/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | If tests fail because servers fail to start, try starting them manually: 4 | 5 | ```bash 6 | # from the root of the repository 7 | export PYTHONPATH="." 8 | export APP_DEFAULT_ROUTER=false 9 | python itests/app_1.py 10 | ``` 11 | -------------------------------------------------------------------------------- /itests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/itests/__init__.py -------------------------------------------------------------------------------- /itests/app_3.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from blacksheep.server import Application 4 | from blacksheep.server.compression import use_gzip_compression 5 | from blacksheep.server.responses import json 6 | 7 | application = Application(show_error_details=True) 8 | app_3 = Application(show_error_details=True) 9 | 10 | use_gzip_compression(app_3) 11 | 12 | 13 | @application.router.get("/foo") 14 | def handle_foo(): 15 | return json({"foo": "bar"}) 16 | 17 | 18 | @application.router.get("/admin/example.json") 19 | def sub_folder_example(): 20 | return json({"foo": "bar"}) 21 | 22 | 23 | @application.router.post("/") 24 | async def handle_post(request): 25 | data = await request.json() 26 | return json(data) 27 | 28 | 29 | async def on_start(_): 30 | await application.start() 31 | 32 | 33 | async def on_stop(_): 34 | await application.stop() 35 | 36 | 37 | app_3.on_start += on_start 38 | app_3.on_stop += on_stop 39 | 40 | 41 | app_3.mount("/foo", app=application) 42 | app_3.mount("/post", app=application) 43 | 44 | if __name__ == "__main__": 45 | uvicorn.run(app_3, host="127.0.0.1", port=44557, log_level="debug") 46 | -------------------------------------------------------------------------------- /itests/client_fixtures.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import multiprocessing 3 | import os 4 | import pathlib 5 | from multiprocessing import Process 6 | from time import sleep 7 | 8 | import pytest 9 | 10 | from blacksheep.client import ClientSession 11 | from blacksheep.client.pool import ConnectionPools 12 | from itests.utils import get_sleep_time 13 | 14 | from .flask_app import app 15 | 16 | multiprocessing.set_start_method("spawn", force=True) 17 | 18 | 19 | def get_static_path(file_name): 20 | static_folder_path = pathlib.Path(__file__).parent.absolute() / "static" 21 | return os.path.join(str(static_folder_path), file_name.lstrip("/")) 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def server_host(): 26 | return "127.0.0.1" 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def server_port(): 31 | return 44777 32 | 33 | 34 | @pytest.fixture(scope="module") 35 | def server_url(server_host, server_port): 36 | return f"http://{server_host}:{server_port}" 37 | 38 | 39 | @pytest.fixture(scope="function") 40 | def session(server_url, event_loop): 41 | # It is important to pass the instance of ConnectionPools, 42 | # to ensure that the connections are reused and closed 43 | session = ClientSession( 44 | loop=event_loop, 45 | base_url=server_url, 46 | pools=ConnectionPools(event_loop), 47 | ) 48 | yield session 49 | asyncio.run(session.close()) 50 | 51 | 52 | @pytest.fixture(scope="function") 53 | def session_alt(event_loop): 54 | session = ClientSession( 55 | loop=event_loop, 56 | default_headers=[(b"X-Default-One", b"AAA"), (b"X-Default-Two", b"BBB")], 57 | ) 58 | yield session 59 | event_loop.run_until_complete(session.close()) 60 | 61 | 62 | def start_server(): 63 | print("[*] Flask app listening on 0.0.0.0:44777") 64 | app.run(host="127.0.0.1", port=44777) 65 | 66 | 67 | @pytest.fixture(scope="module", autouse=True) 68 | def server(server_host, server_port): 69 | server_process = Process(target=start_server) 70 | server_process.start() 71 | sleep(get_sleep_time()) 72 | 73 | yield 1 74 | 75 | sleep(1.2) 76 | server_process.terminate() 77 | -------------------------------------------------------------------------------- /itests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from itests.client_fixtures import * 4 | 5 | os.environ["APP_DEFAULT_ROUTER"] = "false" 6 | -------------------------------------------------------------------------------- /itests/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Page 6 | 7 | 8 |

    Hello, There

    9 | 10 | -------------------------------------------------------------------------------- /itests/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/itests/example.jpg -------------------------------------------------------------------------------- /itests/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | 4 | logger = None 5 | 6 | 7 | def get_logger(): 8 | global logger 9 | 10 | if logger is not None: 11 | return logger 12 | 13 | logger = logging.getLogger("itests") 14 | 15 | logger.setLevel(logging.INFO) 16 | 17 | max_bytes = 24 * 1024 * 1024 18 | 19 | file_handler = logging.handlers.RotatingFileHandler 20 | 21 | handler = file_handler("integration_tests.log", maxBytes=max_bytes, backupCount=5) 22 | 23 | handler.setLevel(logging.DEBUG) 24 | 25 | logger.addHandler(handler) 26 | logger.addHandler(logging.StreamHandler()) 27 | 28 | return logger 29 | -------------------------------------------------------------------------------- /itests/lorem.py: -------------------------------------------------------------------------------- 1 | LOREM_IPSUM = """ 2 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna 3 | aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 4 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint 5 | occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis 6 | unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab 7 | illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia 8 | voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi 9 | nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non 10 | numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, 11 | quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem 12 | vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum 13 | fugiat quo voluptas nulla pariatur? 14 | """ 15 | -------------------------------------------------------------------------------- /itests/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | requests 3 | uvicorn==0.25.0 4 | Hypercorn==0.15.0 5 | websockets==10.1 6 | -------------------------------------------------------------------------------- /itests/server_fixtures.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import multiprocessing 3 | import os 4 | import socket 5 | from multiprocessing import Process 6 | from time import sleep 7 | 8 | import pytest 9 | import uvicorn 10 | from hypercorn.asyncio import serve as hypercorn_serve 11 | from hypercorn.run import Config as HypercornConfig 12 | 13 | from .app_1 import app 14 | from .app_2 import app_2 15 | from .app_3 import app_3 16 | from .app_4 import app_4, configure_json_settings 17 | from .utils import ClientSession, get_sleep_time 18 | 19 | multiprocessing.set_start_method("spawn", force=True) 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def server_host(): 24 | return "127.0.0.1" 25 | 26 | 27 | @pytest.fixture(scope="module") 28 | def server_port_1(): 29 | return 44555 30 | 31 | 32 | @pytest.fixture(scope="module") 33 | def server_port_2(): 34 | return 44556 35 | 36 | 37 | @pytest.fixture(scope="module") 38 | def server_port_3(): 39 | return 44557 40 | 41 | 42 | @pytest.fixture(scope="module") 43 | def server_port_4(): 44 | return 44558 45 | 46 | 47 | @pytest.fixture() 48 | def socket_connection(server_port): 49 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 50 | s.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1) 51 | s.connect(("localhost", server_port)) 52 | yield s 53 | s.close() 54 | 55 | 56 | @pytest.fixture(scope="module") 57 | def session_1(server_host, server_port_1): 58 | return ClientSession(f"http://{server_host}:{server_port_1}") 59 | 60 | 61 | @pytest.fixture(scope="module") 62 | def session_2(server_host, server_port_2): 63 | return ClientSession(f"http://{server_host}:{server_port_2}") 64 | 65 | 66 | @pytest.fixture(scope="module") 67 | def session_3(server_host, server_port_3): 68 | return ClientSession(f"http://{server_host}:{server_port_3}") 69 | 70 | 71 | @pytest.fixture(scope="module") 72 | def session_4(server_host, server_port_4): 73 | return ClientSession(f"http://{server_host}:{server_port_4}") 74 | 75 | 76 | def _start_server(target_app, port: int, init_callback=None): 77 | if init_callback is not None: 78 | init_callback() 79 | 80 | server_type = os.environ.get("ASGI_SERVER", "uvicorn") 81 | 82 | if server_type == "uvicorn": 83 | uvicorn.run(target_app, host="127.0.0.1", port=port, log_level="debug") 84 | elif server_type == "hypercorn": 85 | config = HypercornConfig() 86 | config.bind = [f"localhost:{port}"] 87 | config.loglevel = "DEBUG" 88 | asyncio.run(hypercorn_serve(target_app, config)) 89 | else: 90 | raise ValueError(f"unsupported server type {server_type}") 91 | 92 | 93 | def start_server_1(): 94 | _start_server(app, 44555) 95 | 96 | 97 | def start_server_2(): 98 | _start_server(app_2, 44556) 99 | 100 | 101 | def start_server_3(): 102 | _start_server(app_3, 44557) 103 | 104 | 105 | def start_server_4(): 106 | # Important: leverages process forking to configure JSON settings only in the 107 | # process running the app_4 application - this is important to not change 108 | # global settings for the whole tests suite 109 | _start_server(app_4, 44558, configure_json_settings) 110 | 111 | 112 | def _start_server_process(target): 113 | server_process = Process(target=target) 114 | server_process.start() 115 | sleep(get_sleep_time()) 116 | 117 | if not server_process.is_alive(): 118 | raise TypeError("The server process did not start!") 119 | 120 | yield 1 121 | 122 | sleep(1.2) 123 | server_process.terminate() 124 | 125 | 126 | @pytest.fixture(scope="module", autouse=True) 127 | def server_1(): 128 | yield from _start_server_process(start_server_1) 129 | 130 | 131 | @pytest.fixture(scope="module", autouse=True) 132 | def server_2(): 133 | yield from _start_server_process(start_server_2) 134 | 135 | 136 | @pytest.fixture(scope="module", autouse=True) 137 | def server_3(): 138 | yield from _start_server_process(start_server_3) 139 | 140 | 141 | @pytest.fixture(scope="module", autouse=True) 142 | def server_4(): 143 | yield from _start_server_process(start_server_4) 144 | -------------------------------------------------------------------------------- /itests/static/README.md: -------------------------------------------------------------------------------- 1 | # Test files 2 | These test files are used to test multipart form data posts. 3 | Pictures are taken from [https://www.pexels.com](https://www.pexels.com). -------------------------------------------------------------------------------- /itests/static/example-com.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example Domain 5 | 6 | 7 | 8 | 9 | 36 | 37 | 38 | 39 |
    40 |

    Example Domain

    41 |

    This domain is for use in illustrative examples in documents. You may use this 42 | domain in literature without prior coordination or asking for permission.

    43 |

    More information...

    44 |
    45 | 46 | 47 | -------------------------------------------------------------------------------- /itests/static/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Page 6 | 7 | 8 |

    Hello, There

    9 | 10 | -------------------------------------------------------------------------------- /itests/static/example.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/itests/static/example.xml -------------------------------------------------------------------------------- /itests/static/pexels-photo-126407.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/itests/static/pexels-photo-126407.jpeg -------------------------------------------------------------------------------- /itests/static/pexels-photo-923360.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/itests/static/pexels-photo-923360.jpeg -------------------------------------------------------------------------------- /itests/test_utils_aio.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from blacksheep.utils.aio import ( 6 | FailedRequestError, 7 | HTTPHandler, 8 | _try_parse_content_as_json, 9 | ) 10 | 11 | 12 | async def test_http_handler_fetch_plain_text(server_url): 13 | http_handler = HTTPHandler() 14 | response = await http_handler.fetch(f"{server_url}/hello-world") 15 | assert response == b"Hello, World!" 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "data", 20 | [ 21 | {"name": "Gorun Nova", "type": "Sword"}, 22 | {"id": str(uuid4()), "price": "15.15", "name": "Ravenclaw T-Shirt"}, 23 | ], 24 | ) 25 | async def test_http_handler_post_form(server_url, data): 26 | http_handler = HTTPHandler() 27 | 28 | response = await http_handler.post_form(f"{server_url}/echo-posted-form", data) 29 | 30 | assert response == data 31 | 32 | 33 | async def test_http_handler_post_form_failure(server_url): 34 | http_handler = HTTPHandler() 35 | 36 | with pytest.raises(FailedRequestError) as request_error: 37 | await http_handler.post_form(f"{server_url}/not-existing", {}) 38 | 39 | assert request_error.value.status >= 400 40 | 41 | 42 | async def test_http_handler_fetch_json(server_url): 43 | http_handler = HTTPHandler() 44 | response = await http_handler.fetch_json(f"{server_url}/plain-json") 45 | assert response == {"message": "Hello, World!"} 46 | 47 | 48 | async def test_http_handler_fetch_json_failed_request(server_url): 49 | http_handler = HTTPHandler() 50 | 51 | with pytest.raises(FailedRequestError) as failed_request_error: 52 | await http_handler.fetch(f"{server_url}/plain-json-error-simulation") 53 | 54 | assert failed_request_error.value.data == {"message": "Hello, World!"} 55 | 56 | 57 | def test_try_parse_content_as_json(): 58 | assert _try_parse_content_as_json(b"foo") == b"foo" 59 | -------------------------------------------------------------------------------- /itests/utils.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import socket 4 | from contextlib import contextmanager 5 | from pathlib import Path 6 | from urllib.parse import urljoin 7 | 8 | import requests 9 | 10 | from .logs import get_logger 11 | 12 | logger = get_logger() 13 | 14 | 15 | class ClientSession(requests.Session): 16 | def __init__(self, base_url): 17 | self.base_url = base_url 18 | super().__init__() 19 | 20 | def request(self, method, url, *args, **kwargs): 21 | return super().request(method, urljoin(self.base_url, url), *args, **kwargs) 22 | 23 | 24 | class CrashTest(Exception): 25 | def __init__(self): 26 | super().__init__("Crash Test!") 27 | 28 | 29 | def ensure_success(response): 30 | if response.status_code != 200: 31 | text = response.text 32 | logger.error(text) 33 | 34 | assert response.status_code == 200 35 | 36 | 37 | def get_connection(host: str, port: int): 38 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 39 | s.connect((host, port)) 40 | return s 41 | 42 | 43 | def ensure_folder(path): 44 | try: 45 | os.makedirs(path) 46 | except OSError as exception: 47 | if exception.errno != errno.EEXIST: 48 | raise 49 | 50 | 51 | def assert_files_equals(path_one, path_two): 52 | with open(path_one, mode="rb") as one, open(path_two, mode="rb") as two: 53 | chunk_one = one.read(1024) 54 | chunk_two = two.read(1024) 55 | 56 | assert chunk_one == chunk_two 57 | 58 | 59 | def assert_file_content_equals(file_path, content): 60 | with open(file_path, mode="rt", encoding="utf8") as file: 61 | file_contents = file.read() 62 | assert file_contents == content 63 | 64 | 65 | def get_file_bytes(file_path): 66 | with open(file_path, mode="rb") as file: 67 | return file.read() 68 | 69 | 70 | def get_sleep_time(): 71 | # Return the number of seconds to wait for a test server process to start. 72 | # 2 seconds is a wait time that works most times. 73 | return 2 74 | 75 | 76 | def get_test_files_url(url: str): 77 | return f"my-cdn-foo:{url}" 78 | 79 | 80 | @contextmanager 81 | def temp_file(name: str): 82 | file = Path(name) 83 | if file.exists(): 84 | file.unlink() 85 | 86 | yield file 87 | 88 | if file.exists(): 89 | file.unlink() 90 | -------------------------------------------------------------------------------- /perf/README.md: -------------------------------------------------------------------------------- 1 | # Benchmark 2 | 3 | This folder contains scripts to benchmark the performance of the library. The 4 | purpose of these benchmarks is to measure how changes in code affect 5 | performance, across Git commits, Python versions, and operating system. 6 | 7 | Benchmarks measure execution time and memory utilization. 8 | 9 | > [!TIP] 10 | > 11 | > Download the results from the GitHub Workflow. 12 | > The `benchmark-reports` artifacts include Excel files with tables and charts. 13 | > 14 | > [![Build](https://github.com/Neoteroi/BlackSheep/workflows/Benchmark/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions/workflows/perf.yml) 15 | 16 | The code can both collect information and compare it depending on the Git 17 | commit SHA. 18 | 19 | ```bash 20 | pip install -r req.txt 21 | ``` 22 | 23 | From the root folder: 24 | 25 | ```bash 26 | # Run the benchmark suite 27 | export PYTHONPATH="." 28 | 29 | python perf/main.py 30 | 31 | # To run more than onces: 32 | python perf/main.py --times 3 33 | 34 | # Generate XLSX report 35 | python perf/genreport.py 36 | ``` 37 | 38 | Run to generate results from different points in history: 39 | 40 | ```bash 41 | python perf/historyrun.py --commits 82ed065 1237b1e 42 | ``` 43 | 44 | ## Code organization 45 | 46 | Benchmarks are organized in such way that each file can be run interactively using 47 | **iPython**, but are also automatically imported by `main.py` following the convention 48 | that benchmark functions have names starting with `benchmark_`. 49 | 50 | To run a single benchmark using **iPython**, or [`cProfile`](https://docs.python.org/3.13/library/profile.html#profile-cli): 51 | 52 | ```bash 53 | export PYTHONPATH="." 54 | 55 | ipython perf/benchmarks/writeresponse.py timeit 56 | 57 | python -m cProfile -s tottime perf/benchmarks/writeresponse.py | head -n 50 58 | ``` 59 | 60 | ## Debugging with Visual Studio Code 61 | 62 | To debug specific files with VS Code, use a `.vscode\launch.json` file like: 63 | 64 | ```json 65 | { 66 | "version": "0.2.0", 67 | "configurations": [ 68 | { 69 | "name": "Python Debugger: Current File", 70 | "type": "debugpy", 71 | "request": "launch", 72 | "program": "${file}", 73 | "console": "integratedTerminal", 74 | "env": { 75 | "PYTHONPATH": "${workspaceFolder}" 76 | } 77 | } 78 | ] 79 | } 80 | ``` 81 | 82 | ## When modifying benchmark code 83 | 84 | ```bash 85 | export PYTHONPATH="." 86 | rm -rf benchmark_results && python perf/main.py && python perf/genreport.py 87 | ``` 88 | -------------------------------------------------------------------------------- /perf/benchmarks/__init__.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import time 3 | from contextlib import contextmanager 4 | from dataclasses import dataclass 5 | from typing import TypedDict 6 | 7 | 8 | class BenchmarkResult(TypedDict): 9 | total_time: float 10 | avg_time: float 11 | iterations: int 12 | 13 | 14 | @dataclass 15 | class TimerResult: 16 | elapsed_time: float 17 | 18 | 19 | @contextmanager 20 | def timer(): 21 | result = TimerResult(-1) 22 | start_time = time.perf_counter() # Use perf_counter for high-resolution timing 23 | yield result 24 | end_time = time.perf_counter() 25 | result.elapsed_time = end_time - start_time 26 | 27 | 28 | async def async_benchmark(func, iterations: int) -> BenchmarkResult: 29 | # warmup 30 | warmup_iterations = max(1, min(100, iterations // 10)) 31 | for _ in range(warmup_iterations): 32 | await func() 33 | 34 | # Collect garbage to ensure fair comparison 35 | gc.collect() 36 | 37 | # actual timing 38 | with timer() as result: 39 | for _ in range(iterations): 40 | await func() 41 | 42 | return { 43 | "total_time": result.elapsed_time, 44 | "avg_time": result.elapsed_time / iterations, 45 | "iterations": iterations, 46 | } 47 | 48 | 49 | def sync_benchmark(func, iterations: int) -> BenchmarkResult: 50 | # warmup 51 | warmup_iterations = max(1, min(100, iterations // 10)) 52 | for _ in range(warmup_iterations): 53 | func() 54 | 55 | # Collect garbage to ensure fair comparison 56 | gc.collect() 57 | 58 | # actual timing 59 | with timer() as result: 60 | for _ in range(iterations): 61 | func() 62 | 63 | return { 64 | "total_time": result.elapsed_time, 65 | "avg_time": result.elapsed_time / iterations, 66 | "iterations": iterations, 67 | } 68 | 69 | 70 | def main_run(func): 71 | """ 72 | Run the benchmark function and print the results. 73 | 74 | To use with iPython: 75 | PYTHONPATH="." ipython perf/benchmarks/filename.py timeit 76 | 77 | To use with asyncio: 78 | PYTHONPATH="." ipython perf/benchmarks/filename.py 79 | """ 80 | import asyncio 81 | import sys 82 | 83 | if len(sys.argv) > 1 and sys.argv[1] == "timeit": 84 | from IPython import get_ipython 85 | 86 | ipython = get_ipython() 87 | if ipython: 88 | ipython.run_line_magic("timeit", f"asyncio.run({func.__name__}(1))") 89 | else: 90 | print("ERROR: Use iPython to run the benchmark with timeit.") 91 | sys.exit(1) 92 | else: 93 | asyncio.run(func()) 94 | -------------------------------------------------------------------------------- /perf/benchmarks/url.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL methods. 3 | """ 4 | 5 | from blacksheep.url import URL 6 | from perf.benchmarks import main_run, sync_benchmark 7 | 8 | ITERATIONS = 10000 9 | 10 | 11 | def test_url_instantiate(): 12 | url = URL(b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash") 13 | assert url.value == b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash" 14 | assert url.host == b"www.neoteroi.dev" 15 | assert url.port in {0, 443} 16 | assert url.path == b"/blacksheep/" 17 | assert url.query == b"super=yes" 18 | assert url.is_absolute is True 19 | assert url.schema == b"https" 20 | assert url.fragment == b"some-hash" 21 | 22 | 23 | def benchmark_url_instantiate(iterations=ITERATIONS): 24 | return sync_benchmark(test_url_instantiate, iterations) 25 | 26 | 27 | if __name__ == "__main__": 28 | main_run(benchmark_url_instantiate) 29 | -------------------------------------------------------------------------------- /perf/benchmarks/writeresponse.py: -------------------------------------------------------------------------------- 1 | """ 2 | Benchmarks testing functions used to write response bytes. 3 | """ 4 | 5 | from pathlib import Path 6 | 7 | from blacksheep.contents import TextContent 8 | from blacksheep.messages import Response 9 | from blacksheep.scribe import write_response 10 | from perf.benchmarks import async_benchmark, main_run 11 | 12 | ITERATIONS = 10000 13 | 14 | LOREM_IPSUM = (Path(__file__).parent / "res" / "lorem.txt").read_text(encoding="utf-8") 15 | RESPONSE_HEADERS = [ 16 | (b"Content-Type", b"text/html; charset=utf-8"), 17 | (b"Content-Length", b"123"), 18 | (b"Connection", b"keep-alive"), 19 | (b"Cache-Control", b"no-cache, no-store, must-revalidate"), 20 | (b"Pragma", b"no-cache"), 21 | (b"Expires", b"0"), 22 | (b"X-Frame-Options", b"DENY"), 23 | (b"X-Content-Type-Options", b"nosniff"), 24 | (b"X-XSS-Protection", b"1; mode=block"), 25 | (b"Strict-Transport-Security", b"max-age=31536000; includeSubDomains"), 26 | (b"Server", b"BlackSheep/1.0"), 27 | ] 28 | 29 | 30 | async def test_write_text_response(): 31 | response = Response(200, RESPONSE_HEADERS).with_content(TextContent(LOREM_IPSUM)) 32 | data = bytearray() 33 | async for chunk in write_response(response): 34 | data.extend(chunk) 35 | return data 36 | 37 | 38 | async def test_write_small_response(): 39 | response = Response(404, RESPONSE_HEADERS).with_content(TextContent("Not Found")) 40 | data = bytearray() 41 | async for chunk in write_response(response): 42 | data.extend(chunk) 43 | return data 44 | 45 | 46 | async def benchmark_write_small_response(iterations=ITERATIONS): 47 | return await async_benchmark(test_write_small_response, iterations) 48 | 49 | 50 | async def benchmark_write_text_response(iterations=ITERATIONS): 51 | return await async_benchmark(test_write_text_response, iterations) 52 | 53 | 54 | async def main(): 55 | await benchmark_write_text_response(ITERATIONS) 56 | await benchmark_write_small_response(ITERATIONS) 57 | 58 | 59 | if __name__ == "__main__": 60 | main_run(main) 61 | -------------------------------------------------------------------------------- /perf/req.txt: -------------------------------------------------------------------------------- 1 | memory-profiler==0.61.0 2 | pandas==2.2.3 3 | psutil==7.0.0 4 | XlsxWriter==3.2.3 5 | -------------------------------------------------------------------------------- /perf/utils/md5.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains code to calculate an HASH for all Cython files. 3 | 4 | In this context, it is used to verify if recompiling Cython modules is necessary across 5 | commits, when running benchmarks across commits (historyrun.py). 6 | """ 7 | 8 | import glob 9 | import hashlib 10 | from pathlib import Path 11 | 12 | from _hashlib import HASH 13 | 14 | 15 | def iter_cython_files(): 16 | return (item for item in glob.glob("./**/*.py*") if item[-4:] in {".pyx", ".pxd"}) 17 | 18 | 19 | def md5_cython_files() -> str: 20 | """ 21 | Creates an HASH of all .pyx and .pxd files under the current working directory, 22 | to detect changes across commits and know if a re-compilation is needed. 23 | """ 24 | _hash = hashlib.md5() 25 | for file in iter_cython_files(): 26 | _hash.update(file.encode()) 27 | md5_update_from_file(file, _hash) 28 | return _hash.hexdigest() 29 | 30 | 31 | def md5_update_from_file(filename: str | Path, hash: HASH) -> HASH: 32 | with open(str(filename), "rb") as f: 33 | for chunk in iter(lambda: f.read(4096), b""): 34 | hash.update(chunk) 35 | return hash 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "blacksheep" 7 | dynamic = ["version"] 8 | authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }] 9 | description = "Fast web framework for Python asyncio" 10 | license = { file = "LICENSE" } 11 | readme = "README.md" 12 | requires-python = ">=3.8" 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Environment :: Web Environment", 21 | "Operating System :: OS Independent", 22 | "Framework :: AsyncIO", 23 | ] 24 | keywords = ["blacksheep", "web framework", "asyncio"] 25 | dependencies = [ 26 | "certifi>=2025.4.26", 27 | "charset-normalizer~=3.4.2", 28 | "guardpost>=1.0.2", 29 | "rodi~=2.0.8", 30 | "essentials>=1.1.4,<2.0", 31 | "essentials-openapi>=1.2.0,<2.0", 32 | "python-dateutil~=2.9.0", 33 | "itsdangerous~=2.2.0", 34 | ] 35 | 36 | [tool.setuptools.packages.find] 37 | where = ["."] 38 | include = ["blacksheep*"] 39 | 40 | [tool.setuptools.dynamic] 41 | version = { attr = "blacksheep.__version__" } 42 | 43 | [project.optional-dependencies] 44 | jinja = ["Jinja2~=3.1.6"] 45 | full = [ 46 | "cryptography>=45.0.2,<46.0.0", 47 | "PyJWT~=2.10.1", 48 | "websockets~=15.0.1", 49 | "Jinja2~=3.1.6", 50 | ] 51 | cython = ["httptools>=0.6.4"] 52 | purepython = ["h11==0.16.0"] 53 | 54 | [project.urls] 55 | "Homepage" = "https://github.com/Neoteroi/BlackSheep" 56 | "Bug Tracker" = "https://github.com/Neoteroi/BlackSheep/issues" 57 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | junit_family=xunit1 3 | asyncio_mode=auto 4 | addopts = --ignore=perf 5 | -------------------------------------------------------------------------------- /requirements.pypy.txt: -------------------------------------------------------------------------------- 1 | certifi>=2025.4.26 2 | charset-normalizer~=3.4.2 3 | guardpost>=1.0.2 4 | rodi~=2.0.2 5 | essentials>=1.1.4,<2.0 6 | essentials-openapi>=1.2.0,<2.0 7 | python-dateutil~=2.9.0 8 | itsdangerous~=2.2.0 9 | cryptography>=45.0.2,<46.0.0 10 | h11==0.16.0 11 | Jinja2~=3.1.6 12 | PyJWT==2.10.1 13 | python-dateutil==2.9.0.post0 14 | websockets~=15.0.1 15 | # For tests: 16 | Flask[async]==3.1.1 17 | Hypercorn==0.17.3 18 | requests==2.32.3 19 | pytest==8.3.5 20 | pytest-asyncio==0.26.0 21 | pytest-cov==6.1.1 22 | uvicorn==0.34.2 23 | pydantic==2.11.4 24 | pydantic_core==2.33.2 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi>=2025.4.26 2 | charset-normalizer~=3.4.2 3 | guardpost>=1.0.2 4 | rodi~=2.0.2 5 | essentials>=1.1.4,<2.0 6 | essentials-openapi>=1.2.0,<2.0 7 | python-dateutil~=2.9.0 8 | itsdangerous~=2.2.0 9 | cryptography>=45.0.2,<46.0.0 10 | h11==0.16.0 11 | Jinja2~=3.1.6 12 | PyJWT==2.10.1 13 | python-dateutil==2.9.0.post0 14 | websockets~=15.0.1 15 | httptools==0.6.4 16 | # For tests: 17 | Cython==3.0.12 18 | setuptools==80.7.1 19 | Flask[async]==3.1.1 20 | Hypercorn==0.17.3 21 | requests==2.32.3 22 | pytest==8.3.5 23 | pytest-asyncio==0.26.0 24 | pytest-cov==6.1.1 25 | uvicorn==0.34.2 26 | pydantic==2.11.4 27 | pydantic_core==2.33.2 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is used to specify Python extensions, which are used when using Cython. 3 | Extensions are used only if the current runtime is CPython and only if there is not an 4 | environment variable: `BLACKSHEEP_NO_EXTENSIONS=1`. 5 | The logic is to support PyPy. See: 6 | https://github.com/Neoteroi/BlackSheep/issues/539#issuecomment-2888631226 7 | """ 8 | 9 | import os 10 | from setuptools import Extension, setup 11 | import platform 12 | 13 | COMPILE_ARGS = ["-O2"] 14 | 15 | # Check for environment variable to skip extensions 16 | skip_ext = os.environ.get("BLACKSHEEP_NO_EXTENSIONS", "0") == "1" 17 | 18 | 19 | if platform.python_implementation() == "CPython" and not skip_ext: 20 | ext_modules = [ 21 | Extension( 22 | "blacksheep.url", 23 | ["blacksheep/url.c"], 24 | extra_compile_args=COMPILE_ARGS, 25 | ), 26 | Extension( 27 | "blacksheep.exceptions", 28 | ["blacksheep/exceptions.c"], 29 | extra_compile_args=COMPILE_ARGS, 30 | ), 31 | Extension( 32 | "blacksheep.headers", 33 | ["blacksheep/headers.c"], 34 | extra_compile_args=COMPILE_ARGS, 35 | ), 36 | Extension( 37 | "blacksheep.cookies", 38 | ["blacksheep/cookies.c"], 39 | extra_compile_args=COMPILE_ARGS, 40 | ), 41 | Extension( 42 | "blacksheep.contents", 43 | ["blacksheep/contents.c"], 44 | extra_compile_args=COMPILE_ARGS, 45 | ), 46 | Extension( 47 | "blacksheep.messages", 48 | ["blacksheep/messages.c"], 49 | extra_compile_args=COMPILE_ARGS, 50 | ), 51 | Extension( 52 | "blacksheep.scribe", 53 | ["blacksheep/scribe.c"], 54 | extra_compile_args=COMPILE_ARGS, 55 | ), 56 | Extension( 57 | "blacksheep.baseapp", 58 | ["blacksheep/baseapp.c"], 59 | extra_compile_args=COMPILE_ARGS, 60 | ), 61 | ] 62 | else: 63 | ext_modules = [] 64 | 65 | setup(ext_modules=ext_modules) 66 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/__init__.py -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | """These classes implement the interface used by BlackSheep HTTP client implementation, to simplify testing on the 2 | ClientSession object; including handling of connections and requests timeouts; redirects, etc. 3 | """ 4 | 5 | import asyncio 6 | 7 | 8 | class FakeConnection: 9 | def __init__(self, fake_responses): 10 | self.fake_responses = fake_responses 11 | self.sleep_for = 0.001 12 | self._iter = iter(fake_responses) 13 | 14 | async def send(self, request): 15 | await asyncio.sleep(self.sleep_for) 16 | try: 17 | return next(self._iter) 18 | except StopIteration: 19 | self._iter = iter(self.fake_responses) 20 | return next(self._iter) 21 | 22 | def close(self): 23 | pass 24 | 25 | 26 | class FakePool: 27 | def __init__(self, fake_connection: FakeConnection, delay=0.001): 28 | self.connection = fake_connection 29 | self.sleep_for = delay 30 | 31 | async def get_connection(self): 32 | await asyncio.sleep(self.sleep_for) 33 | return self.connection 34 | 35 | 36 | class FakePools: 37 | def __init__(self, fake_responses): 38 | self.fake_responses = fake_responses 39 | self.pool = FakePool(FakeConnection(self.fake_responses)) 40 | 41 | def get_pool(self, scheme, host, port, ssl): 42 | return self.pool 43 | 44 | def dispose(self): 45 | pass 46 | -------------------------------------------------------------------------------- /tests/client/test_headers.py: -------------------------------------------------------------------------------- 1 | from blacksheep import Response, TextContent 2 | from blacksheep.client import ClientSession 3 | 4 | from . import FakePools 5 | 6 | 7 | async def test_default_headers(): 8 | fake_pools = FakePools([Response(200, [], TextContent("Hello, World!"))]) 9 | 10 | async def middleware_for_assertions(request, next_handler): 11 | assert b"hello" in request.headers 12 | assert request.headers.get_single(b"hello") == b"World" 13 | 14 | assert b"Foo" in request.headers 15 | assert request.headers.get_single(b"Foo") == b"Power" 16 | 17 | return await next_handler(request) 18 | 19 | async with ClientSession( 20 | base_url=b"http://localhost:8080", 21 | pools=fake_pools, 22 | middlewares=[middleware_for_assertions], 23 | default_headers=[(b"Hello", b"World"), (b"Foo", b"Power")], 24 | ) as client: 25 | await client.get(b"/") 26 | 27 | 28 | async def test_request_headers(): 29 | fake_pools = FakePools([Response(200, [], TextContent("Hello, World!"))]) 30 | 31 | async def middleware_for_assertions(request, next_handler): 32 | assert b"Hello" in request.headers 33 | assert request.headers.get_single(b"Hello") == b"World" 34 | 35 | return await next_handler(request) 36 | 37 | async with ClientSession( 38 | base_url=b"http://localhost:8080", 39 | pools=fake_pools, 40 | middlewares=[middleware_for_assertions], 41 | ) as client: 42 | await client.get(b"/", headers=[(b"Hello", b"World")]) 43 | await client.post(b"/", headers=[(b"Hello", b"World")]) 44 | await client.put(b"/", headers=[(b"Hello", b"World")]) 45 | await client.delete(b"/", headers=[(b"Hello", b"World")]) 46 | 47 | 48 | async def test_request_headers_override_default_header(): 49 | fake_pools = FakePools([Response(200, [], TextContent("Hello, World!"))]) 50 | 51 | async def middleware_for_assertions(request, next_handler): 52 | assert b"hello" in request.headers 53 | assert request.headers.get_single(b"hello") == b"Kitty" 54 | 55 | assert b"Foo" in request.headers 56 | assert request.headers.get_single(b"Foo") == b"Power" 57 | 58 | return await next_handler(request) 59 | 60 | async with ClientSession( 61 | base_url=b"http://localhost:8080", 62 | pools=fake_pools, 63 | middlewares=[middleware_for_assertions], 64 | default_headers=[(b"Hello", b"World"), (b"Foo", b"Power")], 65 | ) as client: 66 | await client.get(b"/", headers=[(b"Hello", b"Kitty")]) 67 | -------------------------------------------------------------------------------- /tests/client/test_middlewares.py: -------------------------------------------------------------------------------- 1 | from blacksheep import Response, TextContent 2 | from blacksheep.client import ClientSession 3 | 4 | from . import FakePools 5 | 6 | 7 | async def test_single_middleware(): 8 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 9 | 10 | steps = [] 11 | 12 | async def middleware_one(request, next_handler): 13 | steps.append(1) 14 | response = await next_handler(request) 15 | steps.append(2) 16 | return response 17 | 18 | async with ClientSession( 19 | base_url=b"http://localhost:8080", 20 | pools=fake_pools, 21 | middlewares=[middleware_one], 22 | ) as client: 23 | response = await client.get(b"/") 24 | 25 | assert steps == [1, 2] 26 | assert response.status == 200 27 | text = await response.text() 28 | assert text == "Hello, World!" 29 | 30 | assert len(client.middlewares) == 2 31 | 32 | 33 | async def test_falsy_middleware(): 34 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 35 | 36 | steps = [] 37 | 38 | async def middleware_one(request, next_handler): 39 | steps.append(1) 40 | response = await next_handler(request) 41 | steps.append(2) 42 | return response 43 | 44 | async with ClientSession( 45 | base_url=b"http://localhost:8080", 46 | pools=fake_pools, 47 | middlewares=[middleware_one, None, False], # type: ignore 48 | ) as client: 49 | response = await client.get(b"/") 50 | 51 | assert steps == [1, 2] 52 | assert response.status == 200 53 | text = await response.text() 54 | assert text == "Hello, World!" 55 | 56 | 57 | async def test_multiple_middleware(): 58 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 59 | 60 | steps = [] 61 | 62 | async def middleware_one(request, next_handler): 63 | steps.append(1) 64 | response = await next_handler(request) 65 | steps.append(2) 66 | return response 67 | 68 | async def middleware_two(request, next_handler): 69 | steps.append(3) 70 | response = await next_handler(request) 71 | steps.append(4) 72 | return response 73 | 74 | async def middleware_three(request, next_handler): 75 | steps.append(5) 76 | response = await next_handler(request) 77 | steps.append(6) 78 | return response 79 | 80 | async with ClientSession( 81 | base_url=b"http://localhost:8080", 82 | pools=fake_pools, 83 | middlewares=[middleware_one, middleware_two, middleware_three], 84 | ) as client: 85 | response = await client.get(b"/") 86 | 87 | assert steps == [1, 3, 5, 6, 4, 2] 88 | assert response.status == 200 89 | text = await response.text() 90 | assert text == "Hello, World!" 91 | 92 | 93 | async def test_middlewares_can_be_applied_multiple_times_without_changing(): 94 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 95 | 96 | steps = [] 97 | 98 | async def middleware_one(request, next_handler): 99 | steps.append(1) 100 | response = await next_handler(request) 101 | steps.append(2) 102 | return response 103 | 104 | async def middleware_two(request, next_handler): 105 | steps.append(3) 106 | response = await next_handler(request) 107 | steps.append(4) 108 | return response 109 | 110 | async def middleware_three(request, next_handler): 111 | steps.append(5) 112 | response = await next_handler(request) 113 | steps.append(6) 114 | return response 115 | 116 | async with ClientSession( 117 | base_url=b"http://localhost:8080", pools=fake_pools 118 | ) as client: 119 | client.add_middlewares([middleware_one]) 120 | client.add_middlewares([middleware_two]) 121 | client.add_middlewares([middleware_three]) 122 | 123 | assert middleware_one in client._middlewares 124 | assert middleware_two in client._middlewares 125 | assert middleware_three in client._middlewares 126 | 127 | client._build_middlewares_chain() 128 | 129 | response = await client.get(b"/") 130 | 131 | assert steps == [1, 3, 5, 6, 4, 2] 132 | assert response.status == 200 133 | text = await response.text() 134 | assert text == "Hello, World!" 135 | -------------------------------------------------------------------------------- /tests/client/test_pool.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | import pytest 4 | 5 | from blacksheep.client.connection import ( 6 | INSECURE_SSLCONTEXT, 7 | SECURE_SSLCONTEXT, 8 | ClientConnection, 9 | ) 10 | from blacksheep.client.pool import ConnectionPool, get_ssl_context 11 | from blacksheep.exceptions import InvalidArgument 12 | from blacksheep.utils.aio import get_running_loop 13 | 14 | example_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) 15 | example_context.check_hostname = False 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "scheme,ssl_option,expected_result", 20 | [ 21 | (b"https", False, INSECURE_SSLCONTEXT), 22 | (b"https", True, SECURE_SSLCONTEXT), 23 | (b"https", None, SECURE_SSLCONTEXT), 24 | (b"https", example_context, example_context), 25 | ], 26 | ) 27 | def test_get_ssl_context(scheme, ssl_option, expected_result): 28 | assert get_ssl_context(scheme, ssl_option) is expected_result 29 | 30 | 31 | def test_get_ssl_context_raises_for_http(): 32 | with pytest.raises(InvalidArgument): 33 | get_ssl_context(b"http", True) 34 | 35 | with pytest.raises(InvalidArgument): 36 | get_ssl_context(b"http", SECURE_SSLCONTEXT) 37 | 38 | 39 | def test_get_ssl_context_raises_for_invalid_argument(): 40 | with pytest.raises(InvalidArgument): 41 | get_ssl_context(b"https", 1) # type: ignore 42 | 43 | with pytest.raises(InvalidArgument): 44 | get_ssl_context(b"https", {}) # type: ignore 45 | 46 | 47 | def test_return_connection_disposed_pool_does_nothing(): 48 | pool = ConnectionPool(get_running_loop(), b"http", b"foo.com", 80, None) 49 | 50 | pool.dispose() 51 | pool.try_return_connection(ClientConnection(pool.loop, pool)) 52 | 53 | 54 | def test_return_connection_does_nothing_if_the_queue_is_full(): 55 | pool = ConnectionPool(get_running_loop(), b"http", b"foo.com", 80, None, max_size=2) 56 | 57 | for i in range(5): 58 | pool.try_return_connection(ClientConnection(pool.loop, pool)) 59 | 60 | if i + 1 >= 2: 61 | assert pool._idle_connections.full() is True 62 | -------------------------------------------------------------------------------- /tests/client/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blacksheep import Response, TextContent 4 | from blacksheep.client import ClientSession 5 | 6 | from . import FakePools 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "params,expected_query", 11 | [ 12 | [{}, None], 13 | [{"hello": "world"}, b"hello=world"], 14 | [{"foo": True}, b"foo=True"], 15 | [{"foo": True, "ufo": "ufo"}, b"foo=True&ufo=ufo"], 16 | [[("x", "a"), ("x", "b"), ("x", "c")], b"x=a&x=b&x=c"], 17 | [{"v": "Hello World!"}, b"v=Hello+World%21"], 18 | [{"name": "Łukasz"}, b"name=%C5%81ukasz"], 19 | ], 20 | ) 21 | async def test_query_params(params, expected_query): 22 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 23 | 24 | async def middleware_for_assertions(request, next_handler): 25 | assert expected_query == request.url.query 26 | return await next_handler(request) 27 | 28 | async with ClientSession( 29 | base_url=b"http://localhost:8080", 30 | pools=fake_pools, 31 | middlewares=[middleware_for_assertions], 32 | ) as client: 33 | await client.get(b"/", params=params) 34 | await client.head(b"/", params=params) 35 | await client.post(b"/", params=params) 36 | await client.put(b"/", params=params) 37 | await client.patch(b"/", params=params) 38 | await client.delete(b"/", params=params) 39 | await client.options(b"/", params=params) 40 | await client.trace(b"/", params=params) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "request_url,params,expected_query", 45 | [ 46 | ["/?foo=power", {}, b"foo=power"], 47 | ["/?foo=power", {"hello": "world"}, b"foo=power&hello=world"], 48 | ["/?foo=power", {"foo": True}, b"foo=power&foo=True"], 49 | [ 50 | "/?foo=power&search=something", 51 | {"ufo": "ufo"}, 52 | b"foo=power&search=something&ufo=ufo", 53 | ], 54 | ], 55 | ) 56 | async def test_query_params_concatenation(request_url, params, expected_query): 57 | fake_pools = FakePools([Response(200, None, TextContent("Hello, World!"))]) 58 | 59 | async def middleware_for_assertions(request, next_handler): 60 | assert expected_query == request.url.query 61 | return await next_handler(request) 62 | 63 | async with ClientSession( 64 | base_url=b"http://localhost:8080", 65 | pools=fake_pools, 66 | middlewares=[middleware_for_assertions], 67 | ) as client: 68 | await client.get(request_url, params=params) 69 | -------------------------------------------------------------------------------- /tests/client/test_timeouts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blacksheep.client import ClientSession, ConnectionTimeout, RequestTimeout 4 | 5 | from . import FakePools 6 | 7 | 8 | async def test_connection_timeout(): 9 | fake_pools = FakePools([]) 10 | fake_pools.pool.sleep_for = ( 11 | 5 # wait for 5 seconds before returning a connection; to test timeout handling 12 | ) 13 | 14 | async with ClientSession( 15 | base_url=b"http://localhost:8080", 16 | pools=fake_pools, 17 | connection_timeout=0.002, # 2ms - not realistic, but ok for this test 18 | ) as client: 19 | with pytest.raises(ConnectionTimeout): 20 | await client.get(b"/") 21 | 22 | 23 | async def test_request_timeout(): 24 | fake_pools = FakePools([]) 25 | fake_pools.pool.connection.sleep_for = ( 26 | 5 # wait for 5 seconds before returning a response; 27 | ) 28 | 29 | async with ClientSession( 30 | base_url=b"http://localhost:8080", 31 | pools=fake_pools, 32 | request_timeout=0.002, # 2ms - not realistic, but ok for this test 33 | ) as client: 34 | with pytest.raises(RequestTimeout): 35 | await client.get(b"/") 36 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from blacksheep.server.rendering.jinja2 import JinjaRenderer 6 | from blacksheep.settings.html import html_settings 7 | from tests.utils.application import FakeApplication 8 | 9 | # configures default Jinja settings for tests 10 | os.environ["APP_DEFAULT_ROUTER"] = "0" 11 | os.environ["APP_JINJA_PACKAGE_NAME"] = "tests.testapp" 12 | os.environ["APP_JINJA_PACKAGE_PATH"] = "templates" 13 | os.environ["APP_SIGNAL_HANDLER"] = "0" 14 | 15 | 16 | @pytest.fixture 17 | def app(): 18 | return FakeApplication() 19 | 20 | 21 | ASYNC_RENDERER = JinjaRenderer(enable_async=True) 22 | 23 | 24 | @pytest.fixture() 25 | def async_jinja_env(): 26 | """ 27 | Configures an async renderer for a test (Jinja does not support synch and async 28 | rendering in the same environment). 29 | """ 30 | default_renderer = html_settings.renderer 31 | html_settings._renderer = ASYNC_RENDERER 32 | yield True 33 | html_settings._renderer = default_renderer 34 | -------------------------------------------------------------------------------- /tests/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/examples/__init__.py -------------------------------------------------------------------------------- /tests/files/README.md: -------------------------------------------------------------------------------- 1 | # Test files 2 | These test files are used to test multipart form data posts. 3 | Pictures are taken from [https://www.pexels.com](https://www.pexels.com). -------------------------------------------------------------------------------- /tests/files/example.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna 2 | aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis 3 | aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint 4 | occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -------------------------------------------------------------------------------- /tests/files/lorem-ipsum.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet 2 | -------------------------------------------------------------------------------- /tests/files/pexels-photo-126407.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/files/pexels-photo-126407.jpeg -------------------------------------------------------------------------------- /tests/files/pexels-photo-302280.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/files/pexels-photo-302280.jpeg -------------------------------------------------------------------------------- /tests/files/pexels-photo-730896.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/files/pexels-photo-730896.jpeg -------------------------------------------------------------------------------- /tests/files/pexels-photo-923360.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/files/pexels-photo-923360.jpeg -------------------------------------------------------------------------------- /tests/files2/example.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/files2/example.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/files2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example. 5 | 6 | 7 | 8 |

    Lorem ipsum

    9 |

    Dolor sit amet.

    10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/files2/scripts/main.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | console.log("Here"); 3 | })(); 4 | -------------------------------------------------------------------------------- /tests/files2/styles/fonts/foo.txt: -------------------------------------------------------------------------------- 1 | - 2 | -------------------------------------------------------------------------------- /tests/files2/styles/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | } 4 | 5 | p:before { 6 | display: inline-block; 7 | content: "🍎"; 8 | } 9 | 10 | .øø { 11 | font-weight: bold; 12 | } 13 | -------------------------------------------------------------------------------- /tests/files3/lorem-ipsum.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet 2 | -------------------------------------------------------------------------------- /tests/mock_protocol.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | class MockContext: 5 | def __init__(self): 6 | self.connections = [] 7 | 8 | 9 | class MockProtocol(asyncio.Protocol): 10 | def __init__(self, context: MockContext): 11 | self.context = context 12 | context.connections.append(self) 13 | 14 | def data_received(self, data): 15 | pass 16 | 17 | def eof_received(self): 18 | pass 19 | 20 | def connection_made(self, transport): 21 | pass 22 | 23 | def connection_lost(self, exc): 24 | pass 25 | 26 | def pause_writing(self): 27 | pass 28 | 29 | def resume_writing(self): 30 | pass 31 | -------------------------------------------------------------------------------- /tests/res/0.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHM7vHSAQxuS26 3 | 7lEtSqIAEk2q3iR2b/m5ywqBJ0dR1RxuQbbx0sCKSgVOSL1rCbJqlJs7zjOUoxof 4 | bihs57jiF3fax14w51CnRPGnET3kd9hUW4BZ18IzgHP/HfZKrnV7vHqhl3T284MU 5 | VvBOUwnbss8AS4jkbpRVArGEgH/ADYU4+Rsd4OdwHuNi/2QuJ2+k2Cnk8L5oocq/ 6 | 1rLqCLsz7BRASl5AyOnIMU3an+rXYFZJRuxHA6OFi0K5KaYE2HTsyLPNUhCOmZlT 7 | l/qqZkfD76IlIEPnbCwjzuK1hsHBFwM3l98KiWQWkDy9rSEOVKEjapWbC1DctSll 8 | WK8q4k5LAgMBAAECggEATxXv8D9cQu11BWkGW4fs50BdC4BkU41DRQsiYYJZo1iL 9 | kA6Q9lMo0/5tOtZQNYXFCuFy+/xyqAlVHrNaY1pgIYsVr4tFjv7XG4GYuy5yNxmJ 10 | jnxBaenqFQ5jfx7DIIVA6V48BZme+0hUeyfFAiOfn1TPMBvM/nwUcee+2I83qORB 11 | u5uCnquDzhKEXYIriZ+4c6M+0oPYKeMrwty5vH7uwalDNvkGvKLoGoRounH0X2tV 12 | Yi5xzxUVC+KOopIk3fIWwdTSEJuQzoKo1gnZvt07f8sOh36dMCMONpxhM1GMX0Pg 13 | TgrJEg80UqDSxc3gYoAy0NpX4ttaWU4AziAIcf4XOQKBgQDtpHJg6Ya0WDsCuOnf 14 | IyGVPxoo6tTlLjP9ujpbt8ohzKTSnIc28DtKO4HQVqdNegJ0JsBV8VNAkhEh32/l 15 | i1Mde6F8uK4wt78yAslx256+t3Jjn59vcF3Tm8AaR8GB4CdG5PtIVKEUqXH4Rfkn 16 | 87YHSmPL6VhN+JL04B4DSty/fQKBgQDWlxoIuj1Y2Xf1pAwpkn42YN10V1iY8/L5 17 | hfIDLd6UC5G1hUxrePScOEWhZk9Ov2o2qAXBhchugx64H5CSTK9lbw1C0HWyL4v7 18 | zup1omfqrjn7XXoX512LyTIYyV0oBWCm3V6kOJN3Ea7tALvVIQdfe4Z4fb9HSP/M 19 | FqGIOm+/ZwKBgQCi7VAN6Y2VL7ilkSmm9msb6/t/eiEkT50NpBRGtac7rRaD3xVF 20 | MUc1Cb9im0Zw8+miwL61LZMqffqJAquw8Oi3GgAJhoTGmfPX0dlS2oPntdYTP2kL 21 | +joZznrSice9x3SmQm+Vk5Asnk+pLDA6l/iA3xu0vfLw4i+++7kYAMd/8QKBgQCw 22 | 34bD3s4l58mqnHax5V9GbvzZog0StTB2XuMln68wE4EcPyzIAMCN6wvphqyj2b4w 23 | Irnr0ttry4OMe+frzm1bi/dANRZtsicNfHVgVGaW1thPybKS9U7zovg52e+Axz3t 24 | C9WwQjm6EMc/7jTj7P9owiYKNotstEyy6Yxm/tOQzQKBgQCz1j1d3cLUEKY9IFJ1 25 | Ew28dwwuL9pGFPHCXcDTqAAAKME9V3U8F7ZtYThE9u6pRfgXKLJWEY+qpH6FhuHQ 26 | DJdfiREM84kh/99Y6ZNjvHM7CK/5EZFTaEaqZGVlgR93Hkrs5LbKuucYffmlF69/ 27 | PDRlkWb0YatyOxBKznBgNJsz5A== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/res/foreign.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA0e9Q8U6bSzzkyyLImTi5g+MMHPvEwp54A2ZI2Bk+cATGTB4Q 3 | ptPi8MZlXNgwTVJVOzu1JI+ktcAfZOr6llbVgMs5X+a3ikjPU5YnRpfLtSfhvtcF 4 | YGsjJUt1pj2d60mi2XkujG5dHp4yzwiZGtAfaJ0nysltKxkL4clLC3yMVdRF4zh9 5 | PoF2Z/i++w2yBXLbjIqZ5I/QG/ckhM9v6Rq5WU8BraRo6DnomBrBVpcce89tbLbe 6 | nqv8/AaOU4J9G3rX1hXOcy9V4hLbnI1ixyWvIzrehscLogyka4E1le+MUNAFPHRh 7 | y1JYTNPFFbac6I/Cii76bZ5DodHbKzIhfQ99EwIDAQABAoIBAQCEtalQceWfoUXs 8 | 1/dBTOeRZPUiWVHfybeKRp2j5glfXNVSBut12poqpPAsygl2x6ZThIIVM6zmrCXs 9 | cNKqOQhMm6uQYVQDWdWePFxlts2ynhyJvHmMow2bpOEhjvAGbg1BNubjJV0+Xrk1 10 | wXSvy4MfacFe4whc5z9oowwKndhE5H5jCnKFhEvYowaWlTg2US/Hknur77UdhqfP 11 | AYp9Y6B5xZEKSs/uR7LZ9aCbsNBEcxUvCLBdjRvbC2xNEEP9ynxtxzwxXkmh/zpp 12 | 0Pr2qGkmof1IyOEjB1Qi600GAhLn+MaieYlTPPWOqexdboUukgEOhbBO2dzKG2vA 13 | PhzcAslpAoGBAP2u1P6QzF0bbrwv5flSwAXkgzq81St9usipTYaiw/xcjXzlUYtI 14 | F9L3V35Nuh6mhLbKBDmj6nDB/YzBT2W3R5QY1W/c/jyUUXS7oluV/VwbV7iEGncY 15 | YJ4mq710jk0c5UGKFqDYgYRBfq9HJbwIs6Zz77TGfRC5Eo34FLAziB7PAoGBANPa 16 | MPOI1K9U3QPtQnXY/r1ngOQpL15iI0mcuveiwUHFTAPqSruCY3deAvXPhGmMVNGZ 17 | 3lRKGDpLjTcUkm49iKRTT3Jv1yXFjaqruxpsj/6nJnv3FLN1lJK/EBYAhjMF8gcJ 18 | YAj7OuifnN6LMCnJ++9lNQcnEea5taTvR7OmzO59AoGBAMAQJ+58Dl3HsTUFRqZX 19 | Qk6zza8g3HvK+ymFFM8EWEGuiOiwbeZ3tKHi5fkYO/uMsxn5JO3G7m5kUOTKTqSB 20 | +M1lZ+MDe/C9klZA4RFaI7IieW2Xhrn4WN4kBQ6xOjOj1uah97Pbd2N8er+VrDoK 21 | lIlHaYqCZJnOpP+bfX5R5ZL9AoGBAMiFre/Vg/qxCMG+wTlquzPr1EQh94QOv/fd 22 | MdtTYjku1lSeXz48nIlPot2oHl0JRv9d9OMzftsux+tqvW87Lyra8EgRNEO8SetR 23 | wTexqloPPI35wM5cbNS5pDAvLtb8uamPZicaJRgqfADpHh1v4dcmpJqwkHEjNpt3 24 | IRcaur7BAoGAPIDJzFC7VhJuoGQd0vnU9JIE6BUZWI0kwiE10jbSQstEkDhscMnC 25 | /2UtuUEaPfSrCX4HoMmg+PLsGsg9qQ/wo+rs8hCFNj/km3oZV+ZW82QH3iCFtrOY 26 | m8MkmVCnJOKhT/Q8r1onfYq1j0sQyHTXin6tiYLws9ED48HyVrkZKOY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/res/jwks.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kty": "RSA", 5 | "kid": "0", 6 | "n": "xzO7x0gEMbktuu5RLUqiABJNqt4kdm_5ucsKgSdHUdUcbkG28dLAikoFTki9awmyapSbO84zlKMaH24obOe44hd32sdeMOdQp0TxpxE95HfYVFuAWdfCM4Bz_x32Sq51e7x6oZd09vODFFbwTlMJ27LPAEuI5G6UVQKxhIB_wA2FOPkbHeDncB7jYv9kLidvpNgp5PC-aKHKv9ay6gi7M-wUQEpeQMjpyDFN2p_q12BWSUbsRwOjhYtCuSmmBNh07MizzVIQjpmZU5f6qmZHw--iJSBD52wsI87itYbBwRcDN5ffColkFpA8va0hDlShI2qVmwtQ3LUpZVivKuJOSw==", 7 | "e": "AQAB" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tests/res/multipart-mix.dat: -------------------------------------------------------------------------------- 1 | ------WebKitFormBoundarygKWtIe0dRcq6RJaJ 2 | Content-Disposition: form-data; name="files"; filename="red-dot.png" 3 | Content-Type: image/png 4 | 5 | �PNG 6 |  7 | 8 | IHDR�Ԛs�iCCPICC profile(�}�=H�@�_�JE��A�C��dAT��T�J[�U�K��IC���(��X�:�8���*� �NN�.R���B�X������� 4*L5}���YF*��U�� 9 | B��3�Dz1������ExV�s�~%o2�#�1ݰ�7�����y�8�J�B|N str: 12 | response = Response(200) 13 | handler.set_cookie(data, response) 14 | return handler.cookie_name + "=" + response.cookies[handler.cookie_name].value 15 | 16 | 17 | async def test_cookie_authentication(): 18 | handler = CookieAuthentication() 19 | 20 | request = Request("GET", b"/", headers=[]) 21 | 22 | await handler.authenticate(request) 23 | 24 | assert request.user is not None 25 | assert request.user.is_authenticated() is False 26 | 27 | request = Request( 28 | "GET", 29 | b"/", 30 | headers=[ 31 | ( 32 | b"cookie", 33 | get_auth_cookie( 34 | handler, {"id": 1, "email": "example@neoteroi.dev"} 35 | ).encode(), 36 | ) 37 | ], 38 | ) 39 | 40 | await handler.authenticate(request) 41 | 42 | assert isinstance(request.user, Identity) 43 | assert request.user.is_authenticated() is True 44 | assert request.user.authentication_mode == handler.auth_scheme 45 | assert request.user.claims.get("email") == "example@neoteroi.dev" 46 | 47 | 48 | async def test_cookie_authentication_handles_invalid_signature(): 49 | handler = CookieAuthentication() 50 | 51 | request = Request( 52 | "GET", 53 | b"/", 54 | headers=[ 55 | ( 56 | b"cookie", 57 | get_auth_cookie( 58 | handler, {"id": 1, "email": "example@neoteroi.dev"} 59 | ).encode(), 60 | ) 61 | ], 62 | ) 63 | 64 | other_handler = CookieAuthentication(secret_keys=[generate_secret()]) 65 | await other_handler.authenticate(request) 66 | 67 | assert request.user is not None 68 | assert request.user.is_authenticated() is False 69 | 70 | 71 | def test_cookie_authentication_unset_cookie(): 72 | handler = CookieAuthentication() 73 | 74 | response = Response(200) 75 | handler.unset_cookie(response) 76 | 77 | cookie_header = response.cookies[handler.cookie_name] 78 | assert cookie_header is not None 79 | assert cookie_header.expires is not None 80 | assert cookie_header.expires < utcnow() 81 | -------------------------------------------------------------------------------- /tests/test_caching.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from blacksheep.server.controllers import Controller 4 | from blacksheep.server.headers.cache import ( 5 | CacheControlMiddleware, 6 | cache_control, 7 | write_cache_control_response_header, 8 | ) 9 | from blacksheep.server.routing import RoutesRegistry 10 | from blacksheep.testing.helpers import get_example_scope 11 | from blacksheep.testing.messages import MockReceive, MockSend 12 | 13 | CACHE_CONTROL_PARAMS_EXPECTED = [ 14 | ({"max_age": 0}, b"max-age=0"), 15 | ({"max_age": 120}, b"max-age=120"), 16 | ({"shared_max_age": 604800}, b"s-maxage=604800"), 17 | ({"no_cache": True}, b"no-cache"), 18 | ({"no_store": True}, b"no-store"), 19 | ({"must_understand": True, "no_store": True}, b"no-store, must-understand"), 20 | ({"private": True}, b"private"), 21 | ({"public": True}, b"public"), 22 | ({"no_cache": True, "no_store": True}, b"no-cache, no-store"), 23 | ({"max_age": 0, "must_revalidate": True}, b"max-age=0, must-revalidate"), 24 | ({"max_age": 0, "proxy_revalidate": True}, b"max-age=0, proxy-revalidate"), 25 | ({"no_transform": True}, b"no-transform"), 26 | ( 27 | {"public": True, "max_age": 604800, "immutable": True}, 28 | b"public, max-age=604800, immutable", 29 | ), 30 | ( 31 | {"max_age": 604800, "stale_while_revalidate": 86400}, 32 | b"max-age=604800, stale-while-revalidate=86400", 33 | ), 34 | ( 35 | {"max_age": 604800, "stale_if_error": 86400}, 36 | b"max-age=604800, stale-if-error=86400", 37 | ), 38 | ] 39 | 40 | 41 | async def _assert_scenario(app, expected_header: bytes): 42 | @app.router.get("/no-cache") 43 | @cache_control(no_cache=True, no_store=True) 44 | def example_no(): 45 | return "Example" 46 | 47 | await app.start() 48 | await app(get_example_scope("GET", "/", []), MockReceive(), MockSend()) 49 | 50 | response = app.response 51 | assert response.status == 200 52 | cache_control_value = response.headers[b"cache-control"] 53 | assert len(cache_control_value) == 1 54 | assert cache_control_value[0] == expected_header 55 | 56 | await app(get_example_scope("GET", "/no-cache", []), MockReceive(), MockSend()) 57 | 58 | response = app.response 59 | assert response.status == 200 60 | cache_control_value = response.headers[b"cache-control"] 61 | assert len(cache_control_value) == 1 62 | assert cache_control_value[0] == b"no-cache, no-store" 63 | 64 | 65 | def test_write_cache_control_response_header_raises_for_priv_pub(): 66 | with pytest.raises(ValueError): 67 | write_cache_control_response_header(private=True, public=True) 68 | 69 | 70 | @pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED) 71 | async def test_cache_control_decorator(app, params, expected_header): 72 | @app.router.get("/") 73 | @cache_control(**params) 74 | def example(): 75 | return "Example" 76 | 77 | await _assert_scenario(app, expected_header) 78 | 79 | 80 | @pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED) 81 | async def test_cache_control_in_controller(app, params, expected_header): 82 | app.controllers_router = RoutesRegistry() 83 | get = app.controllers_router.get 84 | 85 | class Home(Controller): 86 | @get("/") 87 | @cache_control(**params) 88 | async def index(self): 89 | return "Example" 90 | 91 | await _assert_scenario(app, expected_header) 92 | 93 | 94 | @pytest.mark.parametrize("params,expected_header", CACHE_CONTROL_PARAMS_EXPECTED) 95 | async def test_cache_control_middleware(app, params, expected_header): 96 | app.middlewares.append(CacheControlMiddleware(**params)) 97 | 98 | @app.router.get("/") 99 | def example(): 100 | return "Example" 101 | 102 | await _assert_scenario(app, expected_header) 103 | -------------------------------------------------------------------------------- /tests/test_dataprotection.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from itsdangerous import BadSignature 5 | 6 | from blacksheep.server.dataprotection import generate_secret, get_keys, get_serializer 7 | 8 | 9 | def test_get_keys_creates_default_keys(): 10 | default_keys = get_keys() 11 | 12 | assert default_keys is not None 13 | assert len(default_keys) == 3 14 | 15 | assert default_keys != get_keys() 16 | 17 | 18 | def test_get_keys_returns_keys_configured_as_env_variables(): 19 | env_variables = [] 20 | for i in range(4): 21 | key = generate_secret() 22 | os.environ[f"APP_SECRET_{i}"] = key 23 | env_variables.append(key) 24 | 25 | assert get_keys() == env_variables 26 | assert get_keys() == env_variables 27 | 28 | 29 | def test_get_serializer(): 30 | serializer = get_serializer(get_keys()) 31 | 32 | data = {"id": "0000"} 33 | secret = serializer.dumps(data) 34 | assert isinstance(secret, str) 35 | 36 | parsed = serializer.loads(secret) 37 | assert data == parsed 38 | 39 | 40 | def test_get_serializer_with_different_purpose(): 41 | keys = get_keys() 42 | serializer = get_serializer(keys) 43 | other_serializer = get_serializer(keys, purpose="test") 44 | 45 | data = {"id": "0000"} 46 | secret = serializer.dumps(data) 47 | 48 | with pytest.raises(BadSignature): 49 | other_serializer.loads(secret) 50 | 51 | 52 | def test_get_serializer_with_default_keys(): 53 | serializer = get_serializer() 54 | 55 | data = {"id": "0000"} 56 | secret = serializer.dumps(data) 57 | assert isinstance(secret, str) 58 | 59 | parsed = serializer.loads(secret) 60 | assert data == parsed 61 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from blacksheep.server.env import EnvironmentSettings 4 | 5 | 6 | def test_env_settings(): 7 | os.environ["APP_SHOW_ERROR_DETAILS"] = "1" 8 | env = EnvironmentSettings() 9 | 10 | assert env.show_error_details is True 11 | 12 | os.environ["APP_SHOW_ERROR_DETAILS"] = "0" 13 | env = EnvironmentSettings() 14 | 15 | assert env.show_error_details is False 16 | 17 | os.environ["APP_SHOW_ERROR_DETAILS"] = "true" 18 | env = EnvironmentSettings() 19 | 20 | assert env.show_error_details is True 21 | -------------------------------------------------------------------------------- /tests/test_gzip.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | 3 | import pytest 4 | 5 | from blacksheep.server.compression import GzipMiddleware 6 | from blacksheep.testing.helpers import get_example_scope 7 | from blacksheep.testing.messages import MockReceive, MockSend 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "comp_level, comp_size", 12 | (zip(range(0, 10), (468, 283, 283, 283, 282, 282, 282, 282, 282, 282))), 13 | ) 14 | async def test_gzip_output(app, comp_level, comp_size): 15 | return_value = ( 16 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " 17 | "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim " 18 | "veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " 19 | "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " 20 | "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, " 21 | "sunt in culpa qui officia deserunt mollit anim id est laborum." 22 | ) 23 | 24 | @app.router.get("/") 25 | async def home(): 26 | return return_value 27 | 28 | app.middlewares.append(GzipMiddleware(min_size=0, comp_level=comp_level)) 29 | 30 | await app.start() 31 | 32 | await app( 33 | get_example_scope( 34 | "GET", 35 | "/", 36 | ), 37 | MockReceive([]), 38 | MockSend(), 39 | ) 40 | 41 | response = app.response 42 | assert response.status == 200 43 | assert response.content.length == comp_size 44 | assert gzip.decompress(response.content.body) == return_value.encode("ascii") 45 | assert response.headers.get_single(b"content-encoding") == b"gzip" 46 | 47 | 48 | async def test_skip_gzip_small_output(app): 49 | @app.router.get("/") 50 | async def home(): 51 | return "Hello, World" 52 | 53 | app.middlewares.append(GzipMiddleware(min_size=16)) 54 | 55 | await app.start() 56 | 57 | await app( 58 | get_example_scope( 59 | "GET", 60 | "/", 61 | ), 62 | MockReceive([]), 63 | MockSend(), 64 | ) 65 | 66 | response = app.response 67 | assert response.status == 200 68 | assert response.content.body == b"Hello, World" 69 | assert response.content.length == 12 70 | with pytest.raises(ValueError): 71 | assert response.headers.get_single(b"content-encoding") 72 | 73 | 74 | async def test_skip_gzip_output_without_header(app): 75 | @app.router.get("/") 76 | async def home(): 77 | return "Hello, World" 78 | 79 | app.middlewares.append(GzipMiddleware(min_size=0)) 80 | 81 | await app.start() 82 | 83 | await app( 84 | get_example_scope( 85 | "GET", 86 | "/", 87 | accept_encoding=b"deflate", 88 | ), 89 | MockReceive([]), 90 | MockSend(), 91 | ) 92 | 93 | response = app.response 94 | assert response.status == 200 95 | assert response.content.body == b"Hello, World" 96 | assert response.content.length == 12 97 | with pytest.raises(ValueError): 98 | assert response.headers.get_single(b"content-encoding") 99 | 100 | 101 | async def test_skip_gzip_output_for_unhandled_type(app): 102 | @app.router.get("/") 103 | async def home(): 104 | return "Hello, World" 105 | 106 | app.middlewares.append(GzipMiddleware(min_size=0, handled_types=[b"text/html"])) 107 | 108 | await app.start() 109 | 110 | await app( 111 | get_example_scope( 112 | "GET", 113 | "/", 114 | accept_encoding=b"deflate", 115 | ), 116 | MockReceive([]), 117 | MockSend(), 118 | ) 119 | 120 | response = app.response 121 | assert response.status == 200 122 | assert response.content.length == 12 123 | assert response.content.body == b"Hello, World" 124 | with pytest.raises(ValueError): 125 | assert response.headers.get_single(b"content-encoding") 126 | -------------------------------------------------------------------------------- /tests/test_pathutils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from blacksheep.common.files.pathsutils import ( 6 | get_file_extension_from_name, 7 | get_mime_type_from_name, 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "full_path,expected_result", 13 | [ 14 | ("hello.txt", ".txt"), 15 | (".gitignore", ".gitignore"), 16 | ("ØØ Void.album", ".album"), 17 | ("", ""), 18 | ], 19 | ) 20 | def test_get_file_extension_from_name(full_path, expected_result): 21 | assert get_file_extension_from_name(full_path) == expected_result 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "full_path,expected_result", 26 | [ 27 | ("example.ogg", "audio/ogg"), 28 | ("example.jpg", "image/jpeg"), 29 | ("example.jpeg", "image/jpeg"), 30 | ("example.png", "image/png"), 31 | ( 32 | "example.js", 33 | ( 34 | "text/javascript" 35 | if sys.version_info >= (3, 12) 36 | else "application/javascript" 37 | ), 38 | ), 39 | ("example.json", "application/json"), 40 | ("example.woff2", "font/woff2"), 41 | ("hello.txt", "text/plain"), 42 | (".gitignore", "application/octet-stream"), 43 | ("ØØ Void.album", "application/octet-stream"), 44 | ("", "application/octet-stream"), 45 | ], 46 | ) 47 | def test_get_mime_type(full_path, expected_result): 48 | assert get_mime_type_from_name(full_path) == expected_result 49 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from typing import AnyStr, Sequence 2 | 3 | import pytest 4 | 5 | from blacksheep.utils import ensure_bytes, ensure_str, join_fragments 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "fragments,expected_value", 10 | [ 11 | [["a"], "/a"], 12 | [["a", "b", "c", "d"], "/a/b/c/d"], 13 | [["a", None, "b", "c", "", "d"], "/a/b/c/d"], 14 | [[b"a", b"b", b"c", b"d"], "/a/b/c/d"], 15 | [[b"a", "b", "c", b"d"], "/a/b/c/d"], 16 | [["hello/world", "today"], "/hello/world/today"], 17 | [[b"hello/world", b"today"], "/hello/world/today"], 18 | [["//hello///world", "/today/"], "/hello/world/today"], 19 | ], 20 | ) 21 | def test_join_url_fragments(fragments: Sequence[AnyStr], expected_value: str): 22 | joined = join_fragments(*fragments) 23 | assert joined == expected_value 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "value,expected_result", [("hello", b"hello"), (b"hello", b"hello")] 28 | ) 29 | def test_ensure_bytes(value, expected_result): 30 | assert ensure_bytes(value) == expected_result 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "value,expected_result", [("hello", "hello"), (b"hello", "hello")] 35 | ) 36 | def test_ensure_str(value, expected_result): 37 | assert ensure_str(value) == expected_result 38 | 39 | 40 | def test_ensure_bytes_throws_for_invalid_value(): 41 | with pytest.raises(ValueError): 42 | ensure_bytes(True) # type: ignore 43 | 44 | 45 | def test_ensure_str_throws_for_invalid_value(): 46 | with pytest.raises(ValueError): 47 | ensure_str(True) # type: ignore 48 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep/4e2d9dc89ba5ec91fa85254cb0eea59d2c7ae3b4/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/templates/form_1.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 |
    11 | {% af_input %} 12 | 13 | 14 |
    15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/testapp/templates/form_2.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 |
    11 | {% af_input %} 12 | 13 | 14 |
    15 | 18 |
    19 | {% af_input %} 20 | 21 | 22 |
    23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/testapp/templates/home.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 | 11 | -------------------------------------------------------------------------------- /tests/testapp/templates/lorem/form_1.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 |
    11 | {% af_input %} 12 | 13 | 14 |
    15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/testapp/templates/lorem/hello.jinja: -------------------------------------------------------------------------------- 1 |
    2 |

    Hello, {{name}}!

    3 | 4 | 9 |
    10 | -------------------------------------------------------------------------------- /tests/testapp/templates/lorem/index.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 | 11 | -------------------------------------------------------------------------------- /tests/testapp/templates/lorem/nomodel.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 |

    Hello World!

    9 |

    Lorem ipsum dolor sit amet

    10 | 11 | -------------------------------------------------------------------------------- /tests/testapp/templates/lorem/specific.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Specific {{title}} 6 | 7 | 8 |

    {{heading}}

    9 |

    {{paragraph}}

    10 | 11 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | 4 | 5 | # https://stackoverflow.com/questions/2059482/temporarily-modify-the-current- 6 | # processs-environment 7 | @contextlib.contextmanager 8 | def modified_env(*remove, **update): 9 | """ 10 | Temporarily updates the ``os.environ`` dictionary in-place. 11 | 12 | The ``os.environ`` dictionary is updated in-place so that the modification 13 | is sure to work in all situations. 14 | 15 | :param remove: Environment variables to remove. 16 | :param update: Dictionary of environment variables and values to add/update. 17 | """ 18 | env = os.environ 19 | update = update or {} 20 | remove = remove or [] 21 | 22 | # List of environment variables being updated or removed. 23 | stomped = (set(update.keys()) | set(remove)) & set(env.keys()) 24 | # Environment variables and values to restore on exit. 25 | update_after = {k: env[k] for k in stomped} 26 | # Environment variables and values to remove on exit. 27 | remove_after = frozenset(k for k in update if k not in env) 28 | 29 | try: 30 | env.update(update) 31 | [env.pop(k, None) for k in remove] 32 | yield 33 | finally: 34 | env.update(update_after) 35 | [env.pop(k) for k in remove_after] 36 | -------------------------------------------------------------------------------- /tests/utils/application.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from essentials.meta import deprecated 4 | 5 | from blacksheep.messages import Request, Response 6 | from blacksheep.server import Application 7 | 8 | 9 | class FakeApplication(Application): 10 | """Application class used for testing.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.auto_start: bool = True 15 | self.request: Optional[Request] = None 16 | self.response: Optional[Response] = None 17 | 18 | @deprecated( 19 | "This function is not needed anymore, and will be removed. Rely instead on " 20 | "await app.start() or the automatic start happening on await app(...)." 21 | ) 22 | def setup_controllers(self): 23 | pass 24 | 25 | async def handle(self, request): 26 | response = await super().handle(request) 27 | self.request = request 28 | self.response = response 29 | return response 30 | 31 | async def __call__(self, scope, receive, send): 32 | if not self.started and self.auto_start: 33 | await self.start() 34 | return await super().__call__(scope, receive, send) 35 | -------------------------------------------------------------------------------- /tests/utils/folder.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | 4 | 5 | def ensure_folder(path): 6 | try: 7 | os.makedirs(path) 8 | except OSError as exception: 9 | if exception.errno != errno.EEXIST: 10 | raise 11 | --------------------------------------------------------------------------------