├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE │ └── adafruit_circuitpython_pr.md └── workflows │ ├── build.yml │ ├── failure-help-text.yml │ ├── release_gh.yml │ └── release_pypi.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── LICENSES ├── CC-BY-4.0.txt ├── MIT.txt └── Unlicense.txt ├── README.rst ├── README.rst.license ├── adafruit_httpserver ├── __init__.py ├── authentication.py ├── exceptions.py ├── headers.py ├── interfaces.py ├── methods.py ├── mime_types.py ├── request.py ├── response.py ├── route.py ├── server.py └── status.py ├── docs ├── _static │ ├── favicon.ico │ └── favicon.ico.license ├── api.rst ├── api.rst.license ├── conf.py ├── examples.rst ├── examples.rst.license ├── index.rst ├── index.rst.license ├── requirements.txt ├── starting_methods.rst └── starting_methods.rst.license ├── examples ├── directory_listing.tpl.html ├── home.html ├── httpserver_authentication_handlers.py ├── httpserver_authentication_server.py ├── httpserver_chunked.py ├── httpserver_cookies.py ├── httpserver_cpu_information.py ├── httpserver_cpython.py ├── httpserver_form_data.py ├── httpserver_handler_serves_file.py ├── httpserver_https.py ├── httpserver_mdns.py ├── httpserver_methods.py ├── httpserver_multiple_servers.py ├── httpserver_neopixel.py ├── httpserver_redirects.py ├── httpserver_simpletest_auto_connection_manager.py ├── httpserver_simpletest_auto_settings_toml.py ├── httpserver_simpletest_manual_ap.py ├── httpserver_simpletest_manual_ethernet.py ├── httpserver_simpletest_manual_wifi.py ├── httpserver_sse.py ├── httpserver_start_and_poll.py ├── httpserver_start_and_poll_asyncio.py ├── httpserver_static_files_serving.py ├── httpserver_templates.py ├── httpserver_url_parameters.py ├── httpserver_video_stream.py ├── httpserver_websocket.py └── settings.toml ├── optional_requirements.txt ├── pyproject.toml ├── requirements.txt └── ruff.toml /.gitattributes: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | .py text eol=lf 6 | .rst text eol=lf 7 | .txt text eol=lf 8 | .yaml text eol=lf 9 | .toml text eol=lf 10 | .license text eol=lf 11 | .md text eol=lf 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | Thank you for contributing! Before you submit a pull request, please read the following. 6 | 7 | Make sure any changes you're submitting are in line with the CircuitPython Design Guide, available here: https://docs.circuitpython.org/en/latest/docs/design_guide.html 8 | 9 | If your changes are to documentation, please verify that the documentation builds locally by following the steps found here: https://adafru.it/build-docs 10 | 11 | Before submitting the pull request, make sure you've run Pylint and Black locally on your code. You can do this manually or using pre-commit. Instructions are available here: https://adafru.it/check-your-code 12 | 13 | Please remove all of this text before submitting. Include an explanation or list of changes included in your PR, as well as, if applicable, a link to any related issues. 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Build CI 6 | 7 | on: [pull_request, push] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Run Build CI workflow 14 | uses: adafruit/workflows-circuitpython-libs/build@main 15 | -------------------------------------------------------------------------------- /.github/workflows/failure-help-text.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Scott Shawcroft for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Failure help text 6 | 7 | on: 8 | workflow_run: 9 | workflows: ["Build CI"] 10 | types: 11 | - completed 12 | 13 | jobs: 14 | post-help: 15 | runs-on: ubuntu-latest 16 | if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' }} 17 | steps: 18 | - name: Post comment to help 19 | uses: adafruit/circuitpython-action-library-ci-failed@v1 20 | -------------------------------------------------------------------------------- /.github/workflows/release_gh.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: GitHub Release Actions 6 | 7 | on: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | upload-release-assets: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Run GitHub Release CI workflow 16 | uses: adafruit/workflows-circuitpython-libs/release-gh@main 17 | with: 18 | github-token: ${{ secrets.GITHUB_TOKEN }} 19 | upload-url: ${{ github.event.release.upload_url }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release_pypi.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: PyPI Release Actions 6 | 7 | on: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | upload-release-assets: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Run PyPI Release CI workflow 16 | uses: adafruit/workflows-circuitpython-libs/release-pypi@main 17 | with: 18 | pypi-username: ${{ secrets.pypi_username }} 19 | pypi-password: ${{ secrets.pypi_password }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Kattni Rembor, written for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # Do not include files and directories created by your personal work environment, such as the IDE 6 | # you use, except for those already listed here. Pull requests including changes to this file will 7 | # not be accepted. 8 | 9 | # This .gitignore file contains rules for files generated by working with CircuitPython libraries, 10 | # including building Sphinx, testing with pip, and creating a virual environment, as well as the 11 | # MacOS and IDE-specific files generated by using MacOS in general, or the PyCharm or VSCode IDEs. 12 | 13 | # If you find that there are files being generated on your machine that should not be included in 14 | # your git commit, you should create a .gitignore_global file on your computer to include the 15 | # files created by your personal setup. To do so, follow the two steps below. 16 | 17 | # First, create a file called .gitignore_global somewhere convenient for you, and add rules for 18 | # the files you want to exclude from git commits. 19 | 20 | # Second, configure Git to use the exclude file for all Git repositories by running the 21 | # following via commandline, replacing "path/to/your/" with the actual path to your newly created 22 | # .gitignore_global file: 23 | # git config --global core.excludesfile path/to/your/.gitignore_global 24 | 25 | # CircuitPython-specific files 26 | *.mpy 27 | 28 | # Python-specific files 29 | __pycache__ 30 | *.pyc 31 | 32 | # Sphinx build-specific files 33 | _build 34 | 35 | # This file results from running `pip -e install .` in a local repository 36 | *.egg-info 37 | 38 | # Virtual environment-specific files 39 | .env 40 | .venv 41 | 42 | # MacOS-specific files 43 | *.DS_Store 44 | 45 | # IDE-specific files 46 | .idea 47 | .vscode 48 | *~ 49 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.3.4 14 | hooks: 15 | - id: ruff-format 16 | - id: ruff 17 | args: ["--fix"] 18 | - repo: https://github.com/fsfe/reuse-tool 19 | rev: v3.0.1 20 | hooks: 21 | - id: reuse 22 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Melissa LeBlanc-Williams for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | # Read the Docs configuration file 6 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 7 | 8 | # Required 9 | version: 2 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | build: 15 | os: ubuntu-20.04 16 | tools: 17 | python: "3" 18 | 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | - requirements: requirements.txt 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 7 | # Adafruit Community Code of Conduct 8 | 9 | ## Our Pledge 10 | 11 | In the interest of fostering an open and welcoming environment, we as 12 | contributors and leaders pledge to making participation in our project and 13 | our community a harassment-free experience for everyone, regardless of age, body 14 | size, disability, ethnicity, gender identity and expression, level or type of 15 | experience, education, socio-economic status, nationality, personal appearance, 16 | race, religion, or sexual identity and orientation. 17 | 18 | ## Our Standards 19 | 20 | We are committed to providing a friendly, safe and welcoming environment for 21 | all. 22 | 23 | Examples of behavior that contributes to creating a positive environment 24 | include: 25 | 26 | * Be kind and courteous to others 27 | * Using welcoming and inclusive language 28 | * Being respectful of differing viewpoints and experiences 29 | * Collaborating with other community members 30 | * Gracefully accepting constructive criticism 31 | * Focusing on what is best for the community 32 | * Showing empathy towards other community members 33 | 34 | Examples of unacceptable behavior by participants include: 35 | 36 | * The use of sexualized language or imagery and sexual attention or advances 37 | * The use of inappropriate images, including in a community member's avatar 38 | * The use of inappropriate language, including in a community member's nickname 39 | * Any spamming, flaming, baiting or other attention-stealing behavior 40 | * Excessive or unwelcome helping; answering outside the scope of the question 41 | asked 42 | * Discussion or promotion of activities or projects that intend or pose a risk of 43 | significant harm 44 | * Trolling, insulting/derogatory comments, and personal or political attacks 45 | * Promoting or spreading disinformation, lies, or conspiracy theories against 46 | a person, group, organisation, project, or community 47 | * Public or private harassment 48 | * Publishing others' private information, such as a physical or electronic 49 | address, without explicit permission 50 | * Other conduct which could reasonably be considered inappropriate 51 | 52 | The goal of the standards and moderation guidelines outlined here is to build 53 | and maintain a respectful community. We ask that you don’t just aim to be 54 | "technically unimpeachable", but rather try to be your best self. 55 | 56 | We value many things beyond technical expertise, including collaboration and 57 | supporting others within our community. Providing a positive experience for 58 | other community members can have a much more significant impact than simply 59 | providing the correct answer. 60 | 61 | ## Our Responsibilities 62 | 63 | Project leaders are responsible for clarifying the standards of acceptable 64 | behavior and are expected to take appropriate and fair corrective action in 65 | response to any instances of unacceptable behavior. 66 | 67 | Project leaders have the right and responsibility to remove, edit, or 68 | reject messages, comments, commits, code, issues, and other contributions 69 | that are not aligned to this Code of Conduct, or to ban temporarily or 70 | permanently any community member for other behaviors that they deem 71 | inappropriate, threatening, offensive, or harmful. 72 | 73 | ## Moderation 74 | 75 | Instances of behaviors that violate the Adafruit Community Code of Conduct 76 | may be reported by any member of the community. Community members are 77 | encouraged to report these situations, including situations they witness 78 | involving other community members. 79 | 80 | You may report in the following ways: 81 | 82 | In any situation, you may email . 83 | 84 | On the Adafruit Discord, you may send an open message from any channel 85 | to all Community Moderators by tagging @community moderators. You may 86 | also send an open message from any channel, or a direct message to 87 | any Community Moderator. 88 | 89 | Email and direct message reports will be kept confidential. 90 | 91 | In situations on Discord where the issue is particularly offensive, possibly 92 | illegal, requires immediate action, or violates the Discord terms of service, 93 | you should also report the message directly to [Discord](https://discord.com/safety). 94 | 95 | These are the steps for upholding our community’s standards of conduct. 96 | 97 | 1. Any member of the community may report any situation that violates the 98 | CircuitPython Community Code of Conduct. All reports will be reviewed and 99 | investigated. 100 | 2. If the behavior is a severe violation, the community member who 101 | committed the violation may be banned immediately, without warning. 102 | 3. Otherwise, moderators will first respond to such behavior with a warning. 103 | 4. Moderators follow a soft "three strikes" policy - the community member may 104 | be given another chance, if they are receptive to the warning and change their 105 | behavior. 106 | 5. If the community member is unreceptive or unreasonable when warned by a 107 | moderator, or the warning goes unheeded, they may be banned for a first or 108 | second offense. Repeated offenses will result in the community member being 109 | banned. 110 | 6. Disciplinary actions (warnings, bans, etc) for Code of Conduct violations apply 111 | to the platform where the violation occurred. However, depending on the severity 112 | of the violation, the disciplinary action may be applied across Adafruit's other 113 | community platforms. For example, a severe violation on the Adafruit Discord 114 | server may result in a ban on not only the Adafruit Discord server, but also on 115 | the Adafruit GitHub organisation, Adafruit Forums, Adafruit Twitter, etc. 116 | 117 | ## Scope 118 | 119 | This Code of Conduct and the enforcement policies listed above apply to all 120 | Adafruit Community venues. This includes but is not limited to any community 121 | spaces (both public and private), the entire Adafruit Discord server, and 122 | Adafruit GitHub repositories. Examples of Adafruit Community spaces include 123 | but are not limited to meet-ups, audio chats on the Adafruit Discord, or 124 | interaction at a conference. 125 | 126 | This Code of Conduct applies both within project spaces and in public spaces 127 | when an individual is representing the project or its community. As a community 128 | member, you are representing our community, and are expected to behave 129 | accordingly. 130 | 131 | ## Attribution 132 | 133 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), 134 | version 1.4, available on [contributor-covenant.org](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html), 135 | and the [Rust Code of Conduct](https://www.rust-lang.org/en-US/conduct.html). 136 | 137 | For other projects adopting the Adafruit Community Code of 138 | Conduct, please contact the maintainers of those projects for enforcement. 139 | If you wish to use this code of conduct for your own project, consider 140 | explicitly mentioning your moderation policy or making a copy with your 141 | own moderation policy so as to avoid confusion. 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Dan Halbert for Adafruit Industries 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 | -------------------------------------------------------------------------------- /LICENSES/CC-BY-4.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution 4.0 International Creative Commons Corporation 2 | ("Creative Commons") is not a law firm and does not provide legal services 3 | or legal advice. Distribution of Creative Commons public licenses does not 4 | create a lawyer-client or other relationship. Creative Commons makes its licenses 5 | and related information available on an "as-is" basis. Creative Commons gives 6 | no warranties regarding its licenses, any material licensed under their terms 7 | and conditions, or any related information. Creative Commons disclaims all 8 | liability for damages resulting from their use to the fullest extent possible. 9 | 10 | Using Creative Commons Public Licenses 11 | 12 | Creative Commons public licenses provide a standard set of terms and conditions 13 | that creators and other rights holders may use to share original works of 14 | authorship and other material subject to copyright and certain other rights 15 | specified in the public license below. The following considerations are for 16 | informational purposes only, are not exhaustive, and do not form part of our 17 | licenses. 18 | 19 | Considerations for licensors: Our public licenses are intended for use by 20 | those authorized to give the public permission to use material in ways otherwise 21 | restricted by copyright and certain other rights. Our licenses are irrevocable. 22 | Licensors should read and understand the terms and conditions of the license 23 | they choose before applying it. Licensors should also secure all rights necessary 24 | before applying our licenses so that the public can reuse the material as 25 | expected. Licensors should clearly mark any material not subject to the license. 26 | This includes other CC-licensed material, or material used under an exception 27 | or limitation to copyright. More considerations for licensors : wiki.creativecommons.org/Considerations_for_licensors 28 | 29 | Considerations for the public: By using one of our public licenses, a licensor 30 | grants the public permission to use the licensed material under specified 31 | terms and conditions. If the licensor's permission is not necessary for any 32 | reason–for example, because of any applicable exception or limitation to copyright–then 33 | that use is not regulated by the license. Our licenses grant only permissions 34 | under copyright and certain other rights that a licensor has authority to 35 | grant. Use of the licensed material may still be restricted for other reasons, 36 | including because others have copyright or other rights in the material. A 37 | licensor may make special requests, such as asking that all changes be marked 38 | or described. Although not required by our licenses, you are encouraged to 39 | respect those requests where reasonable. More considerations for the public 40 | : wiki.creativecommons.org/Considerations_for_licensees Creative Commons Attribution 41 | 4.0 International Public License 42 | 43 | By exercising the Licensed Rights (defined below), You accept and agree to 44 | be bound by the terms and conditions of this Creative Commons Attribution 45 | 4.0 International Public License ("Public License"). To the extent this Public 46 | License may be interpreted as a contract, You are granted the Licensed Rights 47 | in consideration of Your acceptance of these terms and conditions, and the 48 | Licensor grants You such rights in consideration of benefits the Licensor 49 | receives from making the Licensed Material available under these terms and 50 | conditions. 51 | 52 | Section 1 – Definitions. 53 | 54 | a. Adapted Material means material subject to Copyright and Similar Rights 55 | that is derived from or based upon the Licensed Material and in which the 56 | Licensed Material is translated, altered, arranged, transformed, or otherwise 57 | modified in a manner requiring permission under the Copyright and Similar 58 | Rights held by the Licensor. For purposes of this Public License, where the 59 | Licensed Material is a musical work, performance, or sound recording, Adapted 60 | Material is always produced where the Licensed Material is synched in timed 61 | relation with a moving image. 62 | 63 | b. Adapter's License means the license You apply to Your Copyright and Similar 64 | Rights in Your contributions to Adapted Material in accordance with the terms 65 | and conditions of this Public License. 66 | 67 | c. Copyright and Similar Rights means copyright and/or similar rights closely 68 | related to copyright including, without limitation, performance, broadcast, 69 | sound recording, and Sui Generis Database Rights, without regard to how the 70 | rights are labeled or categorized. For purposes of this Public License, the 71 | rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 72 | 73 | d. Effective Technological Measures means those measures that, in the absence 74 | of proper authority, may not be circumvented under laws fulfilling obligations 75 | under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, 76 | and/or similar international agreements. 77 | 78 | e. Exceptions and Limitations means fair use, fair dealing, and/or any other 79 | exception or limitation to Copyright and Similar Rights that applies to Your 80 | use of the Licensed Material. 81 | 82 | f. Licensed Material means the artistic or literary work, database, or other 83 | material to which the Licensor applied this Public License. 84 | 85 | g. Licensed Rights means the rights granted to You subject to the terms and 86 | conditions of this Public License, which are limited to all Copyright and 87 | Similar Rights that apply to Your use of the Licensed Material and that the 88 | Licensor has authority to license. 89 | 90 | h. Licensor means the individual(s) or entity(ies) granting rights under this 91 | Public License. 92 | 93 | i. Share means to provide material to the public by any means or process that 94 | requires permission under the Licensed Rights, such as reproduction, public 95 | display, public performance, distribution, dissemination, communication, or 96 | importation, and to make material available to the public including in ways 97 | that members of the public may access the material from a place and at a time 98 | individually chosen by them. 99 | 100 | j. Sui Generis Database Rights means rights other than copyright resulting 101 | from Directive 96/9/EC of the European Parliament and of the Council of 11 102 | March 1996 on the legal protection of databases, as amended and/or succeeded, 103 | as well as other essentially equivalent rights anywhere in the world. 104 | 105 | k. You means the individual or entity exercising the Licensed Rights under 106 | this Public License. Your has a corresponding meaning. 107 | 108 | Section 2 – Scope. 109 | 110 | a. License grant. 111 | 112 | 1. Subject to the terms and conditions of this Public License, the Licensor 113 | hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, 114 | irrevocable license to exercise the Licensed Rights in the Licensed Material 115 | to: 116 | 117 | A. reproduce and Share the Licensed Material, in whole or in part; and 118 | 119 | B. produce, reproduce, and Share Adapted Material. 120 | 121 | 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions 122 | and Limitations apply to Your use, this Public License does not apply, and 123 | You do not need to comply with its terms and conditions. 124 | 125 | 3. Term. The term of this Public License is specified in Section 6(a). 126 | 127 | 4. Media and formats; technical modifications allowed. The Licensor authorizes 128 | You to exercise the Licensed Rights in all media and formats whether now known 129 | or hereafter created, and to make technical modifications necessary to do 130 | so. The Licensor waives and/or agrees not to assert any right or authority 131 | to forbid You from making technical modifications necessary to exercise the 132 | Licensed Rights, including technical modifications necessary to circumvent 133 | Effective Technological Measures. For purposes of this Public License, simply 134 | making modifications authorized by this Section 2(a)(4) never produces Adapted 135 | Material. 136 | 137 | 5. Downstream recipients. 138 | 139 | A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed 140 | Material automatically receives an offer from the Licensor to exercise the 141 | Licensed Rights under the terms and conditions of this Public License. 142 | 143 | B. No downstream restrictions. You may not offer or impose any additional 144 | or different terms or conditions on, or apply any Effective Technological 145 | Measures to, the Licensed Material if doing so restricts exercise of the Licensed 146 | Rights by any recipient of the Licensed Material. 147 | 148 | 6. No endorsement. Nothing in this Public License constitutes or may be construed 149 | as permission to assert or imply that You are, or that Your use of the Licensed 150 | Material is, connected with, or sponsored, endorsed, or granted official status 151 | by, the Licensor or others designated to receive attribution as provided in 152 | Section 3(a)(1)(A)(i). 153 | 154 | b. Other rights. 155 | 156 | 1. Moral rights, such as the right of integrity, are not licensed under this 157 | Public License, nor are publicity, privacy, and/or other similar personality 158 | rights; however, to the extent possible, the Licensor waives and/or agrees 159 | not to assert any such rights held by the Licensor to the limited extent necessary 160 | to allow You to exercise the Licensed Rights, but not otherwise. 161 | 162 | 2. Patent and trademark rights are not licensed under this Public License. 163 | 164 | 3. To the extent possible, the Licensor waives any right to collect royalties 165 | from You for the exercise of the Licensed Rights, whether directly or through 166 | a collecting society under any voluntary or waivable statutory or compulsory 167 | licensing scheme. In all other cases the Licensor expressly reserves any right 168 | to collect such royalties. 169 | 170 | Section 3 – License Conditions. 171 | 172 | Your exercise of the Licensed Rights is expressly made subject to the following 173 | conditions. 174 | 175 | a. Attribution. 176 | 177 | 1. If You Share the Licensed Material (including in modified form), You must: 178 | 179 | A. retain the following if it is supplied by the Licensor with the Licensed 180 | Material: 181 | 182 | i. identification of the creator(s) of the Licensed Material and any others 183 | designated to receive attribution, in any reasonable manner requested by the 184 | Licensor (including by pseudonym if designated); 185 | 186 | ii. a copyright notice; 187 | 188 | iii. a notice that refers to this Public License; 189 | 190 | iv. a notice that refers to the disclaimer of warranties; 191 | 192 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 193 | 194 | B. indicate if You modified the Licensed Material and retain an indication 195 | of any previous modifications; and 196 | 197 | C. indicate the Licensed Material is licensed under this Public License, and 198 | include the text of, or the URI or hyperlink to, this Public License. 199 | 200 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner 201 | based on the medium, means, and context in which You Share the Licensed Material. 202 | For example, it may be reasonable to satisfy the conditions by providing a 203 | URI or hyperlink to a resource that includes the required information. 204 | 205 | 3. If requested by the Licensor, You must remove any of the information required 206 | by Section 3(a)(1)(A) to the extent reasonably practicable. 207 | 208 | 4. If You Share Adapted Material You produce, the Adapter's License You apply 209 | must not prevent recipients of the Adapted Material from complying with this 210 | Public License. 211 | 212 | Section 4 – Sui Generis Database Rights. 213 | 214 | Where the Licensed Rights include Sui Generis Database Rights that apply to 215 | Your use of the Licensed Material: 216 | 217 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, 218 | reuse, reproduce, and Share all or a substantial portion of the contents of 219 | the database; 220 | 221 | b. if You include all or a substantial portion of the database contents in 222 | a database in which You have Sui Generis Database Rights, then the database 223 | in which You have Sui Generis Database Rights (but not its individual contents) 224 | is Adapted Material; and 225 | 226 | c. You must comply with the conditions in Section 3(a) if You Share all or 227 | a substantial portion of the contents of the database. 228 | 229 | For the avoidance of doubt, this Section 4 supplements and does not replace 230 | Your obligations under this Public License where the Licensed Rights include 231 | other Copyright and Similar Rights. 232 | 233 | Section 5 – Disclaimer of Warranties and Limitation of Liability. 234 | 235 | a. Unless otherwise separately undertaken by the Licensor, to the extent possible, 236 | the Licensor offers the Licensed Material as-is and as-available, and makes 237 | no representations or warranties of any kind concerning the Licensed Material, 238 | whether express, implied, statutory, or other. This includes, without limitation, 239 | warranties of title, merchantability, fitness for a particular purpose, non-infringement, 240 | absence of latent or other defects, accuracy, or the presence or absence of 241 | errors, whether or not known or discoverable. Where disclaimers of warranties 242 | are not allowed in full or in part, this disclaimer may not apply to You. 243 | 244 | b. To the extent possible, in no event will the Licensor be liable to You 245 | on any legal theory (including, without limitation, negligence) or otherwise 246 | for any direct, special, indirect, incidental, consequential, punitive, exemplary, 247 | or other losses, costs, expenses, or damages arising out of this Public License 248 | or use of the Licensed Material, even if the Licensor has been advised of 249 | the possibility of such losses, costs, expenses, or damages. Where a limitation 250 | of liability is not allowed in full or in part, this limitation may not apply 251 | to You. 252 | 253 | c. The disclaimer of warranties and limitation of liability provided above 254 | shall be interpreted in a manner that, to the extent possible, most closely 255 | approximates an absolute disclaimer and waiver of all liability. 256 | 257 | Section 6 – Term and Termination. 258 | 259 | a. This Public License applies for the term of the Copyright and Similar Rights 260 | licensed here. However, if You fail to comply with this Public License, then 261 | Your rights under this Public License terminate automatically. 262 | 263 | b. Where Your right to use the Licensed Material has terminated under Section 264 | 6(a), it reinstates: 265 | 266 | 1. automatically as of the date the violation is cured, provided it is cured 267 | within 30 days of Your discovery of the violation; or 268 | 269 | 2. upon express reinstatement by the Licensor. 270 | 271 | c. For the avoidance of doubt, this Section 6(b) does not affect any right 272 | the Licensor may have to seek remedies for Your violations of this Public 273 | License. 274 | 275 | d. For the avoidance of doubt, the Licensor may also offer the Licensed Material 276 | under separate terms or conditions or stop distributing the Licensed Material 277 | at any time; however, doing so will not terminate this Public License. 278 | 279 | e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 280 | 281 | Section 7 – Other Terms and Conditions. 282 | 283 | a. The Licensor shall not be bound by any additional or different terms or 284 | conditions communicated by You unless expressly agreed. 285 | 286 | b. Any arrangements, understandings, or agreements regarding the Licensed 287 | Material not stated herein are separate from and independent of the terms 288 | and conditions of this Public License. 289 | 290 | Section 8 – Interpretation. 291 | 292 | a. For the avoidance of doubt, this Public License does not, and shall not 293 | be interpreted to, reduce, limit, restrict, or impose conditions on any use 294 | of the Licensed Material that could lawfully be made without permission under 295 | this Public License. 296 | 297 | b. To the extent possible, if any provision of this Public License is deemed 298 | unenforceable, it shall be automatically reformed to the minimum extent necessary 299 | to make it enforceable. If the provision cannot be reformed, it shall be severed 300 | from this Public License without affecting the enforceability of the remaining 301 | terms and conditions. 302 | 303 | c. No term or condition of this Public License will be waived and no failure 304 | to comply consented to unless expressly agreed to by the Licensor. 305 | 306 | d. Nothing in this Public License constitutes or may be interpreted as a limitation 307 | upon, or waiver of, any privileges and immunities that apply to the Licensor 308 | or You, including from the legal processes of any jurisdiction or authority. 309 | 310 | Creative Commons is not a party to its public licenses. Notwithstanding, Creative 311 | Commons may elect to apply one of its public licenses to material it publishes 312 | and in those instances will be considered the "Licensor." The text of the 313 | Creative Commons public licenses is dedicated to the public domain under the 314 | CC0 Public Domain Dedication. Except for the limited purpose of indicating 315 | that material is shared under a Creative Commons public license or as otherwise 316 | permitted by the Creative Commons policies published at creativecommons.org/policies, 317 | Creative Commons does not authorize the use of the trademark "Creative Commons" 318 | or any other trademark or logo of Creative Commons without its prior written 319 | consent including, without limitation, in connection with any unauthorized 320 | modifications to any of its public licenses or any other arrangements, understandings, 321 | or agreements concerning use of licensed material. For the avoidance of doubt, 322 | this paragraph does not form part of the public licenses. 323 | 324 | Creative Commons may be contacted at creativecommons.org. 325 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /LICENSES/Unlicense.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute 4 | this software, either in source code form or as a compiled binary, for any 5 | purpose, commercial or non-commercial, and by any means. 6 | 7 | In jurisdictions that recognize copyright laws, the author or authors of this 8 | software dedicate any and all copyright interest in the software to the public 9 | domain. We make this dedication for the benefit of the public at large and 10 | to the detriment of our heirs and successors. We intend this dedication to 11 | be an overt act of relinquishment in perpetuity of all present and future 12 | rights to this software under copyright law. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH 19 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, 20 | please refer to 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | 5 | .. image:: https://readthedocs.org/projects/adafruit-circuitpython-httpserver/badge/?version=latest 6 | :target: https://docs.circuitpython.org/projects/httpserver/en/latest/ 7 | :alt: Documentation Status 8 | 9 | 10 | .. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg 11 | :target: https://adafru.it/discord 12 | :alt: Discord 13 | 14 | 15 | .. image:: https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer/workflows/Build%20CI/badge.svg 16 | :target: https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer/actions 17 | :alt: Build Status 18 | 19 | 20 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 21 | :target: https://github.com/astral-sh/ruff 22 | :alt: Code Style: Ruff 23 | 24 | HTTP Server for CircuitPython. 25 | 26 | - Supports `socketpool` or `socket` as a source of sockets; can be used in CPython. 27 | - HTTP 1.1. 28 | - Serves files from a designated root. 29 | - Routing for serving computed responses from handlers. 30 | - Gives access to request headers, query parameters, form data, body and client's address (the one from which the request came). 31 | - Supports chunked transfer encoding. 32 | - Supports URL parameters and wildcard URLs. 33 | - Supports HTTP Basic and Bearer Authentication on both server and route per level. 34 | - Supports Websockets and Server-Sent Events. 35 | - Limited support for HTTPS (only on selected microcontrollers with enough memory e.g. ESP32-S3). 36 | 37 | 38 | Dependencies 39 | ============= 40 | This driver depends on: 41 | 42 | * `Adafruit CircuitPython `_ 43 | 44 | Please ensure all dependencies are available on the CircuitPython filesystem. 45 | This is easily achieved by downloading 46 | `the Adafruit library and driver bundle `_ 47 | or individual libraries can be installed using 48 | `circup `_. 49 | 50 | 51 | Installing from PyPI 52 | ===================== 53 | On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from 54 | PyPI `_. 55 | To install for current user: 56 | 57 | .. code-block:: shell 58 | 59 | pip3 install adafruit-circuitpython-httpserver 60 | 61 | To install system-wide (this may be required in some cases): 62 | 63 | .. code-block:: shell 64 | 65 | sudo pip3 install adafruit-circuitpython-httpserver 66 | 67 | To install in a virtual environment in your current project: 68 | 69 | .. code-block:: shell 70 | 71 | mkdir project-name && cd project-name 72 | python3 -m venv .venv 73 | source .venv/bin/activate 74 | pip3 install adafruit-circuitpython-httpserver 75 | 76 | 77 | 78 | Installing to a Connected CircuitPython Device with Circup 79 | ========================================================== 80 | 81 | Make sure that you have ``circup`` installed in your Python environment. 82 | Install it with the following command if necessary: 83 | 84 | .. code-block:: shell 85 | 86 | pip3 install circup 87 | 88 | With ``circup`` installed and your CircuitPython device connected use the 89 | following command to install: 90 | 91 | .. code-block:: shell 92 | 93 | circup install adafruit_httpserver 94 | 95 | Or the following command to update an existing version: 96 | 97 | .. code-block:: shell 98 | 99 | circup update 100 | 101 | Security 102 | ======== 103 | 104 | The HTTP server implementation in this package is not robust and should only be deployed on trusted networks. 105 | For instance, there are trivial denial of service attacks against adafruit_httpserver. 106 | Pull requests that improve the server's security and robustness are of course welcome. 107 | 108 | Contributing 109 | ============ 110 | 111 | Contributions are welcome! Please read our `Code of Conduct 112 | `_ 113 | before contributing to help this project stay welcoming. 114 | 115 | Documentation 116 | ============= 117 | API documentation for this library can be found on `Read the Docs `_. 118 | 119 | For information on building library documentation, please check out 120 | `this guide `_. 121 | -------------------------------------------------------------------------------- /README.rst.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /adafruit_httpserver/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver` 6 | ================================================================================ 7 | 8 | Socket based HTTP Server for CircuitPython 9 | 10 | 11 | * Author(s): Dan Halbert, Michał Pokusa 12 | 13 | Implementation Notes 14 | -------------------- 15 | 16 | **Software and Dependencies:** 17 | 18 | * Adafruit CircuitPython firmware for the supported boards: 19 | https://github.com/adafruit/circuitpython/releases 20 | """ 21 | 22 | __version__ = "0.0.0+auto.0" 23 | __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" 24 | 25 | 26 | from .authentication import ( 27 | Basic, 28 | Bearer, 29 | Token, 30 | check_authentication, 31 | require_authentication, 32 | ) 33 | from .exceptions import ( 34 | AuthenticationError, 35 | BackslashInPathError, 36 | FileNotExistsError, 37 | InvalidPathError, 38 | ParentDirectoryReferenceError, 39 | ServerStoppedError, 40 | ServingFilesDisabledError, 41 | ) 42 | from .headers import Headers 43 | from .methods import ( 44 | CONNECT, 45 | DELETE, 46 | GET, 47 | HEAD, 48 | OPTIONS, 49 | PATCH, 50 | POST, 51 | PUT, 52 | TRACE, 53 | ) 54 | from .mime_types import MIMETypes 55 | from .request import FormData, QueryParams, Request 56 | from .response import ( 57 | ChunkedResponse, 58 | FileResponse, 59 | JSONResponse, 60 | Redirect, 61 | Response, 62 | SSEResponse, 63 | Websocket, 64 | ) 65 | from .route import Route, as_route 66 | from .server import ( 67 | CONNECTION_TIMED_OUT, 68 | NO_REQUEST, 69 | REQUEST_HANDLED_NO_RESPONSE, 70 | REQUEST_HANDLED_RESPONSE_SENT, 71 | Server, 72 | ) 73 | from .status import ( 74 | ACCEPTED_202, 75 | BAD_REQUEST_400, 76 | CREATED_201, 77 | FORBIDDEN_403, 78 | FOUND_302, 79 | INTERNAL_SERVER_ERROR_500, 80 | METHOD_NOT_ALLOWED_405, 81 | MOVED_PERMANENTLY_301, 82 | NO_CONTENT_204, 83 | NOT_FOUND_404, 84 | NOT_IMPLEMENTED_501, 85 | OK_200, 86 | PARTIAL_CONTENT_206, 87 | PERMANENT_REDIRECT_308, 88 | SERVICE_UNAVAILABLE_503, 89 | SWITCHING_PROTOCOLS_101, 90 | TEMPORARY_REDIRECT_307, 91 | TOO_MANY_REQUESTS_429, 92 | UNAUTHORIZED_401, 93 | Status, 94 | ) 95 | -------------------------------------------------------------------------------- /adafruit_httpserver/authentication.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.authentication` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import List, Union 12 | except ImportError: 13 | pass 14 | 15 | from binascii import b2a_base64 16 | 17 | from .exceptions import AuthenticationError 18 | from .request import Request 19 | 20 | 21 | class Basic: 22 | """Represents HTTP Basic Authentication.""" 23 | 24 | def __init__(self, username: str, password: str) -> None: 25 | self._value = b2a_base64(f"{username}:{password}".encode()).decode().strip() 26 | 27 | def __str__(self) -> str: 28 | return f"Basic {self._value}" 29 | 30 | 31 | class Token: 32 | """Represents HTTP Token Authentication.""" 33 | 34 | prefix = "Token" 35 | 36 | def __init__(self, token: str) -> None: 37 | self._value = token 38 | 39 | def __str__(self) -> str: 40 | return f"{self.prefix} {self._value}" 41 | 42 | 43 | class Bearer(Token): 44 | """Represents HTTP Bearer Token Authentication.""" 45 | 46 | prefix = "Bearer" 47 | 48 | 49 | def check_authentication(request: Request, auths: List[Union[Basic, Token, Bearer]]) -> bool: 50 | """ 51 | Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise. 52 | 53 | Example:: 54 | 55 | check_authentication(request, [Basic("username", "password")]) 56 | """ 57 | 58 | auth_header = request.headers.get_directive("Authorization") 59 | 60 | if auth_header is None: 61 | return False 62 | 63 | return any(auth_header == str(auth) for auth in auths) 64 | 65 | 66 | def require_authentication(request: Request, auths: List[Union[Basic, Token, Bearer]]) -> None: 67 | """ 68 | Checks if the request is authorized and raises ``AuthenticationError`` if not. 69 | 70 | If the error is not caught, the server will return ``401 Unauthorized``. 71 | 72 | Example:: 73 | 74 | require_authentication(request, [Basic("username", "password")]) 75 | """ 76 | 77 | if not check_authentication(request, auths): 78 | raise AuthenticationError( 79 | "Request is not authenticated by any of the provided authentications" 80 | ) 81 | -------------------------------------------------------------------------------- /adafruit_httpserver/exceptions.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.exceptions` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | 11 | class ServerStoppedError(Exception): 12 | """ 13 | Raised when ``.poll`` is called on a stopped ``Server``. 14 | """ 15 | 16 | 17 | class AuthenticationError(Exception): 18 | """ 19 | Raised by ``require_authentication`` when the ``Request`` is not authorized. 20 | """ 21 | 22 | 23 | class InvalidPathError(Exception): 24 | """ 25 | Parent class for all path related errors. 26 | """ 27 | 28 | 29 | class ParentDirectoryReferenceError(InvalidPathError): 30 | """ 31 | Path contains ``..``, a reference to the parent directory. 32 | """ 33 | 34 | def __init__(self, path: str) -> None: 35 | """Creates a new ``ParentDirectoryReferenceError`` for the ``path``.""" 36 | super().__init__(f"Parent directory reference in path: {path}") 37 | 38 | 39 | class BackslashInPathError(InvalidPathError): 40 | """ 41 | Backslash ``\\`` in path. 42 | """ 43 | 44 | def __init__(self, path: str) -> None: 45 | """Creates a new ``BackslashInPathError`` for the ``path``.""" 46 | super().__init__(f"Backslash in path: {path}") 47 | 48 | 49 | class ServingFilesDisabledError(Exception): 50 | """ 51 | Raised when ``root_path`` is not set and there is no handler for ``request``. 52 | """ 53 | 54 | 55 | class FileNotExistsError(Exception): 56 | """ 57 | Raised when a file does not exist. 58 | """ 59 | 60 | def __init__(self, path: str) -> None: 61 | """ 62 | Creates a new ``FileNotExistsError`` for the file at ``path``. 63 | """ 64 | super().__init__(f"File does not exist: {path}") 65 | -------------------------------------------------------------------------------- /adafruit_httpserver/headers.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.headers` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import Dict, List, Union 12 | except ImportError: 13 | pass 14 | 15 | from .interfaces import _IFieldStorage 16 | 17 | 18 | class Headers(_IFieldStorage): 19 | """ 20 | A dict-like class for storing HTTP headers. 21 | 22 | Allows access to headers using **case insensitive** names. 23 | 24 | Does **not** implement all dict methods. 25 | 26 | Examples:: 27 | 28 | headers = Headers("Content-Type: text/html\\r\\nContent-Length: 1024\\r\\n") 29 | # or 30 | headers = Headers({"Content-Type": "text/html", "Content-Length": "1024"}) 31 | 32 | len(headers) 33 | # 2 34 | 35 | headers.setdefault("Access-Control-Allow-Origin", "*") 36 | headers["Access-Control-Allow-Origin"] 37 | # '*' 38 | 39 | headers["Content-Length"] 40 | # '1024' 41 | 42 | headers["content-type"] 43 | # 'text/html' 44 | 45 | headers["User-Agent"] 46 | # KeyError: User-Agent 47 | 48 | "CONTENT-TYPE" in headers 49 | # True 50 | """ 51 | 52 | _storage: Dict[str, List[str]] 53 | 54 | def __init__(self, headers: Union[str, Dict[str, str]] = None) -> None: 55 | self._storage = {} 56 | 57 | if isinstance(headers, str): 58 | for header_line in headers.strip().splitlines(): 59 | name, value = header_line.split(": ", 1) 60 | self.add(name, value) 61 | else: 62 | for key, value in (headers or {}).items(): 63 | self.add(key, value) 64 | 65 | def add(self, field_name: str, value: str): 66 | """ 67 | Adds a header with the given field name and value. 68 | Allows adding multiple headers with the same name. 69 | """ 70 | self._add_field_value(field_name.lower(), value) 71 | 72 | def get(self, field_name: str, default: str = None) -> Union[str, None]: 73 | """Returns the value for the given header name, or default if not found.""" 74 | return super().get(field_name.lower(), default) 75 | 76 | def get_list(self, field_name: str) -> List[str]: 77 | """Get the list of values of a field.""" 78 | return super().get_list(field_name.lower()) 79 | 80 | def get_directive(self, name: str, default: str = None) -> Union[str, None]: 81 | """ 82 | Returns the main value (directive) for the given header name, or default if not found. 83 | 84 | Example:: 85 | 86 | headers = Headers({"Content-Type": "text/html; charset=utf-8"}) 87 | headers.get_directive("Content-Type") 88 | # 'text/html' 89 | """ 90 | 91 | header_value = self.get(name) 92 | if header_value is None: 93 | return default 94 | return header_value.split(";")[0].strip('" ') 95 | 96 | def get_parameter(self, name: str, parameter: str, default: str = None) -> Union[str, None]: 97 | """ 98 | Returns the value of the given parameter for the given header name, or default if not found. 99 | 100 | Example:: 101 | 102 | headers = Headers({"Content-Type": "text/html; charset=utf-8"}) 103 | headers.get_parameter("Content-Type", "charset") 104 | # 'utf-8' 105 | """ 106 | 107 | header_value = self.get(name) 108 | if header_value is None: 109 | return default 110 | for header_parameter in header_value.split(";"): 111 | if header_parameter.strip().startswith(parameter): 112 | return header_parameter.strip().split("=")[1].strip('" ') 113 | return default 114 | 115 | def set(self, name: str, value: str): 116 | """Sets the value for the given header name.""" 117 | self._storage[name.lower()] = [value] 118 | 119 | def setdefault(self, name: str, default: str = None): 120 | """Sets the value for the given header name if it does not exist.""" 121 | return self._storage.setdefault(name.lower(), [default]) 122 | 123 | def update(self, headers: Dict[str, str]): 124 | """Updates the headers with the given dict.""" 125 | return self._storage.update({key.lower(): [value] for key, value in headers.items()}) 126 | 127 | def copy(self): 128 | """Returns a copy of the headers.""" 129 | return Headers( 130 | "\r\n".join(f"{key}: {value}" for key in self.fields for value in self.get_list(key)) 131 | ) 132 | 133 | def __getitem__(self, name: str): 134 | return super().__getitem__(name.lower()) 135 | 136 | def __setitem__(self, name: str, value: str): 137 | self._storage[name.lower()] = [value] 138 | 139 | def __delitem__(self, name: str): 140 | del self._storage[name.lower()] 141 | 142 | def __contains__(self, key: str): 143 | return super().__contains__(key.lower()) 144 | -------------------------------------------------------------------------------- /adafruit_httpserver/interfaces.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.interfaces` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import Any, Dict, List, Tuple, Union 12 | except ImportError: 13 | pass 14 | 15 | 16 | class _ISocket: 17 | """A class for typing necessary methods for a socket object.""" 18 | 19 | def accept(self) -> Tuple["_ISocket", Tuple[str, int]]: ... 20 | 21 | def bind(self, address: Tuple[str, int]) -> None: ... 22 | 23 | def setblocking(self, flag: bool) -> None: ... 24 | 25 | def settimeout(self, value: "Union[float, None]") -> None: ... 26 | 27 | def setsockopt(self, level: int, optname: int, value: int) -> None: ... 28 | 29 | def listen(self, backlog: int) -> None: ... 30 | 31 | def send(self, data: bytes) -> int: ... 32 | 33 | def recv_into(self, buffer: memoryview, nbytes: int) -> int: ... 34 | 35 | def close(self) -> None: ... 36 | 37 | 38 | class _ISocketPool: 39 | """A class to typing necessary methods and properties for a socket pool object.""" 40 | 41 | AF_INET: int 42 | SO_REUSEADDR: int 43 | SOCK_STREAM: int 44 | SOL_SOCKET: int 45 | 46 | def socket( 47 | self, 48 | family: int = ..., 49 | type: int = ..., 50 | proto: int = ..., 51 | ) -> _ISocket: ... 52 | 53 | def getaddrinfo( 54 | self, 55 | host: str, 56 | port: int, 57 | family: int = ..., 58 | type: int = ..., 59 | proto: int = ..., 60 | flags: int = ..., 61 | ) -> Tuple[int, int, int, str, Tuple[str, int]]: ... 62 | 63 | 64 | class _IFieldStorage: 65 | """Interface with shared methods for QueryParams, FormData and Headers.""" 66 | 67 | _storage: Dict[str, List[Any]] 68 | 69 | def _add_field_value(self, field_name: str, value: Any) -> None: 70 | if field_name not in self._storage: 71 | self._storage[field_name] = [value] 72 | else: 73 | self._storage[field_name].append(value) 74 | 75 | def get(self, field_name: str, default: Any = None) -> Union[Any, None]: 76 | """Get the value of a field.""" 77 | return self._storage.get(field_name, [default])[0] 78 | 79 | def get_list(self, field_name: str) -> List[Any]: 80 | """Get the list of values of a field.""" 81 | return self._storage.get(field_name, []) 82 | 83 | @property 84 | def fields(self): 85 | """Returns a list of field names.""" 86 | return list(self._storage.keys()) 87 | 88 | def items(self): 89 | """Returns a list of (name, value) tuples.""" 90 | return [(key, value) for key in self.fields for value in self.get_list(key)] 91 | 92 | def keys(self): 93 | """Returns a list of header names.""" 94 | return self.fields 95 | 96 | def values(self): 97 | """Returns a list of header values.""" 98 | return [value for key in self.keys() for value in self.get_list(key)] 99 | 100 | def __getitem__(self, field_name: str): 101 | return self._storage[field_name][0] 102 | 103 | def __iter__(self): 104 | return iter(self._storage) 105 | 106 | def __len__(self) -> int: 107 | return len(self._storage) 108 | 109 | def __contains__(self, key: str) -> bool: 110 | return key in self._storage 111 | 112 | def __repr__(self) -> str: 113 | return f"<{self.__class__.__name__} {repr(self._storage)}>" 114 | 115 | 116 | def _encode_html_entities(value: Union[str, None]) -> Union[str, None]: 117 | """Encodes unsafe HTML characters that could enable XSS attacks.""" 118 | if value is None: 119 | return None 120 | 121 | return ( 122 | str(value) 123 | .replace("&", "&") 124 | .replace("<", "<") 125 | .replace(">", ">") 126 | .replace('"', """) 127 | .replace("'", "'") 128 | ) 129 | 130 | 131 | class _IXSSSafeFieldStorage(_IFieldStorage): 132 | def get(self, field_name: str, default: Any = None, *, safe=True) -> Union[Any, None]: 133 | if safe: 134 | return _encode_html_entities(super().get(field_name, default)) 135 | 136 | _debug_warning_nonencoded_output() 137 | return super().get(field_name, default) 138 | 139 | def get_list(self, field_name: str, *, safe=True) -> List[Any]: 140 | if safe: 141 | return [_encode_html_entities(value) for value in super().get_list(field_name)] 142 | 143 | _debug_warning_nonencoded_output() 144 | return super().get_list(field_name) 145 | 146 | 147 | def _debug_warning_nonencoded_output(): 148 | """Warns about XSS risks.""" 149 | print( 150 | "WARNING: Setting safe to False makes XSS vulnerabilities possible by " 151 | "allowing access to raw untrusted values submitted by users. If this data is reflected " 152 | "or shown within HTML without proper encoding it could enable Cross-Site Scripting." 153 | ) 154 | -------------------------------------------------------------------------------- /adafruit_httpserver/methods.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.methods` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | GET = "GET" 11 | 12 | POST = "POST" 13 | 14 | PUT = "PUT" 15 | 16 | DELETE = "DELETE" 17 | 18 | PATCH = "PATCH" 19 | 20 | HEAD = "HEAD" 21 | 22 | OPTIONS = "OPTIONS" 23 | 24 | TRACE = "TRACE" 25 | 26 | CONNECT = "CONNECT" 27 | -------------------------------------------------------------------------------- /adafruit_httpserver/mime_types.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.mime_types` 6 | ==================================================== 7 | * Author(s): Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import Dict, List 12 | except ImportError: 13 | pass 14 | 15 | 16 | class MIMETypes: 17 | """ 18 | Contains MIME types for common file extensions. 19 | Allows to set default type for unknown files, unregister unused types and register new ones 20 | using the ``MIMETypes.configure()``. 21 | """ 22 | 23 | DEFAULT = "text/plain" 24 | """ 25 | Default MIME type for unknown files. 26 | Can be changed using ``MIMETypes.configure(default_to=...)``. 27 | """ 28 | 29 | REGISTERED = { 30 | ".7z": "application/x-7z-compressed", 31 | ".aac": "audio/aac", 32 | ".abw": "application/x-abiword", 33 | ".arc": "application/x-freearc", 34 | ".avi": "video/x-msvideo", 35 | ".azw": "application/vnd.amazon.ebook", 36 | ".bin": "application/octet-stream", 37 | ".bmp": "image/bmp", 38 | ".bz": "application/x-bzip", 39 | ".bz2": "application/x-bzip2", 40 | ".cda": "application/x-cdf", 41 | ".csh": "application/x-csh", 42 | ".css": "text/css", 43 | ".csv": "text/csv", 44 | ".doc": "application/msword", 45 | ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 46 | ".eot": "application/vnd.ms-fontobject", 47 | ".epub": "application/epub+zip", 48 | ".gif": "image/gif", 49 | ".gz": "application/gzip", 50 | ".htm": "text/html", 51 | ".html": "text/html", 52 | ".ico": "image/vnd.microsoft.icon", 53 | ".ics": "text/calendar", 54 | ".jar": "application/java-archive", 55 | ".jpeg": "image/jpeg", 56 | ".jpg": "image/jpeg", 57 | ".js": "text/javascript", 58 | ".json": "application/json", 59 | ".jsonld": "application/ld+json", 60 | ".mid": "audio/midi", 61 | ".midi": "audio/midi", 62 | ".mjs": "text/javascript", 63 | ".mp3": "audio/mpeg", 64 | ".mp4": "video/mp4", 65 | ".mpeg": "video/mpeg", 66 | ".mpkg": "application/vnd.apple.installer+xml", 67 | ".odp": "application/vnd.oasis.opendocument.presentation", 68 | ".ods": "application/vnd.oasis.opendocument.spreadsheet", 69 | ".odt": "application/vnd.oasis.opendocument.text", 70 | ".oga": "audio/ogg", 71 | ".ogv": "video/ogg", 72 | ".ogx": "application/ogg", 73 | ".opus": "audio/opus", 74 | ".otf": "font/otf", 75 | ".pdf": "application/pdf", 76 | ".php": "application/x-httpd-php", 77 | ".png": "image/png", 78 | ".ppt": "application/vnd.ms-powerpoint", 79 | ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", 80 | ".rar": "application/vnd.rar", 81 | ".rtf": "application/rtf", 82 | ".sh": "application/x-sh", 83 | ".svg": "image/svg+xml", 84 | ".swf": "application/x-shockwave-flash", 85 | ".tar": "application/x-tar", 86 | ".tif": "image/tiff", 87 | ".tiff": "image/tiff", 88 | ".ts": "video/mp2t", 89 | ".ttf": "font/ttf", 90 | ".txt": "text/plain", 91 | ".vsd": "application/vnd.visio", 92 | ".wav": "audio/wav", 93 | ".weba": "audio/webm", 94 | ".webm": "video/webm", 95 | ".webp": "image/webp", 96 | ".woff": "font/woff", 97 | ".woff2": "font/woff2", 98 | ".xhtml": "application/xhtml+xml", 99 | ".xls": "application/vnd.ms-excel", 100 | ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 101 | ".xml": "application/xml", 102 | ".xul": "application/vnd.mozilla.xul+xml", 103 | ".zip": "application/zip", 104 | } 105 | 106 | @staticmethod 107 | def __check_all_start_with_dot(extensions: List[str]) -> None: 108 | for extension in extensions: 109 | if not extension.startswith("."): 110 | raise ValueError( 111 | f'Invalid extension: "{extension}". All extensions must start with a dot.' 112 | ) 113 | 114 | @classmethod 115 | def __check_all_are_registered(cls, extensions: List[str]) -> None: 116 | registered_extensions = cls.REGISTERED.keys() 117 | 118 | for extension in extensions: 119 | if not extension in registered_extensions: 120 | raise ValueError(f'Extension "{extension}" is not registered. ') 121 | 122 | @classmethod 123 | def _default_to(cls, mime_type: str) -> None: 124 | """ 125 | Set the default MIME type for unknown files. 126 | 127 | :param str mime_type: The MIME type to use for unknown files. 128 | """ 129 | cls.DEFAULT = mime_type 130 | 131 | @classmethod 132 | def _keep_for(cls, extensions: List[str]) -> None: 133 | """ 134 | Unregisters all MIME types except the ones for the given extensions,\ 135 | **decreasing overall memory usage**. 136 | """ 137 | 138 | cls.__check_all_start_with_dot(extensions) 139 | cls.__check_all_are_registered(extensions) 140 | 141 | current_extensions = iter(cls.REGISTERED.keys()) 142 | 143 | cls.REGISTERED = { 144 | extension: cls.REGISTERED[extension] 145 | for extension in current_extensions 146 | if extension in extensions 147 | } 148 | 149 | @classmethod 150 | def _register(cls, mime_types: dict) -> None: 151 | """ 152 | Register multiple MIME types. 153 | 154 | :param dict mime_types: A dictionary mapping file extensions to MIME types. 155 | """ 156 | cls.__check_all_start_with_dot(mime_types.keys()) 157 | cls.REGISTERED.update(mime_types) 158 | 159 | @classmethod 160 | def configure( 161 | cls, 162 | default_to: str = None, 163 | keep_for: List[str] = None, 164 | register: Dict[str, str] = None, 165 | ) -> None: 166 | """ 167 | Allows to globally configure the MIME types. 168 | 169 | It is recommended to **always** call this method before starting the ``Server``. 170 | Unregistering unused MIME types will **decrease overall memory usage**. 171 | 172 | :param str default_to: The MIME type to use for unknown files. 173 | :param List[str] keep_for: File extensions to keep. All other will be unregistered. 174 | :param Dict[str, str] register: A dictionary mapping file extensions to MIME types. 175 | 176 | Example:: 177 | 178 | MIMETypes.configure( 179 | default_to="text/plain", 180 | keep_for=[".jpg", ".mp4", ".txt"], 181 | register={".foo": "text/foo", ".bar": "text/bar", ".baz": "text/baz"}, 182 | ) 183 | """ 184 | if default_to is not None: 185 | cls._default_to(default_to) 186 | if keep_for is not None: 187 | cls._keep_for(keep_for) 188 | if register is not None: 189 | cls._register(register) 190 | 191 | @classmethod 192 | def get_for_filename(cls, filename: str, default: str = None) -> str: 193 | """ 194 | Return the MIME type for the given file name. 195 | If the file extension is not registered, ``default`` is returned. 196 | 197 | :param str filename: The file name to look up. 198 | :param str default: Default MIME type to return if the file extension is not registered. 199 | """ 200 | if default is None: 201 | default = cls.DEFAULT 202 | 203 | try: 204 | extension = filename.rsplit(".", 1)[-1].lower() 205 | return cls.REGISTERED.get(f".{extension}", default) 206 | except IndexError: 207 | return default 208 | -------------------------------------------------------------------------------- /adafruit_httpserver/request.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.request` 6 | ==================================================== 7 | * Author(s): Dan Halbert, Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union 12 | 13 | if TYPE_CHECKING: 14 | from .server import Server 15 | except ImportError: 16 | pass 17 | 18 | import json 19 | 20 | from .headers import Headers 21 | from .interfaces import _IFieldStorage, _ISocket, _IXSSSafeFieldStorage 22 | from .methods import DELETE, PATCH, POST, PUT 23 | 24 | 25 | class QueryParams(_IXSSSafeFieldStorage): 26 | """ 27 | Class for parsing and storing GET query parameters requests. 28 | 29 | Examples:: 30 | 31 | query_params = QueryParams("foo=bar&baz=qux&baz=quux") 32 | # QueryParams({"foo": ["bar"], "baz": ["qux", "quux"]}) 33 | 34 | query_params.get("foo") # "bar" 35 | query_params["foo"] # "bar" 36 | query_params.get("non-existent-key") # None 37 | query_params.get_list("baz") # ["qux", "quux"] 38 | "unknown-key" in query_params # False 39 | query_params.fields # ["foo", "baz"] 40 | """ 41 | 42 | _storage: Dict[str, List[str]] 43 | 44 | def __init__(self, query_string: str) -> None: 45 | self._storage = {} 46 | 47 | for query_param in query_string.split("&"): 48 | if "=" in query_param: 49 | key, value = query_param.split("=", 1) 50 | self._add_field_value(key, value) 51 | elif query_param: 52 | self._add_field_value(query_param, "") 53 | 54 | def _add_field_value(self, field_name: str, value: str) -> None: 55 | super()._add_field_value(field_name, value) 56 | 57 | def get(self, field_name: str, default: str = None, *, safe=True) -> Union[str, None]: 58 | return super().get(field_name, default, safe=safe) 59 | 60 | def get_list(self, field_name: str, *, safe=True) -> List[str]: 61 | return super().get_list(field_name, safe=safe) 62 | 63 | def __str__(self) -> str: 64 | return "&".join( 65 | f"{field_name}={value}" 66 | for field_name in self.fields 67 | for value in self.get_list(field_name) 68 | ) 69 | 70 | 71 | class File: 72 | """ 73 | Class representing a file uploaded via POST. 74 | 75 | Examples:: 76 | 77 | file = request.form_data.files.get("uploaded_file") 78 | # File(filename="foo.txt", content_type="text/plain", size=14) 79 | 80 | file.content 81 | # "Hello, world!\\n" 82 | """ 83 | 84 | filename: str 85 | """Filename of the file.""" 86 | 87 | content_type: str 88 | """Content type of the file.""" 89 | 90 | content: Union[str, bytes] 91 | """Content of the file.""" 92 | 93 | def __init__(self, filename: str, content_type: str, content: Union[str, bytes]) -> None: 94 | self.filename = filename 95 | self.content_type = content_type 96 | self.content = content 97 | 98 | @property 99 | def content_bytes(self) -> bytes: 100 | """ 101 | Content of the file as bytes. 102 | It is recommended to use this instead of ``content`` as it will always return bytes. 103 | 104 | Example:: 105 | 106 | file = request.form_data.files.get("uploaded_file") 107 | 108 | with open(file.filename, "wb") as f: 109 | f.write(file.content_bytes) 110 | """ 111 | return self.content.encode("utf-8") if isinstance(self.content, str) else self.content 112 | 113 | @property 114 | def size(self) -> int: 115 | """Length of the file content.""" 116 | return len(self.content) 117 | 118 | def __repr__(self) -> str: 119 | filename, content_type, size = ( 120 | self.filename, 121 | self.content_type, 122 | self.size, 123 | ) 124 | return f"<{self.__class__.__name__} {filename=}, {content_type=}, {size=}>" 125 | 126 | 127 | class Files(_IFieldStorage): 128 | """Class for files uploaded via POST.""" 129 | 130 | _storage: Dict[str, List[File]] 131 | 132 | def __init__(self) -> None: 133 | self._storage = {} 134 | 135 | def _add_field_value(self, field_name: str, value: File) -> None: 136 | super()._add_field_value(field_name, value) 137 | 138 | def get(self, field_name: str, default: Any = None) -> Union[File, Any, None]: 139 | return super().get(field_name, default) 140 | 141 | def get_list(self, field_name: str) -> List[File]: 142 | return super().get_list(field_name) 143 | 144 | 145 | class FormData(_IXSSSafeFieldStorage): 146 | """ 147 | Class for parsing and storing form data from POST requests. 148 | 149 | Supports ``application/x-www-form-urlencoded``, ``multipart/form-data`` and ``text/plain`` 150 | content types. 151 | 152 | Examples:: 153 | 154 | form_data = FormData(b"foo=bar&baz=qux&baz=quuz", "application/x-www-form-urlencoded") 155 | # or 156 | form_data = FormData(b"foo=bar\\r\\nbaz=qux\\r\\nbaz=quux", "text/plain") 157 | # FormData({"foo": ["bar"], "baz": ["qux", "quux"]}) 158 | 159 | form_data.get("foo") # "bar" 160 | form_data["foo"] # "bar" 161 | form_data.get("non-existent-key") # None 162 | form_data.get_list("baz") # ["qux", "quux"] 163 | "unknown-key" in form_data # False 164 | form_data.fields # ["foo", "baz"] 165 | """ 166 | 167 | _storage: Dict[str, List[Union[str, bytes]]] 168 | files: Files 169 | 170 | @staticmethod 171 | def _check_is_supported_content_type(content_type: str) -> None: 172 | return content_type in { 173 | "application/x-www-form-urlencoded", 174 | "multipart/form-data", 175 | "text/plain", 176 | } 177 | 178 | def __init__(self, data: bytes, headers: Headers, *, debug: bool = False) -> None: 179 | self._storage = {} 180 | self.files = Files() 181 | 182 | self.content_type = headers.get_directive("Content-Type") 183 | content_length = int(headers.get("Content-Length", 0)) 184 | 185 | if debug and not self._check_is_supported_content_type(self.content_type): 186 | _debug_unsupported_form_content_type(self.content_type) 187 | 188 | if self.content_type == "application/x-www-form-urlencoded": 189 | self._parse_x_www_form_urlencoded(data[:content_length]) 190 | 191 | elif self.content_type == "multipart/form-data": 192 | boundary = headers.get_parameter("Content-Type", "boundary") 193 | self._parse_multipart_form_data(data[:content_length], boundary) 194 | 195 | elif self.content_type == "text/plain": 196 | self._parse_text_plain(data[:content_length]) 197 | 198 | def _parse_x_www_form_urlencoded(self, data: bytes) -> None: 199 | if not (decoded_data := data.decode("utf-8").strip("&")): 200 | return 201 | 202 | for field_name, value in [ 203 | key_value.split("=", 1) if "=" in key_value else (key_value, "") 204 | for key_value in decoded_data.split("&") 205 | ]: 206 | self._add_field_value(field_name, value) 207 | 208 | def _parse_multipart_form_data(self, data: bytes, boundary: str) -> None: 209 | blocks = data.split(b"--" + boundary.encode())[1:-1] 210 | 211 | for block in blocks: 212 | header_bytes, content_bytes = block.split(b"\r\n\r\n", 1) 213 | headers = Headers(header_bytes.decode("utf-8").strip()) 214 | 215 | field_name = headers.get_parameter("Content-Disposition", "name") 216 | filename = headers.get_parameter("Content-Disposition", "filename") 217 | content_type = headers.get_directive("Content-Type", "text/plain") 218 | charset = headers.get_parameter("Content-Type", "charset", "utf-8") 219 | 220 | content = content_bytes[:-2] # remove trailing \r\n 221 | value = content.decode(charset) if content_type == "text/plain" else content 222 | 223 | # TODO: Other text content types (e.g. application/json) should be decoded as well and 224 | 225 | if filename is not None: 226 | self.files._add_field_value(field_name, File(filename, content_type, value)) 227 | else: 228 | self._add_field_value(field_name, value) 229 | 230 | def _parse_text_plain(self, data: bytes) -> None: 231 | lines = data.decode("utf-8").split("\r\n")[:-1] 232 | 233 | for line in lines: 234 | field_name, value = line.split("=", 1) 235 | 236 | self._add_field_value(field_name, value) 237 | 238 | def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None: 239 | super()._add_field_value(field_name, value) 240 | 241 | def get( 242 | self, field_name: str, default: Union[str, bytes] = None, *, safe=True 243 | ) -> Union[str, bytes, None]: 244 | return super().get(field_name, default, safe=safe) 245 | 246 | def get_list(self, field_name: str, *, safe=True) -> List[Union[str, bytes]]: 247 | return super().get_list(field_name, safe=safe) 248 | 249 | def __repr__(self) -> str: 250 | class_name = self.__class__.__name__ 251 | return f"<{class_name} {repr(self._storage)}, files={repr(self.files._storage)}>" 252 | 253 | 254 | class Request: 255 | """ 256 | Incoming request, constructed from raw incoming bytes. 257 | It is passed as first argument to all route handlers. 258 | """ 259 | 260 | server: "Server" 261 | """ 262 | Server object that received the request. 263 | """ 264 | 265 | connection: _ISocket 266 | """ 267 | Socket object used to send and receive data on the connection. 268 | """ 269 | 270 | client_address: Tuple[str, int] 271 | """ 272 | Address and port bound to the socket on the other end of the connection. 273 | 274 | Example:: 275 | 276 | request.client_address # ('192.168.137.1', 40684) 277 | """ 278 | 279 | method: str 280 | """Request method e.g. "GET" or "POST".""" 281 | 282 | path: str 283 | """Path of the request, e.g. ``"/foo/bar"``.""" 284 | 285 | query_params: QueryParams 286 | """ 287 | Query/GET parameters in the request. 288 | 289 | Example:: 290 | 291 | request = Request(..., raw_request=b"GET /?foo=bar&baz=qux HTTP/1.1...") 292 | 293 | request.query_params # QueryParams({"foo": "bar"}) 294 | request.query_params["foo"] # "bar" 295 | request.query_params.get_list("baz") # ["qux"] 296 | """ 297 | 298 | http_version: str 299 | """HTTP version, e.g. ``"HTTP/1.1"``.""" 300 | 301 | headers: Headers 302 | """ 303 | Headers from the request. 304 | """ 305 | 306 | raw_request: bytes 307 | """ 308 | Raw ``bytes`` that were received from the client. 309 | 310 | Should **not** be modified directly. 311 | """ 312 | 313 | def __init__( 314 | self, 315 | server: "Server", 316 | connection: _ISocket, 317 | client_address: Tuple[str, int], 318 | raw_request: bytes = None, 319 | ) -> None: 320 | self.server = server 321 | self.connection = connection 322 | self.client_address = client_address 323 | self.raw_request = raw_request 324 | self._form_data = None 325 | self._cookies = None 326 | 327 | if raw_request is None: 328 | raise ValueError("raw_request cannot be None") 329 | 330 | try: 331 | ( 332 | self.method, 333 | self.path, 334 | self.query_params, 335 | self.http_version, 336 | self.headers, 337 | ) = self._parse_request_header(self._raw_header_bytes) 338 | except Exception as error: 339 | raise ValueError("Unparseable raw_request: ", raw_request) from error 340 | 341 | @property 342 | def body(self) -> bytes: 343 | """Body of the request, as bytes.""" 344 | return self._raw_body_bytes 345 | 346 | @body.setter 347 | def body(self, body: bytes) -> None: 348 | self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body 349 | 350 | @staticmethod 351 | def _parse_cookies(cookie_header: str) -> None: 352 | """Parse cookies from headers.""" 353 | if cookie_header is None: 354 | return {} 355 | 356 | return { 357 | name: value.strip('"') 358 | for name, value in [cookie.strip().split("=", 1) for cookie in cookie_header.split(";")] 359 | } 360 | 361 | @property 362 | def cookies(self) -> Dict[str, str]: 363 | """ 364 | Cookies sent with the request. 365 | 366 | Example:: 367 | 368 | request.headers["Cookie"] 369 | # "foo=bar; baz=qux; foo=quux" 370 | 371 | request.cookies 372 | # {"foo": "quux", "baz": "qux"} 373 | """ 374 | if self._cookies is None: 375 | self._cookies = self._parse_cookies(self.headers.get("Cookie")) 376 | return self._cookies 377 | 378 | @property 379 | def form_data(self) -> Union[FormData, None]: 380 | """ 381 | POST data of the request. 382 | 383 | Example:: 384 | 385 | # application/x-www-form-urlencoded 386 | request = Request(..., 387 | raw_request=b\"\"\"... 388 | foo=bar&baz=qux\"\"\" 389 | ) 390 | 391 | # or 392 | 393 | # multipart/form-data 394 | request = Request(..., 395 | raw_request=b\"\"\"... 396 | --boundary 397 | Content-Disposition: form-data; name="foo" 398 | 399 | bar 400 | --boundary 401 | Content-Disposition: form-data; name="baz" 402 | 403 | qux 404 | --boundary--\"\"\" 405 | ) 406 | 407 | # or 408 | 409 | # text/plain 410 | request = Request(..., 411 | raw_request=b\"\"\"... 412 | foo=bar 413 | baz=qux 414 | \"\"\" 415 | ) 416 | 417 | request.form_data # FormData({'foo': ['bar'], 'baz': ['qux']}) 418 | request.form_data["foo"] # "bar" 419 | request.form_data.get_list("baz") # ["qux"] 420 | """ 421 | if self._form_data is None and self.method == "POST": 422 | self._form_data = FormData(self.body, self.headers, debug=self.server.debug) 423 | return self._form_data 424 | 425 | def json(self) -> Union[dict, None]: 426 | """ 427 | Body of the request, as a JSON-decoded dictionary. 428 | Only available for POST, PUT, PATCH and DELETE requests. 429 | """ 430 | return ( 431 | json.loads(self.body) 432 | if (self.body and self.method in {POST, PUT, PATCH, DELETE}) 433 | else None 434 | ) 435 | 436 | @property 437 | def _raw_header_bytes(self) -> bytes: 438 | """Returns headers bytes.""" 439 | empty_line_index = self.raw_request.find(b"\r\n\r\n") 440 | 441 | return self.raw_request[:empty_line_index] 442 | 443 | @property 444 | def _raw_body_bytes(self) -> bytes: 445 | """Returns body bytes.""" 446 | empty_line_index = self.raw_request.find(b"\r\n\r\n") 447 | 448 | return self.raw_request[empty_line_index + 4 :] 449 | 450 | @staticmethod 451 | def _parse_request_header( 452 | header_bytes: bytes, 453 | ) -> Tuple[str, str, QueryParams, str, Headers]: 454 | """Parse HTTP Start line to method, path, query_params and http_version.""" 455 | 456 | start_line, headers_string = header_bytes.decode("utf-8").strip().split("\r\n", 1) 457 | 458 | method, path, http_version = start_line.strip().split() 459 | 460 | path = path if "?" in path else path + "?" 461 | path, query_string = path.split("?", 1) 462 | 463 | query_params = QueryParams(query_string) 464 | headers = Headers(headers_string) 465 | 466 | return method, path, query_params, http_version, headers 467 | 468 | def __repr__(self) -> str: 469 | path = self.path + (f"?{self.query_params}" if self.query_params else "") 470 | return f'<{self.__class__.__name__} "{self.method} {path}">' 471 | 472 | 473 | def _debug_unsupported_form_content_type(content_type: str) -> None: 474 | """Warns when an unsupported form content type is used.""" 475 | print( 476 | f"WARNING: Unsupported Content-Type: {content_type}. " 477 | "Only `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are " 478 | "supported." 479 | ) 480 | -------------------------------------------------------------------------------- /adafruit_httpserver/route.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.route` 6 | ==================================================== 7 | * Author(s): Dan Halbert, Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import TYPE_CHECKING, Callable, Dict, Iterable, Literal, Tuple, Union 12 | 13 | if TYPE_CHECKING: 14 | from .response import Response 15 | except ImportError: 16 | pass 17 | 18 | import re 19 | 20 | from .methods import GET 21 | 22 | 23 | class Route: 24 | """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" 25 | 26 | @staticmethod 27 | def _prepare_path_pattern(path: str, append_slash: bool) -> str: 28 | # Escape all dots 29 | path = re.sub(r"\.", r"\\.", path) 30 | 31 | # Replace url parameters with regex groups 32 | path = re.sub(r"<\w+>", r"([^/]+)", path) 33 | 34 | # Replace wildcards with corresponding regex 35 | path = path.replace(r"\.\.\.\.", r".+").replace(r"\.\.\.", r"[^/]+") 36 | 37 | # Add optional slash at the end if append_slash is True 38 | if append_slash: 39 | path += r"/?" 40 | 41 | # Add start and end of string anchors 42 | return f"^{path}$" 43 | 44 | def __init__( 45 | self, 46 | path: str = "", 47 | methods: Union[str, Iterable[str]] = GET, 48 | handler: Callable = None, 49 | *, 50 | append_slash: bool = False, 51 | ) -> None: 52 | self._validate_path(path, append_slash) 53 | 54 | self.path = path 55 | self.methods = set(methods) if isinstance(methods, (set, list, tuple)) else set([methods]) 56 | self.handler = handler 57 | self.parameters_names = [ 58 | name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" 59 | ] 60 | self.path_pattern = re.compile(self._prepare_path_pattern(path, append_slash)) 61 | 62 | @staticmethod 63 | def _validate_path(path: str, append_slash: bool) -> None: 64 | if not path.startswith("/"): 65 | raise ValueError("Path must start with a slash.") 66 | 67 | if path.endswith("/") and append_slash: 68 | raise ValueError("Cannot use append_slash=True when path ends with /") 69 | 70 | if "//" in path: 71 | raise ValueError("Path cannot contain double slashes.") 72 | 73 | if "<>" in path: 74 | raise ValueError("All URL parameters must be named.") 75 | 76 | if re.search(r"[^/]<[^/]+>|<[^/]+>[^/]", path): 77 | raise ValueError("All URL parameters must be between slashes.") 78 | 79 | if re.search(r"[^/.]\.\.\.\.?|\.?\.\.\.[^/.]", path): 80 | raise ValueError("... and .... must be between slashes") 81 | 82 | if "....." in path: 83 | raise ValueError("Path cannot contain more than 4 dots in a row.") 84 | 85 | def matches( 86 | self, method: str, path: str 87 | ) -> Union[Tuple[Literal[False], None], Tuple[Literal[True], Dict[str, str]]]: 88 | """ 89 | Checks if the route matches given ``method`` and ``path``. 90 | 91 | If the route contains parameters, it will check if the ``path`` contains values for 92 | them. 93 | 94 | Returns tuple of a boolean that indicates if the routes matches and a dict containing 95 | values for url parameters. 96 | If the route does not match ``path`` or ``method`` if will return ``None`` instead of dict. 97 | 98 | Examples:: 99 | 100 | route = Route("/example", GET, append_slash=True) 101 | 102 | route.matches(GET, "/example") # True, {} 103 | route.matches(GET, "/example/") # True, {} 104 | 105 | route.matches(GET, "/other-example") # False, None 106 | route.matches(POST, "/example/") # False, None 107 | 108 | ... 109 | 110 | route = Route("/example/", GET) 111 | 112 | route.matches(GET, "/example/123") # True, {"parameter": "123"} 113 | 114 | route.matches(GET, "/other-example") # False, None 115 | 116 | ... 117 | 118 | route = Route("/example/.../something", GET) 119 | route.matches(GET, "/example/123/something") # True, {} 120 | 121 | route = Route("/example/..../something", GET) 122 | route.matches(GET, "/example/123/456/something") # True, {} 123 | """ 124 | 125 | if method not in self.methods: 126 | return False, None 127 | 128 | path_match = self.path_pattern.match(path) 129 | if path_match is None: 130 | return False, None 131 | 132 | url_parameters_values = path_match.groups() 133 | 134 | return True, dict(zip(self.parameters_names, url_parameters_values)) 135 | 136 | def __repr__(self) -> str: 137 | path = self.path 138 | methods = self.methods 139 | handler = self.handler 140 | 141 | return f"" 142 | 143 | 144 | def as_route( 145 | path: str, 146 | methods: Union[str, Iterable[str]] = GET, 147 | *, 148 | append_slash: bool = False, 149 | ) -> "Callable[[Callable[..., Response]], Route]": 150 | """ 151 | Decorator used to convert a function into a ``Route`` object. 152 | 153 | ``as_route`` can be only used once per function, because it replaces the function with 154 | a ``Route`` object that has the same name as the function. 155 | 156 | Later it can be imported and registered in the ``Server``. 157 | 158 | :param str path: URL path 159 | :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. 160 | :param bool append_slash: If True, the route will be accessible with and without a 161 | trailing slash 162 | 163 | Example:: 164 | 165 | # Converts a function into a Route object 166 | @as_route("/example") 167 | def some_func(request): 168 | ... 169 | 170 | some_func # Route(path="/example", methods={"GET"}, handler=) 171 | 172 | # WRONG: as_route can be used only once per function 173 | @as_route("/wrong-example1") 174 | @as_route("/wrong-example2") 175 | def wrong_func2(request): 176 | ... 177 | 178 | # If a route is in another file, you can import it and register it to the server 179 | 180 | from .routes import some_func 181 | 182 | ... 183 | 184 | server.add_routes([ 185 | some_func, 186 | ]) 187 | """ 188 | 189 | def route_decorator(func: Callable) -> Route: 190 | if isinstance(func, Route): 191 | raise ValueError("as_route can be used only once per function.") 192 | 193 | return Route(path, methods, func, append_slash=append_slash) 194 | 195 | return route_decorator 196 | -------------------------------------------------------------------------------- /adafruit_httpserver/server.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.server` 6 | ==================================================== 7 | * Author(s): Dan Halbert, Michał Pokusa 8 | """ 9 | 10 | try: 11 | from typing import Callable, Dict, Iterable, List, Tuple, Union 12 | except ImportError: 13 | pass 14 | 15 | from errno import EAGAIN, ECONNRESET, ETIMEDOUT 16 | from ssl import SSLContext, create_default_context 17 | from sys import implementation 18 | from time import monotonic, sleep 19 | from traceback import print_exception 20 | 21 | from .authentication import Basic, Bearer, Token, require_authentication 22 | from .exceptions import ( 23 | AuthenticationError, 24 | FileNotExistsError, 25 | InvalidPathError, 26 | ServerStoppedError, 27 | ServingFilesDisabledError, 28 | ) 29 | from .headers import Headers 30 | from .interfaces import _ISocket, _ISocketPool 31 | from .methods import GET, HEAD 32 | from .request import Request 33 | from .response import FileResponse, Response 34 | from .route import Route 35 | from .status import BAD_REQUEST_400, FORBIDDEN_403, NOT_FOUND_404, UNAUTHORIZED_401 36 | 37 | if implementation.name != "circuitpython": 38 | from ssl import CERT_NONE, Purpose, SSLError 39 | 40 | 41 | NO_REQUEST = "no_request" 42 | CONNECTION_TIMED_OUT = "connection_timed_out" 43 | REQUEST_HANDLED_NO_RESPONSE = "request_handled_no_response" 44 | REQUEST_HANDLED_RESPONSE_SENT = "request_handled_response_sent" 45 | 46 | # CircuitPython does not have these error codes 47 | MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE = -30592 48 | 49 | 50 | class Server: 51 | """A basic socket-based HTTP server.""" 52 | 53 | host: str 54 | """Host name or IP address the server is listening on. ``None`` if server is stopped.""" 55 | 56 | port: int 57 | """Port the server is listening on. ``None`` if server is stopped.""" 58 | 59 | root_path: str 60 | """Root directory to serve files from. ``None`` if serving files is disabled.""" 61 | 62 | @staticmethod 63 | def _validate_https_cert_provided( 64 | certfile: Union[str, None], keyfile: Union[str, None] 65 | ) -> None: 66 | if certfile is None or keyfile is None: 67 | raise ValueError("Both certfile and keyfile must be specified for HTTPS") 68 | 69 | @staticmethod 70 | def _create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: 71 | ssl_context = create_default_context() 72 | 73 | ssl_context.load_verify_locations(cadata="") 74 | ssl_context.load_cert_chain(certfile, keyfile) 75 | 76 | return ssl_context 77 | 78 | @staticmethod 79 | def _create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: 80 | ssl_context = create_default_context(purpose=Purpose.CLIENT_AUTH) 81 | 82 | ssl_context.load_cert_chain(certfile, keyfile) 83 | 84 | ssl_context.verify_mode = CERT_NONE 85 | ssl_context.check_hostname = False 86 | 87 | return ssl_context 88 | 89 | @classmethod 90 | def _create_ssl_context(cls, certfile: str, keyfile: str) -> SSLContext: 91 | return ( 92 | cls._create_circuitpython_ssl_context(certfile, keyfile) 93 | if implementation.name == "circuitpython" 94 | else cls._create_cpython_ssl_context(certfile, keyfile) 95 | ) 96 | 97 | def __init__( 98 | self, 99 | socket_source: _ISocketPool, 100 | root_path: str = None, 101 | *, 102 | https: bool = False, 103 | certfile: str = None, 104 | keyfile: str = None, 105 | debug: bool = False, 106 | ) -> None: 107 | """Create a server, and get it ready to run. 108 | 109 | :param socket: An object that is a source of sockets. This could be a `socketpool` 110 | in CircuitPython or the `socket` module in CPython. 111 | :param str root_path: Root directory to serve files from 112 | :param bool debug: Enables debug messages useful during development 113 | :param bool https: If True, the server will use HTTPS 114 | :param str certfile: Path to the certificate file, required if ``https`` is True 115 | :param str keyfile: Path to the private key file, required if ``https`` is True 116 | """ 117 | self._buffer = bytearray(1024) 118 | self._timeout = 1 119 | 120 | self._auths = [] 121 | self._routes: "List[Route]" = [] 122 | self.headers = Headers() 123 | 124 | self._socket_source = socket_source 125 | self._sock = None 126 | 127 | self.host, self.port = None, None 128 | self.root_path = root_path 129 | self.https = https 130 | 131 | if https: 132 | self._validate_https_cert_provided(certfile, keyfile) 133 | self._ssl_context = self._create_ssl_context(certfile, keyfile) 134 | else: 135 | self._ssl_context = None 136 | 137 | if root_path in {"", "/"} and debug: 138 | _debug_warning_exposed_files(root_path) 139 | self.stopped = True 140 | 141 | self.debug = debug 142 | 143 | def route( 144 | self, 145 | path: str, 146 | methods: Union[str, Iterable[str]] = GET, 147 | *, 148 | append_slash: bool = False, 149 | ) -> Callable: 150 | """ 151 | Decorator used to add a route. 152 | 153 | If request matches multiple routes, the first matched one added will be used. 154 | 155 | :param str path: URL path 156 | :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. 157 | :param bool append_slash: If True, the route will be accessible with and without a 158 | trailing slash 159 | 160 | Example:: 161 | 162 | # Default method is GET 163 | @server.route("/example") 164 | def route_func(request): 165 | ... 166 | 167 | # It is necessary to specify other methods like POST, PUT, etc. 168 | @server.route("/example", POST) 169 | def route_func(request): 170 | ... 171 | 172 | # If you want to access URL with and without trailing slash, use append_slash=True 173 | @server.route("/example-with-slash", append_slash=True) 174 | # which is equivalent to 175 | @server.route("/example-with-slash") 176 | @server.route("/example-with-slash/") 177 | def route_func(request): 178 | ... 179 | 180 | # Multiple methods can be specified 181 | @server.route("/example", [GET, POST]) 182 | def route_func(request): 183 | ... 184 | 185 | # URL parameters can be specified 186 | @server.route("/example/", GET) e.g. /example/123 187 | def route_func(request, my_parameter): 188 | ... 189 | 190 | # It is possible to use wildcard that can match any number of path segments 191 | @server.route("/example/.../something", GET) # e.g. /example/123/something 192 | @server.route("/example/..../something", GET) # e.g. /example/123/456/something 193 | def route_func(request): 194 | ... 195 | """ 196 | 197 | def route_decorator(func: Callable) -> Callable: 198 | self._routes.append(Route(path, methods, func, append_slash=append_slash)) 199 | return func 200 | 201 | return route_decorator 202 | 203 | def add_routes(self, routes: List[Route]) -> None: 204 | """ 205 | Add multiple routes at once. 206 | 207 | :param List[Route] routes: List of routes to add to the server 208 | 209 | Example:: 210 | 211 | from separate_file import external_route1, external_route2 212 | 213 | ... 214 | 215 | server.add_routes([ 216 | Route("/example", GET, route_func1, append_slash=True), 217 | Route("/example/", GET, route_func2), 218 | Route("/example/..../something", [GET, POST], route_func3), 219 | external_route1, 220 | external_route2, 221 | ]} 222 | """ 223 | self._routes.extend(routes) 224 | 225 | def _verify_can_start(self, host: str, port: int) -> None: 226 | """Check if the server can be successfully started. Raises RuntimeError if not.""" 227 | 228 | if host is None or port is None: 229 | raise RuntimeError("Host and port cannot be None") 230 | 231 | try: 232 | self._socket_source.getaddrinfo(host, port) 233 | except OSError as error: 234 | raise RuntimeError(f"Cannot start server on {host}:{port}") from error 235 | 236 | def serve_forever( 237 | self, host: str = "0.0.0.0", port: int = 5000, *, poll_interval: float = 0.1 238 | ) -> None: 239 | """ 240 | Wait for HTTP requests at the given host and port. Does not return. 241 | Ignores any exceptions raised by the handler function and continues to serve. 242 | Returns only when the server is stopped by calling ``.stop()``. 243 | 244 | :param str host: host name or IP address 245 | :param int port: port 246 | :param float poll_interval: interval between polls in seconds 247 | """ 248 | self.start(host, port) 249 | 250 | while not self.stopped: 251 | try: 252 | if self.poll() == NO_REQUEST and poll_interval is not None: 253 | sleep(poll_interval) 254 | except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development 255 | self.stop() 256 | return 257 | except Exception: 258 | pass # Ignore exceptions in handler function 259 | 260 | @staticmethod 261 | def _create_server_socket( 262 | socket_source: _ISocketPool, 263 | ssl_context: "SSLContext | None", 264 | host: str, 265 | port: int, 266 | ) -> _ISocket: 267 | sock = socket_source.socket(socket_source.AF_INET, socket_source.SOCK_STREAM) 268 | 269 | # TODO: Temporary backwards compatibility, remove after CircuitPython 9.0.0 release 270 | if implementation.version >= (9,) or implementation.name != "circuitpython": 271 | sock.setsockopt(socket_source.SOL_SOCKET, socket_source.SO_REUSEADDR, 1) 272 | 273 | if ssl_context is not None: 274 | sock = ssl_context.wrap_socket(sock, server_side=True) 275 | 276 | sock.bind((host, port)) 277 | sock.listen(10) 278 | sock.setblocking(False) # Non-blocking socket 279 | 280 | return sock 281 | 282 | def start(self, host: str = "0.0.0.0", port: int = 5000) -> None: 283 | """ 284 | Start the HTTP server at the given host and port. Requires calling 285 | ``.poll()`` in a while loop to handle incoming requests. 286 | 287 | :param str host: host name or IP address 288 | :param int port: port 289 | """ 290 | self._verify_can_start(host, port) 291 | 292 | self.host, self.port = host, port 293 | 294 | self.stopped = False 295 | self._sock = self._create_server_socket(self._socket_source, self._ssl_context, host, port) 296 | 297 | if self.debug: 298 | _debug_started_server(self) 299 | 300 | def stop(self) -> None: 301 | """ 302 | Stops the server from listening for new connections and closes the socket. 303 | Current requests will be processed. Server can be started again by calling ``.start()`` 304 | or ``.serve_forever()``. 305 | """ 306 | self.host, self.port = None, None 307 | 308 | self.stopped = True 309 | self._sock.close() 310 | 311 | if self.debug: 312 | _debug_stopped_server(self) 313 | 314 | def _receive_header_bytes(self, sock: _ISocket) -> bytes: 315 | """Receive bytes until a empty line is received.""" 316 | received_bytes = b"" 317 | while b"\r\n\r\n" not in received_bytes: 318 | try: 319 | length = sock.recv_into(self._buffer, len(self._buffer)) 320 | received_bytes += self._buffer[:length] 321 | except OSError as ex: 322 | if ex.errno == ETIMEDOUT: 323 | break 324 | raise 325 | except Exception as ex: 326 | raise ex 327 | return received_bytes 328 | 329 | def _receive_body_bytes( 330 | self, 331 | sock: _ISocket, 332 | received_body_bytes: bytes, 333 | content_length: int, 334 | ) -> bytes: 335 | """Receive bytes until the given content length is received.""" 336 | while len(received_body_bytes) < content_length: 337 | try: 338 | length = sock.recv_into(self._buffer, len(self._buffer)) 339 | received_body_bytes += self._buffer[:length] 340 | except OSError as ex: 341 | if ex.errno == ETIMEDOUT: 342 | break 343 | raise 344 | except Exception as ex: 345 | raise ex 346 | return received_body_bytes[:content_length] 347 | 348 | def _receive_request( 349 | self, 350 | sock: _ISocket, 351 | client_address: Tuple[str, int], 352 | ) -> Request: 353 | """Receive bytes from socket until the whole request is received.""" 354 | 355 | # Receiving data until empty line 356 | header_bytes = self._receive_header_bytes(sock) 357 | 358 | # Return if no data received 359 | if not header_bytes: 360 | return None 361 | 362 | request = Request(self, sock, client_address, header_bytes) 363 | 364 | content_length = int(request.headers.get_directive("Content-Length", 0)) 365 | received_body_bytes = request.body 366 | 367 | # Receiving remaining body bytes 368 | request.body = self._receive_body_bytes(sock, received_body_bytes, content_length) 369 | 370 | return request 371 | 372 | def _find_handler(self, method: str, path: str) -> Union[Callable[..., "Response"], None]: 373 | """ 374 | Finds a handler for a given route. 375 | 376 | If route used URL parameters, the handler will be wrapped to pass the parameters to the 377 | handler. 378 | 379 | Example:: 380 | 381 | @server.route("/example/", GET) 382 | def route_func(request, my_parameter): 383 | ... 384 | request.path == "/example/123" # True 385 | my_parameter == "123" # True 386 | """ 387 | for route in self._routes: 388 | route_matches, url_parameters = route.matches(method, path) 389 | 390 | if route_matches: 391 | 392 | def wrapped_handler(request): 393 | return route.handler(request, **url_parameters) 394 | 395 | return wrapped_handler 396 | 397 | return None 398 | 399 | def _handle_request( 400 | self, request: Request, handler: Union[Callable, None] 401 | ) -> Union[Response, None]: 402 | try: 403 | # Check server authentications if necessary 404 | if self._auths: 405 | require_authentication(request, self._auths) 406 | 407 | # Handler for route exists and is callable 408 | if handler is not None and callable(handler): 409 | return handler(request) 410 | 411 | # No root_path, access to filesystem disabled, return 404. 412 | if self.root_path is None: 413 | raise ServingFilesDisabledError 414 | 415 | # Method is GET or HEAD, try to serve a file from the filesystem. 416 | if request.method in {GET, HEAD}: 417 | return FileResponse( 418 | request, 419 | filename=request.path, 420 | head_only=request.method == HEAD, 421 | ) 422 | 423 | return Response(request, status=BAD_REQUEST_400) 424 | 425 | except AuthenticationError: 426 | return Response( 427 | request, 428 | status=UNAUTHORIZED_401, 429 | headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, 430 | ) 431 | 432 | except InvalidPathError as error: 433 | return Response( 434 | request, 435 | str(error) if self.debug else "Invalid path", 436 | status=FORBIDDEN_403, 437 | ) 438 | 439 | except (FileNotExistsError, ServingFilesDisabledError) as error: 440 | return Response( 441 | request, 442 | str(error) if self.debug else "File not found", 443 | status=NOT_FOUND_404, 444 | ) 445 | 446 | def _set_default_server_headers(self, response: Response) -> None: 447 | for name, value in self.headers.items(): 448 | response._headers.setdefault(name, value) 449 | 450 | def poll( 451 | self, 452 | ) -> str: 453 | """ 454 | Call this method inside your main loop to get the server to check for new incoming client 455 | requests. When a request comes in, it will be handled by the handler function. 456 | 457 | Returns str representing the result of the poll 458 | e.g. ``NO_REQUEST`` or ``REQUEST_HANDLED_RESPONSE_SENT``. 459 | """ 460 | if self.stopped: 461 | raise ServerStoppedError 462 | 463 | conn = None 464 | try: 465 | if self.debug: 466 | _debug_start_time = monotonic() 467 | 468 | conn, client_address = self._sock.accept() 469 | conn.settimeout(self._timeout) 470 | 471 | # Receive the whole request 472 | if (request := self._receive_request(conn, client_address)) is None: 473 | conn.close() 474 | return CONNECTION_TIMED_OUT 475 | 476 | # Find a route that matches the request's method and path and get its handler 477 | handler = self._find_handler(request.method, request.path) 478 | 479 | # Handle the request 480 | response = self._handle_request(request, handler) 481 | 482 | if response is None: 483 | conn.close() 484 | return REQUEST_HANDLED_NO_RESPONSE 485 | 486 | self._set_default_server_headers(response) 487 | 488 | # Send the response 489 | response._send() 490 | 491 | if self.debug: 492 | _debug_end_time = monotonic() 493 | _debug_response_sent(response, _debug_end_time - _debug_start_time) 494 | 495 | return REQUEST_HANDLED_RESPONSE_SENT 496 | 497 | except Exception as error: 498 | if isinstance(error, OSError): 499 | # There is no data available right now, try again later. 500 | if error.errno == EAGAIN: 501 | return NO_REQUEST 502 | # Connection reset by peer, try again later. 503 | if error.errno == ECONNRESET: 504 | return NO_REQUEST 505 | # Handshake failed, try again later. 506 | if error.errno == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE: 507 | return NO_REQUEST 508 | 509 | # CPython specific SSL related errors 510 | if implementation.name != "circuitpython" and isinstance(error, SSLError): 511 | # Ignore unknown SSL certificate errors 512 | if getattr(error, "reason", None) == "SSLV3_ALERT_CERTIFICATE_UNKNOWN": 513 | return NO_REQUEST 514 | 515 | if self.debug: 516 | _debug_exception_in_handler(error) 517 | 518 | if conn is not None: 519 | conn.close() 520 | raise error # Raise the exception again to be handled by the user. 521 | 522 | def require_authentication(self, auths: List[Union[Basic, Token, Bearer]]) -> None: 523 | """ 524 | Requires authentication for all routes and files in ``root_path``. 525 | Any non-authenticated request will be rejected with a 401 status code. 526 | 527 | Example:: 528 | 529 | server = Server(pool, "/static") 530 | server.require_authentication([Basic("username", "password")]) 531 | """ 532 | self._auths = auths 533 | 534 | @property 535 | def headers(self) -> Headers: 536 | """ 537 | Headers to be sent with every response, without the need to specify them in each handler. 538 | 539 | If a header is specified in both the handler and the server, the handler's header will be 540 | used. 541 | 542 | Example:: 543 | 544 | server = Server(pool, "/static") 545 | server.headers = { 546 | "X-Server": "Adafruit CircuitPython HTTP Server", 547 | "Access-Control-Allow-Origin": "*", 548 | } 549 | """ 550 | return self._headers 551 | 552 | @headers.setter 553 | def headers(self, value: Union[Headers, Dict[str, str]]) -> None: 554 | self._headers = value.copy() if isinstance(value, Headers) else Headers(value) 555 | 556 | @property 557 | def request_buffer_size(self) -> int: 558 | """ 559 | The maximum size of the incoming request buffer. If the default size isn't 560 | adequate to handle your incoming data you can set this after creating the 561 | server instance. 562 | 563 | Default size is 1024 bytes. 564 | 565 | Example:: 566 | 567 | server = Server(pool, "/static") 568 | server.request_buffer_size = 2048 569 | 570 | server.serve_forever(str(wifi.radio.ipv4_address)) 571 | """ 572 | return len(self._buffer) 573 | 574 | @request_buffer_size.setter 575 | def request_buffer_size(self, value: int) -> None: 576 | self._buffer = bytearray(value) 577 | 578 | @property 579 | def socket_timeout(self) -> int: 580 | """ 581 | Timeout after which the socket will stop waiting for more incoming data. 582 | 583 | Must be set to positive integer or float. Default is 1 second. 584 | 585 | When exceeded, raises `OSError` with `errno.ETIMEDOUT`. 586 | 587 | Example:: 588 | 589 | server = Server(pool, "/static") 590 | server.socket_timeout = 3 591 | 592 | server.serve_forever(str(wifi.radio.ipv4_address)) 593 | """ 594 | return self._timeout 595 | 596 | @socket_timeout.setter 597 | def socket_timeout(self, value: int) -> None: 598 | if isinstance(value, (int, float)) and value > 0: 599 | self._timeout = value 600 | else: 601 | raise ValueError("Server.socket_timeout must be a positive numeric value.") 602 | 603 | def __repr__(self) -> str: 604 | host = self.host 605 | port = self.port 606 | root_path = self.root_path 607 | 608 | return f"" 609 | 610 | 611 | def _debug_warning_exposed_files(root_path: str): 612 | """Warns about exposing all files on the device.""" 613 | print( 614 | f"WARNING: Setting root_path to '{root_path}' will expose all files on your device " 615 | "through the webserver, including potentially sensitive files like settings.toml. " 616 | "Consider making a sub-directory on your device and using that for your root_path instead." 617 | ) 618 | 619 | 620 | def _debug_started_server(server: "Server"): 621 | """Prints a message when the server starts.""" 622 | scheme = "https" if server.https else "http" 623 | host, port = server.host, server.port 624 | 625 | print(f"Started development server on {scheme}://{host}:{port}") 626 | 627 | 628 | def _debug_response_sent(response: "Response", time_elapsed: float): 629 | """Prints a message after a response is sent.""" 630 | client_ip = response._request.client_address[0] 631 | method = response._request.method 632 | query_params = response._request.query_params 633 | path = response._request.path + (f"?{query_params}" if query_params else "") 634 | req_size = len(response._request.raw_request) 635 | status = response._status 636 | res_size = response._size 637 | time_elapsed_ms = f"{round(time_elapsed*1000)}ms" 638 | 639 | print( 640 | f'{client_ip} -- "{method} {path}" {req_size} -- "{status}" {res_size} -- {time_elapsed_ms}' 641 | ) 642 | 643 | 644 | def _debug_stopped_server(server: "Server"): 645 | """Prints a message after the server stops.""" 646 | print("Stopped development server") 647 | 648 | 649 | def _debug_exception_in_handler(error: Exception): 650 | """Prints a message when an exception is raised in a handler.""" 651 | print_exception(error) 652 | -------------------------------------------------------------------------------- /adafruit_httpserver/status.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | `adafruit_httpserver.status` 6 | ==================================================== 7 | * Author(s): Dan Halbert, Michał Pokusa 8 | """ 9 | 10 | 11 | class Status: 12 | """HTTP status code.""" 13 | 14 | def __init__(self, code: int, text: str): 15 | """ 16 | Define a status code. 17 | 18 | :param int code: Numeric value: 200, 404, etc. 19 | :param str text: Short phrase: "OK", "Not Found', etc. 20 | """ 21 | self.code = code 22 | self.text = text 23 | 24 | def __eq__(self, other: "Status"): 25 | return self.code == other.code and self.text == other.text 26 | 27 | def __str__(self): 28 | return f"{self.code} {self.text}" 29 | 30 | def __repr__(self): 31 | code = self.code 32 | text = self.text 33 | 34 | return f'' 35 | 36 | 37 | SWITCHING_PROTOCOLS_101 = Status(101, "Switching Protocols") 38 | 39 | OK_200 = Status(200, "OK") 40 | 41 | CREATED_201 = Status(201, "Created") 42 | 43 | ACCEPTED_202 = Status(202, "Accepted") 44 | 45 | NO_CONTENT_204 = Status(204, "No Content") 46 | 47 | PARTIAL_CONTENT_206 = Status(206, "Partial Content") 48 | 49 | MOVED_PERMANENTLY_301 = Status(301, "Moved Permanently") 50 | 51 | FOUND_302 = Status(302, "Found") 52 | 53 | TEMPORARY_REDIRECT_307 = Status(307, "Temporary Redirect") 54 | 55 | PERMANENT_REDIRECT_308 = Status(308, "Permanent Redirect") 56 | 57 | BAD_REQUEST_400 = Status(400, "Bad Request") 58 | 59 | UNAUTHORIZED_401 = Status(401, "Unauthorized") 60 | 61 | FORBIDDEN_403 = Status(403, "Forbidden") 62 | 63 | NOT_FOUND_404 = Status(404, "Not Found") 64 | 65 | METHOD_NOT_ALLOWED_405 = Status(405, "Method Not Allowed") 66 | 67 | TOO_MANY_REQUESTS_429 = Status(429, "Too Many Requests") 68 | 69 | INTERNAL_SERVER_ERROR_500 = Status(500, "Internal Server Error") 70 | 71 | NOT_IMPLEMENTED_501 = Status(501, "Not Implemented") 72 | 73 | SERVICE_UNAVAILABLE_503 = Status(503, "Service Unavailable") 74 | -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_HTTPServer/b6a95f34a891d2c08ec6dbeb063256971f723d5b/docs/_static/favicon.ico -------------------------------------------------------------------------------- /docs/_static/favicon.ico.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2018 Phillip Torrone for Adafruit Industries 2 | 3 | SPDX-License-Identifier: CC-BY-4.0 4 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | .. If you created a package, create one automodule per module in the package. 3 | 4 | .. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py) 5 | .. use this format as the module name: "adafruit_foo.foo" 6 | 7 | API Reference 8 | ############# 9 | 10 | .. automodule:: adafruit_httpserver 11 | :members: 12 | 13 | .. automodule:: adafruit_httpserver.server 14 | :members: 15 | 16 | .. automodule:: adafruit_httpserver.request 17 | :members: 18 | :inherited-members: 19 | 20 | .. automodule:: adafruit_httpserver.response 21 | :members: 22 | 23 | .. automodule:: adafruit_httpserver.headers 24 | :members: 25 | :inherited-members: 26 | 27 | .. automodule:: adafruit_httpserver.status 28 | :members: 29 | 30 | .. automodule:: adafruit_httpserver.mime_types 31 | :members: 32 | 33 | .. automodule:: adafruit_httpserver.exceptions 34 | :members: 35 | -------------------------------------------------------------------------------- /docs/api.rst.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries 3 | 4 | SPDX-License-Identifier: MIT 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import datetime 6 | import os 7 | import sys 8 | 9 | sys.path.insert(0, os.path.abspath("..")) 10 | 11 | # -- General configuration ------------------------------------------------ 12 | 13 | # Add any Sphinx extension module names here, as strings. They can be 14 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 15 | # ones. 16 | extensions = [ 17 | "sphinx.ext.autodoc", 18 | "sphinxcontrib.jquery", 19 | "sphinx.ext.intersphinx", 20 | "sphinx.ext.napoleon", 21 | "sphinx.ext.todo", 22 | ] 23 | 24 | # TODO: Please Read! 25 | # Uncomment the below if you use native CircuitPython modules such as 26 | # digitalio, micropython and busio. List the modules you use. Without it, the 27 | # autodoc module docs will fail to generate with a warning. 28 | autodoc_mock_imports = ["microcontroller"] 29 | 30 | 31 | intersphinx_mapping = { 32 | "python": ("https://docs.python.org/3", None), 33 | "CircuitPython": ("https://docs.circuitpython.org/en/latest/", None), 34 | } 35 | 36 | # Show the docstring from both the class and its __init__() method. 37 | autoclass_content = "both" 38 | 39 | # Do not evaluate default argument values. 40 | autodoc_preserve_defaults = True 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | source_suffix = ".rst" 46 | 47 | # The master toctree document. 48 | master_doc = "index" 49 | 50 | # General information about the project. 51 | project = "Adafruit CircuitPython HTTPServer Library" 52 | creation_year = "2022" 53 | current_year = str(datetime.datetime.now().year) 54 | year_duration = ( 55 | current_year if current_year == creation_year else creation_year + " - " + current_year 56 | ) 57 | copyright = year_duration + " Dan Halbert" 58 | author = "Dan Halbert" 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = "1.0" 66 | # The full version, including alpha/beta/rc tags. 67 | release = "1.0" 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = "en" 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = [ 80 | "_build", 81 | "Thumbs.db", 82 | ".DS_Store", 83 | ".env", 84 | "CODE_OF_CONDUCT.md", 85 | ] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | # 90 | default_role = "any" 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | # 94 | add_function_parentheses = True 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = "sphinx" 98 | 99 | # If true, `todo` and `todoList` produce output, else they produce nothing. 100 | todo_include_todos = False 101 | 102 | # If this is True, todo emits a warning for each TODO entries. The default is False. 103 | todo_emit_warnings = True 104 | 105 | napoleon_numpy_docstring = False 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | # 112 | import sphinx_rtd_theme 113 | 114 | html_theme = "sphinx_rtd_theme" 115 | 116 | # Add any paths that contain custom static files (such as style sheets) here, 117 | # relative to this directory. They are copied after the builtin static files, 118 | # so a file named "default.css" will overwrite the builtin "default.css". 119 | html_static_path = ["_static"] 120 | 121 | # The name of an image file (relative to this directory) to use as a favicon of 122 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | # 125 | html_favicon = "_static/favicon.ico" 126 | 127 | # Output file base name for HTML help builder. 128 | htmlhelp_basename = "Adafruit_CircuitPython_Httpserver_Librarydoc" 129 | 130 | # -- Options for LaTeX output --------------------------------------------- 131 | 132 | latex_elements = { 133 | # The paper size ('letterpaper' or 'a4paper'). 134 | # 'papersize': 'letterpaper', 135 | # The font size ('10pt', '11pt' or '12pt'). 136 | # 'pointsize': '10pt', 137 | # Additional stuff for the LaTeX preamble. 138 | # 'preamble': '', 139 | # Latex figure (float) alignment 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | ( 148 | master_doc, 149 | "Adafruit_CircuitPython_HTTPServer_Library.tex", 150 | "Adafruit CircuitPython HTTPServer Library Documentation", 151 | author, 152 | "manual", 153 | ), 154 | ] 155 | 156 | # -- Options for manual page output --------------------------------------- 157 | 158 | # One entry per manual page. List of tuples 159 | # (source start file, name, description, authors, manual section). 160 | man_pages = [ 161 | ( 162 | master_doc, 163 | "Adafruit_CircuitPython_HTTPServer_Library", 164 | "Adafruit CircuitPython HTTPServer Library Documentation", 165 | [author], 166 | 1, 167 | ), 168 | ] 169 | 170 | # -- Options for Texinfo output ------------------------------------------- 171 | 172 | # Grouping the document tree into Texinfo files. List of tuples 173 | # (source start file, target name, title, author, 174 | # dir menu entry, description, category) 175 | texinfo_documents = [ 176 | ( 177 | master_doc, 178 | "Adafruit_CircuitPython_HTTPServer_Library", 179 | "Adafruit CircuitPython HTTPServer Library Documentation", 180 | author, 181 | "Adafruit_CircuitPython_HTTPServer_Library", 182 | "One line description of project.", 183 | "Miscellaneous", 184 | ), 185 | ] 186 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | All examples in this document are using ``Server`` in ``debug`` mode. 3 | This mode is useful for development, but it is not recommended to use it in production. 4 | More about Debug mode at the end of Examples section. 5 | 6 | Different ways of starting the server 7 | ------------------------------------- 8 | 9 | There are several ways to start the server on CircuitPython, mostly depending on the device you are using and 10 | whether you have access to external network. 11 | 12 | Functionally, all of them are the same, not features of the server are limited or disabled in any way. 13 | 14 | Below you can find examples of different ways to start the server: 15 | 16 | .. toctree:: 17 | 18 | starting_methods 19 | 20 | CPython usage 21 | -------------------- 22 | 23 | Library can also be used in CPython, no changes other than changing the ``socket_source`` are necessary. 24 | 25 | .. literalinclude:: ../examples/httpserver_cpython.py 26 | :caption: examples/httpserver_cpython.py 27 | :emphasize-lines: 5,10 28 | :linenos: 29 | 30 | 31 | Serving static files 32 | -------------------- 33 | 34 | It is possible to serve static files from the filesystem. 35 | In this example we are serving files from the ``/static`` directory. 36 | 37 | In order to save memory, we are unregistering unused MIME types and registering additional ones. 38 | `More about MIME types. `_ 39 | 40 | .. literalinclude:: ../examples/httpserver_static_files_serving.py 41 | :caption: examples/httpserver_static_files_serving.py 42 | :emphasize-lines: 11-17,22-25 43 | :linenos: 44 | 45 | You can also serve a specific file from the handler. 46 | By default ``FileResponse`` looks for the file in the server's ``root_path`` directory 47 | (``/default-static-directory`` in the example below), but you can change it manually in every ``FileResponse`` 48 | (to e.g. ``/other-static-directory``, as in example below). 49 | 50 | By doing that, you can serve files from multiple directories, and decide exactly which files are accessible. 51 | 52 | .. literalinclude:: ../examples/httpserver_handler_serves_file.py 53 | :caption: examples/httpserver_handler_serves_file.py 54 | :emphasize-lines: 12,21 55 | :linenos: 56 | 57 | .. literalinclude:: ../examples/home.html 58 | :language: html 59 | :caption: www/home.html 60 | :lines: 7- 61 | :linenos: 62 | 63 | Tasks between requests 64 | ---------------------- 65 | 66 | If you want your code to do more than just serve web pages, 67 | use the ``.start()``/``.poll()`` methods as shown in this example. 68 | 69 | Between calling ``.poll()`` you can do something useful, 70 | for example read a sensor and capture an average or 71 | a running total of the last 10 samples. 72 | 73 | ``.poll()`` return value can be used to check if there was a request and if it was handled. 74 | 75 | .. literalinclude:: ../examples/httpserver_start_and_poll.py 76 | :caption: examples/httpserver_start_and_poll.py 77 | :emphasize-lines: 28,37 78 | :linenos: 79 | 80 | 81 | If you need to perform some action periodically, or there are multiple tasks that need to be done, 82 | it might be better to use ``asyncio`` module to handle them, which makes it really easy to add new tasks 83 | without needing to manually manage the timing of each task. 84 | 85 | ``asyncio`` **is not included in CircuitPython by default, it has to be installed separately.** 86 | 87 | .. literalinclude:: ../examples/httpserver_start_and_poll_asyncio.py 88 | :caption: examples/httpserver_start_and_poll_asyncio.py 89 | :emphasize-lines: 5,6,34,43,46,51,56-63 90 | :linenos: 91 | 92 | Server with MDNS 93 | ---------------- 94 | 95 | It is possible to use the MDNS protocol to make the server accessible via a hostname in addition 96 | to an IP address. It is worth noting that it takes a bit longer to get the response from the server 97 | when accessing it via the hostname. 98 | 99 | In this example, the server is accessible via the IP and ``http://custom-mdns-hostname.local:5000/``. 100 | On some routers it is also possible to use ``http://custom-mdns-hostname:5000/``, but **this is not guaranteed to work**. 101 | 102 | .. literalinclude:: ../examples/httpserver_mdns.py 103 | :caption: examples/httpserver_mdns.py 104 | :emphasize-lines: 11-13 105 | :linenos: 106 | 107 | Get CPU information 108 | ------------------- 109 | 110 | You can return data from sensors or any computed value as JSON. 111 | That makes it easy to use the data in other applications. 112 | 113 | If you want to use the data in a web browser, it might be necessary to enable CORS. 114 | More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS 115 | 116 | .. literalinclude:: ../examples/httpserver_cpu_information.py 117 | :caption: examples/httpserver_cpu_information.py 118 | :emphasize-lines: 9,14-17,32 119 | :linenos: 120 | 121 | Handling different methods 122 | --------------------------------------- 123 | 124 | On every ``server.route()`` call you can specify which HTTP methods are allowed. 125 | By default, only ``GET`` method is allowed. 126 | 127 | You can pass a list of methods or a single method as a string. 128 | 129 | It is recommended to use the the values in ``adafruit_httpserver.methods`` module to avoid typos and for future proofness. 130 | 131 | If you want to route a given path with and without trailing slash, use ``append_slash=True`` parameter. 132 | 133 | In example below, handler for ``/api`` and ``/api/`` route will be called when any of ``GET``, ``POST``, ``PUT``, ``DELETE`` methods is used. 134 | 135 | .. literalinclude:: ../examples/httpserver_methods.py 136 | :caption: examples/httpserver_methods.py 137 | :emphasize-lines: 8,18,25,29,46 138 | :linenos: 139 | 140 | Change NeoPixel color 141 | --------------------- 142 | 143 | There are several ways to pass data to the handler function: 144 | 145 | - In your handler function you can access the query/GET parameters using ``request.query_params`` 146 | - You can also access the POST data directly using ``request.body`` or if you data is in JSON format, 147 | you can use ``request.json()`` to parse it into a dictionary 148 | - Alternatively for short pieces of data you can use URL parameters, which are described later in this document 149 | For more complex data, it is recommended to use JSON format. 150 | 151 | All of these approaches allow you to pass data to the handler function and use it in your code. 152 | 153 | For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` or ``/change-neopixel-color/255/0/0`` 154 | you can change the color of the NeoPixel to red. 155 | Tested on ESP32-S2 Feather. 156 | 157 | .. literalinclude:: ../examples/httpserver_neopixel.py 158 | :caption: examples/httpserver_neopixel.py 159 | :emphasize-lines: 25-27,40,51,67,73 160 | :linenos: 161 | 162 | Templates 163 | --------- 164 | 165 | With the help of the ``adafruit_templateengine`` library, it is possible to achieve somewhat of a 166 | server-side rendering of HTML pages. 167 | 168 | Instead of using string formatting, you can use templates, which can include more complex logic like loops and conditionals. 169 | This makes it very easy to create dynamic pages, witout using JavaScript and exposing any API endpoints. 170 | 171 | Templates also allow splitting the code into multiple files, that can be reused in different places. 172 | You can find more information about the template syntax in the 173 | `adafruit_templateengine documentation `_. 174 | 175 | .. literalinclude:: ../examples/directory_listing.tpl.html 176 | :caption: examples/directory_listing.tpl.html 177 | :language: django 178 | :lines: 9- 179 | :emphasize-lines: 1-2,6,10,15-23,27 180 | :linenos: 181 | 182 | .. literalinclude:: ../examples/httpserver_templates.py 183 | :caption: examples/httpserver_templates.py 184 | :emphasize-lines: 12-15,51-59 185 | :linenos: 186 | 187 | Form data parsing 188 | --------------------- 189 | 190 | Another way to pass data to the handler function is to use form data. 191 | Remember that it is only possible to use it with ``POST`` method. 192 | `More about POST method. `_ 193 | 194 | It is important to use correct ``enctype``, depending on the type of data you want to send. 195 | 196 | - ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces. 197 | If you use it, values will be automatically parsed as strings, but special characters will be URL encoded 198 | e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"`` 199 | - ``multipart/form-data`` - For sending text with special characters and files 200 | When used, non-file values will be automatically parsed as strings and non plain text files will be saved as ``bytes``. 201 | e.g. ``"Hello World! ^-$%"`` will be saved as ``'Hello World! ^-$%'``, and e.g. a PNG file will be saved as ``b'\x89PNG\r\n\x1a\n\x00\...``. 202 | - ``text/plain`` - For sending text data with special characters. 203 | If used, values will be automatically parsed as strings, including special characters, emojis etc. 204 | e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello World! ^-$%"``, this is the **recommended** option. 205 | 206 | If you pass multiple values with the same name, they will be saved as a list, that can be accessed using ``request.form_data.get_list()``. 207 | Even if there is only one value, it will still get a list, and if there multiple values, but you use ``request.form_data.get()`` it will 208 | return only the first one. 209 | 210 | .. literalinclude:: ../examples/httpserver_form_data.py 211 | :caption: examples/httpserver_form_data.py 212 | :emphasize-lines: 31,46,49 213 | :linenos: 214 | 215 | Cookies 216 | --------------------- 217 | 218 | You can use cookies to store data on the client side, that will be sent back to the server with every request. 219 | They are often used to store authentication tokens, session IDs, but also user preferences e.g. theme. 220 | 221 | To access cookies, use ``request.cookies`` dictionary. 222 | In order to set cookies, pass ``cookies`` dictionary to ``Response`` constructor or manually add ``Set-Cookie`` header. 223 | 224 | .. literalinclude:: ../examples/httpserver_cookies.py 225 | :caption: examples/httpserver_cookies.py 226 | :emphasize-lines: 69,73-74,81 227 | :linenos: 228 | 229 | Chunked response 230 | ---------------- 231 | 232 | Library supports chunked responses. This is useful for streaming large amounts of data. 233 | In order to use it, you need pass a generator that yields chunks of data to a ``ChunkedResponse`` 234 | constructor. 235 | 236 | .. literalinclude:: ../examples/httpserver_chunked.py 237 | :caption: examples/httpserver_chunked.py 238 | :emphasize-lines: 8,20-25,27 239 | :linenos: 240 | 241 | URL parameters and wildcards 242 | ---------------------------- 243 | 244 | Alternatively to using query parameters, you can use URL parameters. 245 | They are a better choice when you want to perform different actions based on the URL. 246 | Query/GET parameters are better suited for modifying the behaviour of the handler function. 247 | 248 | Of course it is only a suggestion, you can use them interchangeably and/or both at the same time. 249 | 250 | In order to use URL parameters, you need to wrap them inside with angle brackets in ``Server.route``, e.g. ````. 251 | 252 | All URL parameters values are **passed as keyword arguments** to the handler function. 253 | 254 | Notice how the handler function in example below accepts two additional arguments : ``device_id`` and ``action``. 255 | 256 | If you specify multiple routes for single handler function and they have different number of URL parameters, 257 | make sure to add default values for all the ones that might not be passed. 258 | In the example below the second route has only one URL parameter, so the ``action`` parameter has a default value. 259 | 260 | Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type. 261 | Also note that the names of the function parameters **have to match** with the ones used in route, but they **do not have to** be in the same order. 262 | 263 | Alternatively you can use e.g. ``**params`` to get all the parameters as a dictionary and access them using ``params['parameter_name']``. 264 | 265 | It is also possible to specify a wildcard route: 266 | 267 | - ``...`` - matches one path segment, e.g ``/api/...`` will match ``/api/123``, but **not** ``/api/123/456`` 268 | - ``....`` - matches multiple path segments, e.g ``/api/....`` will match ``/api/123`` and ``/api/123/456`` 269 | 270 | In both cases, wildcards will not match empty path segment, so ``/api/.../users`` will match ``/api/v1/users``, but not ``/api//users`` or ``/api/users``. 271 | 272 | .. literalinclude:: ../examples/httpserver_url_parameters.py 273 | :caption: examples/httpserver_url_parameters.py 274 | :emphasize-lines: 29-31,48-49,60-61 275 | :linenos: 276 | 277 | Authentication 278 | -------------- 279 | 280 | In order to increase security of your server, you can use ``Basic`` and ``Bearer`` authentication. 281 | Remember that it is **not a replacement for HTTPS**, traffic is still sent **in plain text**, but it can be used to protect your server from unauthorized access. 282 | 283 | If you want to apply authentication to the whole server, you need to call ``.require_authentication`` on ``Server`` instance. 284 | 285 | .. literalinclude:: ../examples/httpserver_authentication_server.py 286 | :caption: examples/httpserver_authentication_server.py 287 | :emphasize-lines: 8,10-15,19 288 | :linenos: 289 | 290 | On the other hand, if you want to apply authentication to a set of routes, you need to call ``require_authentication`` function. 291 | In both cases you can check if ``request`` is authenticated by calling ``check_authentication`` on it. 292 | 293 | .. literalinclude:: ../examples/httpserver_authentication_handlers.py 294 | :caption: examples/httpserver_authentication_handlers.py 295 | :emphasize-lines: 9-16,21-26,34,48,60 296 | :linenos: 297 | 298 | Redirects 299 | --------- 300 | 301 | Sometimes you might want to redirect the user to a different URL, either on the same server or on a different one. 302 | 303 | You can do that by returning ``Redirect`` from your handler function. 304 | 305 | You can specify wheter the redirect is permanent or temporary by passing ``permanent=...`` to ``Redirect``. 306 | If you need the redirect to preserve the original request method, you can set ``preserve_method=True``. 307 | 308 | Alternatively, you can pass a ``status`` object directly to ``Redirect`` constructor. 309 | 310 | .. literalinclude:: ../examples/httpserver_redirects.py 311 | :caption: examples/httpserver_redirects.py 312 | :emphasize-lines: 21-25,31,37,49,61 313 | :linenos: 314 | 315 | Server-Sent Events 316 | ------------------ 317 | 318 | All types of responses until now were synchronous, meaning that the response was sent immediately after the handler function returned. 319 | However, sometimes you might want to send data to the client at a later time, e.g. when some event occurs. 320 | This can be overcomed by periodically polling the server, but it is not an elegant solution. Instead, you can use Server-Sent Events (SSE). 321 | 322 | Response is initialized on ``return``, events can be sent using ``.send_event()`` method. Due to the nature of SSE, it is necessary to store the 323 | response object somewhere, so that it can be accessed later. 324 | 325 | 326 | .. warning:: 327 | Because of the limited number of concurrently open sockets, it is **not possible to process more than one SSE response at the same time**. 328 | This might change in the future, but for now, it is recommended to use SSE **only with one client at a time**. 329 | 330 | .. literalinclude:: ../examples/httpserver_sse.py 331 | :caption: examples/httpserver_sse.py 332 | :emphasize-lines: 11,17,46-53,63 333 | :linenos: 334 | 335 | Websockets 336 | ---------- 337 | 338 | Although SSE provide a simple way to send data from the server to the client, they are not suitable for sending data the other way around. 339 | 340 | For that purpose, you can use Websockets. They are more complex than SSE, but they provide a persistent two-way communication channel between 341 | the client and the server. 342 | 343 | Remember, that because Websockets also receive data, you have to explicitly call ``.receive()`` on the ``Websocket`` object to get the message. 344 | This is anologous to calling ``.poll()`` on the ``Server`` object. 345 | 346 | The following example uses ``asyncio``, which has to be installed separately. It is not necessary to use ``asyncio`` to use Websockets, 347 | but it is recommended as it makes it easier to handle multiple tasks. It can be used in any of the examples, but here it is particularly useful. 348 | 349 | .. warning:: 350 | Because of the limited number of concurrently open sockets, it is **not possible to process more than one Websocket response at the same time**. 351 | This might change in the future, but for now, it is recommended to use Websocket **only with one client at a time**. 352 | 353 | .. literalinclude:: ../examples/httpserver_websocket.py 354 | :caption: examples/httpserver_websocket.py 355 | :emphasize-lines: 14,21,66-73,89,100 356 | :linenos: 357 | 358 | Custom response types e.g. video streaming 359 | ------------------------------------------ 360 | 361 | The built-in response types may not always meet your specific requirements. In such cases, you can define custom response types and implement 362 | the necessary logic. 363 | 364 | The example below demonstrates a ``XMixedReplaceResponse`` class, which uses the ``multipart/x-mixed-replace`` content type to stream video frames 365 | from a camera, similar to a CCTV system. 366 | 367 | To ensure the server remains responsive, a global list of open connections is maintained. By running tasks asynchronously, the server can stream 368 | video to multiple clients while simultaneously handling other requests. 369 | 370 | .. literalinclude:: ../examples/httpserver_video_stream.py 371 | :caption: examples/httpserver_video_stream.py 372 | :emphasize-lines: 30-72,87 373 | :linenos: 374 | 375 | HTTPS 376 | ----- 377 | 378 | .. warning:: 379 | HTTPS on CircuitPython **works only on boards with enough memory e.g. ESP32-S3**. 380 | 381 | When you want to expose your server to the internet or an untrusted network, it is recommended to use HTTPS. 382 | Together with authentication, it provides a relatively secure way to communicate with the server. 383 | 384 | .. note:: 385 | Using HTTPS slows down the server, because of additional work with encryption and decryption. 386 | 387 | Enabling HTTPS is straightforward and comes down to passing the path to the certificate and key files to the ``Server`` constructor 388 | and setting ``https=True``. 389 | 390 | .. literalinclude:: ../examples/httpserver_https.py 391 | :caption: examples/httpserver_https.py 392 | :emphasize-lines: 14-16 393 | :linenos: 394 | 395 | 396 | To create your own certificate, you can use the following command: 397 | 398 | .. code-block:: bash 399 | 400 | sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem 401 | 402 | You might have to change permissions of the files, so that the server can read them. 403 | 404 | Multiple servers 405 | ---------------- 406 | 407 | Although it is not the primary use case, it is possible to run multiple servers at the same time. 408 | In order to do that, you need to create multiple ``Server`` instances and call ``.start()`` and ``.poll()`` on each of them. 409 | Using ``.serve_forever()`` for this is not possible because of it's blocking behaviour. 410 | 411 | Each server **must have a different port number**. 412 | 413 | To distinguish between responses from different servers a 'X-Server' header is added to each response. 414 | **This is an optional step**, both servers will work without it. 415 | 416 | In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. 417 | You can share same handler functions between servers or use different ones for each server. 418 | 419 | .. literalinclude:: ../examples/httpserver_multiple_servers.py 420 | :caption: examples/httpserver_multiple_servers.py 421 | :emphasize-lines: 12-13,15-16,19,27,35-36,47-48,53-54 422 | :linenos: 423 | 424 | Debug mode 425 | ---------------- 426 | 427 | It is highly recommended to **disable debug mode in production**. 428 | 429 | During development it is useful to see the logs from the server. 430 | You can enable debug mode by setting ``debug=True`` on ``Server`` instance or in constructor, 431 | it is disabled by default. 432 | 433 | Debug mode prints messages on server startup, after sending a response to a request and if exception 434 | occurs during handling of the request in ``.serve_forever()``. 435 | 436 | This is how the logs might look like when debug mode is enabled:: 437 | 438 | Started development server on http://192.168.0.100:5000 439 | 192.168.0.101 -- "GET /" 194 -- "200 OK" 154 -- 96ms 440 | 192.168.0.101 -- "GET /example" 134 -- "404 Not Found" 172 -- 123ms 441 | 192.168.0.102 -- "POST /api" 1241 -- "401 Unauthorized" 95 -- 64ms 442 | Traceback (most recent call last): 443 | ... 444 | File "code.py", line 55, in example_handler 445 | KeyError: non_existent_key 446 | 192.168.0.103 -- "GET /index.html" 242 -- "200 OK" 154 -- 182ms 447 | Stopped development server 448 | 449 | This is the default format of the logs:: 450 | 451 | {client_ip} -- "{request_method} {path}" {request_size} -- "{response_status}" {response_size} -- {elapsed_ms} 452 | 453 | If you need more information about the server or request, or you want it in a different format you can modify 454 | functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. 455 | 456 | .. note:: 457 | This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code. 458 | -------------------------------------------------------------------------------- /docs/examples.rst.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries 3 | 4 | SPDX-License-Identifier: MIT 5 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../README.rst 3 | 4 | Table of Contents 5 | ================= 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | :hidden: 10 | 11 | self 12 | 13 | .. toctree:: 14 | :caption: Examples 15 | 16 | examples 17 | 18 | .. toctree:: 19 | :caption: API Reference 20 | :maxdepth: 3 21 | 22 | api 23 | 24 | .. toctree:: 25 | :caption: Tutorials 26 | 27 | .. toctree:: 28 | :caption: Related Products 29 | 30 | .. toctree:: 31 | :caption: Other Links 32 | 33 | Download from GitHub 34 | Download Library Bundle 35 | CircuitPython Reference Documentation 36 | CircuitPython Support Forum 37 | Discord Chat 38 | Adafruit Learning System 39 | Adafruit Blog 40 | Adafruit Store 41 | 42 | Indices and tables 43 | ================== 44 | 45 | * :ref:`genindex` 46 | * :ref:`modindex` 47 | * :ref:`search` 48 | -------------------------------------------------------------------------------- /docs/index.rst.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries 3 | 4 | SPDX-License-Identifier: MIT 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Kattni Rembor for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | sphinx 6 | sphinxcontrib-jquery 7 | sphinx-rtd-theme 8 | -------------------------------------------------------------------------------- /docs/starting_methods.rst: -------------------------------------------------------------------------------- 1 | 2 | Manual WiFi 3 | ----------- 4 | 5 | This is the minimal example of using the library with CircuitPython. 6 | This example is serving a simple static text message. 7 | 8 | It also manually connects to the WiFi network. SSID and password are stored in the code, but they 9 | can as well be stored in the ``settings.toml`` file, and then read from there using ``os.getenv()``. 10 | 11 | .. literalinclude:: ../examples/httpserver_simpletest_manual_wifi.py 12 | :caption: examples/httpserver_simpletest_manual_wifi.py 13 | :emphasize-lines: 10-17 14 | :linenos: 15 | 16 | Manual AP (access point) 17 | ------------------------ 18 | 19 | If there is no external network available, it is possible to create an access point (AP) and run a server on it. 20 | It is important to note that only devices connected to the AP will be able to access the server and depending on the device, 21 | it may not be able to access the internet. 22 | 23 | .. literalinclude:: ../examples/httpserver_simpletest_manual_ap.py 24 | :caption: examples/httpserver_simpletest_manual_ap.py 25 | :emphasize-lines: 10-15,29 26 | :linenos: 27 | 28 | Manual Ethernet 29 | --------------- 30 | 31 | Most of the time, the WiFi will be a preferred way of connecting to the network. 32 | Nevertheless it is also possible to use Ethernet instead of WiFi. 33 | The only difference in usage is related to configuring the ``socket_source`` differently. 34 | 35 | .. literalinclude:: ../examples/httpserver_simpletest_manual_ethernet.py 36 | :caption: examples/httpserver_simpletest_manual_ethernet.py 37 | :emphasize-lines: 7,10,12-24,37 38 | :linenos: 39 | 40 | Automatic WiFi using ``settings.toml`` 41 | -------------------------------------- 42 | 43 | From the version 8.0.0 of CircuitPython, 44 | `it is possible to use the environment variables `_ 45 | defined in ``settings.toml`` file to store secrets and configure the WiFi network 46 | using the ``CIRCUITPY_WIFI_SSID`` and ``CIRCUITPY_WIFI_PASSWORD`` variables. 47 | 48 | By default the library uses ``0.0.0.0`` and port ``5000`` for the server, as port ``80`` is reserved for the CircuitPython Web Workflow. 49 | If you want to use port ``80`` , you need to set ``CIRCUITPY_WEB_API_PORT`` to any other port, and then set ``port`` parameter in ``Server`` constructor to ``80`` . 50 | 51 | This is the same example as above, but it uses the ``settings.toml`` file to configure the WiFi network. 52 | 53 | .. note:: 54 | From now on, all the examples will use the ``settings.toml`` file to configure the WiFi network. 55 | 56 | .. literalinclude:: ../examples/settings.toml 57 | :caption: settings.toml 58 | :lines: 5- 59 | :linenos: 60 | 61 | Note that we still need to import ``socketpool`` and ``wifi`` modules. 62 | 63 | .. literalinclude:: ../examples/httpserver_simpletest_auto_settings_toml.py 64 | :caption: examples/httpserver_simpletest_auto_settings_toml.py 65 | :emphasize-lines: 10 66 | :linenos: 67 | 68 | 69 | Helper for socket pool using ``adafruit_connection_manager`` 70 | ------------------------------------------------------------ 71 | 72 | If you do not want to configure the socket pool manually, you can use the ``adafruit_connection_manager`` library, 73 | which provides helpers for getting socket pool and SSL context for common boards. 74 | 75 | Note that it is not installed by default. 76 | You can read `more about it here `_. 77 | 78 | 79 | .. literalinclude:: ../examples/httpserver_simpletest_auto_connection_manager.py 80 | :caption: examples/httpserver_simpletest_auto_connection_manager.py 81 | :emphasize-lines: 6,10 82 | :linenos: 83 | -------------------------------------------------------------------------------- /docs/starting_methods.rst.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Michał Pokusa 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /examples/directory_listing.tpl.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | {% exec path = context.get("path") %} 10 | {% exec items = context.get("items") %} 11 | 12 | 13 | 14 | Directory listing for /{{ path }} 15 | 16 | 17 | 18 |

Directory listing for /{{ path }}

19 | 20 | 21 | 22 |
    23 | {# Going to parent directory if not alredy in #} 24 | {% if path %} 25 |
  • ..
  • 26 | {% endif %} 27 | 28 | {# Listing items #} 29 | {% for item in items %} 30 |
  • {{ item }}
  • 31 | {% endfor %} 32 | 33 |
34 | 35 | {# Script for filtering items #} 36 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/home.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Adafruit HTTPServer 13 | 14 | 15 |

Hello from the CircuitPython HTTP Server!

16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/httpserver_authentication_handlers.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import UNAUTHORIZED_401, Request, Response, Server 9 | from adafruit_httpserver.authentication import ( 10 | AuthenticationError, 11 | Basic, 12 | Bearer, 13 | Token, 14 | check_authentication, 15 | require_authentication, 16 | ) 17 | 18 | pool = socketpool.SocketPool(wifi.radio) 19 | server = Server(pool, debug=True) 20 | 21 | # Create a list of available authentication methods. 22 | auths = [ 23 | Basic("user", "password"), 24 | Token("2db53340-4f9c-4f70-9037-d25bee77eca6"), 25 | Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), 26 | ] 27 | 28 | 29 | @server.route("/check") 30 | def check_if_authenticated(request: Request): 31 | """ 32 | Check if the request is authenticated and return a appropriate response. 33 | """ 34 | is_authenticated = check_authentication(request, auths) 35 | 36 | return Response( 37 | request, 38 | body="Authenticated" if is_authenticated else "Not authenticated", 39 | content_type="text/plain", 40 | ) 41 | 42 | 43 | @server.route("/require-or-401") 44 | def require_authentication_or_401(request: Request): 45 | """ 46 | Require authentication and return a default server 401 response if not authenticated. 47 | """ 48 | require_authentication(request, auths) 49 | 50 | return Response(request, body="Authenticated", content_type="text/plain") 51 | 52 | 53 | @server.route("/require-or-handle") 54 | def require_authentication_or_manually_handle(request: Request): 55 | """ 56 | Require authentication and manually handle request if not authenticated. 57 | """ 58 | 59 | try: 60 | require_authentication(request, auths) 61 | 62 | return Response(request, body="Authenticated", content_type="text/plain") 63 | 64 | except AuthenticationError: 65 | return Response( 66 | request, 67 | body="Not authenticated - Manually handled", 68 | content_type="text/plain", 69 | status=UNAUTHORIZED_401, 70 | ) 71 | 72 | 73 | server.serve_forever(str(wifi.radio.ipv4_address)) 74 | -------------------------------------------------------------------------------- /examples/httpserver_authentication_server.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Basic, Bearer, Request, Response, Server, Token 9 | 10 | # Create a list of available authentication methods. 11 | auths = [ 12 | Basic("user", "password"), 13 | Token("2db53340-4f9c-4f70-9037-d25bee77eca6"), 14 | Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), 15 | ] 16 | 17 | pool = socketpool.SocketPool(wifi.radio) 18 | server = Server(pool, "/static", debug=True) 19 | server.require_authentication(auths) 20 | 21 | 22 | @server.route("/implicit-require") 23 | def implicit_require_authentication(request: Request): 24 | """ 25 | Implicitly require authentication because of the server.require_authentication() call. 26 | """ 27 | 28 | return Response(request, body="Authenticated", content_type="text/plain") 29 | 30 | 31 | server.serve_forever(str(wifi.radio.ipv4_address)) 32 | -------------------------------------------------------------------------------- /examples/httpserver_chunked.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import ChunkedResponse, Request, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, debug=True) 12 | 13 | 14 | @server.route("/chunked") 15 | def chunked(request: Request): 16 | """ 17 | Return the response with ``Transfer-Encoding: chunked``. 18 | """ 19 | 20 | def body(): 21 | yield "Adaf" 22 | yield b"ruit" # Data chunk can be bytes or str. 23 | yield " Indus" 24 | yield b"tr" 25 | yield "ies" 26 | 27 | return ChunkedResponse(request, body) 28 | 29 | 30 | server.serve_forever(str(wifi.radio.ipv4_address)) 31 | -------------------------------------------------------------------------------- /examples/httpserver_cookies.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import GET, Headers, Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, debug=True) 12 | 13 | 14 | THEMES = { 15 | "dark": { 16 | "background-color": "#1c1c1c", 17 | "color": "white", 18 | "button-color": "#181818", 19 | }, 20 | "light": { 21 | "background-color": "white", 22 | "color": "#1c1c1c", 23 | "button-color": "white", 24 | }, 25 | } 26 | 27 | 28 | def themed_template(user_preferred_theme: str): 29 | theme = THEMES[user_preferred_theme] 30 | 31 | return f""" 32 | 33 | 34 | Cookie Example 35 | 49 | 50 | 51 | 52 | 53 |
54 |

55 | After changing the theme, close the tab and open again. 56 | Notice that theme stays the same. 57 |

58 | 59 | 60 | """ 61 | 62 | 63 | @server.route("/", GET) 64 | def themed_from_cookie(request: Request): 65 | """ 66 | Serve a simple themed page, based on the user's cookie. 67 | """ 68 | 69 | user_theme = request.cookies.get("theme", "light") 70 | wanted_theme = request.query_params.get("theme", user_theme) 71 | 72 | headers = Headers() 73 | headers.add("Set-Cookie", "cookie1=value1") 74 | headers.add("Set-Cookie", "cookie2=value2") 75 | 76 | return Response( 77 | request, 78 | themed_template(wanted_theme), 79 | content_type="text/html", 80 | headers=headers, 81 | cookies={} if user_theme == wanted_theme else {"theme": wanted_theme}, 82 | ) 83 | 84 | 85 | server.serve_forever(str(wifi.radio.ipv4_address)) 86 | -------------------------------------------------------------------------------- /examples/httpserver_cpu_information.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import microcontroller 6 | import socketpool 7 | import wifi 8 | 9 | from adafruit_httpserver import JSONResponse, Request, Server 10 | 11 | pool = socketpool.SocketPool(wifi.radio) 12 | server = Server(pool, debug=True) 13 | 14 | # (Optional) Allow cross-origin requests. 15 | server.headers = { 16 | "Access-Control-Allow-Origin": "*", 17 | } 18 | 19 | 20 | @server.route("/cpu-information", append_slash=True) 21 | def cpu_information_handler(request: Request): 22 | """ 23 | Return the current CPU temperature, frequency, and voltage as JSON. 24 | """ 25 | 26 | data = { 27 | "temperature": microcontroller.cpu.temperature, 28 | "frequency": microcontroller.cpu.frequency, 29 | "voltage": microcontroller.cpu.voltage, 30 | } 31 | 32 | return JSONResponse(request, data) 33 | 34 | 35 | server.serve_forever(str(wifi.radio.ipv4_address)) 36 | -------------------------------------------------------------------------------- /examples/httpserver_cpython.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socket 6 | 7 | from adafruit_httpserver import Request, Response, Server 8 | 9 | pool = socket 10 | server = Server(pool, "/static", debug=True) 11 | 12 | 13 | @server.route("/") 14 | def base(request: Request): 15 | """ 16 | Serve a default static plain text message. 17 | """ 18 | return Response(request, "Hello from the CircuitPython HTTP Server!") 19 | 20 | 21 | # Ports below 1024 are reserved for root user only. 22 | # If you want to run this example on a port below 1024, you need to run it as root (or with `sudo`). 23 | server.serve_forever("0.0.0.0", 5000) 24 | -------------------------------------------------------------------------------- /examples/httpserver_form_data.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import GET, POST, Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, debug=True) 12 | 13 | 14 | FORM_HTML_TEMPLATE = """ 15 | 16 | 17 | Form with {enctype} enctype 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 |
26 | 27 | 28 |
29 | 30 |

Form with {enctype} enctype

31 |
32 | 33 | 34 |
35 | {submitted_value} 36 | 37 | 38 | """ 39 | 40 | 41 | @server.route("/form", [GET, POST]) 42 | def form(request: Request): 43 | """ 44 | Serve a form with the given enctype, and display back the submitted value. 45 | """ 46 | enctype = request.query_params.get("enctype", "text/plain") 47 | 48 | if request.method == POST: 49 | posted_value = request.form_data.get("something") 50 | 51 | return Response( 52 | request, 53 | FORM_HTML_TEMPLATE.format( 54 | enctype=enctype, 55 | submitted_value=( 56 | f"

Enctype: {enctype}

\n

Submitted form value: {posted_value}

" 57 | if request.method == POST 58 | else "" 59 | ), 60 | ), 61 | content_type="text/html", 62 | ) 63 | 64 | 65 | server.serve_forever(str(wifi.radio.ipv4_address)) 66 | -------------------------------------------------------------------------------- /examples/httpserver_handler_serves_file.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries, Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | 6 | import socketpool 7 | import wifi 8 | 9 | from adafruit_httpserver import FileResponse, Request, Server 10 | 11 | pool = socketpool.SocketPool(wifi.radio) 12 | server = Server(pool, "/default-static-folder", debug=True) 13 | 14 | 15 | @server.route("/home") 16 | def home(request: Request): 17 | """ 18 | Serves the file /other-static-folder/home.html. 19 | """ 20 | 21 | return FileResponse(request, "home.html", "/other-static-folder") 22 | 23 | 24 | server.serve_forever(str(wifi.radio.ipv4_address)) 25 | -------------------------------------------------------------------------------- /examples/httpserver_https.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server( 12 | pool, 13 | root_path="/static", 14 | https=True, 15 | certfile="cert.pem", 16 | keyfile="key.pem", 17 | debug=True, 18 | ) 19 | 20 | 21 | @server.route("/") 22 | def base(request: Request): 23 | """ 24 | Serve a default static plain text message. 25 | """ 26 | return Response(request, "Hello from the CircuitPython HTTPS Server!") 27 | 28 | 29 | server.serve_forever(str(wifi.radio.ipv4_address), 443) 30 | -------------------------------------------------------------------------------- /examples/httpserver_mdns.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import mdns 6 | import socketpool 7 | import wifi 8 | 9 | from adafruit_httpserver import FileResponse, Request, Server 10 | 11 | mdns_server = mdns.Server(wifi.radio) 12 | mdns_server.hostname = "custom-mdns-hostname" 13 | mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=5000) 14 | 15 | pool = socketpool.SocketPool(wifi.radio) 16 | server = Server(pool, "/static", debug=True) 17 | 18 | 19 | @server.route("/") 20 | def base(request: Request): 21 | """ 22 | Serve the default index.html file. 23 | """ 24 | 25 | return FileResponse(request, "index.html", "/www") 26 | 27 | 28 | server.serve_forever(str(wifi.radio.ipv4_address)) 29 | -------------------------------------------------------------------------------- /examples/httpserver_methods.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import DELETE, GET, POST, PUT, JSONResponse, Request, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, debug=True) 12 | 13 | objects = [ 14 | {"id": 1, "name": "Object 1"}, 15 | ] 16 | 17 | 18 | @server.route("/api", [GET, POST, PUT, DELETE], append_slash=True) 19 | def api(request: Request): 20 | """ 21 | Performs different operations depending on the HTTP method. 22 | """ 23 | 24 | # Get objects 25 | if request.method == GET: 26 | return JSONResponse(request, objects) 27 | 28 | # Upload or update objects 29 | if request.method in {POST, PUT}: 30 | uploaded_object = request.json() 31 | 32 | # Find object with same ID 33 | for i, obj in enumerate(objects): 34 | if obj["id"] == uploaded_object["id"]: 35 | objects[i] = uploaded_object 36 | 37 | return JSONResponse( 38 | request, {"message": "Object updated", "object": uploaded_object} 39 | ) 40 | 41 | # If not found, add it 42 | objects.append(uploaded_object) 43 | return JSONResponse(request, {"message": "Object added", "object": uploaded_object}) 44 | 45 | # Delete objects 46 | if request.method == DELETE: 47 | deleted_object = request.json() 48 | 49 | # Find object with same ID 50 | for i, obj in enumerate(objects): 51 | if obj["id"] == deleted_object["id"]: 52 | del objects[i] 53 | 54 | return JSONResponse( 55 | request, {"message": "Object deleted", "object": deleted_object} 56 | ) 57 | 58 | # If not found, return error 59 | return JSONResponse(request, {"message": "Object not found", "object": deleted_object}) 60 | 61 | # If we get here, something went wrong 62 | return JSONResponse(request, {"message": "Something went wrong"}) 63 | 64 | 65 | server.serve_forever(str(wifi.radio.ipv4_address)) 66 | -------------------------------------------------------------------------------- /examples/httpserver_multiple_servers.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | 12 | bedroom_server = Server(pool, "/bedroom", debug=True) 13 | bedroom_server.headers["X-Server"] = "Bedroom" 14 | 15 | office_server = Server(pool, "/office", debug=True) 16 | office_server.headers["X-Server"] = "Office" 17 | 18 | 19 | @bedroom_server.route("/bedroom") 20 | def bedroom(request: Request): 21 | """ 22 | This route is registered only on ``bedroom_server``. 23 | """ 24 | return Response(request, "Hello from the bedroom!") 25 | 26 | 27 | @office_server.route("/office") 28 | def office(request: Request): 29 | """ 30 | This route is registered only on ``office_server``. 31 | """ 32 | return Response(request, "Hello from the office!") 33 | 34 | 35 | @bedroom_server.route("/home") 36 | @office_server.route("/home") 37 | def home(request: Request): 38 | """ 39 | This route is registered on both servers. 40 | """ 41 | return Response(request, "Hello from home!") 42 | 43 | 44 | ip_address = str(wifi.radio.ipv4_address) 45 | 46 | # Start the servers. 47 | bedroom_server.start(ip_address, 5000) 48 | office_server.start(ip_address, 8000) 49 | 50 | while True: 51 | try: 52 | # Process any waiting requests for both servers. 53 | bedroom_server.poll() 54 | office_server.poll() 55 | except OSError as error: 56 | print(error) 57 | continue 58 | -------------------------------------------------------------------------------- /examples/httpserver_neopixel.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import board 6 | import neopixel 7 | import socketpool 8 | import wifi 9 | 10 | from adafruit_httpserver import GET, POST, Request, Response, Route, Server, as_route 11 | 12 | pool = socketpool.SocketPool(wifi.radio) 13 | server = Server(pool, "/static", debug=True) 14 | 15 | pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) 16 | 17 | 18 | # This is the simplest way to register a route. It uses the Server object in current scope. 19 | @server.route("/change-neopixel-color", GET) 20 | def change_neopixel_color_handler_query_params(request: Request): 21 | """Changes the color of the built-in NeoPixel using query/GET params.""" 22 | 23 | # e.g. /change-neopixel-color?r=255&g=0&b=0 24 | 25 | r = request.query_params.get("r") or 0 26 | g = request.query_params.get("g") or 0 27 | b = request.query_params.get("b") or 0 28 | 29 | pixel.fill((int(r), int(g), int(b))) 30 | 31 | return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") 32 | 33 | 34 | # This is another way to register a route. It uses the decorator that converts the function into 35 | # a Route object that can be imported and registered later. 36 | @as_route("/change-neopixel-color/form-data", POST) 37 | def change_neopixel_color_handler_post_form_data(request: Request): 38 | """Changes the color of the built-in NeoPixel using POST form data.""" 39 | 40 | data = request.form_data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0 41 | r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) 42 | 43 | pixel.fill((int(r), int(g), int(b))) 44 | 45 | return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") 46 | 47 | 48 | def change_neopixel_color_handler_post_json(request: Request): 49 | """Changes the color of the built-in NeoPixel using JSON POST body.""" 50 | 51 | data = request.json() # e.g {"r": 255, "g": 0, "b": 0} 52 | r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) 53 | 54 | pixel.fill((r, g, b)) 55 | 56 | return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") 57 | 58 | 59 | # You can always manually create a Route object and import or register it later. 60 | # Using this approach you can also use the same handler for multiple routes. 61 | post_json_route = Route( 62 | "/change-neopixel-color/json", POST, change_neopixel_color_handler_post_json 63 | ) 64 | 65 | 66 | def change_neopixel_color_handler_url_params( 67 | request: Request, r: str = "0", g: str = "0", b: str = "0" 68 | ): 69 | """Changes the color of the built-in NeoPixel using URL params.""" 70 | 71 | # e.g. /change-neopixel-color/255/0/0 72 | 73 | pixel.fill((int(r), int(g), int(b))) 74 | 75 | return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") 76 | 77 | 78 | # Registering Route objects 79 | server.add_routes( 80 | [ 81 | change_neopixel_color_handler_post_form_data, 82 | post_json_route, 83 | # You can also register a inline created Route object 84 | Route( 85 | path="/change-neopixel-color///", 86 | methods=GET, 87 | handler=change_neopixel_color_handler_url_params, 88 | ), 89 | ] 90 | ) 91 | 92 | 93 | server.serve_forever(str(wifi.radio.ipv4_address)) 94 | -------------------------------------------------------------------------------- /examples/httpserver_redirects.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import ( 9 | MOVED_PERMANENTLY_301, 10 | NOT_FOUND_404, 11 | POST, 12 | Redirect, 13 | Request, 14 | Response, 15 | Server, 16 | ) 17 | 18 | pool = socketpool.SocketPool(wifi.radio) 19 | server = Server(pool, debug=True) 20 | 21 | REDIRECTS = { 22 | "google": "https://www.google.com", 23 | "adafruit": "https://www.adafruit.com", 24 | "circuitpython": "https://circuitpython.org", 25 | } 26 | 27 | 28 | @server.route("/blinka") 29 | def redirect_blinka(request: Request): 30 | """Always redirect to a Blinka page as permanent redirect.""" 31 | return Redirect(request, "https://circuitpython.org/blinka", permanent=True) 32 | 33 | 34 | @server.route("/adafruit") 35 | def redirect_adafruit(request: Request): 36 | """Permanent redirect to Adafruit website with explicitly set status code.""" 37 | return Redirect(request, "https://www.adafruit.com/", status=MOVED_PERMANENTLY_301) 38 | 39 | 40 | @server.route("/fake-login", POST) 41 | def fake_login(request: Request): 42 | """Fake login page.""" 43 | return Response(request, "Fake login page with POST data preserved.") 44 | 45 | 46 | @server.route("/login", POST) 47 | def temporary_login_redirect(request: Request): 48 | """Temporary moved login page with preserved POST data.""" 49 | return Redirect(request, "/fake-login", preserve_method=True) 50 | 51 | 52 | @server.route("/") 53 | def redirect_other(request: Request, slug: str = None): 54 | """ 55 | Redirect to a URL based on the slug. 56 | """ 57 | 58 | if slug is None or slug not in REDIRECTS: 59 | return Response(request, "Unknown redirect", status=NOT_FOUND_404) 60 | 61 | return Redirect(request, REDIRECTS.get(slug)) 62 | 63 | 64 | server.serve_forever(str(wifi.radio.ipv4_address)) 65 | -------------------------------------------------------------------------------- /examples/httpserver_simpletest_auto_connection_manager.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 DJDevon3 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import wifi 6 | from adafruit_connection_manager import get_radio_socketpool 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | pool = get_radio_socketpool(wifi.radio) 11 | server = Server(pool, "/static", debug=True) 12 | 13 | 14 | @server.route("/") 15 | def base(request: Request): 16 | """ 17 | Serve a default static plain text message. 18 | """ 19 | return Response(request, "Hello from the CircuitPython HTTP Server!") 20 | 21 | 22 | server.serve_forever(str(wifi.radio.ipv4_address)) 23 | -------------------------------------------------------------------------------- /examples/httpserver_simpletest_auto_settings_toml.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, "/static", debug=True) 12 | 13 | 14 | @server.route("/") 15 | def base(request: Request): 16 | """ 17 | Serve a default static plain text message. 18 | """ 19 | return Response(request, "Hello from the CircuitPython HTTP Server!") 20 | 21 | 22 | server.serve_forever(str(wifi.radio.ipv4_address)) 23 | -------------------------------------------------------------------------------- /examples/httpserver_simpletest_manual_ap.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | AP_SSID = "..." 11 | AP_PASSWORD = "..." 12 | 13 | print("Creating access point...") 14 | wifi.radio.start_ap(ssid=AP_SSID, password=AP_PASSWORD) 15 | print(f"Created access point {AP_SSID}") 16 | 17 | pool = socketpool.SocketPool(wifi.radio) 18 | server = Server(pool, "/static", debug=True) 19 | 20 | 21 | @server.route("/") 22 | def base(request: Request): 23 | """ 24 | Serve a default static plain text message. 25 | """ 26 | return Response(request, "Hello from the CircuitPython HTTP Server!") 27 | 28 | 29 | server.serve_forever(str(wifi.radio.ipv4_address_ap)) 30 | -------------------------------------------------------------------------------- /examples/httpserver_simpletest_manual_ethernet.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Tim C for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import board 6 | import digitalio 7 | from adafruit_wiznet5k import adafruit_wiznet5k_socket as socket 8 | from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K 9 | 10 | from adafruit_httpserver import Request, Response, Server 11 | 12 | # For Adafruit Ethernet FeatherWing 13 | cs = digitalio.DigitalInOut(board.D10) 14 | 15 | # For Particle Ethernet FeatherWing 16 | # cs = digitalio.DigitalInOut(board.D5) 17 | 18 | spi_bus = board.SPI() 19 | 20 | # Initialize ethernet interface with DHCP 21 | eth = WIZNET5K(spi_bus, cs) 22 | 23 | # Set the interface on the socket source 24 | socket.set_interface(eth) 25 | 26 | server = Server(socket, "/static", debug=True) 27 | 28 | 29 | @server.route("/") 30 | def base(request: Request): 31 | """ 32 | Serve a default static plain text message. 33 | """ 34 | return Response(request, "Hello from the CircuitPython HTTP Server!") 35 | 36 | 37 | server.serve_forever(str(eth.pretty_ip(eth.ip_address))) 38 | -------------------------------------------------------------------------------- /examples/httpserver_simpletest_manual_wifi.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | WIFI_SSID = "..." 11 | WIFI_PASSWORD = "..." 12 | 13 | print(f"Connecting to {WIFI_SSID}...") 14 | wifi.radio.connect(WIFI_SSID, WIFI_PASSWORD) 15 | print(f"Connected to {WIFI_SSID}") 16 | 17 | pool = socketpool.SocketPool(wifi.radio) 18 | 19 | server = Server(pool, "/static", debug=True) 20 | 21 | 22 | @server.route("/") 23 | def base(request: Request): 24 | """ 25 | Serve a default static plain text message. 26 | """ 27 | return Response(request, "Hello from the CircuitPython HTTP Server!") 28 | 29 | 30 | server.serve_forever(str(wifi.radio.ipv4_address)) 31 | -------------------------------------------------------------------------------- /examples/httpserver_sse.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | from time import monotonic 6 | 7 | import microcontroller 8 | import socketpool 9 | import wifi 10 | 11 | from adafruit_httpserver import GET, Request, Response, Server, SSEResponse 12 | 13 | pool = socketpool.SocketPool(wifi.radio) 14 | server = Server(pool, debug=True) 15 | 16 | 17 | sse_response: SSEResponse = None 18 | next_event_time = monotonic() 19 | 20 | HTML_TEMPLATE = """ 21 | 22 | 23 | Server-Sent Events Client 24 | 25 | 26 |

CPU temperature: -°C

27 | 34 | 35 | 36 | """ 37 | 38 | 39 | @server.route("/client", GET) 40 | def client(request: Request): 41 | return Response(request, HTML_TEMPLATE, content_type="text/html") 42 | 43 | 44 | @server.route("/connect-client", GET) 45 | def connect_client(request: Request): 46 | global sse_response 47 | 48 | if sse_response is not None: 49 | sse_response.close() # Close any existing connection 50 | 51 | sse_response = SSEResponse(request) 52 | 53 | return sse_response 54 | 55 | 56 | server.start(str(wifi.radio.ipv4_address)) 57 | while True: 58 | server.poll() 59 | 60 | # Send an event every second 61 | if sse_response is not None and next_event_time < monotonic(): 62 | cpu_temp = round(microcontroller.cpu.temperature, 2) 63 | sse_response.send_event(str(cpu_temp)) 64 | next_event_time = monotonic() + 1 65 | -------------------------------------------------------------------------------- /examples/httpserver_start_and_poll.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import ( 9 | REQUEST_HANDLED_RESPONSE_SENT, 10 | FileResponse, 11 | Request, 12 | Server, 13 | ) 14 | 15 | pool = socketpool.SocketPool(wifi.radio) 16 | server = Server(pool, "/static", debug=True) 17 | 18 | 19 | @server.route("/") 20 | def base(request: Request): 21 | """ 22 | Serve the default index.html file. 23 | """ 24 | return FileResponse(request, "index.html") 25 | 26 | 27 | # Start the server. 28 | server.start(str(wifi.radio.ipv4_address)) 29 | 30 | while True: 31 | try: 32 | # Do something useful in this section, 33 | # for example read a sensor and capture an average, 34 | # or a running total of the last 10 samples 35 | 36 | # Process any waiting requests 37 | pool_result = server.poll() 38 | 39 | if pool_result == REQUEST_HANDLED_RESPONSE_SENT: 40 | # Do something only after handling a request 41 | pass 42 | 43 | # If you want you can stop the server by calling server.stop() anywhere in your code 44 | except OSError as error: 45 | print(error) 46 | continue 47 | -------------------------------------------------------------------------------- /examples/httpserver_start_and_poll_asyncio.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | from asyncio import create_task, gather, run 6 | from asyncio import sleep as async_sleep 7 | 8 | import socketpool 9 | import wifi 10 | 11 | from adafruit_httpserver import ( 12 | REQUEST_HANDLED_RESPONSE_SENT, 13 | FileResponse, 14 | Request, 15 | Server, 16 | ) 17 | 18 | pool = socketpool.SocketPool(wifi.radio) 19 | server = Server(pool, "/static", debug=True) 20 | 21 | 22 | @server.route("/") 23 | def base(request: Request): 24 | """ 25 | Serve the default index.html file. 26 | """ 27 | return FileResponse(request, "index.html") 28 | 29 | 30 | # Start the server. 31 | server.start(str(wifi.radio.ipv4_address)) 32 | 33 | 34 | async def handle_http_requests(): 35 | while True: 36 | # Process any waiting requests 37 | pool_result = server.poll() 38 | 39 | if pool_result == REQUEST_HANDLED_RESPONSE_SENT: 40 | # Do something only after handling a request 41 | pass 42 | 43 | await async_sleep(0) 44 | 45 | 46 | async def do_something_useful(): 47 | while True: 48 | # Do something useful in this section, 49 | # for example read a sensor and capture an average, 50 | # or a running total of the last 10 samples 51 | await async_sleep(1) 52 | 53 | # If you want you can stop the server by calling server.stop() anywhere in your code 54 | 55 | 56 | async def main(): 57 | await gather( 58 | create_task(handle_http_requests()), 59 | create_task(do_something_useful()), 60 | ) 61 | 62 | 63 | run(main()) 64 | -------------------------------------------------------------------------------- /examples/httpserver_static_files_serving.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | 6 | import socketpool 7 | import wifi 8 | 9 | from adafruit_httpserver import MIMETypes, Server 10 | 11 | MIMETypes.configure( 12 | default_to="text/plain", 13 | # Unregistering unnecessary MIME types can save memory 14 | keep_for=[".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico"], 15 | # If you need to, you can add additional MIME types 16 | register={".foo": "text/foo", ".bar": "text/bar"}, 17 | ) 18 | 19 | pool = socketpool.SocketPool(wifi.radio) 20 | server = Server(pool, "/static", debug=True) 21 | 22 | # You don't have to add any routes, by default the server will serve files 23 | # from it's root_path, which is set to "/static" in this example. 24 | 25 | # If you don't set a root_path, the server will not serve any files. 26 | 27 | server.serve_forever(str(wifi.radio.ipv4_address)) 28 | -------------------------------------------------------------------------------- /examples/httpserver_templates.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michal Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | import os 5 | import re 6 | 7 | import socketpool 8 | import wifi 9 | 10 | from adafruit_httpserver import FileResponse, Request, Response, Server 11 | 12 | try: 13 | from adafruit_templateengine import render_template 14 | except ImportError as e: 15 | raise ImportError("This example requires adafruit_templateengine library.") from e 16 | 17 | 18 | pool = socketpool.SocketPool(wifi.radio) 19 | server = Server(pool, "/static", debug=True) 20 | 21 | # Create /static directory if it doesn't exist 22 | try: 23 | os.listdir("/static") 24 | except OSError as e: 25 | raise OSError("Please create a /static directory on the CIRCUITPY drive.") from e 26 | 27 | 28 | def is_file(path: str): 29 | return (os.stat(path.rstrip("/"))[0] & 0b_11110000_00000000) == 0b_10000000_00000000 30 | 31 | 32 | @server.route("/") 33 | def directory_listing(request: Request): 34 | path = request.query_params.get("path", "").replace("%20", " ") 35 | 36 | # Preventing path traversal by removing all ../ from path 37 | path = re.sub(r"\/(\.\.)\/|\/(\.\.)|(\.\.)\/", "/", path).strip("/") 38 | 39 | # If path is a file, return it as a file response 40 | if is_file(f"/static/{path}"): 41 | return FileResponse(request, path) 42 | 43 | items = sorted( 44 | [ 45 | item + ("" if is_file(f"/static/{path}/{item}") else "/") 46 | for item in os.listdir(f"/static/{path}") 47 | ], 48 | key=lambda item: not item.endswith("/"), 49 | ) 50 | 51 | # Otherwise, return a directory listing 52 | return Response( 53 | request, 54 | render_template( 55 | "directory_listing.tpl.html", 56 | context={"path": path, "items": items}, 57 | ), 58 | content_type="text/html", 59 | ) 60 | 61 | 62 | # Start the server. 63 | server.serve_forever(str(wifi.radio.ipv4_address)) 64 | -------------------------------------------------------------------------------- /examples/httpserver_url_parameters.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | import socketpool 6 | import wifi 7 | 8 | from adafruit_httpserver import Request, Response, Server 9 | 10 | pool = socketpool.SocketPool(wifi.radio) 11 | server = Server(pool, debug=True) 12 | 13 | 14 | class Device: 15 | def turn_on(self): 16 | print("Turning on device.") 17 | 18 | def turn_off(self): 19 | print("Turning off device.") 20 | 21 | 22 | def get_device(device_id: str) -> Device: 23 | """ 24 | This is a **made up** function that returns a `Device` object. 25 | """ 26 | return Device() 27 | 28 | 29 | @server.route("/device//action/") 30 | @server.route("/device/emergency-power-off/") 31 | def perform_action(request: Request, device_id: str, action: str = "emergency_power_off"): 32 | """ 33 | Performs an "action" on a specified device. 34 | """ 35 | 36 | device = get_device(device_id) 37 | 38 | if action in {"turn_on"}: 39 | device.turn_on() 40 | elif action in {"turn_off", "emergency_power_off"}: 41 | device.turn_off() 42 | else: 43 | return Response(request, f"Unknown action ({action})") 44 | 45 | return Response(request, f"Action ({action}) performed on device with ID: {device_id}") 46 | 47 | 48 | @server.route("/device//status/") 49 | def device_status_on_date(request: Request, **params: dict): 50 | """ 51 | Return the status of a specified device between two dates. 52 | """ 53 | 54 | device_id = params.get("device_id") 55 | date = params.get("date") 56 | 57 | return Response(request, f"Status of {device_id} on {date}: ...") 58 | 59 | 60 | @server.route("/device/.../status", append_slash=True) 61 | @server.route("/device/....", append_slash=True) 62 | def device_status(request: Request): 63 | """ 64 | Returns the status of all devices no matter what their ID is. 65 | Unknown commands also return the status of all devices. 66 | """ 67 | 68 | return Response(request, "Status of all devices: ...") 69 | 70 | 71 | server.serve_forever(str(wifi.radio.ipv4_address)) 72 | -------------------------------------------------------------------------------- /examples/httpserver_video_stream.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | try: 6 | from typing import Dict, List, Tuple, Union 7 | except ImportError: 8 | pass 9 | 10 | from asyncio import create_task, gather, run, sleep 11 | from random import choice 12 | 13 | import socketpool 14 | import wifi 15 | from adafruit_pycamera import PyCamera 16 | 17 | from adafruit_httpserver import OK_200, Headers, Request, Response, Server, Status 18 | 19 | pool = socketpool.SocketPool(wifi.radio) 20 | server = Server(pool, debug=True) 21 | 22 | 23 | camera = PyCamera() 24 | camera.display.brightness = 0 25 | camera.mode = 0 # JPEG, required for `capture_into_jpeg()` 26 | camera.resolution = "1280x720" 27 | camera.effect = 0 # No effect 28 | 29 | 30 | class XMixedReplaceResponse(Response): 31 | def __init__( 32 | self, 33 | request: Request, 34 | frame_content_type: str, 35 | *, 36 | status: Union[Status, Tuple[int, str]] = OK_200, 37 | headers: Union[Headers, Dict[str, str]] = None, 38 | cookies: Dict[str, str] = None, 39 | ) -> None: 40 | super().__init__( 41 | request=request, 42 | headers=headers, 43 | cookies=cookies, 44 | status=status, 45 | ) 46 | self._boundary = self._get_random_boundary() 47 | self._headers.setdefault( 48 | "Content-Type", f"multipart/x-mixed-replace; boundary={self._boundary}" 49 | ) 50 | self._frame_content_type = frame_content_type 51 | 52 | @staticmethod 53 | def _get_random_boundary() -> str: 54 | symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 55 | return "--" + "".join([choice(symbols) for _ in range(16)]) 56 | 57 | def send_frame(self, frame: Union[str, bytes] = "") -> None: 58 | encoded_frame = bytes(frame.encode("utf-8") if isinstance(frame, str) else frame) 59 | 60 | self._send_bytes(self._request.connection, bytes(f"{self._boundary}\r\n", "utf-8")) 61 | self._send_bytes( 62 | self._request.connection, 63 | bytes(f"Content-Type: {self._frame_content_type}\r\n\r\n", "utf-8"), 64 | ) 65 | self._send_bytes(self._request.connection, encoded_frame) 66 | self._send_bytes(self._request.connection, bytes("\r\n", "utf-8")) 67 | 68 | def _send(self) -> None: 69 | self._send_headers() 70 | 71 | def close(self) -> None: 72 | self._close_connection() 73 | 74 | 75 | stream_connections: List[XMixedReplaceResponse] = [] 76 | 77 | 78 | @server.route("/frame") 79 | def frame_handler(request: Request): 80 | frame = camera.capture_into_jpeg() 81 | 82 | return Response(request, body=frame, content_type="image/jpeg") 83 | 84 | 85 | @server.route("/stream") 86 | def stream_handler(request: Request): 87 | response = XMixedReplaceResponse(request, frame_content_type="image/jpeg") 88 | stream_connections.append(response) 89 | 90 | return response 91 | 92 | 93 | async def send_stream_frames(): 94 | while True: 95 | await sleep(0.1) 96 | 97 | frame = camera.capture_into_jpeg() 98 | 99 | for connection in iter(stream_connections): 100 | try: 101 | connection.send_frame(frame) 102 | except BrokenPipeError: 103 | connection.close() 104 | stream_connections.remove(connection) 105 | 106 | 107 | async def handle_http_requests(): 108 | server.start(str(wifi.radio.ipv4_address)) 109 | 110 | while True: 111 | await sleep(0) 112 | 113 | server.poll() 114 | 115 | 116 | async def main(): 117 | await gather( 118 | create_task(send_stream_frames()), 119 | create_task(handle_http_requests()), 120 | ) 121 | 122 | 123 | run(main()) 124 | -------------------------------------------------------------------------------- /examples/httpserver_websocket.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | from asyncio import create_task, gather, run 6 | from asyncio import sleep as async_sleep 7 | 8 | import board 9 | import microcontroller 10 | import neopixel 11 | import socketpool 12 | import wifi 13 | 14 | from adafruit_httpserver import GET, Request, Response, Server, Websocket 15 | 16 | pool = socketpool.SocketPool(wifi.radio) 17 | server = Server(pool, debug=True) 18 | 19 | pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) 20 | 21 | websocket: Websocket = None 22 | 23 | HTML_TEMPLATE = """ 24 | 25 | 26 | Websocket Client 27 | 28 | 29 |

CPU temperature: -°C

30 |

NeoPixel Color:

31 | 54 | 55 | 56 | """ 57 | 58 | 59 | @server.route("/client", GET) 60 | def client(request: Request): 61 | return Response(request, HTML_TEMPLATE, content_type="text/html") 62 | 63 | 64 | @server.route("/connect-websocket", GET) 65 | def connect_client(request: Request): 66 | global websocket 67 | 68 | if websocket is not None: 69 | websocket.close() # Close any existing connection 70 | 71 | websocket = Websocket(request) 72 | 73 | return websocket 74 | 75 | 76 | server.start(str(wifi.radio.ipv4_address)) 77 | 78 | 79 | async def handle_http_requests(): 80 | while True: 81 | server.poll() 82 | 83 | await async_sleep(0) 84 | 85 | 86 | async def handle_websocket_requests(): 87 | while True: 88 | if websocket is not None: 89 | if (data := websocket.receive(fail_silently=True)) is not None: 90 | r, g, b = int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16) 91 | pixel.fill((r, g, b)) 92 | 93 | await async_sleep(0) 94 | 95 | 96 | async def send_websocket_messages(): 97 | while True: 98 | if websocket is not None: 99 | cpu_temp = round(microcontroller.cpu.temperature, 2) 100 | websocket.send_message(str(cpu_temp), fail_silently=True) 101 | 102 | await async_sleep(1) 103 | 104 | 105 | async def main(): 106 | await gather( 107 | create_task(handle_http_requests()), 108 | create_task(handle_websocket_requests()), 109 | create_task(send_websocket_messages()), 110 | ) 111 | 112 | 113 | run(main()) 114 | -------------------------------------------------------------------------------- /examples/settings.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Michał Pokusa 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | # Setting these variables will automatically connect board to WiFi on boot 6 | CIRCUITPY_WIFI_SSID="Your WiFi SSID Here" 7 | CIRCUITPY_WIFI_PASSWORD="Your WiFi Password Here" 8 | -------------------------------------------------------------------------------- /optional_requirements.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Alec Delaney for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [build-system] 6 | requires = [ 7 | "setuptools", 8 | "wheel", 9 | "setuptools-scm", 10 | ] 11 | 12 | [project] 13 | name = "adafruit-circuitpython-httpserver" 14 | description = "Simple HTTP Server for CircuitPython" 15 | version = "0.0.0+auto.0" 16 | readme = "README.rst" 17 | authors = [ 18 | {name = "Adafruit Industries", email = "circuitpython@adafruit.com"} 19 | ] 20 | urls = {Homepage = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git"} 21 | keywords = [ 22 | "adafruit", 23 | "blinka", 24 | "circuitpython", 25 | "micropython", 26 | "httpserver", 27 | "web", 28 | "server", 29 | "webserver", 30 | "http", 31 | ] 32 | license = {text = "MIT"} 33 | classifiers = [ 34 | "Intended Audience :: Developers", 35 | "Topic :: Software Development :: Libraries", 36 | "Topic :: Software Development :: Embedded Systems", 37 | "Topic :: System :: Hardware", 38 | "License :: OSI Approved :: MIT License", 39 | "Programming Language :: Python :: 3", 40 | ] 41 | dynamic = ["dependencies", "optional-dependencies"] 42 | 43 | [tool.setuptools] 44 | packages = ["adafruit_httpserver"] 45 | 46 | [tool.setuptools.dynamic] 47 | dependencies = {file = ["requirements.txt"]} 48 | optional-dependencies = {optional = {file = ["optional_requirements.txt"]}} 49 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: Unlicense 4 | 5 | Adafruit-Blinka 6 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | target-version = "py38" 6 | line-length = 100 7 | 8 | [lint] 9 | preview = true 10 | select = ["I", "PL", "UP"] 11 | 12 | extend-select = [ 13 | "D419", # empty-docstring 14 | "E501", # line-too-long 15 | "W291", # trailing-whitespace 16 | "PLC0414", # useless-import-alias 17 | "PLC2401", # non-ascii-name 18 | "PLC2801", # unnecessary-dunder-call 19 | "PLC3002", # unnecessary-direct-lambda-call 20 | "E999", # syntax-error 21 | "PLE0101", # return-in-init 22 | "F706", # return-outside-function 23 | "F704", # yield-outside-function 24 | "PLE0116", # continue-in-finally 25 | "PLE0117", # nonlocal-without-binding 26 | "PLE0241", # duplicate-bases 27 | "PLE0302", # unexpected-special-method-signature 28 | "PLE0604", # invalid-all-object 29 | "PLE0605", # invalid-all-format 30 | "PLE0643", # potential-index-error 31 | "PLE0704", # misplaced-bare-raise 32 | "PLE1141", # dict-iter-missing-items 33 | "PLE1142", # await-outside-async 34 | "PLE1205", # logging-too-many-args 35 | "PLE1206", # logging-too-few-args 36 | "PLE1307", # bad-string-format-type 37 | "PLE1310", # bad-str-strip-call 38 | "PLE1507", # invalid-envvar-value 39 | "PLE2502", # bidirectional-unicode 40 | "PLE2510", # invalid-character-backspace 41 | "PLE2512", # invalid-character-sub 42 | "PLE2513", # invalid-character-esc 43 | "PLE2514", # invalid-character-nul 44 | "PLE2515", # invalid-character-zero-width-space 45 | "PLR0124", # comparison-with-itself 46 | "PLR0202", # no-classmethod-decorator 47 | "PLR0203", # no-staticmethod-decorator 48 | "UP004", # useless-object-inheritance 49 | "PLR0206", # property-with-parameters 50 | "PLR0904", # too-many-public-methods 51 | "PLR0911", # too-many-return-statements 52 | "PLR0912", # too-many-branches 53 | "PLR0913", # too-many-arguments 54 | "PLR0914", # too-many-locals 55 | "PLR0915", # too-many-statements 56 | "PLR0916", # too-many-boolean-expressions 57 | "PLR1702", # too-many-nested-blocks 58 | "PLR1704", # redefined-argument-from-local 59 | "PLR1711", # useless-return 60 | "C416", # unnecessary-comprehension 61 | "PLR1733", # unnecessary-dict-index-lookup 62 | "PLR1736", # unnecessary-list-index-lookup 63 | 64 | # ruff reports this rule is unstable 65 | #"PLR6301", # no-self-use 66 | 67 | "PLW0108", # unnecessary-lambda 68 | "PLW0120", # useless-else-on-loop 69 | "PLW0127", # self-assigning-variable 70 | "PLW0129", # assert-on-string-literal 71 | "B033", # duplicate-value 72 | "PLW0131", # named-expr-without-context 73 | "PLW0245", # super-without-brackets 74 | "PLW0406", # import-self 75 | "PLW0602", # global-variable-not-assigned 76 | "PLW0603", # global-statement 77 | "PLW0604", # global-at-module-level 78 | 79 | # fails on the try: import typing used by libraries 80 | #"F401", # unused-import 81 | 82 | "F841", # unused-variable 83 | "E722", # bare-except 84 | "PLW0711", # binary-op-exception 85 | "PLW1501", # bad-open-mode 86 | "PLW1508", # invalid-envvar-default 87 | "PLW1509", # subprocess-popen-preexec-fn 88 | "PLW2101", # useless-with-lock 89 | "PLW3301", # nested-min-max 90 | ] 91 | 92 | ignore = [ 93 | "PLR2004", # magic-value-comparison 94 | "UP030", # format literals 95 | "PLW1514", # unspecified-encoding 96 | "PLR0913", # too-many-arguments 97 | "PLR0915", # too-many-statements 98 | "PLR0917", # too-many-positional-arguments 99 | "PLR0904", # too-many-public-methods 100 | "PLR0912", # too-many-branches 101 | "PLR0916", # too-many-boolean-expressions 102 | "PLR6301", # could-be-static no-self-use 103 | "PLC0415", # import outside toplevel 104 | "PLC2701", # private import 105 | "PLR0911", # too many return 106 | "PLW1641", # object not implement hash 107 | "PLW0603", # global statement 108 | "PLC1901", # string falsey simplified 109 | ] 110 | 111 | [format] 112 | line-ending = "lf" 113 | --------------------------------------------------------------------------------