├── .codecov.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.rst ├── CONTRIBUTORS.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ └── .keep │ ├── api.rst │ ├── conf.py │ ├── index.rst │ └── installation.rst ├── pyproject.toml ├── src └── hyperframe │ ├── __init__.py │ ├── exceptions.py │ ├── flags.py │ ├── frame.py │ └── py.typed └── tests ├── __init__.py ├── test_external_collection.py ├── test_flags.py └── test_frames.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | tox: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 5 14 | matrix: 15 | python-version: 16 | - "3.9" 17 | - "3.10" 18 | - "3.11" 19 | - "3.12" 20 | - "3.13" 21 | - "pypy3.9" 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | with: 26 | submodules: recursive 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install tox 31 | run: | 32 | python -m pip install --upgrade pip setuptools 33 | pip install --upgrade tox tox-gh-actions 34 | - name: Initialize tox envs 35 | run: | 36 | tox --parallel auto --notest 37 | - name: Test with tox 38 | run: | 39 | tox --parallel 0 40 | - uses: codecov/codecov-action@v5 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | files: ./coverage.xml 44 | disable_search: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | env/ 3 | dist/ 4 | *.egg-info/ 5 | *.pyc 6 | __pycache__ 7 | .coverage 8 | coverage.xml 9 | .tox/ 10 | .cache/ 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/http2-frame-test-case"] 2 | path = tests/http2-frame-test-case 3 | url = https://github.com/http2jp/http2-frame-test-case.git 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Release History 2 | =============== 3 | 4 | dev 5 | --- 6 | 7 | **API Changes (Backward Incompatible)** 8 | 9 | - 10 | 11 | **Bugfixes** 12 | 13 | - 14 | 15 | 6.1.0 (2025-01-22) 16 | ------------------ 17 | 18 | **API Changes (Backward Incompatible)** 19 | 20 | - Support for Python 3.6 has been removed. 21 | - Support for Python 3.7 has been removed. 22 | - Support for Python 3.8 has been removed. 23 | 24 | **API Changes (Backward Compatible)** 25 | 26 | - Support for Python 3.10 has been added. 27 | - Support for Python 3.11 has been added. 28 | - Support for Python 3.12 has been added. 29 | - Support for Python 3.13 has been added. 30 | - Updated packaging and testing infrastructure. 31 | - Code cleanup and linting. 32 | - Improved type hints. 33 | 34 | 6.0.1 (2021-04-17) 35 | ------------------ 36 | 37 | **API Changes (Backward Compatible)** 38 | 39 | - Added support for Python 3.9. 40 | - Added type hints. 41 | 42 | 6.0.0 (2020-09-06) 43 | ------------------ 44 | 45 | **API Changes (Backward Incompatible)** 46 | 47 | - Introduce ``HyperframeError`` base exception class for all errors raised within hyperframe. 48 | - Change exception base class of ``UnknownFrameError`` to ``HyperframeError`` 49 | - Change exception base class of ``InvalidPaddingError`` to ``HyperframeError`` 50 | - Change exception base class of ``InvalidFrameError`` to ``HyperframeError`` 51 | - Invalid frames with wrong stream id (zero vs. non-zero) now raise ``InvalidDataError``. 52 | - Invalid SETTINGS frames (non-empty but ACK) now raise ``InvalidDataError``. 53 | - Invalid ALTSVC frames with non-bytestring field or origin now raise ``InvalidDataError``. 54 | 55 | **API Changes (Backward Compatible)** 56 | 57 | - Deprecate ``total_padding`` - use `pad_length` instead. 58 | - Improve repr() output for all frame classes. 59 | - Introduce Frame.explain(data) for quick introspection of raw data. 60 | 61 | **Bugfixes** 62 | 63 | - Fixed padding parsing for ``PushPromiseFrame``. 64 | - Fixed unchecked frame length for ``PriorityFrame``. It now correctly raises ``InvalidFrameError``. 65 | - Fixed promised stream id validation for ``PushPromiseFrame``. It now raises ``InvalidDataError``. 66 | - Fixed unchecked frame length for ``WindowUpdateFrame``. It now correctly raises ``InvalidFrameError``. 67 | - Fixed window increment value range validation. It now raises ``InvalidDataError``. 68 | - Fixed parsing of ``SettingsFrame`` with mutual exclusion of ACK flag and payload. 69 | 70 | **Other Changes** 71 | 72 | - Removed support for Python 2.7, 3.4, 3.5, pypy. 73 | - Added support for Python 3.8. 74 | 75 | 5.2.0 (2019-01-18) 76 | ------------------ 77 | 78 | **API Changes (Backward Compatible)** 79 | 80 | - Add a new ENABLE_CONNECT_PROTOCOL settings parameter. 81 | 82 | **Other Changes** 83 | 84 | - Fix collections.abc deprecation. 85 | - Drop support for Python 3.3 and support 3.7. 86 | 87 | 5.1.0 (2017-04-24) 88 | ------------------ 89 | 90 | **API Changes (Backward Compatible)** 91 | 92 | - Added support for ``DataFrame.data`` being a ``memoryview`` object. 93 | 94 | 5.0.0 (2017-03-07) 95 | ------------------ 96 | 97 | **Backwards Incompatible API Changes** 98 | 99 | - Added support for unknown extension frames. These will be returned in the new 100 | ``ExtensionFrame`` object. The flag information for these frames is persisted 101 | in ``flag_byte`` if needed. 102 | 103 | 4.0.2 (2017-02-20) 104 | ------------------ 105 | 106 | **Bugfixes** 107 | 108 | - Fixed AltSvc stream association, which was incorrectly set to ``'both'``: 109 | should have been ``'either'``. 110 | - Fixed a bug where stream IDs on received frames were allowed to be 32-bit, 111 | instead of 31-bit. 112 | - Fixed a bug with frames that had the ``PADDING`` flag set but zero-length 113 | padding, whose flow-controlled length was calculated wrongly. 114 | - Miscellaneous performance improvements to serialization and parsing logic. 115 | 116 | 4.0.1 (2016-03-13) 117 | ------------------ 118 | 119 | **Bugfixes** 120 | 121 | - Fixed bug with the repr of ``AltSvcFrame``, where building it could throw 122 | exceptions if the frame had been received from the network. 123 | 124 | 4.0.0 (2016-03-13) 125 | ------------------ 126 | 127 | **Backwards Incompatible API Changes** 128 | 129 | - Updated old ALTSVC frame definition to match the newly specified RFC 7838. 130 | - Remove BLOCKED frame, which was never actually specified. 131 | - Removed previously deprecated ``SettingsFrame.SETTINGS_MAX_FRAME_SIZE`` and 132 | ``SettingsFrame.SETTINGS_MAX_HEADER_LIST_SIZE``. 133 | 134 | 3.2.0 (2016-02-02) 135 | ------------------ 136 | 137 | **API Changes (Backward Compatible)** 138 | 139 | - Invalid PING frame bodies now raise ``InvalidFrameError``, not 140 | ``ValueError``. Note that ``InvalidFrameError`` is a ``ValueError`` subclass. 141 | - Invalid RST_STREAM frame bodies now raise ``InvalidFramError``, not 142 | ``ValueError``. Note that ``InvalidFrameError`` is a ``ValueError`` subclass. 143 | - Canonicalized the names of ``SettingsFrame.SETTINGS_MAX_FRAME_SIZE`` and 144 | ``SettingsFrame.SETTINGS_MAX_HEADER_LIST_SIZE`` to match their peers, by 145 | adding new properties ``SettingsFrame.MAX_FRAME_SIZE`` and 146 | ``SettingsFrame.SETTINGS_MAX_HEADER_LIST_SIZE``. The old names are still 147 | present, but will be deprecated in 4.0.0. 148 | 149 | **Bugfixes** 150 | 151 | - The change in ``3.1.0`` that ensured that ``InvalidFrameError`` would be 152 | thrown did not affect certain invalid values in ALT_SVC frames. This has been 153 | fixed: ``ValueError`` will no longer be thrown from invalid ALT_SVC bodies. 154 | 155 | 3.1.1 (2016-01-18) 156 | ------------------ 157 | 158 | **Bugfixes** 159 | 160 | - Correctly error when receiving Ping frames that have insufficient data. 161 | 162 | 3.1.0 (2016-01-13) 163 | ------------------ 164 | 165 | **API Changes** 166 | 167 | - Added new ``InvalidFrameError`` that is thrown instead of ``struct.error`` 168 | when parsing a frame. 169 | 170 | **Bugfixes** 171 | 172 | - Fixed error when trying to serialize frames that use Priority information 173 | with the defaults for that information. 174 | - Fixed errors when displaying the repr of frames with non-printable bodies. 175 | 176 | 3.0.1 (2016-01-08) 177 | ------------------ 178 | 179 | **Bugfixes** 180 | 181 | - Fix issue where unpadded DATA, PUSH_PROMISE and HEADERS frames that had empty 182 | bodies would raise ``InvalidPaddingError`` exceptions when parsed. 183 | 184 | 3.0.0 (2016-01-08) 185 | ------------------ 186 | 187 | **Backwards Incompatible API Changes** 188 | 189 | - Parsing padded frames that have invalid padding sizes now throws an 190 | ``InvalidPaddingError``. 191 | 192 | 2.2.0 (2015-10-15) 193 | ------------------ 194 | 195 | **API Changes** 196 | 197 | - When an unknown frame is encountered, ``parse_frame_header`` now throws a 198 | ``ValueError`` subclass: ``UnknownFrameError``. This subclass contains the 199 | frame type and the length of the frame body. 200 | 201 | 2.1.0 (2015-10-06) 202 | ------------------ 203 | 204 | **API Changes** 205 | 206 | - Frames parsed from binary data now carry a ``body_len`` attribute that 207 | matches the frame length (minus the frame header). 208 | 209 | 2.0.0 (2015-09-21) 210 | ------------------ 211 | 212 | **API Changes** 213 | 214 | - Attempting to parse unrecognised frames now throws ``ValueError`` instead of 215 | ``KeyError``. Thanks to @Kriechi! 216 | - Flags are now validated for correctness, preventing setting flags that 217 | ``hyperframe`` does not recognise and that would not serialize. Thanks to 218 | @mhils! 219 | - Frame properties can now be initialized in the constructors. Thanks to @mhils 220 | and @Kriechi! 221 | - Frames that cannot be sent on a stream now have their stream ID defaulted 222 | to ``0``. Thanks to @Kriechi! 223 | 224 | **Other Changes** 225 | 226 | - Frames have a more useful repr. Thanks to @mhils! 227 | 228 | 1.1.1 (2015-07-20) 229 | ------------------ 230 | 231 | - Fix a bug where ``FRAME_MAX_LEN`` was one byte too small. 232 | 233 | 1.1.0 (2015-06-28) 234 | ------------------ 235 | 236 | - Add ``body_len`` property to frames to enable introspection of the actual 237 | frame length. Thanks to @jdecuyper! 238 | 239 | 1.0.1 (2015-06-27) 240 | ------------------ 241 | 242 | - Fix bug where the frame header would have an incorrect length added to it. 243 | 244 | 1.0.0 (2015-04-12) 245 | ------------------ 246 | 247 | - Initial extraction from hyper. 248 | -------------------------------------------------------------------------------- /CONTRIBUTORS.rst: -------------------------------------------------------------------------------- 1 | Hyper is written and maintained by Cory Benfield and various contributors: 2 | 3 | Development Lead 4 | ```````````````` 5 | 6 | - Cory Benfield 7 | 8 | Contributors 9 | ```````````` 10 | 11 | In chronological order: 12 | 13 | - Sriram Ganesan (@elricL) 14 | 15 | - Implemented the Huffman encoding/decoding logic. 16 | 17 | - Alek Storm (@alekstorm) 18 | 19 | - Implemented Python 2.7 support. 20 | - Implemented HTTP/2 draft 10 support. 21 | - Implemented server push. 22 | 23 | - Tetsuya Morimoto (@t2y) 24 | 25 | - Fixed a bug where large or incomplete frames were not handled correctly. 26 | - Added hyper command-line tool. 27 | - General code cleanups. 28 | 29 | - Jerome De Cuyper (@jdecuyper) 30 | 31 | - Updated documentation and tests. 32 | 33 | - Maximilian Hils (@mhils) 34 | 35 | - Added repr for frames. 36 | - Improved frame initialization code. 37 | - Added flag validation. 38 | 39 | - Thomas Kriechbaumer (@Kriechi) 40 | 41 | - Improved initialization code. 42 | - Fixed bugs in frame initialization code. 43 | - Improved frame repr for frames with non-printable bodies. 44 | 45 | - Davey Shafik (@dshafik) 46 | 47 | - Fixed Alt Svc frame stream association. 48 | 49 | - Seth Michael Larson (@SethMichaelLarson) 50 | 51 | - Performance improvements to serialization and parsing. 52 | 53 | - Fred Thomsen (@fredthomsen) 54 | 55 | - Support for memoryview in DataFrames. 56 | 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Cory Benfield 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src/hyperframe 2 | graft docs 3 | graft tests 4 | 5 | prune docs/build 6 | prune tests/http2-frame-test-case 7 | 8 | include README.rst LICENSE CHANGELOG.rst CONTRIBUTORS.rst pyproject.toml .gitmodules 9 | 10 | global-exclude *.pyc *.pyo *.swo *.swp *.map *.yml *.DS_Store 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | hyperframe: Pure-Python HTTP/2 framing 3 | ====================================== 4 | 5 | .. image:: https://github.com/python-hyper/hyperframe/workflows/CI/badge.svg 6 | :target: https://github.com/python-hyper/hyperframe/actions 7 | :alt: Build Status 8 | .. image:: https://codecov.io/gh/python-hyper/hyperframe/branch/master/graph/badge.svg 9 | :target: https://codecov.io/gh/python-hyper/hyperframe 10 | :alt: Code Coverage 11 | .. image:: https://readthedocs.org/projects/hyperframe/badge/?version=latest 12 | :target: https://hyperframe.readthedocs.io/en/latest/ 13 | :alt: Documentation Status 14 | .. image:: https://img.shields.io/badge/chat-join_now-brightgreen.svg 15 | :target: https://gitter.im/python-hyper/community 16 | :alt: Chat community 17 | 18 | This library contains the HTTP/2 framing code used in the `hyper`_ project. It 19 | provides a pure-Python codebase that is capable of decoding a binary stream 20 | into HTTP/2 frames. 21 | 22 | This library is used directly by `hyper`_ and a number of other projects to 23 | provide HTTP/2 frame decoding logic. 24 | 25 | Contributing 26 | ============ 27 | 28 | hyperframe welcomes contributions from anyone! Unlike many other projects we 29 | are happy to accept cosmetic contributions and small contributions, in addition 30 | to large feature requests and changes. 31 | 32 | Before you contribute (either by opening an issue or filing a pull request), 33 | please `read the contribution guidelines`_. 34 | 35 | .. _read the contribution guidelines: http://hyper.readthedocs.org/en/development/contributing.html 36 | 37 | License 38 | ======= 39 | 40 | hyperframe is made available under the MIT License. For more details, see the 41 | ``LICENSE`` file in the repository. 42 | 43 | Authors 44 | ======= 45 | 46 | hyperframe is maintained by Cory Benfield, with contributions from others. For 47 | more details about the contributors, please see ``CONTRIBUTORS.rst``. 48 | 49 | .. _hyper: http://python-hyper.org/ 50 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-hyper/hyperframe/b57beaff1cce7d7b7c38ea3514a349cb05a80d3c/docs/source/_static/.keep -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | hyperframe API 2 | ============== 3 | 4 | This document provides the hyperframe API. 5 | 6 | All frame classes are subclasses of :class:`Frame `, 7 | and provide the methods and attributes defined there. Additionally, some frames 8 | use the :class:`Priority ` and 9 | :class:`Padding ` mixins, and make the methods and 10 | attributes defined on *those* mixins available as well. 11 | 12 | Rather than clutter up the documentation repeatedly documenting those methods 13 | and objects, we explicitly show the inheritance hierarchy of the frames: don't 14 | forget to consult the parent classes before deciding if a method or attribute 15 | you need is not present! 16 | 17 | .. autoclass:: hyperframe.frame.Frame 18 | :members: 19 | 20 | .. autoclass:: hyperframe.frame.Padding 21 | :members: 22 | 23 | .. autoclass:: hyperframe.frame.Priority 24 | :members: 25 | 26 | .. autoclass:: hyperframe.frame.DataFrame 27 | :show-inheritance: 28 | :members: 29 | 30 | .. autoclass:: hyperframe.frame.PriorityFrame 31 | :show-inheritance: 32 | :members: 33 | 34 | .. autoclass:: hyperframe.frame.RstStreamFrame 35 | :show-inheritance: 36 | :members: 37 | 38 | .. autoclass:: hyperframe.frame.SettingsFrame 39 | :show-inheritance: 40 | :members: 41 | 42 | .. autoclass:: hyperframe.frame.PushPromiseFrame 43 | :show-inheritance: 44 | :members: 45 | 46 | .. autoclass:: hyperframe.frame.PingFrame 47 | :show-inheritance: 48 | :members: 49 | 50 | .. autoclass:: hyperframe.frame.GoAwayFrame 51 | :show-inheritance: 52 | :members: 53 | 54 | .. autoclass:: hyperframe.frame.WindowUpdateFrame 55 | :show-inheritance: 56 | :members: 57 | 58 | .. autoclass:: hyperframe.frame.HeadersFrame 59 | :show-inheritance: 60 | :members: 61 | 62 | .. autoclass:: hyperframe.frame.ContinuationFrame 63 | :show-inheritance: 64 | :members: 65 | 66 | .. autoclass:: hyperframe.frame.ExtensionFrame 67 | :show-inheritance: 68 | :members: 69 | 70 | .. autodata:: hyperframe.frame.FRAMES 71 | 72 | Exceptions 73 | ---------- 74 | 75 | .. autoclass:: hyperframe.exceptions.HyperframeError 76 | :members: 77 | 78 | .. autoclass:: hyperframe.exceptions.UnknownFrameError 79 | :members: 80 | 81 | .. autoclass:: hyperframe.exceptions.InvalidPaddingError 82 | :members: 83 | 84 | .. autoclass:: hyperframe.exceptions.InvalidFrameError 85 | :members: 86 | 87 | .. autoclass:: hyperframe.exceptions.InvalidDataError 88 | :members: 89 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import re 16 | 17 | sys.path.insert(0, os.path.abspath('../..')) 18 | 19 | PROJECT_ROOT = os.path.dirname(__file__) 20 | # Get the version 21 | version_regex = r'__version__ = ["\']([^"\']*)["\']' 22 | with open(os.path.join(PROJECT_ROOT, '../../', 'src/hyperframe/__init__.py')) as file_: 23 | text = file_.read() 24 | match = re.search(version_regex, text) 25 | version = match.group(1) 26 | 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = 'hyperframe' 31 | copyright = '2020, Cory Benfield' 32 | author = 'Cory Benfield' 33 | release = version 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | 'sphinx.ext.autodoc', 42 | 'sphinx.ext.intersphinx', 43 | 'sphinx.ext.viewcode', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | # This pattern also affects html_static_path and html_extra_path. 52 | exclude_patterns = [] 53 | 54 | # Example configuration for intersphinx: refer to the Python standard library. 55 | intersphinx_mapping = { 56 | 'python': ('https://docs.python.org/', None), 57 | } 58 | 59 | master_doc = 'index' 60 | 61 | 62 | # -- Options for HTML output ------------------------------------------------- 63 | 64 | # The theme to use for HTML and HTML Help pages. See the documentation for 65 | # a list of builtin themes. 66 | # 67 | html_theme = 'default' 68 | 69 | # Add any paths that contain custom static files (such as style sheets) here, 70 | # relative to this directory. They are copied after the builtin static files, 71 | # so a file named "default.css" will overwrite the builtin "default.css". 72 | html_static_path = ['_static'] 73 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | hyperframe: HTTP/2 Framing for Python 2 | ===================================== 3 | 4 | hyperframe is a pure-Python tool for working with HTTP/2 frames. This library 5 | allows you to create, serialize, and parse HTTP/2 frames. 6 | 7 | Working with it is easy: 8 | 9 | .. code-block:: python 10 | 11 | import hyperframe.frame 12 | 13 | f = hyperframe.frame.DataFrame(stream_id=5) 14 | f.data = b'some binary data' 15 | f.flags.add('END_STREAM') 16 | f.flags.add('PADDED') 17 | f.padding_length = 30 18 | 19 | data = f.serialize() 20 | 21 | new_frame, length = hyperframe.frame.Frame.parse_frame_header(data[:9]) 22 | new_frame.parse_body(memoryview(data[9:9 + length])) 23 | 24 | hyperframe is pure-Python, contains no external dependencies, and runs on a 25 | wide variety of Python interpreters and platforms. Made available under the MIT 26 | license, why write your own frame parser? 27 | 28 | Contents: 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | installation 34 | api 35 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installing hyperframe 2 | ===================== 3 | 4 | hyperframe is trivial to install from the Python Package Index. Simply run: 5 | 6 | .. code-block:: console 7 | 8 | $ pip install hyperframe 9 | 10 | Alternatively, feel free to download one of the release tarballs from 11 | `our GitHub page`_, extract it to your favourite directory, and then run 12 | 13 | .. code-block:: console 14 | 15 | $ python setup.py install 16 | 17 | hyperframe has no external dependencies. 18 | 19 | 20 | 21 | .. _our GitHub page: https://github.com/python-hyper/hyperframe 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/ 2 | # https://packaging.python.org/en/latest/specifications/pyproject-toml/ 3 | 4 | [build-system] 5 | requires = ["setuptools>=75.6.0"] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "hyperframe" 10 | description = "Pure-Python HTTP/2 framing" 11 | readme = { file = "README.rst", content-type = "text/x-rst" } 12 | license = { file = "LICENSE" } 13 | 14 | authors = [ 15 | { name = "Cory Benfield", email = "cory@lukasa.co.uk" } 16 | ] 17 | maintainers = [ 18 | { name = "Thomas Kriechbaumer", email = "thomas@kriechbaumer.name" }, 19 | ] 20 | 21 | requires-python = ">=3.9" 22 | dependencies = [] 23 | dynamic = ["version"] 24 | 25 | # For a list of valid classifiers, see https://pypi.org/classifiers/ 26 | classifiers = [ 27 | "Development Status :: 5 - Production/Stable", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | "Programming Language :: Python :: Implementation :: CPython", 39 | "Programming Language :: Python :: Implementation :: PyPy", 40 | ] 41 | 42 | [project.urls] 43 | "Homepage" = "https://github.com/python-hyper/hyperframe/" 44 | "Bug Reports" = "https://github.com/python-hyper/hyperframe/issues" 45 | "Source" = "https://github.com/python-hyper/hyperframe/" 46 | "Documentation" = "https://python-hyper.org/" 47 | 48 | [dependency-groups] 49 | dev = [ 50 | { include-group = "testing" }, 51 | { include-group = "linting" }, 52 | { include-group = "packaging" }, 53 | { include-group = "docs" }, 54 | ] 55 | 56 | testing = [ 57 | "pytest>=8.3.3,<9", 58 | "pytest-cov>=6.0.0,<7", 59 | "pytest-xdist>=3.6.1,<4", 60 | ] 61 | 62 | linting = [ 63 | "ruff>=0.8.0,<1", 64 | "mypy>=1.13.0,<2", 65 | ] 66 | 67 | packaging = [ 68 | "check-manifest==0.50", 69 | "readme-renderer==44.0", 70 | "build>=1.2.2,<2", 71 | "twine>=5.1.1,<6", 72 | "wheel>=0.45.0,<1", 73 | ] 74 | 75 | docs = [ 76 | "sphinx>=7.4.7,<9", 77 | ] 78 | 79 | [tool.setuptools.packages.find] 80 | where = [ "src" ] 81 | 82 | [tool.setuptools.package-data] 83 | hyperframe = [ "py.typed" ] 84 | 85 | [tool.setuptools.dynamic] 86 | version = { attr = "hyperframe.__version__" } 87 | 88 | [tool.check-manifest] 89 | ignore = [ 90 | "Makefile", 91 | "tests/http2-frame-test-case", 92 | ] 93 | 94 | [tool.ruff] 95 | line-length = 150 96 | target-version = "py39" 97 | format.preview = true 98 | format.docstring-code-line-length = 100 99 | format.docstring-code-format = true 100 | lint.select = [ 101 | "ALL", 102 | ] 103 | lint.ignore = [ 104 | "ANN401", # kwargs with typing.Any 105 | "CPY", # not required 106 | "D101", # docs readability 107 | "D102", # docs readability 108 | "D105", # docs readability 109 | "D107", # docs readability 110 | "D200", # docs readability 111 | "D205", # docs readability 112 | "D205", # docs readability 113 | "D203", # docs readability 114 | "D212", # docs readability 115 | "D400", # docs readability 116 | "D401", # docs readability 117 | "D415", # docs readability 118 | "PLR2004", # readability 119 | "SIM108", # readability 120 | "RUF012", # readability 121 | "FBT001", # readability 122 | "FBT002", # readability 123 | "PGH003", # readability 124 | ] 125 | lint.isort.required-imports = [ "from __future__ import annotations" ] 126 | 127 | [tool.mypy] 128 | show_error_codes = true 129 | strict = true 130 | 131 | [tool.coverage.run] 132 | branch = true 133 | source = [ "hyperframe" ] 134 | 135 | [tool.coverage.report] 136 | fail_under = 100 137 | show_missing = true 138 | exclude_lines = [ 139 | "pragma: no cover", 140 | "raise NotImplementedError", 141 | ] 142 | 143 | [tool.coverage.paths] 144 | source = [ 145 | "src/", 146 | ".tox/**/site-packages/", 147 | ] 148 | 149 | [tool.tox] 150 | min_version = "4.23.2" 151 | env_list = [ "py39", "py310", "py311", "py312", "py313", "pypy3", "lint", "docs", "packaging" ] 152 | 153 | [tool.tox.gh-actions] 154 | python = """ 155 | 3.9: py39, h2spec, lint, docs, packaging 156 | 3.10: py310 157 | 3.11: py311 158 | 3.12: py312 159 | 3.13: py313 160 | pypy3: pypy3 161 | """ 162 | 163 | [tool.tox.env_run_base] 164 | pass_env = [ 165 | "GITHUB_*", 166 | ] 167 | dependency_groups = ["testing"] 168 | commands = [ 169 | ["pytest", "--cov-report=xml", "--cov-report=term", "--cov=hyperframe", { replace = "posargs", extend = true }] 170 | ] 171 | 172 | [tool.tox.env.pypy3] 173 | # temporarily disable coverage testing on PyPy due to performance problems 174 | commands = [ 175 | ["pytest", { replace = "posargs", extend = true }] 176 | ] 177 | 178 | [tool.tox.env.lint] 179 | dependency_groups = ["linting"] 180 | commands = [ 181 | ["ruff", "check", "src/"], 182 | ["mypy", "src/"], 183 | ] 184 | 185 | [tool.tox.env.docs] 186 | dependency_groups = ["docs"] 187 | allowlist_externals = ["make"] 188 | changedir = "{toxinidir}/docs" 189 | commands = [ 190 | ["make", "clean"], 191 | ["make", "html"], 192 | ] 193 | 194 | [tool.tox.env.packaging] 195 | base_python = ["python39"] 196 | dependency_groups = ["packaging"] 197 | allowlist_externals = ["rm"] 198 | commands = [ 199 | ["rm", "-rf", "dist/"], 200 | ["check-manifest"], 201 | ["python", "-m", "build", "--outdir", "dist/"], 202 | ["twine", "check", "dist/*"], 203 | ] 204 | 205 | [tool.tox.env.publish] 206 | base_python = ["python39"] 207 | dependency_groups = ["packaging"] 208 | allowlist_externals = ["twine"] 209 | commands = [ 210 | ["twine", "upload", "dist/*"], 211 | ] 212 | -------------------------------------------------------------------------------- /src/hyperframe/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a pure-Python HTTP/2 framing layer. 3 | """ 4 | from __future__ import annotations 5 | 6 | __version__ = "6.2.0+dev" 7 | -------------------------------------------------------------------------------- /src/hyperframe/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions that can be thrown by hyperframe. 3 | """ 4 | from __future__ import annotations 5 | 6 | 7 | class HyperframeError(Exception): 8 | """ 9 | The base class for all exceptions for the hyperframe module. 10 | 11 | .. versionadded:: 6.0.0 12 | """ 13 | 14 | 15 | class UnknownFrameError(HyperframeError): 16 | """ 17 | A frame of unknown type was received. 18 | 19 | .. versionchanged:: 6.0.0 20 | Changed base class from `ValueError` to :class:`HyperframeError` 21 | """ 22 | 23 | def __init__(self, frame_type: int, length: int) -> None: 24 | #: The type byte of the unknown frame that was received. 25 | self.frame_type = frame_type 26 | 27 | #: The length of the data portion of the unknown frame. 28 | self.length = length 29 | 30 | def __str__(self) -> str: 31 | return ( 32 | f"UnknownFrameError: Unknown frame type 0x{self.frame_type:X} received, length {self.length} bytes" 33 | ) 34 | 35 | 36 | class InvalidPaddingError(HyperframeError): 37 | """ 38 | A frame with invalid padding was received. 39 | 40 | .. versionchanged:: 6.0.0 41 | Changed base class from `ValueError` to :class:`HyperframeError` 42 | """ 43 | 44 | 45 | class InvalidFrameError(HyperframeError): 46 | """ 47 | Parsing a frame failed because the data was not laid out appropriately. 48 | 49 | .. versionadded:: 3.0.2 50 | 51 | .. versionchanged:: 6.0.0 52 | Changed base class from `ValueError` to :class:`HyperframeError` 53 | """ 54 | 55 | 56 | class InvalidDataError(HyperframeError): 57 | """ 58 | Content or data of a frame was is invalid or violates the specification. 59 | 60 | .. versionadded:: 6.0.0 61 | """ 62 | -------------------------------------------------------------------------------- /src/hyperframe/flags.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic Flag and Flags data structures. 3 | """ 4 | from __future__ import annotations 5 | 6 | from collections.abc import Iterable, Iterator, MutableSet 7 | from typing import NamedTuple 8 | 9 | 10 | class Flag(NamedTuple): 11 | name: str 12 | bit: int 13 | 14 | 15 | class Flags(MutableSet): # type: ignore 16 | """ 17 | A simple MutableSet implementation that will only accept known flags as 18 | elements. 19 | 20 | Will behave like a regular set(), except that a ValueError will be thrown 21 | when .add()ing unexpected flags. 22 | """ 23 | 24 | def __init__(self, defined_flags: Iterable[Flag]) -> None: 25 | self._valid_flags = {flag.name for flag in defined_flags} 26 | self._flags: set[str] = set() 27 | 28 | def __repr__(self) -> str: 29 | return repr(sorted(self._flags)) 30 | 31 | def __contains__(self, x: object) -> bool: 32 | return self._flags.__contains__(x) 33 | 34 | def __iter__(self) -> Iterator[str]: 35 | return self._flags.__iter__() 36 | 37 | def __len__(self) -> int: 38 | return self._flags.__len__() 39 | 40 | def discard(self, value: str) -> None: 41 | return self._flags.discard(value) 42 | 43 | def add(self, value: str) -> None: 44 | if value not in self._valid_flags: 45 | msg = f"Unexpected flag: {value}. Valid flags are: {self._valid_flags}" 46 | raise ValueError(msg) 47 | return self._flags.add(value) 48 | -------------------------------------------------------------------------------- /src/hyperframe/frame.py: -------------------------------------------------------------------------------- 1 | """ 2 | Framing logic for HTTP/2. 3 | 4 | Provides both classes to represent framed 5 | data and logic for aiding the connection when it comes to reading from the 6 | socket. 7 | """ 8 | from __future__ import annotations 9 | 10 | import binascii 11 | import struct 12 | from typing import TYPE_CHECKING, Any 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Iterable # pragma: no cover 16 | 17 | from .exceptions import InvalidDataError, InvalidFrameError, InvalidPaddingError, UnknownFrameError 18 | from .flags import Flag, Flags 19 | 20 | # The maximum initial length of a frame. Some frames have shorter maximum 21 | # lengths. 22 | FRAME_MAX_LEN = (2 ** 14) 23 | 24 | # The maximum allowed length of a frame. 25 | FRAME_MAX_ALLOWED_LEN = (2 ** 24) - 1 26 | 27 | # Stream association enumerations. 28 | _STREAM_ASSOC_HAS_STREAM = "has-stream" 29 | _STREAM_ASSOC_NO_STREAM = "no-stream" 30 | _STREAM_ASSOC_EITHER = "either" 31 | 32 | # Structs for packing and unpacking 33 | _STRUCT_HBBBL = struct.Struct(">HBBBL") 34 | _STRUCT_LL = struct.Struct(">LL") 35 | _STRUCT_HL = struct.Struct(">HL") 36 | _STRUCT_LB = struct.Struct(">LB") 37 | _STRUCT_L = struct.Struct(">L") 38 | _STRUCT_H = struct.Struct(">H") 39 | _STRUCT_B = struct.Struct(">B") 40 | 41 | 42 | class Frame: 43 | """ 44 | The base class for all HTTP/2 frames. 45 | """ 46 | 47 | #: The flags defined on this type of frame. 48 | defined_flags: list[Flag] = [] 49 | 50 | #: The byte used to define the type of the frame. 51 | type: int | None = None 52 | 53 | # If 'has-stream', the frame's stream_id must be non-zero. If 'no-stream', 54 | # it must be zero. If 'either', it's not checked. 55 | stream_association: str | None = None 56 | 57 | def __init__(self, stream_id: int, flags: Iterable[str] = ()) -> None: 58 | #: The stream identifier for the stream this frame was received on. 59 | #: Set to 0 for frames sent on the connection (stream-id 0). 60 | self.stream_id = stream_id 61 | 62 | #: The flags set for this frame. 63 | self.flags = Flags(self.defined_flags) 64 | 65 | #: The frame length, excluding the nine-byte header. 66 | self.body_len = 0 67 | 68 | for flag in flags: 69 | self.flags.add(flag) 70 | 71 | if not self.stream_id and self.stream_association == _STREAM_ASSOC_HAS_STREAM: 72 | msg = f"Stream ID must be non-zero for {type(self).__name__}" 73 | raise InvalidDataError(msg) 74 | if self.stream_id and self.stream_association == _STREAM_ASSOC_NO_STREAM: 75 | msg = f"Stream ID must be zero for {type(self).__name__} with stream_id={self.stream_id}" 76 | raise InvalidDataError(msg) 77 | 78 | def __repr__(self) -> str: 79 | return ( 80 | f"{type(self).__name__}(stream_id={self.stream_id}, flags={self.flags!r}): {self._body_repr()}" 81 | ) 82 | 83 | def _body_repr(self) -> str: 84 | # More specific implementation may be provided by subclasses of Frame. 85 | # This fallback shows the serialized (and truncated) body content. 86 | return _raw_data_repr(self.serialize_body()) 87 | 88 | @staticmethod 89 | def explain(data: memoryview) -> tuple[Frame, int]: 90 | """ 91 | Takes a bytestring and tries to parse a single frame and print it. 92 | 93 | This function is only provided for debugging purposes. 94 | 95 | :param data: A memoryview object containing the raw data of at least 96 | one complete frame (header and body). 97 | 98 | .. versionadded:: 6.0.0 99 | """ 100 | frame, length = Frame.parse_frame_header(data[:9]) 101 | frame.parse_body(data[9:9 + length]) 102 | print(frame) # noqa: T201 103 | return frame, length 104 | 105 | @staticmethod 106 | def parse_frame_header(header: memoryview, strict: bool = False) -> tuple[Frame, int]: 107 | """ 108 | Takes a 9-byte frame header and returns a tuple of the appropriate 109 | Frame object and the length that needs to be read from the socket. 110 | 111 | This populates the flags field, and determines how long the body is. 112 | 113 | :param header: A memoryview object containing the 9-byte frame header 114 | data of a frame. Must not contain more or less. 115 | 116 | :param strict: Whether to raise an exception when encountering a frame 117 | not defined by spec and implemented by hyperframe. 118 | 119 | :raises hyperframe.exceptions.UnknownFrameError: If a frame of unknown 120 | type is received. 121 | 122 | .. versionchanged:: 5.0.0 123 | Added ``strict`` parameter to accommodate :class:`ExtensionFrame` 124 | """ 125 | try: 126 | fields = _STRUCT_HBBBL.unpack(header) 127 | except struct.error as err: 128 | msg = "Invalid frame header" 129 | raise InvalidFrameError(msg) from err 130 | 131 | # First 24 bits are frame length. 132 | length = (fields[0] << 8) + fields[1] 133 | typ_e = fields[2] 134 | flags = fields[3] 135 | stream_id = fields[4] & 0x7FFFFFFF 136 | 137 | try: 138 | frame = FRAMES[typ_e](stream_id) 139 | except KeyError as err: 140 | if strict: 141 | raise UnknownFrameError(typ_e, length) from err 142 | frame = ExtensionFrame(type=typ_e, stream_id=stream_id) 143 | 144 | frame.parse_flags(flags) 145 | return (frame, length) 146 | 147 | def parse_flags(self, flag_byte: int) -> Flags: 148 | for flag, flag_bit in self.defined_flags: 149 | if flag_byte & flag_bit: 150 | self.flags.add(flag) 151 | 152 | return self.flags 153 | 154 | def serialize(self) -> bytes: 155 | """ 156 | Convert a frame into a bytestring, representing the serialized form of 157 | the frame. 158 | """ 159 | body = self.serialize_body() 160 | self.body_len = len(body) 161 | 162 | # Build the common frame header. 163 | # First, get the flags. 164 | flags = 0 165 | 166 | for flag, flag_bit in self.defined_flags: 167 | if flag in self.flags: 168 | flags |= flag_bit 169 | 170 | header = _STRUCT_HBBBL.pack( 171 | (self.body_len >> 8) & 0xFFFF, # Length spread over top 24 bits 172 | self.body_len & 0xFF, 173 | self.type, 174 | flags, 175 | self.stream_id & 0x7FFFFFFF, # Stream ID is 32 bits. 176 | ) 177 | 178 | return header + body 179 | 180 | def serialize_body(self) -> bytes: 181 | raise NotImplementedError 182 | 183 | def parse_body(self, data: memoryview) -> None: 184 | """ 185 | Given the body of a frame, parses it into frame data. This populates 186 | the non-header parts of the frame: that is, it does not populate the 187 | stream ID or flags. 188 | 189 | :param data: A memoryview object containing the body data of the frame. 190 | Must not contain *more* data than the length returned by 191 | :meth:`parse_frame_header 192 | `. 193 | """ 194 | raise NotImplementedError 195 | 196 | 197 | class Padding: 198 | """ 199 | Mixin for frames that contain padding. Defines extra fields that can be 200 | used and set by frames that can be padded. 201 | """ 202 | 203 | def __init__(self, stream_id: int, pad_length: int = 0, **kwargs: Any) -> None: 204 | super().__init__(stream_id, **kwargs) # type: ignore 205 | 206 | #: The length of the padding to use. 207 | self.pad_length = pad_length 208 | 209 | def serialize_padding_data(self) -> bytes: 210 | if "PADDED" in self.flags: # type: ignore 211 | return _STRUCT_B.pack(self.pad_length) 212 | return b"" 213 | 214 | def parse_padding_data(self, data: memoryview) -> int: 215 | if "PADDED" in self.flags: # type: ignore 216 | try: 217 | self.pad_length = struct.unpack("!B", data[:1])[0] 218 | except struct.error as err: 219 | msg = "Invalid Padding data" 220 | raise InvalidFrameError(msg) from err 221 | return 1 222 | return 0 223 | 224 | #: .. deprecated:: 5.2.1 225 | #: Use self.pad_length instead. 226 | @property 227 | def total_padding(self) -> int: # pragma: no cover 228 | import warnings 229 | warnings.warn( 230 | "total_padding contains the same information as pad_length.", 231 | DeprecationWarning, 232 | stacklevel=2, 233 | ) 234 | return self.pad_length 235 | 236 | 237 | class Priority: 238 | """ 239 | Mixin for frames that contain priority data. Defines extra fields that can 240 | be used and set by frames that contain priority data. 241 | """ 242 | 243 | def __init__(self, 244 | stream_id: int, 245 | depends_on: int = 0x0, 246 | stream_weight: int = 0x0, 247 | exclusive: bool = False, 248 | **kwargs: Any) -> None: 249 | super().__init__(stream_id, **kwargs) # type: ignore 250 | 251 | #: The stream ID of the stream on which this stream depends. 252 | self.depends_on = depends_on 253 | 254 | #: The weight of the stream. This is an integer between 0 and 256. 255 | self.stream_weight = stream_weight 256 | 257 | #: Whether the exclusive bit was set. 258 | self.exclusive = exclusive 259 | 260 | def serialize_priority_data(self) -> bytes: 261 | return _STRUCT_LB.pack( 262 | self.depends_on + (0x80000000 if self.exclusive else 0), 263 | self.stream_weight, 264 | ) 265 | 266 | def parse_priority_data(self, data: memoryview) -> int: 267 | try: 268 | self.depends_on, self.stream_weight = _STRUCT_LB.unpack(data[:5]) 269 | except struct.error as err: 270 | msg = "Invalid Priority data" 271 | raise InvalidFrameError(msg) from err 272 | 273 | self.exclusive = bool(self.depends_on >> 31) 274 | self.depends_on &= 0x7FFFFFFF 275 | return 5 276 | 277 | 278 | class DataFrame(Padding, Frame): 279 | """ 280 | DATA frames convey arbitrary, variable-length sequences of octets 281 | associated with a stream. One or more DATA frames are used, for instance, 282 | to carry HTTP request or response payloads. 283 | """ 284 | 285 | #: The flags defined for DATA frames. 286 | defined_flags = [ 287 | Flag("END_STREAM", 0x01), 288 | Flag("PADDED", 0x08), 289 | ] 290 | 291 | #: The type byte for data frames. 292 | type = 0x0 293 | 294 | stream_association = _STREAM_ASSOC_HAS_STREAM 295 | 296 | def __init__(self, stream_id: int, data: bytes = b"", **kwargs: Any) -> None: 297 | super().__init__(stream_id, **kwargs) 298 | 299 | #: The data contained on this frame. 300 | self.data = data 301 | 302 | def serialize_body(self) -> bytes: 303 | padding_data = self.serialize_padding_data() 304 | padding = b"\0" * self.pad_length 305 | if isinstance(self.data, memoryview): 306 | self.data = self.data.tobytes() 307 | return b"".join([padding_data, self.data, padding]) 308 | 309 | def parse_body(self, data: memoryview) -> None: 310 | padding_data_length = self.parse_padding_data(data) 311 | self.data = ( 312 | data[padding_data_length:len(data)-self.pad_length].tobytes() 313 | ) 314 | self.body_len = len(data) 315 | 316 | if self.pad_length and self.pad_length >= self.body_len: 317 | msg = "Padding is too long." 318 | raise InvalidPaddingError(msg) 319 | 320 | @property 321 | def flow_controlled_length(self) -> int: 322 | """ 323 | The length of the frame that needs to be accounted for when considering 324 | flow control. 325 | """ 326 | padding_len = 0 327 | if "PADDED" in self.flags: 328 | # Account for extra 1-byte padding length field, which is still 329 | # present if possibly zero-valued. 330 | padding_len = self.pad_length + 1 331 | return len(self.data) + padding_len 332 | 333 | 334 | class PriorityFrame(Priority, Frame): 335 | """ 336 | The PRIORITY frame specifies the sender-advised priority of a stream. It 337 | can be sent at any time for an existing stream. This enables 338 | reprioritisation of existing streams. 339 | """ 340 | 341 | #: The flags defined for PRIORITY frames. 342 | defined_flags: list[Flag] = [] 343 | 344 | #: The type byte defined for PRIORITY frames. 345 | type = 0x02 346 | 347 | stream_association = _STREAM_ASSOC_HAS_STREAM 348 | 349 | def _body_repr(self) -> str: 350 | return f"exclusive={self.exclusive}, depends_on={self.depends_on}, stream_weight={self.stream_weight}" 351 | 352 | def serialize_body(self) -> bytes: 353 | return self.serialize_priority_data() 354 | 355 | def parse_body(self, data: memoryview) -> None: 356 | if len(data) > 5: 357 | msg = f"PRIORITY must have 5 byte body: actual length {len(data)}." 358 | raise InvalidFrameError(msg) 359 | 360 | self.parse_priority_data(data) 361 | self.body_len = 5 362 | 363 | 364 | class RstStreamFrame(Frame): 365 | """ 366 | The RST_STREAM frame allows for abnormal termination of a stream. When sent 367 | by the initiator of a stream, it indicates that they wish to cancel the 368 | stream or that an error condition has occurred. When sent by the receiver 369 | of a stream, it indicates that either the receiver is rejecting the stream, 370 | requesting that the stream be cancelled or that an error condition has 371 | occurred. 372 | """ 373 | 374 | #: The flags defined for RST_STREAM frames. 375 | defined_flags: list[Flag] = [] 376 | 377 | #: The type byte defined for RST_STREAM frames. 378 | type = 0x03 379 | 380 | stream_association = _STREAM_ASSOC_HAS_STREAM 381 | 382 | def __init__(self, stream_id: int, error_code: int = 0, **kwargs: Any) -> None: 383 | super().__init__(stream_id, **kwargs) 384 | 385 | #: The error code used when resetting the stream. 386 | self.error_code = error_code 387 | 388 | def _body_repr(self) -> str: 389 | return f"error_code={self.error_code}" 390 | 391 | def serialize_body(self) -> bytes: 392 | return _STRUCT_L.pack(self.error_code) 393 | 394 | def parse_body(self, data: memoryview) -> None: 395 | if len(data) != 4: 396 | msg = f"RST_STREAM must have 4 byte body: actual length {len(data)}." 397 | raise InvalidFrameError(msg) 398 | 399 | try: 400 | self.error_code = _STRUCT_L.unpack(data)[0] 401 | except struct.error as err: # pragma: no cover 402 | msg = "Invalid RST_STREAM body" 403 | raise InvalidFrameError(msg) from err 404 | 405 | self.body_len = 4 406 | 407 | 408 | class SettingsFrame(Frame): 409 | """ 410 | The SETTINGS frame conveys configuration parameters that affect how 411 | endpoints communicate. The parameters are either constraints on peer 412 | behavior or preferences. 413 | 414 | Settings are not negotiated. Settings describe characteristics of the 415 | sending peer, which are used by the receiving peer. Different values for 416 | the same setting can be advertised by each peer. For example, a client 417 | might set a high initial flow control window, whereas a server might set a 418 | lower value to conserve resources. 419 | """ 420 | 421 | #: The flags defined for SETTINGS frames. 422 | defined_flags = [Flag("ACK", 0x01)] 423 | 424 | #: The type byte defined for SETTINGS frames. 425 | type = 0x04 426 | 427 | stream_association = _STREAM_ASSOC_NO_STREAM 428 | 429 | # We need to define the known settings, they may as well be class 430 | # attributes. 431 | #: The byte that signals the SETTINGS_HEADER_TABLE_SIZE setting. 432 | HEADER_TABLE_SIZE = 0x01 433 | #: The byte that signals the SETTINGS_ENABLE_PUSH setting. 434 | ENABLE_PUSH = 0x02 435 | #: The byte that signals the SETTINGS_MAX_CONCURRENT_STREAMS setting. 436 | MAX_CONCURRENT_STREAMS = 0x03 437 | #: The byte that signals the SETTINGS_INITIAL_WINDOW_SIZE setting. 438 | INITIAL_WINDOW_SIZE = 0x04 439 | #: The byte that signals the SETTINGS_MAX_FRAME_SIZE setting. 440 | MAX_FRAME_SIZE = 0x05 441 | #: The byte that signals the SETTINGS_MAX_HEADER_LIST_SIZE setting. 442 | MAX_HEADER_LIST_SIZE = 0x06 443 | #: The byte that signals SETTINGS_ENABLE_CONNECT_PROTOCOL setting. 444 | ENABLE_CONNECT_PROTOCOL = 0x08 445 | 446 | def __init__(self, stream_id: int = 0, settings: dict[int, int] | None = None, **kwargs: Any) -> None: 447 | super().__init__(stream_id, **kwargs) 448 | 449 | if settings and "ACK" in kwargs.get("flags", ()): 450 | msg = "Settings must be empty if ACK flag is set." 451 | raise InvalidDataError(msg) 452 | 453 | #: A dictionary of the setting type byte to the value of the setting. 454 | self.settings: dict[int, int] = settings or {} 455 | 456 | def _body_repr(self) -> str: 457 | return f"settings={self.settings}" 458 | 459 | def serialize_body(self) -> bytes: 460 | return b"".join([_STRUCT_HL.pack(setting & 0xFF, value) 461 | for setting, value in self.settings.items()]) 462 | 463 | def parse_body(self, data: memoryview) -> None: 464 | if "ACK" in self.flags and len(data) > 0: 465 | msg = f"SETTINGS ack frame must not have payload: got {len(data)} bytes" 466 | raise InvalidDataError(msg) 467 | 468 | body_len = 0 469 | for i in range(0, len(data), 6): 470 | try: 471 | name, value = _STRUCT_HL.unpack(data[i:i+6]) 472 | except struct.error as err: 473 | msg = "Invalid SETTINGS body" 474 | raise InvalidFrameError(msg) from err 475 | 476 | self.settings[name] = value 477 | body_len += 6 478 | 479 | self.body_len = body_len 480 | 481 | 482 | class PushPromiseFrame(Padding, Frame): 483 | """ 484 | The PUSH_PROMISE frame is used to notify the peer endpoint in advance of 485 | streams the sender intends to initiate. 486 | """ 487 | 488 | #: The flags defined for PUSH_PROMISE frames. 489 | defined_flags = [ 490 | Flag("END_HEADERS", 0x04), 491 | Flag("PADDED", 0x08), 492 | ] 493 | 494 | #: The type byte defined for PUSH_PROMISE frames. 495 | type = 0x05 496 | 497 | stream_association = _STREAM_ASSOC_HAS_STREAM 498 | 499 | def __init__(self, stream_id: int, promised_stream_id: int = 0, data: bytes = b"", **kwargs: Any) -> None: 500 | super().__init__(stream_id, **kwargs) 501 | 502 | #: The stream ID that is promised by this frame. 503 | self.promised_stream_id = promised_stream_id 504 | 505 | #: The HPACK-encoded header block for the simulated request on the new 506 | #: stream. 507 | self.data = data 508 | 509 | def _body_repr(self) -> str: 510 | return f"promised_stream_id={self.promised_stream_id}, data={_raw_data_repr(self.data)}" 511 | 512 | def serialize_body(self) -> bytes: 513 | padding_data = self.serialize_padding_data() 514 | padding = b"\0" * self.pad_length 515 | data = _STRUCT_L.pack(self.promised_stream_id) 516 | return b"".join([padding_data, data, self.data, padding]) 517 | 518 | def parse_body(self, data: memoryview) -> None: 519 | padding_data_length = self.parse_padding_data(data) 520 | 521 | try: 522 | self.promised_stream_id = _STRUCT_L.unpack( 523 | data[padding_data_length:padding_data_length + 4], 524 | )[0] 525 | except struct.error as err: 526 | msg = "Invalid PUSH_PROMISE body" 527 | raise InvalidFrameError(msg) from err 528 | 529 | self.data = ( 530 | data[padding_data_length + 4:len(data)-self.pad_length].tobytes() 531 | ) 532 | self.body_len = len(data) 533 | 534 | if self.promised_stream_id == 0 or self.promised_stream_id % 2 != 0: 535 | msg = f"Invalid PUSH_PROMISE promised stream id: {self.promised_stream_id}" 536 | raise InvalidDataError(msg) 537 | 538 | if self.pad_length and self.pad_length >= self.body_len: 539 | msg = "Padding is too long." 540 | raise InvalidPaddingError(msg) 541 | 542 | 543 | class PingFrame(Frame): 544 | """ 545 | The PING frame is a mechanism for measuring a minimal round-trip time from 546 | the sender, as well as determining whether an idle connection is still 547 | functional. PING frames can be sent from any endpoint. 548 | """ 549 | 550 | #: The flags defined for PING frames. 551 | defined_flags = [Flag("ACK", 0x01)] 552 | 553 | #: The type byte defined for PING frames. 554 | type = 0x06 555 | 556 | stream_association = _STREAM_ASSOC_NO_STREAM 557 | 558 | def __init__(self, stream_id: int = 0, opaque_data: bytes = b"", **kwargs: Any) -> None: 559 | super().__init__(stream_id, **kwargs) 560 | 561 | #: The opaque data sent in this PING frame, as a bytestring. 562 | self.opaque_data = opaque_data 563 | 564 | def _body_repr(self) -> str: 565 | return f"opaque_data={self.opaque_data!r}" 566 | 567 | def serialize_body(self) -> bytes: 568 | if len(self.opaque_data) > 8: 569 | msg = f"PING frame may not have more than 8 bytes of data, got {len(self.opaque_data)}" 570 | raise InvalidFrameError(msg) 571 | 572 | data = self.opaque_data 573 | data += b"\x00" * (8 - len(self.opaque_data)) 574 | return data 575 | 576 | def parse_body(self, data: memoryview) -> None: 577 | if len(data) != 8: 578 | msg = f"PING frame must have 8 byte length: got {len(data)}" 579 | raise InvalidFrameError(msg) 580 | 581 | self.opaque_data = data.tobytes() 582 | self.body_len = 8 583 | 584 | 585 | class GoAwayFrame(Frame): 586 | """ 587 | The GOAWAY frame informs the remote peer to stop creating streams on this 588 | connection. It can be sent from the client or the server. Once sent, the 589 | sender will ignore frames sent on new streams for the remainder of the 590 | connection. 591 | """ 592 | 593 | #: The flags defined for GOAWAY frames. 594 | defined_flags: list[Flag] = [] 595 | 596 | #: The type byte defined for GOAWAY frames. 597 | type = 0x07 598 | 599 | stream_association = _STREAM_ASSOC_NO_STREAM 600 | 601 | def __init__(self, 602 | stream_id: int = 0, 603 | last_stream_id: int = 0, 604 | error_code: int = 0, 605 | additional_data: bytes = b"", 606 | **kwargs: Any) -> None: 607 | super().__init__(stream_id, **kwargs) 608 | 609 | #: The last stream ID definitely seen by the remote peer. 610 | self.last_stream_id = last_stream_id 611 | 612 | #: The error code for connection teardown. 613 | self.error_code = error_code 614 | 615 | #: Any additional data sent in the GOAWAY. 616 | self.additional_data = additional_data 617 | 618 | def _body_repr(self) -> str: 619 | return f"last_stream_id={self.last_stream_id}, error_code={self.error_code}, additional_data={self.additional_data!r}" 620 | 621 | def serialize_body(self) -> bytes: 622 | data = _STRUCT_LL.pack( 623 | self.last_stream_id & 0x7FFFFFFF, 624 | self.error_code, 625 | ) 626 | data += self.additional_data 627 | 628 | return data 629 | 630 | def parse_body(self, data: memoryview) -> None: 631 | try: 632 | self.last_stream_id, self.error_code = _STRUCT_LL.unpack( 633 | data[:8], 634 | ) 635 | except struct.error as err: 636 | msg = "Invalid GOAWAY body." 637 | raise InvalidFrameError(msg) from err 638 | 639 | self.body_len = len(data) 640 | 641 | if len(data) > 8: 642 | self.additional_data = data[8:].tobytes() 643 | 644 | 645 | class WindowUpdateFrame(Frame): 646 | """ 647 | The WINDOW_UPDATE frame is used to implement flow control. 648 | 649 | Flow control operates at two levels: on each individual stream and on the 650 | entire connection. 651 | 652 | Both types of flow control are hop by hop; that is, only between the two 653 | endpoints. Intermediaries do not forward WINDOW_UPDATE frames between 654 | dependent connections. However, throttling of data transfer by any receiver 655 | can indirectly cause the propagation of flow control information toward the 656 | original sender. 657 | """ 658 | 659 | #: The flags defined for WINDOW_UPDATE frames. 660 | defined_flags: list[Flag] = [] 661 | 662 | #: The type byte defined for WINDOW_UPDATE frames. 663 | type = 0x08 664 | 665 | stream_association = _STREAM_ASSOC_EITHER 666 | 667 | def __init__(self, stream_id: int, window_increment: int = 0, **kwargs: Any) -> None: 668 | super().__init__(stream_id, **kwargs) 669 | 670 | #: The amount the flow control window is to be incremented. 671 | self.window_increment = window_increment 672 | 673 | def _body_repr(self) -> str: 674 | return f"window_increment={self.window_increment}" 675 | 676 | def serialize_body(self) -> bytes: 677 | return _STRUCT_L.pack(self.window_increment & 0x7FFFFFFF) 678 | 679 | def parse_body(self, data: memoryview) -> None: 680 | if len(data) > 4: 681 | msg = f"WINDOW_UPDATE frame must have 4 byte length: got {len(data)}" 682 | raise InvalidFrameError(msg) 683 | 684 | try: 685 | self.window_increment = _STRUCT_L.unpack(data)[0] 686 | except struct.error as err: 687 | msg = "Invalid WINDOW_UPDATE body" 688 | raise InvalidFrameError(msg) from err 689 | 690 | if not 1 <= self.window_increment <= 2**31-1: 691 | msg = "WINDOW_UPDATE increment must be between 1 to 2^31-1" 692 | raise InvalidDataError(msg) 693 | 694 | self.body_len = 4 695 | 696 | 697 | class HeadersFrame(Padding, Priority, Frame): 698 | """ 699 | The HEADERS frame carries name-value pairs. It is used to open a stream. 700 | HEADERS frames can be sent on a stream in the "open" or "half closed 701 | (remote)" states. 702 | 703 | The HeadersFrame class is actually basically a data frame in this 704 | implementation, because of the requirement to control the sizes of frames. 705 | A header block fragment that doesn't fit in an entire HEADERS frame needs 706 | to be followed with CONTINUATION frames. From the perspective of the frame 707 | building code the header block is an opaque data segment. 708 | """ 709 | 710 | #: The flags defined for HEADERS frames. 711 | defined_flags = [ 712 | Flag("END_STREAM", 0x01), 713 | Flag("END_HEADERS", 0x04), 714 | Flag("PADDED", 0x08), 715 | Flag("PRIORITY", 0x20), 716 | ] 717 | 718 | #: The type byte defined for HEADERS frames. 719 | type = 0x01 720 | 721 | stream_association = _STREAM_ASSOC_HAS_STREAM 722 | 723 | def __init__(self, stream_id: int, data: bytes = b"", **kwargs: Any) -> None: 724 | super().__init__(stream_id, **kwargs) 725 | 726 | #: The HPACK-encoded header block. 727 | self.data = data 728 | 729 | def _body_repr(self) -> str: 730 | return f"exclusive={self.exclusive}, depends_on={self.depends_on}, stream_weight={self.stream_weight}, data={_raw_data_repr(self.data)}" 731 | 732 | def serialize_body(self) -> bytes: 733 | padding_data = self.serialize_padding_data() 734 | padding = b"\0" * self.pad_length 735 | 736 | if "PRIORITY" in self.flags: 737 | priority_data = self.serialize_priority_data() 738 | else: 739 | priority_data = b"" 740 | 741 | return b"".join([padding_data, priority_data, self.data, padding]) 742 | 743 | def parse_body(self, data: memoryview) -> None: 744 | padding_data_length = self.parse_padding_data(data) 745 | data = data[padding_data_length:] 746 | 747 | if "PRIORITY" in self.flags: 748 | priority_data_length = self.parse_priority_data(data) 749 | else: 750 | priority_data_length = 0 751 | 752 | self.body_len = len(data) 753 | self.data = ( 754 | data[priority_data_length:len(data)-self.pad_length].tobytes() 755 | ) 756 | 757 | if self.pad_length and self.pad_length >= self.body_len: 758 | msg = "Padding is too long." 759 | raise InvalidPaddingError(msg) 760 | 761 | 762 | class ContinuationFrame(Frame): 763 | """ 764 | The CONTINUATION frame is used to continue a sequence of header block 765 | fragments. Any number of CONTINUATION frames can be sent on an existing 766 | stream, as long as the preceding frame on the same stream is one of 767 | HEADERS, PUSH_PROMISE or CONTINUATION without the END_HEADERS flag set. 768 | 769 | Much like the HEADERS frame, hyper treats this as an opaque data frame with 770 | different flags and a different type. 771 | """ 772 | 773 | #: The flags defined for CONTINUATION frames. 774 | defined_flags = [Flag("END_HEADERS", 0x04)] 775 | 776 | #: The type byte defined for CONTINUATION frames. 777 | type = 0x09 778 | 779 | stream_association = _STREAM_ASSOC_HAS_STREAM 780 | 781 | def __init__(self, stream_id: int, data: bytes = b"", **kwargs: Any) -> None: 782 | super().__init__(stream_id, **kwargs) 783 | 784 | #: The HPACK-encoded header block. 785 | self.data = data 786 | 787 | def _body_repr(self) -> str: 788 | return f"data={_raw_data_repr(self.data)}" 789 | 790 | def serialize_body(self) -> bytes: 791 | return self.data 792 | 793 | def parse_body(self, data: memoryview) -> None: 794 | self.data = data.tobytes() 795 | self.body_len = len(data) 796 | 797 | 798 | class AltSvcFrame(Frame): 799 | """ 800 | The ALTSVC frame is used to advertise alternate services that the current 801 | host, or a different one, can understand. This frame is standardised as 802 | part of RFC 7838. 803 | 804 | This frame does no work to validate that the ALTSVC field parameter is 805 | acceptable per the rules of RFC 7838. 806 | 807 | .. note:: If the ``stream_id`` of this frame is nonzero, the origin field 808 | must have zero length. Conversely, if the ``stream_id`` of this 809 | frame is zero, the origin field must have nonzero length. Put 810 | another way, a valid ALTSVC frame has ``stream_id != 0`` XOR 811 | ``len(origin) != 0``. 812 | """ 813 | 814 | type = 0x0A 815 | 816 | stream_association = _STREAM_ASSOC_EITHER 817 | 818 | def __init__(self, stream_id: int, origin: bytes = b"", field: bytes = b"", **kwargs: Any) -> None: 819 | super().__init__(stream_id, **kwargs) 820 | 821 | if not isinstance(origin, bytes): 822 | msg = "AltSvc origin must be a bytestring." 823 | raise InvalidDataError(msg) 824 | if not isinstance(field, bytes): 825 | msg = "AltSvc field must be a bytestring." 826 | raise InvalidDataError(msg) 827 | self.origin = origin 828 | self.field = field 829 | 830 | def _body_repr(self) -> str: 831 | return f"origin={self.origin!r}, field={self.field!r}" 832 | 833 | def serialize_body(self) -> bytes: 834 | origin_len = _STRUCT_H.pack(len(self.origin)) 835 | return b"".join([origin_len, self.origin, self.field]) 836 | 837 | def parse_body(self, data: memoryview) -> None: 838 | try: 839 | origin_len = _STRUCT_H.unpack(data[0:2])[0] 840 | self.origin = data[2:2+origin_len].tobytes() 841 | 842 | if len(self.origin) != origin_len: 843 | msg = "Invalid ALTSVC frame body." 844 | raise InvalidFrameError(msg) 845 | 846 | self.field = data[2+origin_len:].tobytes() 847 | except (struct.error, ValueError) as err: 848 | msg = "Invalid ALTSVC frame body." 849 | raise InvalidFrameError(msg) from err 850 | 851 | self.body_len = len(data) 852 | 853 | 854 | class ExtensionFrame(Frame): 855 | """ 856 | ExtensionFrame is used to wrap frames which are not natively interpretable 857 | by hyperframe. 858 | 859 | Although certain byte prefixes are ordained by specification to have 860 | certain contextual meanings, frames with other prefixes are not prohibited, 861 | and may be used to communicate arbitrary meaning between HTTP/2 peers. 862 | 863 | Thus, hyperframe, rather than raising an exception when such a frame is 864 | encountered, wraps it in a generic frame to be properly acted upon by 865 | upstream consumers which might have additional context on how to use it. 866 | 867 | .. versionadded:: 5.0.0 868 | """ 869 | 870 | stream_association = _STREAM_ASSOC_EITHER 871 | 872 | def __init__(self, type: int, stream_id: int, flag_byte: int = 0x0, body: bytes = b"", **kwargs: Any) -> None: # noqa: A002 873 | super().__init__(stream_id, **kwargs) 874 | self.type = type 875 | self.flag_byte = flag_byte 876 | self.body = body 877 | 878 | def _body_repr(self) -> str: 879 | return f"type={self.type}, flag_byte={self.flag_byte}, body={_raw_data_repr(self.body)}" 880 | 881 | def parse_flags(self, flag_byte: int) -> None: # type: ignore 882 | """ 883 | For extension frames, we parse the flags by just storing a flag byte. 884 | """ 885 | self.flag_byte = flag_byte 886 | 887 | def parse_body(self, data: memoryview) -> None: 888 | self.body = data.tobytes() 889 | self.body_len = len(data) 890 | 891 | def serialize(self) -> bytes: 892 | """ 893 | A broad override of the serialize method that ensures that the data 894 | comes back out exactly as it came in. This should not be used in most 895 | user code: it exists only as a helper method if frames need to be 896 | reconstituted. 897 | """ 898 | # Build the frame header. 899 | # First, get the flags. 900 | flags = self.flag_byte 901 | 902 | header = _STRUCT_HBBBL.pack( 903 | (self.body_len >> 8) & 0xFFFF, # Length spread over top 24 bits 904 | self.body_len & 0xFF, 905 | self.type, 906 | flags, 907 | self.stream_id & 0x7FFFFFFF, # Stream ID is 32 bits. 908 | ) 909 | 910 | return header + self.body 911 | 912 | 913 | def _raw_data_repr(data: bytes | None) -> str: 914 | if not data: 915 | return "None" 916 | r = binascii.hexlify(data).decode("ascii") 917 | if len(r) > 20: 918 | r = r[:20] + "..." 919 | return "" 920 | 921 | 922 | _FRAME_CLASSES: list[type[Frame]] = [ 923 | DataFrame, 924 | HeadersFrame, 925 | PriorityFrame, 926 | RstStreamFrame, 927 | SettingsFrame, 928 | PushPromiseFrame, 929 | PingFrame, 930 | GoAwayFrame, 931 | WindowUpdateFrame, 932 | ContinuationFrame, 933 | AltSvcFrame, 934 | ] 935 | #: FRAMES maps the type byte for each frame to the class used to represent that 936 | #: frame. 937 | FRAMES = {cls.type: cls for cls in _FRAME_CLASSES} 938 | -------------------------------------------------------------------------------- /src/hyperframe/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-hyper/hyperframe/b57beaff1cce7d7b7c38ea3514a349cb05a80d3c/src/hyperframe/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-hyper/hyperframe/b57beaff1cce7d7b7c38ea3514a349cb05a80d3c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_external_collection.py: -------------------------------------------------------------------------------- 1 | # https://github.com/http2jp/http2-frame-test-case 2 | 3 | import os 4 | import json 5 | import pytest 6 | from hyperframe.frame import Frame 7 | 8 | tc_filepaths = [] 9 | root = os.path.dirname(__file__) 10 | path = os.walk(os.path.join(root, "http2-frame-test-case")) 11 | for dirpath, dirnames, filenames in path: 12 | for filename in filenames: 13 | if os.path.splitext(filename)[1] != ".json": 14 | continue 15 | tc_filepaths.append( 16 | os.path.relpath(os.path.join(dirpath, filename), root) 17 | ) 18 | 19 | 20 | def check_valid_frame(tc, data): # noqa: C901 21 | new_frame, length = Frame.parse_frame_header(data[:9], strict=True) 22 | new_frame.parse_body(memoryview(data[9:9 + length])) 23 | 24 | assert tc["frame"]["length"] == length 25 | assert tc["frame"]["stream_identifier"] == new_frame.stream_id 26 | assert tc["frame"]["type"] == new_frame.type 27 | 28 | flags = 0 29 | for flag, flag_bit in new_frame.defined_flags: 30 | if flag in new_frame.flags: 31 | flags |= flag_bit 32 | assert tc["frame"]["flags"] == flags 33 | 34 | p = tc["frame"]["frame_payload"] 35 | if "header_block_fragment" in p: 36 | assert p["header_block_fragment"] == new_frame.data.decode() 37 | if "data" in p: 38 | assert p["data"] == new_frame.data.decode() 39 | if "padding" in p: 40 | # the padding data itself is not retained by hyperframe after parsing 41 | pass 42 | if "padding_length" in p and p["padding_length"]: 43 | assert p["padding_length"] == new_frame.pad_length 44 | if "error_code" in p: 45 | assert p["error_code"] == new_frame.error_code 46 | if "additional_debug_data" in p: 47 | assert p["additional_debug_data"].encode() == new_frame.additional_data 48 | if "last_stream_id" in p: 49 | assert p["last_stream_id"] == new_frame.last_stream_id 50 | if "stream_dependency" in p: 51 | assert p["stream_dependency"] or 0 == new_frame.depends_on 52 | if "weight" in p and p["weight"]: 53 | assert p["weight"] - 1 == new_frame.stream_weight 54 | if "exclusive" in p: 55 | assert (p["exclusive"] or False) == new_frame.exclusive 56 | if "opaque_data" in p: 57 | assert p["opaque_data"].encode() == new_frame.opaque_data 58 | if "promised_stream_id" in p: 59 | assert p["promised_stream_id"] == new_frame.promised_stream_id 60 | if "settings" in p: 61 | assert dict(p["settings"]) == new_frame.settings 62 | if "window_size_increment" in p: 63 | assert p["window_size_increment"] == new_frame.window_increment 64 | 65 | 66 | class TestExternalCollection: 67 | @pytest.mark.parametrize('tc_filepath', tc_filepaths) 68 | def test(self, tc_filepath): 69 | with open(os.path.join(root, tc_filepath)) as f: 70 | tc = json.load(f) 71 | 72 | data = bytes.fromhex(tc["wire"]) 73 | 74 | if tc["error"] is None and tc["frame"]: 75 | check_valid_frame(tc, data) 76 | elif tc["error"] and tc["frame"] is None: 77 | with pytest.raises(Exception): 78 | new_frame, length = Frame.parse_frame_header( 79 | data[:9], 80 | strict=True 81 | ) 82 | new_frame.parse_body(memoryview(data[9:9 + length])) 83 | assert length == new_frame.body_len 84 | else: 85 | pytest.fail("unexpected test case: {} {}".format(tc_filepath, tc)) 86 | -------------------------------------------------------------------------------- /tests/test_flags.py: -------------------------------------------------------------------------------- 1 | from hyperframe.frame import ( 2 | Flags, Flag, 3 | ) 4 | import pytest 5 | 6 | 7 | class TestFlags: 8 | def test_add(self): 9 | flags = Flags([Flag("VALID_FLAG", 0x00)]) 10 | assert not flags 11 | 12 | flags.add("VALID_FLAG") 13 | flags.add("VALID_FLAG") 14 | assert "VALID_FLAG" in flags 15 | assert list(flags) == ["VALID_FLAG"] 16 | assert len(flags) == 1 17 | 18 | def test_remove(self): 19 | flags = Flags([Flag("VALID_FLAG", 0x00)]) 20 | flags.add("VALID_FLAG") 21 | 22 | flags.discard("VALID_FLAG") 23 | assert "VALID_FLAG" not in flags 24 | assert list(flags) == [] 25 | assert len(flags) == 0 26 | 27 | # discarding elements not in the set should not throw an exception 28 | flags.discard("END_STREAM") 29 | 30 | def test_validation(self): 31 | flags = Flags([Flag("VALID_FLAG", 0x00)]) 32 | flags.add("VALID_FLAG") 33 | with pytest.raises(ValueError): 34 | flags.add("INVALID_FLAG") 35 | 36 | def test_repr(self): 37 | flags = Flags([Flag("VALID_FLAG", 0x00), Flag("OTHER_FLAG", 0x01)]) 38 | assert repr(flags) == "[]" 39 | flags.add("VALID_FLAG") 40 | assert repr(flags) == "['VALID_FLAG']" 41 | flags.add("OTHER_FLAG") 42 | assert repr(flags) == "['OTHER_FLAG', 'VALID_FLAG']" 43 | -------------------------------------------------------------------------------- /tests/test_frames.py: -------------------------------------------------------------------------------- 1 | from hyperframe.frame import ( 2 | Frame, Flags, DataFrame, PriorityFrame, RstStreamFrame, SettingsFrame, 3 | PushPromiseFrame, PingFrame, GoAwayFrame, WindowUpdateFrame, HeadersFrame, 4 | ContinuationFrame, AltSvcFrame, ExtensionFrame 5 | ) 6 | from hyperframe.exceptions import ( 7 | UnknownFrameError, InvalidPaddingError, InvalidFrameError, InvalidDataError 8 | ) 9 | import pytest 10 | 11 | 12 | def decode_frame(frame_data): 13 | f, length = Frame.parse_frame_header(frame_data[:9]) 14 | f.parse_body(memoryview(frame_data[9:9 + length])) 15 | assert 9 + length == len(frame_data) 16 | return f 17 | 18 | 19 | class TestGeneralFrameBehaviour: 20 | def test_base_frame_ignores_flags(self): 21 | f = Frame(0) 22 | flags = f.parse_flags(0xFF) 23 | assert not flags 24 | assert isinstance(flags, Flags) 25 | 26 | def test_base_frame_cant_serialize(self): 27 | f = Frame(0) 28 | with pytest.raises(NotImplementedError): 29 | f.serialize() 30 | 31 | def test_base_frame_cant_parse_body(self): 32 | data = b'' 33 | f = Frame(0) 34 | with pytest.raises(NotImplementedError): 35 | f.parse_body(data) 36 | 37 | def test_parse_frame_header_unknown_type_strict(self): 38 | with pytest.raises(UnknownFrameError) as excinfo: 39 | Frame.parse_frame_header( 40 | b'\x00\x00\x59\xFF\x00\x00\x00\x00\x01', 41 | strict=True 42 | ) 43 | exception = excinfo.value 44 | assert exception.frame_type == 0xFF 45 | assert exception.length == 0x59 46 | assert str(exception) == ( 47 | "UnknownFrameError: Unknown frame type 0xFF received, " 48 | "length 89 bytes" 49 | ) 50 | 51 | def test_parse_frame_header_ignore_first_bit_of_stream_id(self): 52 | s = b'\x00\x00\x00\x06\x01\x80\x00\x00\x00' 53 | f, _ = Frame.parse_frame_header(s) 54 | 55 | assert f.stream_id == 0 56 | 57 | def test_parse_frame_header_unknown_type(self): 58 | frame, length = Frame.parse_frame_header( 59 | b'\x00\x00\x59\xFF\x00\x00\x00\x00\x01' 60 | ) 61 | assert frame.type == 0xFF 62 | assert length == 0x59 63 | assert isinstance(frame, ExtensionFrame) 64 | assert frame.stream_id == 1 65 | 66 | def test_flags_are_persisted(self): 67 | frame, length = Frame.parse_frame_header( 68 | b'\x00\x00\x59\xFF\x09\x00\x00\x00\x01' 69 | ) 70 | assert frame.type == 0xFF 71 | assert length == 0x59 72 | assert frame.flag_byte == 0x09 73 | 74 | def test_parse_body_unknown_type(self): 75 | frame = decode_frame( 76 | b'\x00\x00\x0C\xFF\x00\x00\x00\x00\x01hello world!' 77 | ) 78 | assert frame.body == b'hello world!' 79 | assert frame.body_len == 12 80 | assert frame.stream_id == 1 81 | 82 | def test_can_round_trip_unknown_frames(self): 83 | frame_data = b'\x00\x00\x0C\xFF\x00\x00\x00\x00\x01hello world!' 84 | f = decode_frame(frame_data) 85 | assert f.serialize() == frame_data 86 | 87 | def test_repr(self, monkeypatch): 88 | f = Frame(0) 89 | monkeypatch.setattr(Frame, "serialize_body", lambda _: b"body") 90 | assert repr(f) == ( 91 | "Frame(stream_id=0, flags=[]): " 92 | ) 93 | 94 | f.stream_id = 42 95 | f.flags = ["END_STREAM", "PADDED"] 96 | assert repr(f) == ( 97 | "Frame(stream_id=42, flags=['END_STREAM', 'PADDED']): " 98 | ) 99 | 100 | monkeypatch.setattr(Frame, "serialize_body", lambda _: b"A"*25) 101 | assert repr(f) == ( 102 | "Frame(stream_id=42, flags=['END_STREAM', 'PADDED']): ".format("41"*10) 103 | ) 104 | 105 | def test_frame_explain(self, capsys): 106 | d = b'\x00\x00\x08\x00\x01\x00\x00\x00\x01testdata' 107 | Frame.explain(memoryview(d)) 108 | captured = capsys.readouterr() 109 | assert captured.out.strip() == "DataFrame(stream_id=1, flags=['END_STREAM']): " 110 | 111 | def test_cannot_parse_invalid_frame_header(self): 112 | with pytest.raises(InvalidFrameError): 113 | Frame.parse_frame_header(b'\x00\x00\x08\x00\x01\x00\x00\x00') 114 | 115 | 116 | class TestDataFrame: 117 | payload = b'\x00\x00\x08\x00\x01\x00\x00\x00\x01testdata' 118 | payload_with_padding = ( 119 | b'\x00\x00\x13\x00\x09\x00\x00\x00\x01\x0Atestdata' + b'\0' * 10 120 | ) 121 | 122 | def test_repr(self): 123 | f = DataFrame(1, b"testdata") 124 | assert repr(f).endswith("") 125 | 126 | def test_data_frame_has_correct_flags(self): 127 | f = DataFrame(1) 128 | flags = f.parse_flags(0xFF) 129 | assert flags == set([ 130 | 'END_STREAM', 'PADDED' 131 | ]) 132 | 133 | @pytest.mark.parametrize('data', [ 134 | b'testdata', 135 | memoryview(b'testdata') 136 | ]) 137 | def test_data_frame_serializes_properly(self, data): 138 | f = DataFrame(1) 139 | f.flags = set(['END_STREAM']) 140 | f.data = data 141 | 142 | s = f.serialize() 143 | assert s == self.payload 144 | 145 | def test_data_frame_with_padding_serializes_properly(self): 146 | f = DataFrame(1) 147 | f.flags = set(['END_STREAM', 'PADDED']) 148 | f.data = b'testdata' 149 | f.pad_length = 10 150 | 151 | s = f.serialize() 152 | assert s == self.payload_with_padding 153 | 154 | def test_data_frame_parses_properly(self): 155 | f = decode_frame(self.payload) 156 | 157 | assert isinstance(f, DataFrame) 158 | assert f.flags == set(['END_STREAM']) 159 | assert f.pad_length == 0 160 | assert f.data == b'testdata' 161 | assert f.body_len == 8 162 | 163 | def test_data_frame_with_padding_parses_properly(self): 164 | f = decode_frame(self.payload_with_padding) 165 | 166 | assert isinstance(f, DataFrame) 167 | assert f.flags == set(['END_STREAM', 'PADDED']) 168 | assert f.pad_length == 10 169 | assert f.data == b'testdata' 170 | assert f.body_len == 19 171 | 172 | def test_data_frame_with_invalid_padding_errors(self): 173 | with pytest.raises(InvalidFrameError): 174 | decode_frame(self.payload_with_padding[:9]) 175 | 176 | def test_data_frame_with_padding_calculates_flow_control_len(self): 177 | f = DataFrame(1) 178 | f.flags = set(['PADDED']) 179 | f.data = b'testdata' 180 | f.pad_length = 10 181 | 182 | assert f.flow_controlled_length == 19 183 | 184 | def test_data_frame_zero_length_padding_calculates_flow_control_len(self): 185 | f = DataFrame(1) 186 | f.flags = set(['PADDED']) 187 | f.data = b'testdata' 188 | f.pad_length = 0 189 | 190 | assert f.flow_controlled_length == len(b'testdata') + 1 191 | 192 | def test_data_frame_without_padding_calculates_flow_control_len(self): 193 | f = DataFrame(1) 194 | f.data = b'testdata' 195 | 196 | assert f.flow_controlled_length == 8 197 | 198 | def test_data_frame_comes_on_a_stream(self): 199 | with pytest.raises(InvalidDataError): 200 | DataFrame(0) 201 | 202 | def test_long_data_frame(self): 203 | f = DataFrame(1) 204 | 205 | # Use more than 256 bytes of data to force setting higher bits. 206 | f.data = b'\x01' * 300 207 | data = f.serialize() 208 | 209 | # The top three bytes should be numerically equal to 300. That means 210 | # they should read 00 01 2C. 211 | # The weird double index trick is to ensure this test behaves equally 212 | # on Python 2 and Python 3. 213 | assert data[0] == b'\x00'[0] 214 | assert data[1] == b'\x01'[0] 215 | assert data[2] == b'\x2C'[0] 216 | 217 | def test_body_length_behaves_correctly(self): 218 | f = DataFrame(1) 219 | 220 | f.data = b'\x01' * 300 221 | 222 | # Initially the body length is zero. For now this is incidental, but 223 | # I'm going to test it to ensure that the behaviour is codified. We 224 | # should change this test if we change that. 225 | assert f.body_len == 0 226 | 227 | f.serialize() 228 | assert f.body_len == 300 229 | 230 | def test_data_frame_with_invalid_padding_fails_to_parse(self): 231 | # This frame has a padding length of 6 bytes, but a total length of 232 | # only 5. 233 | data = b'\x00\x00\x05\x00\x0b\x00\x00\x00\x01\x06\x54\x65\x73\x74' 234 | 235 | with pytest.raises(InvalidPaddingError): 236 | decode_frame(data) 237 | 238 | def test_data_frame_with_no_length_parses(self): 239 | # Fixes issue with empty data frames raising InvalidPaddingError. 240 | f = DataFrame(1) 241 | f.data = b'' 242 | data = f.serialize() 243 | 244 | new_frame = decode_frame(data) 245 | assert new_frame.data == b'' 246 | 247 | 248 | class TestPriorityFrame: 249 | payload = b'\x00\x00\x05\x02\x00\x00\x00\x00\x01\x80\x00\x00\x04\x40' 250 | 251 | def test_repr(self): 252 | f = PriorityFrame(1) 253 | assert repr(f).endswith("exclusive=False, depends_on=0, stream_weight=0") 254 | f.exclusive = True 255 | f.depends_on = 0x04 256 | f.stream_weight = 64 257 | assert repr(f).endswith("exclusive=True, depends_on=4, stream_weight=64") 258 | 259 | def test_priority_frame_has_no_flags(self): 260 | f = PriorityFrame(1) 261 | flags = f.parse_flags(0xFF) 262 | assert flags == set() 263 | assert isinstance(flags, Flags) 264 | 265 | def test_priority_frame_default_serializes_properly(self): 266 | f = PriorityFrame(1) 267 | 268 | assert f.serialize() == ( 269 | b'\x00\x00\x05\x02\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00' 270 | ) 271 | 272 | def test_priority_frame_with_all_data_serializes_properly(self): 273 | f = PriorityFrame(1) 274 | f.depends_on = 0x04 275 | f.stream_weight = 64 276 | f.exclusive = True 277 | 278 | assert f.serialize() == self.payload 279 | 280 | def test_priority_frame_with_all_data_parses_properly(self): 281 | f = decode_frame(self.payload) 282 | 283 | assert isinstance(f, PriorityFrame) 284 | assert f.flags == set() 285 | assert f.depends_on == 4 286 | assert f.stream_weight == 64 287 | assert f.exclusive is True 288 | assert f.body_len == 5 289 | 290 | def test_priority_frame_invalid(self): 291 | with pytest.raises(InvalidFrameError): 292 | decode_frame( 293 | b'\x00\x00\x06\x02\x00\x00\x00\x00\x01\x80\x00\x00\x04\x40\xFF' 294 | ) 295 | 296 | def test_priority_frame_comes_on_a_stream(self): 297 | with pytest.raises(InvalidDataError): 298 | PriorityFrame(0) 299 | 300 | def test_short_priority_frame_errors(self): 301 | with pytest.raises(InvalidFrameError): 302 | decode_frame(self.payload[:-2]) 303 | 304 | 305 | class TestRstStreamFrame: 306 | def test_repr(self): 307 | f = RstStreamFrame(1) 308 | assert repr(f).endswith("error_code=0") 309 | f.error_code = 420 310 | assert repr(f).endswith("error_code=420") 311 | 312 | def test_rst_stream_frame_has_no_flags(self): 313 | f = RstStreamFrame(1) 314 | flags = f.parse_flags(0xFF) 315 | assert not flags 316 | assert isinstance(flags, Flags) 317 | 318 | def test_rst_stream_frame_serializes_properly(self): 319 | f = RstStreamFrame(1) 320 | f.error_code = 420 321 | 322 | s = f.serialize() 323 | assert s == b'\x00\x00\x04\x03\x00\x00\x00\x00\x01\x00\x00\x01\xa4' 324 | 325 | def test_rst_stream_frame_parses_properly(self): 326 | s = b'\x00\x00\x04\x03\x00\x00\x00\x00\x01\x00\x00\x01\xa4' 327 | f = decode_frame(s) 328 | 329 | assert isinstance(f, RstStreamFrame) 330 | assert f.flags == set() 331 | assert f.error_code == 420 332 | assert f.body_len == 4 333 | 334 | def test_rst_stream_frame_comes_on_a_stream(self): 335 | with pytest.raises(InvalidDataError): 336 | RstStreamFrame(0) 337 | 338 | def test_rst_stream_frame_must_have_body_length_four(self): 339 | f = RstStreamFrame(1) 340 | with pytest.raises(InvalidFrameError): 341 | f.parse_body(b'\x01') 342 | 343 | 344 | class TestSettingsFrame: 345 | serialized = ( 346 | b'\x00\x00\x2A\x04\x01\x00\x00\x00\x00' + # Frame header 347 | b'\x00\x01\x00\x00\x10\x00' + # HEADER_TABLE_SIZE 348 | b'\x00\x02\x00\x00\x00\x00' + # ENABLE_PUSH 349 | b'\x00\x03\x00\x00\x00\x64' + # MAX_CONCURRENT_STREAMS 350 | b'\x00\x04\x00\x00\xFF\xFF' + # INITIAL_WINDOW_SIZE 351 | b'\x00\x05\x00\x00\x40\x00' + # MAX_FRAME_SIZE 352 | b'\x00\x06\x00\x00\xFF\xFF' + # MAX_HEADER_LIST_SIZE 353 | b'\x00\x08\x00\x00\x00\x01' # ENABLE_CONNECT_PROTOCOL 354 | ) 355 | 356 | settings = { 357 | SettingsFrame.HEADER_TABLE_SIZE: 4096, 358 | SettingsFrame.ENABLE_PUSH: 0, 359 | SettingsFrame.MAX_CONCURRENT_STREAMS: 100, 360 | SettingsFrame.INITIAL_WINDOW_SIZE: 65535, 361 | SettingsFrame.MAX_FRAME_SIZE: 16384, 362 | SettingsFrame.MAX_HEADER_LIST_SIZE: 65535, 363 | SettingsFrame.ENABLE_CONNECT_PROTOCOL: 1, 364 | } 365 | 366 | def test_repr(self): 367 | f = SettingsFrame() 368 | assert repr(f).endswith("settings={}") 369 | f.settings[SettingsFrame.MAX_FRAME_SIZE] = 16384 370 | assert repr(f).endswith("settings={5: 16384}") 371 | 372 | def test_settings_frame_has_only_one_flag(self): 373 | f = SettingsFrame() 374 | flags = f.parse_flags(0xFF) 375 | assert flags == set(['ACK']) 376 | 377 | def test_settings_frame_serializes_properly(self): 378 | f = SettingsFrame() 379 | f.parse_flags(0xFF) 380 | f.settings = self.settings 381 | 382 | s = f.serialize() 383 | assert s == self.serialized 384 | 385 | def test_settings_frame_with_settings(self): 386 | f = SettingsFrame(settings=self.settings) 387 | assert f.settings == self.settings 388 | 389 | def test_settings_frame_without_settings(self): 390 | f = SettingsFrame() 391 | assert f.settings == {} 392 | 393 | def test_settings_frame_with_ack(self): 394 | f = SettingsFrame(flags=('ACK',)) 395 | assert 'ACK' in f.flags 396 | 397 | def test_settings_frame_ack_and_settings(self): 398 | with pytest.raises(InvalidDataError): 399 | SettingsFrame(settings=self.settings, flags=('ACK',)) 400 | 401 | with pytest.raises(InvalidDataError): 402 | decode_frame(self.serialized) 403 | 404 | def test_settings_frame_parses_properly(self): 405 | # unset the ACK flag to allow correct parsing 406 | data = self.serialized[:4] + b"\x00" + self.serialized[5:] 407 | 408 | f = decode_frame(data) 409 | 410 | assert isinstance(f, SettingsFrame) 411 | assert f.flags == set() 412 | assert f.settings == self.settings 413 | assert f.body_len == 42 414 | 415 | def test_settings_frame_invalid_body_length(self): 416 | with pytest.raises(InvalidFrameError): 417 | decode_frame( 418 | b'\x00\x00\x2A\x04\x00\x00\x00\x00\x00\xFF\xFF\xFF\xFF' 419 | ) 420 | 421 | def test_settings_frames_never_have_streams(self): 422 | with pytest.raises(InvalidDataError): 423 | SettingsFrame(1) 424 | 425 | def test_short_settings_frame_errors(self): 426 | with pytest.raises(InvalidDataError): 427 | decode_frame(self.serialized[:-2]) 428 | 429 | 430 | class TestPushPromiseFrame: 431 | def test_repr(self): 432 | f = PushPromiseFrame(1) 433 | assert repr(f).endswith("promised_stream_id=0, data=None") 434 | f.promised_stream_id = 4 435 | f.data = b"testdata" 436 | assert repr(f).endswith("promised_stream_id=4, data=") 437 | 438 | def test_push_promise_frame_flags(self): 439 | f = PushPromiseFrame(1) 440 | flags = f.parse_flags(0xFF) 441 | 442 | assert flags == set(['END_HEADERS', 'PADDED']) 443 | 444 | def test_push_promise_frame_serializes_properly(self): 445 | f = PushPromiseFrame(1) 446 | f.flags = set(['END_HEADERS']) 447 | f.promised_stream_id = 4 448 | f.data = b'hello world' 449 | 450 | s = f.serialize() 451 | assert s == ( 452 | b'\x00\x00\x0F\x05\x04\x00\x00\x00\x01' + 453 | b'\x00\x00\x00\x04' + 454 | b'hello world' 455 | ) 456 | 457 | def test_push_promise_frame_parses_properly(self): 458 | s = ( 459 | b'\x00\x00\x0F\x05\x04\x00\x00\x00\x01' + 460 | b'\x00\x00\x00\x04' + 461 | b'hello world' 462 | ) 463 | f = decode_frame(s) 464 | 465 | assert isinstance(f, PushPromiseFrame) 466 | assert f.flags == set(['END_HEADERS']) 467 | assert f.promised_stream_id == 4 468 | assert f.data == b'hello world' 469 | assert f.body_len == 15 470 | 471 | def test_push_promise_frame_with_padding(self): 472 | s = ( 473 | b'\x00\x00\x17\x05\x0C\x00\x00\x00\x01' + 474 | b'\x07\x00\x00\x00\x04' + 475 | b'hello world' + 476 | b'padding' 477 | ) 478 | f = decode_frame(s) 479 | 480 | assert isinstance(f, PushPromiseFrame) 481 | assert f.flags == set(['END_HEADERS', 'PADDED']) 482 | assert f.promised_stream_id == 4 483 | assert f.data == b'hello world' 484 | assert f.body_len == 23 485 | 486 | def test_push_promise_frame_with_invalid_padding_fails_to_parse(self): 487 | # This frame has a padding length of 6 bytes, but a total length of 488 | # only 5. 489 | data = b'\x00\x00\x05\x05\x08\x00\x00\x00\x01\x06\x54\x65\x73\x74' 490 | 491 | with pytest.raises(InvalidPaddingError): 492 | decode_frame(data) 493 | 494 | def test_push_promise_frame_with_no_length_parses(self): 495 | # Fixes issue with empty data frames raising InvalidPaddingError. 496 | f = PushPromiseFrame(1, 2) 497 | f.data = b'' 498 | data = f.serialize() 499 | 500 | new_frame = decode_frame(data) 501 | assert new_frame.data == b'' 502 | 503 | def test_push_promise_frame_invalid(self): 504 | data = PushPromiseFrame(1, 0).serialize() 505 | with pytest.raises(InvalidDataError): 506 | decode_frame(data) 507 | 508 | data = PushPromiseFrame(1, 3).serialize() 509 | with pytest.raises(InvalidDataError): 510 | decode_frame(data) 511 | 512 | def test_short_push_promise_errors(self): 513 | s = ( 514 | b'\x00\x00\x0F\x05\x04\x00\x00\x00\x01' + 515 | b'\x00\x00\x00' # One byte short 516 | ) 517 | 518 | with pytest.raises(InvalidFrameError): 519 | decode_frame(s) 520 | 521 | 522 | class TestPingFrame: 523 | def test_repr(self): 524 | f = PingFrame() 525 | assert repr(f).endswith("opaque_data=b''") 526 | f.opaque_data = b'hello' 527 | assert repr(f).endswith("opaque_data=b'hello'") 528 | 529 | def test_ping_frame_has_only_one_flag(self): 530 | f = PingFrame() 531 | flags = f.parse_flags(0xFF) 532 | 533 | assert flags == set(['ACK']) 534 | 535 | def test_ping_frame_serializes_properly(self): 536 | f = PingFrame() 537 | f.parse_flags(0xFF) 538 | f.opaque_data = b'\x01\x02' 539 | 540 | s = f.serialize() 541 | assert s == ( 542 | b'\x00\x00\x08\x06\x01\x00\x00\x00\x00\x01\x02\x00\x00\x00\x00\x00' 543 | b'\x00' 544 | ) 545 | 546 | def test_no_more_than_8_octets(self): 547 | f = PingFrame() 548 | f.opaque_data = b'\x01\x02\x03\x04\x05\x06\x07\x08\x09' 549 | 550 | with pytest.raises(InvalidFrameError): 551 | f.serialize() 552 | 553 | def test_ping_frame_parses_properly(self): 554 | s = ( 555 | b'\x00\x00\x08\x06\x01\x00\x00\x00\x00\x01\x02\x00\x00\x00\x00\x00' 556 | b'\x00' 557 | ) 558 | f = decode_frame(s) 559 | 560 | assert isinstance(f, PingFrame) 561 | assert f.flags == set(['ACK']) 562 | assert f.opaque_data == b'\x01\x02\x00\x00\x00\x00\x00\x00' 563 | assert f.body_len == 8 564 | 565 | def test_ping_frame_never_has_a_stream(self): 566 | with pytest.raises(InvalidDataError): 567 | PingFrame(1) 568 | 569 | def test_ping_frame_has_no_more_than_body_length_8(self): 570 | f = PingFrame() 571 | with pytest.raises(InvalidFrameError): 572 | f.parse_body(b'\x01\x02\x03\x04\x05\x06\x07\x08\x09') 573 | 574 | def test_ping_frame_has_no_less_than_body_length_8(self): 575 | f = PingFrame() 576 | with pytest.raises(InvalidFrameError): 577 | f.parse_body(b'\x01\x02\x03\x04\x05\x06\x07') 578 | 579 | 580 | class TestGoAwayFrame: 581 | def test_repr(self): 582 | f = GoAwayFrame() 583 | assert repr(f).endswith("last_stream_id=0, error_code=0, additional_data=b''") 584 | f.last_stream_id = 64 585 | f.error_code = 32 586 | f.additional_data = b'hello' 587 | assert repr(f).endswith("last_stream_id=64, error_code=32, additional_data=b'hello'") 588 | 589 | def test_go_away_has_no_flags(self): 590 | f = GoAwayFrame() 591 | flags = f.parse_flags(0xFF) 592 | 593 | assert not flags 594 | assert isinstance(flags, Flags) 595 | 596 | def test_goaway_serializes_properly(self): 597 | f = GoAwayFrame() 598 | f.last_stream_id = 64 599 | f.error_code = 32 600 | f.additional_data = b'hello' 601 | 602 | s = f.serialize() 603 | assert s == ( 604 | b'\x00\x00\x0D\x07\x00\x00\x00\x00\x00' + # Frame header 605 | b'\x00\x00\x00\x40' + # Last Stream ID 606 | b'\x00\x00\x00\x20' + # Error Code 607 | b'hello' # Additional data 608 | ) 609 | 610 | def test_goaway_frame_parses_properly(self): 611 | s = ( 612 | b'\x00\x00\x0D\x07\x00\x00\x00\x00\x00' + # Frame header 613 | b'\x00\x00\x00\x40' + # Last Stream ID 614 | b'\x00\x00\x00\x20' + # Error Code 615 | b'hello' # Additional data 616 | ) 617 | f = decode_frame(s) 618 | 619 | assert isinstance(f, GoAwayFrame) 620 | assert f.flags == set() 621 | assert f.additional_data == b'hello' 622 | assert f.body_len == 13 623 | 624 | s = ( 625 | b'\x00\x00\x08\x07\x00\x00\x00\x00\x00' + # Frame header 626 | b'\x00\x00\x00\x40' + # Last Stream ID 627 | b'\x00\x00\x00\x20' + # Error Code 628 | b'' # Additional data 629 | ) 630 | f = decode_frame(s) 631 | 632 | assert isinstance(f, GoAwayFrame) 633 | assert f.flags == set() 634 | assert f.additional_data == b'' 635 | assert f.body_len == 8 636 | 637 | def test_goaway_frame_never_has_a_stream(self): 638 | with pytest.raises(InvalidDataError): 639 | GoAwayFrame(1) 640 | 641 | def test_short_goaway_frame_errors(self): 642 | s = ( 643 | b'\x00\x00\x0D\x07\x00\x00\x00\x00\x00' + # Frame header 644 | b'\x00\x00\x00\x40' + # Last Stream ID 645 | b'\x00\x00\x00' # short Error Code 646 | ) 647 | with pytest.raises(InvalidFrameError): 648 | decode_frame(s) 649 | 650 | 651 | class TestWindowUpdateFrame: 652 | def test_repr(self): 653 | f = WindowUpdateFrame(0) 654 | assert repr(f).endswith("window_increment=0") 655 | f.stream_id = 1 656 | f.window_increment = 512 657 | assert repr(f).endswith("window_increment=512") 658 | 659 | def test_window_update_has_no_flags(self): 660 | f = WindowUpdateFrame(0) 661 | flags = f.parse_flags(0xFF) 662 | 663 | assert not flags 664 | assert isinstance(flags, Flags) 665 | 666 | def test_window_update_serializes_properly(self): 667 | f = WindowUpdateFrame(0) 668 | f.window_increment = 512 669 | 670 | s = f.serialize() 671 | assert s == b'\x00\x00\x04\x08\x00\x00\x00\x00\x00\x00\x00\x02\x00' 672 | 673 | def test_windowupdate_frame_parses_properly(self): 674 | s = b'\x00\x00\x04\x08\x00\x00\x00\x00\x00\x00\x00\x02\x00' 675 | f = decode_frame(s) 676 | 677 | assert isinstance(f, WindowUpdateFrame) 678 | assert f.flags == set() 679 | assert f.window_increment == 512 680 | assert f.body_len == 4 681 | 682 | def test_short_windowupdate_frame_errors(self): 683 | s = b'\x00\x00\x04\x08\x00\x00\x00\x00\x00\x00\x00\x02' # -1 byte 684 | with pytest.raises(InvalidFrameError): 685 | decode_frame(s) 686 | 687 | s = b'\x00\x00\x05\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02' 688 | with pytest.raises(InvalidFrameError): 689 | decode_frame(s) 690 | 691 | with pytest.raises(InvalidDataError): 692 | decode_frame(WindowUpdateFrame(0).serialize()) 693 | 694 | with pytest.raises(InvalidDataError): 695 | decode_frame(WindowUpdateFrame(2**31).serialize()) 696 | 697 | 698 | class TestHeadersFrame: 699 | def test_repr(self): 700 | f = HeadersFrame(1) 701 | assert repr(f).endswith("exclusive=False, depends_on=0, stream_weight=0, data=None") 702 | f.data = b'hello' 703 | f.exclusive = True 704 | f.depends_on = 42 705 | f.stream_weight = 64 706 | assert repr(f).endswith("exclusive=True, depends_on=42, stream_weight=64, data=") 707 | 708 | def test_headers_frame_flags(self): 709 | f = HeadersFrame(1) 710 | flags = f.parse_flags(0xFF) 711 | 712 | assert flags == set(['END_STREAM', 'END_HEADERS', 713 | 'PADDED', 'PRIORITY']) 714 | 715 | def test_headers_frame_serializes_properly(self): 716 | f = HeadersFrame(1) 717 | f.flags = set(['END_STREAM', 'END_HEADERS']) 718 | f.data = b'hello world' 719 | 720 | s = f.serialize() 721 | assert s == ( 722 | b'\x00\x00\x0B\x01\x05\x00\x00\x00\x01' + 723 | b'hello world' 724 | ) 725 | 726 | def test_headers_frame_parses_properly(self): 727 | s = ( 728 | b'\x00\x00\x0B\x01\x05\x00\x00\x00\x01' + 729 | b'hello world' 730 | ) 731 | f = decode_frame(s) 732 | 733 | assert isinstance(f, HeadersFrame) 734 | assert f.flags == set(['END_STREAM', 'END_HEADERS']) 735 | assert f.data == b'hello world' 736 | assert f.body_len == 11 737 | 738 | def test_headers_frame_with_priority_parses_properly(self): 739 | # This test also tests that we can receive a HEADERS frame with no 740 | # actual headers on it. This is technically possible. 741 | s = ( 742 | b'\x00\x00\x05\x01\x20\x00\x00\x00\x01' + 743 | b'\x80\x00\x00\x04\x40' 744 | ) 745 | f = decode_frame(s) 746 | 747 | assert isinstance(f, HeadersFrame) 748 | assert f.flags == set(['PRIORITY']) 749 | assert f.data == b'' 750 | assert f.depends_on == 4 751 | assert f.stream_weight == 64 752 | assert f.exclusive is True 753 | assert f.body_len == 5 754 | 755 | def test_headers_frame_with_priority_serializes_properly(self): 756 | # This test also tests that we can receive a HEADERS frame with no 757 | # actual headers on it. This is technically possible. 758 | s = ( 759 | b'\x00\x00\x05\x01\x20\x00\x00\x00\x01' + 760 | b'\x80\x00\x00\x04\x40' 761 | ) 762 | f = HeadersFrame(1) 763 | f.flags = set(['PRIORITY']) 764 | f.data = b'' 765 | f.depends_on = 4 766 | f.stream_weight = 64 767 | f.exclusive = True 768 | 769 | assert f.serialize() == s 770 | 771 | def test_headers_frame_with_invalid_padding_fails_to_parse(self): 772 | # This frame has a padding length of 6 bytes, but a total length of 773 | # only 5. 774 | data = b'\x00\x00\x05\x01\x08\x00\x00\x00\x01\x06\x54\x65\x73\x74' 775 | 776 | with pytest.raises(InvalidPaddingError): 777 | decode_frame(data) 778 | 779 | def test_headers_frame_with_no_length_parses(self): 780 | # Fixes issue with empty data frames raising InvalidPaddingError. 781 | f = HeadersFrame(1) 782 | f.data = b'' 783 | data = f.serialize() 784 | 785 | new_frame = decode_frame(data) 786 | assert new_frame.data == b'' 787 | 788 | 789 | class TestContinuationFrame: 790 | def test_repr(self): 791 | f = ContinuationFrame(1) 792 | assert repr(f).endswith("data=None") 793 | f.data = b'hello' 794 | assert repr(f).endswith("data=") 795 | 796 | def test_continuation_frame_flags(self): 797 | f = ContinuationFrame(1) 798 | flags = f.parse_flags(0xFF) 799 | 800 | assert flags == set(['END_HEADERS']) 801 | 802 | def test_continuation_frame_serializes(self): 803 | f = ContinuationFrame(1) 804 | f.parse_flags(0x04) 805 | f.data = b'hello world' 806 | 807 | s = f.serialize() 808 | assert s == ( 809 | b'\x00\x00\x0B\x09\x04\x00\x00\x00\x01' + 810 | b'hello world' 811 | ) 812 | 813 | def test_continuation_frame_parses_properly(self): 814 | s = b'\x00\x00\x0B\x09\x04\x00\x00\x00\x01hello world' 815 | f = decode_frame(s) 816 | 817 | assert isinstance(f, ContinuationFrame) 818 | assert f.flags == set(['END_HEADERS']) 819 | assert f.data == b'hello world' 820 | assert f.body_len == 11 821 | 822 | 823 | class TestAltSvcFrame: 824 | payload_with_origin = ( 825 | b'\x00\x00\x31' # Length 826 | b'\x0A' # Type 827 | b'\x00' # Flags 828 | b'\x00\x00\x00\x00' # Stream ID 829 | b'\x00\x0B' # Origin len 830 | b'example.com' # Origin 831 | b'h2="alt.example.com:8000", h2=":443"' # Field Value 832 | ) 833 | payload_without_origin = ( 834 | b'\x00\x00\x13' # Length 835 | b'\x0A' # Type 836 | b'\x00' # Flags 837 | b'\x00\x00\x00\x01' # Stream ID 838 | b'\x00\x00' # Origin len 839 | b'' # Origin 840 | b'h2=":8000"; ma=60' # Field Value 841 | ) 842 | payload_with_origin_and_stream = ( 843 | b'\x00\x00\x36' # Length 844 | b'\x0A' # Type 845 | b'\x00' # Flags 846 | b'\x00\x00\x00\x01' # Stream ID 847 | b'\x00\x0B' # Origin len 848 | b'example.com' # Origin 849 | b'Alt-Svc: h2=":443"; ma=2592000; persist=1' # Field Value 850 | ) 851 | 852 | def test_repr(self): 853 | f = AltSvcFrame(0) 854 | assert repr(f).endswith("origin=b'', field=b''") 855 | f.field = b'h2="alt.example.com:8000", h2=":443"' 856 | assert repr(f).endswith("origin=b'', field=b'h2=\"alt.example.com:8000\", h2=\":443\"'") 857 | f.origin = b'example.com' 858 | assert repr(f).endswith("origin=b'example.com', field=b'h2=\"alt.example.com:8000\", h2=\":443\"'") 859 | 860 | def test_altsvc_frame_flags(self): 861 | f = AltSvcFrame(0) 862 | flags = f.parse_flags(0xFF) 863 | 864 | assert flags == set() 865 | 866 | def test_altsvc_frame_with_origin_serializes_properly(self): 867 | f = AltSvcFrame(0) 868 | f.origin = b'example.com' 869 | f.field = b'h2="alt.example.com:8000", h2=":443"' 870 | 871 | s = f.serialize() 872 | assert s == self.payload_with_origin 873 | 874 | def test_altsvc_frame_with_origin_parses_properly(self): 875 | f = decode_frame(self.payload_with_origin) 876 | 877 | assert isinstance(f, AltSvcFrame) 878 | assert f.origin == b'example.com' 879 | assert f.field == b'h2="alt.example.com:8000", h2=":443"' 880 | assert f.body_len == 49 881 | assert f.stream_id == 0 882 | 883 | def test_altsvc_frame_without_origin_serializes_properly(self): 884 | f = AltSvcFrame(1, origin=b'', field=b'h2=":8000"; ma=60') 885 | s = f.serialize() 886 | assert s == self.payload_without_origin 887 | 888 | def test_altsvc_frame_without_origin_parses_properly(self): 889 | f = decode_frame(self.payload_without_origin) 890 | 891 | assert isinstance(f, AltSvcFrame) 892 | assert f.origin == b'' 893 | assert f.field == b'h2=":8000"; ma=60' 894 | assert f.body_len == 19 895 | assert f.stream_id == 1 896 | 897 | def test_altsvc_frame_with_origin_and_stream_serializes_properly(self): 898 | # This frame is not valid, but we allow it to be serialized anyway. 899 | f = AltSvcFrame(1) 900 | f.origin = b'example.com' 901 | f.field = b'Alt-Svc: h2=":443"; ma=2592000; persist=1' 902 | 903 | assert f.serialize() == self.payload_with_origin_and_stream 904 | 905 | def test_short_altsvc_frame_errors(self): 906 | with pytest.raises(InvalidFrameError): 907 | decode_frame(self.payload_with_origin[:12]) 908 | 909 | with pytest.raises(InvalidFrameError): 910 | decode_frame(self.payload_with_origin[:10]) 911 | 912 | def test_altsvc_with_unicode_origin_fails(self): 913 | with pytest.raises(InvalidDataError): 914 | AltSvcFrame( 915 | stream_id=0, origin=u'hello', field=b'h2=":8000"; ma=60' 916 | 917 | ) 918 | 919 | def test_altsvc_with_unicode_field_fails(self): 920 | with pytest.raises(InvalidDataError): 921 | AltSvcFrame( 922 | stream_id=0, origin=b'hello', field=u'h2=":8000"; ma=60' 923 | ) 924 | 925 | 926 | class TestExtensionFrame: 927 | def test_repr(self): 928 | f = ExtensionFrame(0xFF, 1, 42, b'hello') 929 | assert repr(f).endswith("type=255, flag_byte=42, body=") 930 | --------------------------------------------------------------------------------