├── python └── httparse │ ├── py.typed │ ├── _types.py │ ├── __init__.py │ └── _httparse.pyi ├── requirements-dev.txt ├── requirements-bench.txt ├── .gitignore ├── Cargo.toml ├── Makefile ├── README.md ├── LICENSE.txt ├── .pre-commit-config.yaml ├── pyproject.toml ├── test_httparse.py ├── bench.ipynb ├── src └── lib.rs ├── Cargo.lock └── .github └── workflows └── python.yaml /python/httparse/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /python/httparse/_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.2.5 2 | maturin>=0.13.0<14 3 | pre-commit>=2.16.0 4 | -------------------------------------------------------------------------------- /requirements-bench.txt: -------------------------------------------------------------------------------- 1 | -r requirements-dev.txt 2 | matplotlib==3.5.0 3 | jupyter==1.0.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !/.gitignore 4 | !/src 5 | !/Cargo.toml 6 | !/Cargo.lock 7 | !/pyproject.toml 8 | !/python 9 | !/test_httparse.py 10 | !/bench.ipynb 11 | !/requirements-dev.txt 12 | !/requirements-bench.txt 13 | !/.pre-commit-config.yaml 14 | !/Makefile 15 | !/README.md 16 | !/.github 17 | !/LICENSE.txt 18 | 19 | __pycache__ 20 | *.so -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "httparse" 3 | version = "0.2.1" 4 | edition = "2021" 5 | description = "Push parser for HTTP 1.x" 6 | readme = "README.md" 7 | license-file = "LICENSE.txt" 8 | 9 | [lib] 10 | name = "httparse" 11 | crate-type = ["cdylib"] 12 | 13 | [dependencies.pyo3] 14 | version = "^0.17.2" 15 | features = ["extension-module", "abi3-py37"] 16 | 17 | [dependencies.httparse] 18 | version = "^1.8.0" 19 | 20 | [package.metadata.maturin] 21 | python-source = "python" 22 | name = "httparse._httparse" 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PHONY: init build test 2 | 3 | .init: 4 | rm -rf .venv 5 | python -m venv .venv 6 | ./.venv/bin/pip install -U pip wheel setuptools 7 | ./.venv/bin/pip install -r requirements-dev.txt -r requirements-bench.txt 8 | ./.venv/bin/pre-commit install 9 | touch .init 10 | 11 | .clean: 12 | rm -rf .init 13 | 14 | init: .clean .init 15 | 16 | build-develop: .init 17 | . ./.venv/bin/activate && maturin develop --release --strip 18 | 19 | test: build-develop 20 | ./.venv/bin/python test_httparse.py 21 | 22 | lint: build-develop 23 | ./.venv/bin/pre-commit run --all-files 24 | -------------------------------------------------------------------------------- /python/httparse/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from httparse._httparse import ( 4 | Header, 5 | InvalidByteInNewLine, 6 | InvalidByteRangeInResponseStatus, 7 | InvalidChunkSize, 8 | InvalidHeaderName, 9 | InvalidHeaderValue, 10 | InvalidHTTPVersion, 11 | InvalidStatus, 12 | InvalidToken, 13 | ParsedRequest, 14 | ParsingError, 15 | RequestParser, 16 | TooManyHeaders, 17 | ) 18 | 19 | __all__ = ( 20 | "RequestParser", 21 | "ParsedRequest", 22 | "Header", 23 | "ParsingError", 24 | "InvalidChunkSize", 25 | "InvalidHeaderName", 26 | "InvalidHeaderValue", 27 | "InvalidByteInNewLine", 28 | "InvalidByteRangeInResponseStatus", 29 | "InvalidToken", 30 | "TooManyHeaders", 31 | "InvalidHTTPVersion", 32 | "InvalidStatus", 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httparse 2 | 3 | ![CI](https://github.com/adriangb/httparse/actions/workflows/python.yaml/badge.svg) 4 | 5 | Python wrapper for Rust's [httparse](https://github.com/seanmonstar/httparse). 6 | See this project on [GitHub](https://github.com/adriangb/httparse). 7 | 8 | ## Example 9 | 10 | ```python 11 | from httparse import RequestParser 12 | 13 | parser = RequestParser() 14 | 15 | buff = b"GET /index.html HTTP/1.1\r\nHost" 16 | parsed = parser.parse(buff) 17 | assert parsed is None 18 | 19 | # a partial request, so we try again once we have more data 20 | buff = b"GET /index.html HTTP/1.1\r\nHost: example.domain\r\n\r\n" 21 | parsed = parser.parse(buff) 22 | assert parsed is not None 23 | assert parsed.method == "GET" 24 | assert parsed.path == "/index.html" 25 | assert parsed.version == 1 26 | assert parsed.body_start_offset == len(buff) 27 | headers = [(h.name.encode(), h.value) for h in parsed.headers] 28 | assert headers == [(b"Host", b"example.domain")] 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Adrian Garcia Badaracco 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | files: ^python/.*|^tests/.*|^src/.* 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 22.3.0 5 | hooks: 6 | - id: black 7 | - repo: local 8 | hooks: 9 | - id: cargo-fmt 10 | name: cargo-fmt 11 | entry: cargo fmt 12 | language: system 13 | types: [rust] 14 | pass_filenames: false 15 | - id: cargo-clippy 16 | name: cargo-clippy 17 | entry: cargo clippy 18 | language: system 19 | types: [rust] 20 | pass_filenames: false 21 | - repo: https://gitlab.com/pycqa/flake8 22 | rev: 3.9.2 23 | hooks: 24 | - id: flake8 25 | args: ["--max-line-length=88"] 26 | - repo: https://github.com/pre-commit/mirrors-mypy 27 | rev: v0.950 28 | hooks: 29 | - id: mypy 30 | - repo: https://github.com/pre-commit/pre-commit-hooks 31 | rev: v4.2.0 32 | hooks: 33 | - id: end-of-file-fixer 34 | - id: trailing-whitespace 35 | - repo: https://github.com/pycqa/isort 36 | rev: 5.10.1 37 | hooks: 38 | - id: isort 39 | name: isort (python) 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "httparse" 4 | repository = "https://github.com/adriangb/httparse" 5 | description = "Push parser for HTTP 1.x" 6 | authors = [ 7 | {name = "Adrian Garcia Badaracco"} 8 | ] 9 | license = { text = "MIT" } 10 | classifiers=[ 11 | "Development Status :: 3 - Alpha", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Topic :: Software Development", 15 | "Topic :: Software Development :: Libraries", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | ] 18 | dependencies = [ 19 | "typing-extensions>=3; python_version < '3.8'", 20 | ] 21 | requires-python = ">=3.7" 22 | 23 | 24 | [project.urls] 25 | homepage = "https://github.com/adriangb/httparse" 26 | documentation = "https://github.com/adriangb/httparse/README.md" 27 | repository = "https://github.com/adriangb/httparse" 28 | 29 | [build-system] 30 | requires = ["maturin>=0.13.0<14"] 31 | build-backend = "maturin" 32 | 33 | [tool.maturin] 34 | sdist-include = ["Cargo.lock"] 35 | strip = true 36 | 37 | [tool.isort] 38 | profile = "black" 39 | -------------------------------------------------------------------------------- /python/httparse/_httparse.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Sequence 5 | 6 | if sys.version_info < (3, 8): 7 | from typing_extensions import Protocol 8 | else: 9 | from typing import Protocol 10 | 11 | class InvalidChunkSize(Exception): 12 | pass 13 | 14 | class ParsingError(Exception): 15 | pass 16 | 17 | class InvalidHeaderName(ParsingError): 18 | pass 19 | 20 | class InvalidHeaderValue(ParsingError): 21 | pass 22 | 23 | class InvalidByteInNewLine(ParsingError): 24 | pass 25 | 26 | class InvalidByteRangeInResponseStatus(ParsingError): 27 | pass 28 | 29 | class InvalidToken(ParsingError): 30 | pass 31 | 32 | class TooManyHeaders(ParsingError): 33 | pass 34 | 35 | class InvalidHTTPVersion(ParsingError): 36 | pass 37 | 38 | class InvalidStatus(ParsingError): 39 | pass 40 | 41 | class Header(Protocol): 42 | @property 43 | def name(self) -> str: ... 44 | @property 45 | def value(self) -> bytes: ... 46 | 47 | class ParsedRequest: 48 | @property 49 | def method(self) -> str: ... 50 | @property 51 | def path(self) -> str: ... 52 | @property 53 | def version(self) -> int: ... 54 | @property 55 | def headers(self) -> Sequence[Header]: ... 56 | @property 57 | def body_start_offset(self) -> int: ... 58 | 59 | class RequestParser: 60 | def parse(self, __buf: bytes) -> ParsedRequest | None: ... 61 | -------------------------------------------------------------------------------- /test_httparse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from dataclasses import dataclass 5 | from typing import Dict, Set, Type 6 | 7 | import pytest 8 | 9 | from httparse import RequestParser, ParsedRequest, InvalidHTTPVersion, InvalidHeaderName 10 | 11 | 12 | Headers = Dict[str, Set[bytes]] 13 | 14 | 15 | @dataclass(frozen=True) 16 | class ParsedResultWrapper: 17 | method: str 18 | path: str 19 | version: int 20 | headers: Headers 21 | body_start_offset: int 22 | 23 | @classmethod 24 | def from_res(cls, res: ParsedRequest) -> ParsedResultWrapper: 25 | headers: Dict[str, Set[bytes]] = defaultdict(set) 26 | for header in res.headers: 27 | for value in header.value.split(b","): 28 | headers[header.name].add(value) 29 | return cls( 30 | method=res.method, 31 | path=res.path, 32 | version=res.version, 33 | headers=headers, 34 | body_start_offset=res.body_start_offset, 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "buff,expected", 40 | [ 41 | ( 42 | b"GET /index.html HTTP/1.1\r\nHost: example.domain\r\n\r\n", 43 | ParsedResultWrapper(method="GET", path="/index.html", version=1, headers={"Host": {b"example.domain"}}, body_start_offset=50), 44 | ), 45 | ( 46 | b"PUT / HTTP/1.1\r\nX-Foo: foo1,foo2\r\nX-Bar: bar\r\nX-Foo: foo3\r\n\r\n", 47 | ParsedResultWrapper(method="PUT", path="/", version=1, headers={"X-Bar": {b"bar"}, "X-Foo": {b"foo1", b"foo2", b"foo3"}}, body_start_offset=61), 48 | ), 49 | ], 50 | ) 51 | def test_parse_complete( 52 | buff: bytes, 53 | expected: ParsedResultWrapper, 54 | ) -> None: 55 | parser = RequestParser() 56 | parsed = parser.parse(buff) 57 | assert parsed is not None 58 | 59 | got = ParsedResultWrapper.from_res(parsed) 60 | 61 | assert got == expected 62 | 63 | 64 | def test_parse_partial() -> None: 65 | parser = RequestParser() 66 | parsed = parser.parse(b"GET /index.html HTTP/1.1\r\nHost") 67 | 68 | assert parsed is None 69 | 70 | parsed = parser.parse(b"GET /index.html HTTP/1.1\r\nHost: example.domain\r\n\r\n") 71 | 72 | assert parsed is not None 73 | 74 | 75 | def test_parse_bytearray() -> None: 76 | parser = RequestParser() 77 | parsed = parser.parse(bytearray(b"GET /index.html HTTP/1.1\r\nHost: example.domain\r\n\r\n")) 78 | assert parsed is not None 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "buff,exc", [ 83 | (b"GET /index.html HTTP/1.2", InvalidHTTPVersion), 84 | ("GET /index.html HTTP/1.1\r\nX-Café: example.domain\r\n\r\n".encode(), InvalidHeaderName) 85 | ] 86 | ) 87 | def test_parsing_exceptions( 88 | buff: bytes, 89 | exc: Type[Exception] 90 | ) -> None: 91 | parser = RequestParser() 92 | with pytest.raises(exc): 93 | parser.parse(buff) 94 | 95 | 96 | 97 | if __name__ == "__main__": 98 | pytest.main( 99 | [ 100 | __file__, 101 | ] 102 | ) 103 | -------------------------------------------------------------------------------- /bench.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from httparse import RequestParser\n", 10 | "from httptools import HttpRequestParser" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "small = b\"GET /cookies/foo/bar/baz?a=1&b=2 HTTP/1.1\\r\\nHost: 127.0.0.1:8090\\r\\nConnection: keep-alive\\r\\nCache-Control: max-age=0\\r\\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\\r\\nUser-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.17 (KHTML, like Gecko) Chrome/24.0.1312.56 Safari/537.17\\r\\nAccept-Encoding: gzip,deflate,sdch\\r\\nAccept-Language: en-US,en;q=0.8\\r\\nAccept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3\\r\\nCookie: name=wookie\\r\\n\\r\\n\"" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": 3, 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "import string\n", 29 | "import random\n", 30 | "import itertools\n", 31 | "from typing import Iterable\n", 32 | "\n", 33 | "CHARS = string.ascii_uppercase + string.digits + string.ascii_lowercase\n", 34 | "\n", 35 | "CHUNK_SIZE = 65_536\n", 36 | "\n", 37 | "\n", 38 | "def get_random_string(n: int) -> str:\n", 39 | " return ''.join(random.choices(CHARS, k=n))\n", 40 | "\n", 41 | "headers = [\n", 42 | " (f\"X-{get_random_string(15)}\".encode(), get_random_string(1024))\n", 43 | " for _ in range(128)\n", 44 | "]\n", 45 | "\n", 46 | "headers_data = \"\\r\\n\".join([f\"{name}: {val}\" for name, val in headers])\n", 47 | "\n", 48 | "large = f\"GET /cookies/foo/bar/baz?a=1&b=2 HTTP/1.1\\r\\n{headers_data}\\r\\n\\r\\n\".encode()\n", 49 | "\n", 50 | "def grouper(n: int, data: bytes) -> Iterable[bytes]:\n", 51 | " it = iter(data)\n", 52 | " while True:\n", 53 | " chunk = tuple(itertools.islice(it, n))\n", 54 | " if not chunk:\n", 55 | " return\n", 56 | " yield bytes(chunk)" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 4, 62 | "metadata": {}, 63 | "outputs": [], 64 | "source": [ 65 | "def parse_until_complete_httparse(parser: RequestParser, chunks: Iterable[bytes]):\n", 66 | " buff = bytearray()\n", 67 | " for chunk in chunks:\n", 68 | " buff.extend(chunk)\n", 69 | " res = parser.parse(bytes(buff))\n", 70 | " if res is not None:\n", 71 | " return\n", 72 | "\n", 73 | "class Proto:\n", 74 | " __slots__ = (\"done\")\n", 75 | " def __init__(self):\n", 76 | " self.done = False\n", 77 | " def on_headers_complete(self):\n", 78 | " self.done = True\n", 79 | "\n", 80 | "def parse_until_complete_httptools(chunks: Iterable[bytes]):\n", 81 | " proto = Proto()\n", 82 | " parser = HttpRequestParser(proto)\n", 83 | " for chunk in chunks:\n", 84 | " parser.feed_data(chunk)\n", 85 | " if proto.done:\n", 86 | " return\n", 87 | "\n", 88 | "\n", 89 | "SMALL_CHUNKS = list(grouper(CHUNK_SIZE, small))\n", 90 | "LARGE_CHUNKS = list(grouper(CHUNK_SIZE, large))" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 5, 96 | "metadata": {}, 97 | "outputs": [ 98 | { 99 | "name": "stdout", 100 | "output_type": "stream", 101 | "text": [ 102 | "2.21 µs ± 64.4 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)\n" 103 | ] 104 | } 105 | ], 106 | "source": [ 107 | "%timeit parse_until_complete_httptools(SMALL_CHUNKS)" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": 6, 113 | "metadata": {}, 114 | "outputs": [ 115 | { 116 | "name": "stdout", 117 | "output_type": "stream", 118 | "text": [ 119 | "1.32 µs ± 10.3 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)\n" 120 | ] 121 | } 122 | ], 123 | "source": [ 124 | "parser = RequestParser()\n", 125 | "%timeit parse_until_complete_httparse(parser, SMALL_CHUNKS)" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": 7, 131 | "metadata": {}, 132 | "outputs": [ 133 | { 134 | "name": "stdout", 135 | "output_type": "stream", 136 | "text": [ 137 | "49.1 µs ± 739 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)\n" 138 | ] 139 | } 140 | ], 141 | "source": [ 142 | "%timeit parse_until_complete_httptools(LARGE_CHUNKS)" 143 | ] 144 | }, 145 | { 146 | "cell_type": "code", 147 | "execution_count": 8, 148 | "metadata": {}, 149 | "outputs": [ 150 | { 151 | "name": "stdout", 152 | "output_type": "stream", 153 | "text": [ 154 | "102 µs ± 1.31 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)\n" 155 | ] 156 | } 157 | ], 158 | "source": [ 159 | "parser = RequestParser()\n", 160 | "%timeit parse_until_complete_httparse(parser, LARGE_CHUNKS)" 161 | ] 162 | } 163 | ], 164 | "metadata": { 165 | "kernelspec": { 166 | "display_name": "Python 3.10.3 ('.venv': venv)", 167 | "language": "python", 168 | "name": "python3" 169 | }, 170 | "language_info": { 171 | "codemirror_mode": { 172 | "name": "ipython", 173 | "version": 3 174 | }, 175 | "file_extension": ".py", 176 | "mimetype": "text/x-python", 177 | "name": "python", 178 | "nbconvert_exporter": "python", 179 | "pygments_lexer": "ipython3", 180 | "version": "3.10.8" 181 | }, 182 | "orig_nbformat": 4, 183 | "vscode": { 184 | "interpreter": { 185 | "hash": "648e8473d8d9cae672a869becd3efe538bf298395a14c3f7dba08be45ce71d6a" 186 | } 187 | } 188 | }, 189 | "nbformat": 4, 190 | "nbformat_minor": 2 191 | } 192 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::str; 2 | 3 | use pyo3::create_exception; 4 | use pyo3::exceptions::PyException; 5 | use pyo3::prelude::*; 6 | use pyo3::types::{PyByteArray, PyBytes, PyList, PyString}; 7 | use pyo3::Python; 8 | 9 | create_exception!(_httparse, InvalidChunkSize, PyException); 10 | create_exception!(_httparse, ParsingError, PyException); 11 | create_exception!(_httparse, InvalidHeaderName, ParsingError); 12 | create_exception!(_httparse, InvalidHeaderValue, ParsingError); 13 | create_exception!(_httparse, InvalidByteInNewLine, ParsingError); 14 | create_exception!(_httparse, InvalidByteRangeInResponseStatus, ParsingError); 15 | create_exception!(_httparse, InvalidToken, ParsingError); 16 | create_exception!(_httparse, TooManyHeaders, ParsingError); 17 | create_exception!(_httparse, InvalidHTTPVersion, ParsingError); 18 | create_exception!(_httparse, InvalidStatus, ParsingError); 19 | 20 | #[pyclass(module = "httparse._httparse")] 21 | #[derive(Clone, Debug)] 22 | struct Header { 23 | #[pyo3(get)] 24 | name: Py, 25 | #[pyo3(get)] 26 | value: Py, 27 | } 28 | 29 | #[pymethods] 30 | impl Header { 31 | fn __repr__(&self, py: Python<'_>) -> PyResult { 32 | let value = self.value.as_ref(py).as_bytes(); 33 | Ok(format!( 34 | "Header(name=\"{}\", value=b\"{}\")", 35 | self.name, 36 | str::from_utf8(value)? 37 | )) 38 | } 39 | fn __str__(&self, py: Python<'_>) -> PyResult { 40 | self.__repr__(py) 41 | } 42 | } 43 | 44 | #[pyclass(module = "httparse._httparse")] 45 | #[derive(Clone, Debug)] 46 | struct ParsedRequest { 47 | #[pyo3(get)] 48 | method: Py, 49 | #[pyo3(get)] 50 | path: Py, 51 | #[pyo3(get)] 52 | version: u8, 53 | #[pyo3(get)] 54 | body_start_offset: usize, 55 | #[pyo3(get)] 56 | headers: Py, 57 | } 58 | 59 | macro_rules! intern_match { 60 | ($py:expr, $value: expr, $($interned:expr),+) => { 61 | match $value { 62 | $($interned => ::pyo3::intern!($py, $interned),)+ 63 | name => ::pyo3::types::PyString::new($py, name), 64 | } 65 | }; 66 | } 67 | 68 | #[pyclass(module = "httparse._httparse")] 69 | #[derive(Clone, Debug)] 70 | struct RequestParser {} 71 | 72 | #[derive(FromPyObject)] 73 | enum PyData<'a> { 74 | Bytes(&'a PyBytes), 75 | ByteArray(&'a PyByteArray), 76 | } 77 | 78 | #[pymethods] 79 | impl RequestParser { 80 | #[new] 81 | fn py_new() -> Self { 82 | RequestParser {} 83 | } 84 | fn parse(&mut self, buff: PyData, py: Python<'_>) -> PyResult> { 85 | let mut empty_headers = [httparse::EMPTY_HEADER; 256]; 86 | let mut request = httparse::Request::new(&mut empty_headers); 87 | let maybe_status = request.parse(match buff { 88 | PyData::Bytes(d) => d.as_bytes(), 89 | PyData::ByteArray(d) => unsafe { d.as_bytes() }, 90 | }); 91 | match maybe_status { 92 | Ok(httparse::Status::Complete(body_start_offset)) => { 93 | let headers: Py = PyList::new( 94 | py, 95 | request.headers.iter_mut().map(|h| { 96 | Py::new( 97 | py, 98 | Header { 99 | name: { 100 | intern_match!( 101 | py, 102 | h.name, 103 | "Host", 104 | "Connection", 105 | "Cache-Control", 106 | "Accept", 107 | "User-Agent", 108 | "Accept-Encoding", 109 | "Accept-Language", 110 | "Accept-Charset", 111 | "Cookie" 112 | ) 113 | } 114 | .into(), 115 | value: PyBytes::new(py, h.value).into(), 116 | }, 117 | ) 118 | .unwrap() 119 | }), 120 | ) 121 | .into(); 122 | let method = intern_match!( 123 | py, 124 | request.method.unwrap(), 125 | "GET", 126 | "POST", 127 | "PUT", 128 | "PATCH", 129 | "DELETE", 130 | "HEAD", 131 | "OPTIONS", 132 | "TRACE", 133 | "CONNECT" 134 | ) 135 | .into(); 136 | 137 | Ok(Some(ParsedRequest { 138 | method, 139 | path: PyString::new(py, request.path.unwrap()).into(), 140 | version: request.version.unwrap(), 141 | headers, 142 | body_start_offset, 143 | })) 144 | } 145 | Ok(httparse::Status::Partial) => Ok(None), 146 | Err(parse_error) => match parse_error { 147 | httparse::Error::HeaderName => Err(InvalidHeaderName::new_err(())), 148 | httparse::Error::HeaderValue => Err(InvalidHeaderValue::new_err(())), 149 | httparse::Error::NewLine => Err(InvalidByteInNewLine::new_err(())), 150 | httparse::Error::Token => Err(InvalidToken::new_err(())), 151 | httparse::Error::TooManyHeaders => Err(TooManyHeaders::new_err(())), 152 | httparse::Error::Version => Err(InvalidHTTPVersion::new_err(())), 153 | httparse::Error::Status => Err(InvalidStatus::new_err(())), 154 | }, 155 | } 156 | } 157 | } 158 | 159 | #[pymodule] 160 | fn _httparse(py: Python<'_>, m: &PyModule) -> PyResult<()> { 161 | m.add_class::
()?; 162 | m.add_class::()?; 163 | m.add_class::()?; 164 | m.add("InvalidChunkSize", py.get_type::())?; 165 | m.add("ParsingError", py.get_type::())?; 166 | m.add("InvalidHeaderName", py.get_type::())?; 167 | m.add("InvalidHeaderValue", py.get_type::())?; 168 | m.add( 169 | "InvalidByteInNewLine", 170 | py.get_type::(), 171 | )?; 172 | m.add( 173 | "InvalidByteRangeInResponseStatus", 174 | py.get_type::(), 175 | )?; 176 | m.add("InvalidToken", py.get_type::())?; 177 | m.add("TooManyHeaders", py.get_type::())?; 178 | m.add("InvalidHTTPVersion", py.get_type::())?; 179 | m.add("InvalidStatus", py.get_type::())?; 180 | Ok(()) 181 | } 182 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.1.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.3.2" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "httparse" 25 | version = "0.2.1" 26 | dependencies = [ 27 | "httparse 1.8.0", 28 | "pyo3", 29 | ] 30 | 31 | [[package]] 32 | name = "httparse" 33 | version = "1.8.0" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" 36 | 37 | [[package]] 38 | name = "indoc" 39 | version = "1.0.4" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "e7906a9fababaeacb774f72410e497a1d18de916322e33797bb2cd29baa23c9e" 42 | dependencies = [ 43 | "unindent", 44 | ] 45 | 46 | [[package]] 47 | name = "instant" 48 | version = "0.1.12" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 51 | dependencies = [ 52 | "cfg-if", 53 | ] 54 | 55 | [[package]] 56 | name = "libc" 57 | version = "0.2.108" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119" 60 | 61 | [[package]] 62 | name = "lock_api" 63 | version = "0.4.5" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" 66 | dependencies = [ 67 | "scopeguard", 68 | ] 69 | 70 | [[package]] 71 | name = "memoffset" 72 | version = "0.6.5" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" 75 | dependencies = [ 76 | "autocfg", 77 | ] 78 | 79 | [[package]] 80 | name = "once_cell" 81 | version = "1.8.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" 84 | 85 | [[package]] 86 | name = "parking_lot" 87 | version = "0.11.2" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" 90 | dependencies = [ 91 | "instant", 92 | "lock_api", 93 | "parking_lot_core", 94 | ] 95 | 96 | [[package]] 97 | name = "parking_lot_core" 98 | version = "0.8.5" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" 101 | dependencies = [ 102 | "cfg-if", 103 | "instant", 104 | "libc", 105 | "redox_syscall", 106 | "smallvec", 107 | "winapi", 108 | ] 109 | 110 | [[package]] 111 | name = "proc-macro2" 112 | version = "1.0.32" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" 115 | dependencies = [ 116 | "unicode-xid", 117 | ] 118 | 119 | [[package]] 120 | name = "pyo3" 121 | version = "0.17.2" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "201b6887e5576bf2f945fe65172c1fcbf3fcf285b23e4d71eb171d9736e38d32" 124 | dependencies = [ 125 | "cfg-if", 126 | "indoc", 127 | "libc", 128 | "memoffset", 129 | "parking_lot", 130 | "pyo3-build-config", 131 | "pyo3-ffi", 132 | "pyo3-macros", 133 | "unindent", 134 | ] 135 | 136 | [[package]] 137 | name = "pyo3-build-config" 138 | version = "0.17.2" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "bf0708c9ed01692635cbf056e286008e5a2927ab1a5e48cdd3aeb1ba5a6fef47" 141 | dependencies = [ 142 | "once_cell", 143 | "target-lexicon", 144 | ] 145 | 146 | [[package]] 147 | name = "pyo3-ffi" 148 | version = "0.17.2" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "90352dea4f486932b72ddf776264d293f85b79a1d214de1d023927b41461132d" 151 | dependencies = [ 152 | "libc", 153 | "pyo3-build-config", 154 | ] 155 | 156 | [[package]] 157 | name = "pyo3-macros" 158 | version = "0.17.2" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "7eb24b804a2d9e88bfcc480a5a6dd76f006c1e3edaf064e8250423336e2cd79d" 161 | dependencies = [ 162 | "proc-macro2", 163 | "pyo3-macros-backend", 164 | "quote", 165 | "syn", 166 | ] 167 | 168 | [[package]] 169 | name = "pyo3-macros-backend" 170 | version = "0.17.2" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "f22bb49f6a7348c253d7ac67a6875f2dc65f36c2ae64a82c381d528972bea6d6" 173 | dependencies = [ 174 | "proc-macro2", 175 | "quote", 176 | "syn", 177 | ] 178 | 179 | [[package]] 180 | name = "quote" 181 | version = "1.0.10" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" 184 | dependencies = [ 185 | "proc-macro2", 186 | ] 187 | 188 | [[package]] 189 | name = "redox_syscall" 190 | version = "0.2.10" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" 193 | dependencies = [ 194 | "bitflags", 195 | ] 196 | 197 | [[package]] 198 | name = "scopeguard" 199 | version = "1.1.0" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 202 | 203 | [[package]] 204 | name = "smallvec" 205 | version = "1.7.0" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" 208 | 209 | [[package]] 210 | name = "syn" 211 | version = "1.0.81" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" 214 | dependencies = [ 215 | "proc-macro2", 216 | "quote", 217 | "unicode-xid", 218 | ] 219 | 220 | [[package]] 221 | name = "target-lexicon" 222 | version = "0.12.4" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "c02424087780c9b71cc96799eaeddff35af2bc513278cda5c99fc1f5d026d3c1" 225 | 226 | [[package]] 227 | name = "unicode-xid" 228 | version = "0.2.2" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 231 | 232 | [[package]] 233 | name = "unindent" 234 | version = "0.1.8" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "514672a55d7380da379785a4d70ca8386c8883ff7eaae877be4d2081cebe73d8" 237 | 238 | [[package]] 239 | name = "winapi" 240 | version = "0.3.9" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 243 | dependencies = [ 244 | "winapi-i686-pc-windows-gnu", 245 | "winapi-x86_64-pc-windows-gnu", 246 | ] 247 | 248 | [[package]] 249 | name = "winapi-i686-pc-windows-gnu" 250 | version = "0.4.0" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 253 | 254 | [[package]] 255 | name = "winapi-x86_64-pc-windows-gnu" 256 | version = "0.4.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 259 | -------------------------------------------------------------------------------- /.github/workflows/python.yaml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PACKAGE_NAME: httparse 15 | PYTHON_VERSION: "3.7" # to build abi3 wheels 16 | 17 | jobs: 18 | macos: 19 | runs-on: macos-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ env.PYTHON_VERSION }} 25 | architecture: x64 26 | - name: Install Rust toolchain 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | profile: minimal 31 | default: true 32 | - name: Build wheels - x86_64 33 | uses: messense/maturin-action@v1 34 | with: 35 | target: x86_64 36 | args: --release --out dist --sdist 37 | maturin-version: "v0.13.0" 38 | - name: Install built wheel - x86_64 39 | run: | 40 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 41 | pip install pytest 42 | pytest -v 43 | - name: Build wheels - universal2 44 | uses: messense/maturin-action@v1 45 | with: 46 | args: --release --universal2 --out dist 47 | maturin-version: "v0.13.0" 48 | - name: Install built wheel - universal2 49 | run: | 50 | pip install dist/${{ env.PACKAGE_NAME }}-*universal2.whl --force-reinstall 51 | pip install pytest 52 | pytest -v 53 | - name: Upload wheels 54 | uses: actions/upload-artifact@v2 55 | with: 56 | name: wheels 57 | path: dist 58 | 59 | windows: 60 | runs-on: windows-latest 61 | strategy: 62 | matrix: 63 | target: [x64, x86] 64 | steps: 65 | - uses: actions/checkout@v3 66 | - uses: actions/setup-python@v4 67 | with: 68 | python-version: ${{ env.PYTHON_VERSION }} 69 | architecture: ${{ matrix.target }} 70 | - name: Install Rust toolchain 71 | uses: actions-rs/toolchain@v1 72 | with: 73 | toolchain: stable 74 | profile: minimal 75 | default: true 76 | - name: Build wheels 77 | uses: messense/maturin-action@v1 78 | with: 79 | target: ${{ matrix.target }} 80 | args: --release --out dist 81 | maturin-version: "v0.13.0" 82 | - name: Install built wheel 83 | shell: bash 84 | run: | 85 | python -m pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 86 | pip install pytest 87 | python -m pytest -v 88 | - name: Upload wheels 89 | uses: actions/upload-artifact@v2 90 | with: 91 | name: wheels 92 | path: dist 93 | 94 | linux: 95 | runs-on: ubuntu-latest 96 | strategy: 97 | matrix: 98 | target: [x86_64, i686] 99 | steps: 100 | - uses: actions/checkout@v3 101 | - uses: actions/setup-python@v4 102 | with: 103 | python-version: ${{ env.PYTHON_VERSION }} 104 | architecture: x64 105 | - name: Build wheels 106 | uses: messense/maturin-action@v1 107 | with: 108 | target: ${{ matrix.target }} 109 | manylinux: auto 110 | args: --release --out dist 111 | maturin-version: "v0.13.0" 112 | - name: Install built wheel 113 | if: matrix.target == 'x86_64' 114 | run: | 115 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 116 | pip install pytest 117 | pytest -v 118 | - name: Upload wheels 119 | uses: actions/upload-artifact@v2 120 | with: 121 | name: wheels 122 | path: dist 123 | 124 | linux-cross: 125 | runs-on: ubuntu-latest 126 | strategy: 127 | matrix: 128 | target: [aarch64, armv7, s390x, ppc64le, ppc64] 129 | steps: 130 | - uses: actions/checkout@v3 131 | - uses: actions/setup-python@v4 132 | with: 133 | python-version: ${{ env.PYTHON_VERSION }} 134 | - name: Build wheels 135 | uses: messense/maturin-action@v1 136 | with: 137 | target: ${{ matrix.target }} 138 | manylinux: auto 139 | args: --release --out dist 140 | maturin-version: "v0.13.0" 141 | - uses: uraimo/run-on-arch-action@v2.0.5 142 | if: matrix.target != 'ppc64' 143 | name: Install built wheel 144 | with: 145 | arch: ${{ matrix.target }} 146 | distro: ubuntu20.04 147 | githubToken: ${{ github.token }} 148 | install: | 149 | apt-get update 150 | apt-get install -y --no-install-recommends python3 python3-pip 151 | pip3 install -U pip 152 | run: | 153 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links dist/ --force-reinstall 154 | pip install pytest 155 | pytest -v 156 | - name: Upload wheels 157 | uses: actions/upload-artifact@v2 158 | with: 159 | name: wheels 160 | path: dist 161 | 162 | musllinux: 163 | runs-on: ubuntu-latest 164 | strategy: 165 | matrix: 166 | target: 167 | - x86_64-unknown-linux-musl 168 | - i686-unknown-linux-musl 169 | steps: 170 | - uses: actions/checkout@v3 171 | - uses: actions/setup-python@v4 172 | with: 173 | python-version: ${{ env.PYTHON_VERSION }} 174 | architecture: x64 175 | - name: Build wheels 176 | uses: messense/maturin-action@v1 177 | with: 178 | target: ${{ matrix.target }} 179 | manylinux: musllinux_1_2 180 | args: --release --out dist 181 | maturin-version: "v0.13.0" 182 | - name: Install built wheel 183 | if: matrix.target == 'x86_64-unknown-linux-musl' 184 | uses: addnab/docker-run-action@v3 185 | with: 186 | image: alpine:latest 187 | options: -v ${{ github.workspace }}:/io -w /io 188 | run: | 189 | apk add py3-pip 190 | pip3 install -U pip pytest 191 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links /io/dist/ --force-reinstall 192 | python3 -m pytest 193 | - name: Upload wheels 194 | uses: actions/upload-artifact@v2 195 | with: 196 | name: wheels 197 | path: dist 198 | 199 | musllinux-cross: 200 | runs-on: ubuntu-latest 201 | strategy: 202 | matrix: 203 | platform: 204 | - target: aarch64-unknown-linux-musl 205 | arch: aarch64 206 | - target: armv7-unknown-linux-musleabihf 207 | arch: armv7 208 | steps: 209 | - uses: actions/checkout@v3 210 | - uses: actions/setup-python@v4 211 | with: 212 | python-version: ${{ env.PYTHON_VERSION }} 213 | - name: Build wheels 214 | uses: messense/maturin-action@v1 215 | with: 216 | target: ${{ matrix.platform.target }} 217 | manylinux: musllinux_1_2 218 | args: --release --out dist 219 | maturin-version: "v0.13.0" 220 | - uses: uraimo/run-on-arch-action@master 221 | name: Install built wheel 222 | with: 223 | arch: ${{ matrix.platform.arch }} 224 | distro: alpine_latest 225 | githubToken: ${{ github.token }} 226 | install: | 227 | apk add py3-pip 228 | pip3 install -U pip pytest 229 | run: | 230 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links dist/ --force-reinstall 231 | python3 -m pytest 232 | - name: Upload wheels 233 | uses: actions/upload-artifact@v2 234 | with: 235 | name: wheels 236 | path: dist 237 | 238 | pypy: 239 | runs-on: ${{ matrix.os }} 240 | strategy: 241 | matrix: 242 | os: [ubuntu-latest, macos-latest] 243 | target: [x86_64, aarch64] 244 | python-version: 245 | - '3.7' 246 | - '3.8' 247 | - '3.9' 248 | exclude: 249 | - os: macos-latest 250 | target: aarch64 251 | steps: 252 | - uses: actions/checkout@v3 253 | - uses: actions/setup-python@v4 254 | with: 255 | python-version: pypy${{ matrix.python-version }} 256 | - name: Build wheels 257 | uses: messense/maturin-action@v1 258 | with: 259 | maturin-version: "v0.13.0" 260 | target: ${{ matrix.target }} 261 | manylinux: auto 262 | args: --release --out dist -i pypy${{ matrix.python-version }} 263 | - name: Install built wheel 264 | if: matrix.target == 'x86_64' 265 | run: | 266 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 267 | pip install pytest 268 | pytest -v 269 | - name: Upload wheels 270 | uses: actions/upload-artifact@v2 271 | with: 272 | name: wheels 273 | path: dist 274 | 275 | lint: 276 | runs-on: ubuntu-latest 277 | strategy: 278 | matrix: 279 | # Lint on earliest and latest 280 | python: ["3.7", "3.x"] 281 | steps: 282 | - uses: actions/checkout@v2 283 | - name: Set up Python 284 | uses: actions/setup-python@v2 285 | with: 286 | python-version: "3.x" 287 | - name: Install Rust toolchain 288 | uses: actions-rs/toolchain@v1 289 | with: 290 | toolchain: stable 291 | profile: minimal 292 | default: true 293 | - name: Lint 294 | run: | 295 | make lint 296 | release: 297 | name: Release 298 | runs-on: ubuntu-latest 299 | needs: 300 | - lint 301 | - macos 302 | - windows 303 | - linux 304 | - linux-cross 305 | - musllinux 306 | - musllinux-cross 307 | - pypy 308 | if: ${{ github.ref == 'refs/heads/main' }} 309 | steps: 310 | - uses: actions/download-artifact@v2 311 | with: 312 | name: wheels 313 | - uses: actions/setup-python@v2 314 | - name: Publish to PyPi 315 | env: 316 | TWINE_USERNAME: __token__ 317 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 318 | run: | 319 | pip install --upgrade twine 320 | twine upload --skip-existing * 321 | --------------------------------------------------------------------------------