Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: TypeChecks
on: push

jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements-dev.txt
allow-prereleases: true
- run: pip install -r requirements-dev.txt
- run: pyflakes mureq.py
- run: flake8 mureq.py
- run: mypy mureq.py
- run: pyrefly check mureq.py
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Test
on: push

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
- run: python -m unittest tests.test_unit
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# Changelog
All notable changes to mureq will be documented in this file.

## [0.3.0] - 2026-03-16

v0.3.0 is the third release of mureq.

### API breaks
* Repeated headers in `Response.headers` are now joined with `, ` instead of `,`, matching the Requests behavior

### Fixed
* Redirect handling of [303 See Other](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303) now clears the request body

### Added
* Added `Response.raw_headers`, which contains the original unjoined headers as a list of string pairs
* Added type annotations (thanks [@hbmartin](https://github.com/hbmartin)!)

## [0.2.0] - 2022-02-03

v0.2.0 is the second release of mureq.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ However, the API design of python-requests is excellent and in my opinion still

mureq supports Python 3.6 and higher. Copy `mureq.py` into a suitable directory of your project, then import as you would any other internal module, e.g. `import .mureq` or `import bar.baz.mureq`.

Supply-chain attacks are considerably mitigated simply by vendoring mureq (i.e. copying it into your tree). If you are also concerned about future attacks on this GitHub account (or GitHub itself), tagged releases of mureq will be signed with the GPG key `0x740FC947B135E7627D4D00F21996B89DF018DCAB` (expires 2025-07-28), or some future key in a chain of trust from it.
Supply-chain attacks are considerably mitigated simply by vendoring mureq (i.e. copying it into your tree). If you are also concerned about future attacks on this GitHub account (or GitHub itself), tagged releases of mureq will be signed with the GPG key `0x740FC947B135E7627D4D00F21996B89DF018DCAB` (expires 2030-07-24), or some future key in a chain of trust from it.

Vendoring mureq's tests is not recommended. The tests rely on third-party HTTP services, so including them in a project-specific test suite or CI/CD pipeline will reduce the reliability of your project's tests and also risks overburdening the third-party services.

Expand All @@ -82,11 +82,11 @@ The core API (`mureq.get`, `mureq.post`, `mureq.request`, etc.) is similar to py
If you're switching from python-requests, there are a few things to keep in mind:

1. `mureq.get`, `mureq.post`, and `mureq.request` mostly work like the [analogous python-requests calls](https://docs.python-requests.org/en/latest/user/quickstart/#make-a-request).
1. The response type is `mureq.HTTPResponse`, which exposes fewer methods and properties than `requests.Response`. In particular, it does not have `text` (since mureq doesn't do any encoding detection). Instead, the response body is in the `body` member, which is always of type `bytes`. (For the sake of compatibility, the `content` property is provided as an alias for `body`.)
1. The response type is `mureq.Response`, which exposes fewer methods and properties than `requests.Response`. In particular, it does not have `text` (since mureq doesn't do any encoding detection). Instead, the response body is in the `body` member, which is always of type `bytes`. (For the sake of compatibility, the `content` property is provided as an alias for `body`.)
1. The default way to send a POST body is with the `body` kwarg, which only accepts `bytes`.
1. The `json` kwarg takes an arbitrary object, which is serialized to JSON, encoded as UTF-8, and sent as the request body with the usual `Content-Type: application/json` header.
1. To send a form-encoded POST body, use the `form` kwarg. This accepts a dictionary of key-value pairs, or any object that can be serialized by [urllib.parse.urlencode](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode). It will add the usual `Content-Type: application/x-www-form-urlencoded` header.
1. To make a request without reading the entire body at once, use `with mureq.yield_response(url, method, **kwargs)`. This yields a [http.client.HTTPResponse](https://docs.python.org/3/library/http.client.html#httpresponse-objects). Exiting the contextmanager automatically closes the socket.
1. To make a request without reading the entire body at once, use `with mureq.yield_response(method, url, **kwargs)`. This yields a [http.client.HTTPResponse](https://docs.python.org/3/library/http.client.html#httpresponse-objects). Exiting the contextmanager automatically closes the socket.
1. mureq does not follow HTTP redirections by default. To enable them, use the kwarg `max_redirects`, which takes an integer number of redirects to allow, e.g. `max_redirects=2`.
1. mureq will throw a subclass of `mureq.HTTPException` (which is actually just [http.client.HTTPException](https://docs.python.org/3/library/http.client.html#http.client.HTTPException)) for any runtime I/O error (including invalid HTTP responses, connection failures, timeouts, and exceeding the redirection limit). It may throw other exceptions (in particular `ValueError`) for programming errors, such as invalid or inconsistent arguments.
1. mureq supports two ways of making HTTP requests over a Unix domain stream socket:
Expand Down
109 changes: 70 additions & 39 deletions mureq.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,32 @@
mureq is copyright 2021 by its contributors and is released under the
0BSD ("zero-clause BSD") license.
"""
# fmt: off
import contextlib
import io
import os.path
import socket
import ssl
import sys
import urllib.parse
from http.client import HTTPConnection, HTTPSConnection, HTTPMessage, HTTPException
from collections.abc import Generator, MutableMapping
from http.client import HTTPConnection, HTTPSConnection, HTTPMessage, HTTPException, HTTPResponse
from typing import Any, cast

__version__ = '0.2.0'
__version__ = '0.3.0'

__all__ = ['HTTPException', 'TooManyRedirects', 'Response',
'yield_response', 'request', 'get', 'post', 'head', 'put', 'patch', 'delete']

DEFAULT_TIMEOUT = 15.0
DEFAULT_TIMEOUT: float = 15.0

# e.g. "Python 3.8.10"
DEFAULT_UA = "Python " + sys.version.split()[0]
DEFAULT_UA: str = "Python " + sys.version.split()[0]

Headers = MutableMapping[str, str] | HTTPMessage

def request(method, url, *, read_limit=None, **kwargs):

def request(method: str, url: str, *, read_limit: int | None = None, **kwargs) -> "Response":
"""request performs an HTTP request and reads the entire response body.

:param str method: HTTP method to request (e.g. 'GET', 'POST')
Expand All @@ -42,45 +47,59 @@ def request(method, url, *, read_limit=None, **kwargs):
body = response.read(read_limit)
except HTTPException:
raise
except IOError as e:
except OSError as e:
raise HTTPException(str(e)) from e
return Response(response.url, response.status, _prepare_incoming_headers(response.headers), body)
headers, raw_headers = _prepare_incoming_headers(response.headers)
return Response(response.url, response.status, headers, raw_headers, body)


def get(url, **kwargs):
def get(url: str, **kwargs) -> "Response":
"""get performs an HTTP GET request."""
return request('GET', url=url, **kwargs)


def post(url, body=None, **kwargs):
def post(url: str, body: bytes | None = None, **kwargs) -> "Response":
"""post performs an HTTP POST request."""
return request('POST', url=url, body=body, **kwargs)


def head(url, **kwargs):
def head(url: str, **kwargs) -> "Response":
"""head performs an HTTP HEAD request."""
return request('HEAD', url=url, **kwargs)


def put(url, body=None, **kwargs):
def put(url: str, body: bytes | None = None, **kwargs) -> "Response":
"""put performs an HTTP PUT request."""
return request('PUT', url=url, body=body, **kwargs)


def patch(url, body=None, **kwargs):
def patch(url: str, body: bytes | None = None, **kwargs) -> "Response":
"""patch performs an HTTP PATCH request."""
return request('PATCH', url=url, body=body, **kwargs)


def delete(url, **kwargs):
def delete(url: str, **kwargs) -> "Response":
"""delete performs an HTTP DELETE request."""
return request('DELETE', url=url, **kwargs)


@contextlib.contextmanager
def yield_response(method, url, *, unix_socket=None, timeout=DEFAULT_TIMEOUT, headers=None,
params=None, body=None, form=None, json=None, verify=True, source_address=None,
max_redirects=None, ssl_context=None):
def yield_response(
method: str,
url: str,
*,
unix_socket: str | None = None,
timeout: float | None = DEFAULT_TIMEOUT,
headers: Headers | list[tuple[str, str]] | None = None,
params: dict[str, str | bytes] | list[tuple[str, str | bytes]] | None = None,
body: bytes | None = None,
form: dict[str, str | bytes] | list[tuple[str, str | bytes]] | None = None,
json: Any = None,
verify: bool = True,
source_address: str | tuple[str, int] | None = None,
max_redirects: int | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> Generator[HTTPResponse, None, None]:
"""yield_response is a low-level API that exposes the actual
http.client.HTTPResponse via a contextmanager.

Expand Down Expand Up @@ -118,20 +137,20 @@ def yield_response(method, url, *, unix_socket=None, timeout=DEFAULT_TIMEOUT, he
enc_params = _prepare_params(params)
body = _prepare_body(body, form, json, headers)

visited_urls = []
visited_urls: list[str] = []

while max_redirects is None or len(visited_urls) <= max_redirects:
url, conn, path = _prepare_request(method, url, enc_params=enc_params, timeout=timeout, unix_socket=unix_socket, verify=verify, source_address=source_address, ssl_context=ssl_context)
enc_params = '' # don't reappend enc_params if we get redirected
visited_urls.append(url)
try:
try:
conn.request(method, path, headers=headers, body=body)
conn.request(method, path, headers=cast(Any, headers), body=body)
response = conn.getresponse()
except HTTPException:
raise
except IOError as e:
# wrap any IOError that is not already an HTTPException
except OSError as e:
# wrap any OSError that is not already an HTTPException
# in HTTPException, exposing a uniform API for remote errors
raise HTTPException(str(e)) from e
redirect_url = _check_redirect(url, response.status, response.headers)
Expand All @@ -144,6 +163,7 @@ def yield_response(method, url, *, unix_socket=None, timeout=DEFAULT_TIMEOUT, he
if response.status == 303:
# 303 See Other: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
method = 'GET'
body = None
finally:
conn.close()

Expand All @@ -156,41 +176,48 @@ class Response:
:ivar str url: the retrieved URL, indicating whether a redirection occurred
:ivar int status_code: the HTTP status code
:ivar http.client.HTTPMessage headers: the HTTP headers
:ivar raw_headers: the original unmerged HTTP headers as a list of tuples
:ivar bytes body: the payload body of the response
"""

__slots__ = ('url', 'status_code', 'headers', 'body')
__slots__ = ('url', 'status_code', 'headers', 'raw_headers', 'body')
url: str
status_code: int
headers: Headers
raw_headers: list[tuple[str, str]]
body: bytes

def __init__(self, url, status_code, headers, body):
self.url, self.status_code, self.headers, self.body = url, status_code, headers, body
def __init__(self, url: str, status_code: int, headers: Headers, raw_headers: list[tuple[str, str]], body: bytes):
self.url, self.status_code, self.headers, self.raw_headers, self.body = \
url, status_code, headers, raw_headers, body

def __repr__(self):
def __repr__(self) -> str:
return f"Response(status_code={self.status_code:d})"

@property
def ok(self):
def ok(self) -> bool:
"""ok returns whether the response had a successful status code
(anything other than a 40x or 50x)."""
return not (400 <= self.status_code < 600)

@property
def content(self):
def content(self) -> bytes:
"""content returns the response body (the `body` member). This is an
alias for compatibility with requests.Response."""
return self.body

def raise_for_status(self):
def raise_for_status(self) -> None:
"""raise_for_status checks the response's success code, raising an
exception for error codes."""
if not self.ok:
raise HTTPErrorStatus(self.status_code)

def json(self):
def json(self) -> Any:
"""Attempts to deserialize the response body as UTF-8 encoded JSON."""
import json as jsonlib
return jsonlib.loads(self.body)

def _debugstr(self):
def _debugstr(self) -> str:
buf = io.StringIO()
print("HTTP", self.status_code, file=buf)
for k, v in self.headers.items():
Expand All @@ -216,10 +243,10 @@ class HTTPErrorStatus(HTTPException):
called explicitly.
"""

def __init__(self, status_code):
def __init__(self, status_code: int):
self.status_code = status_code

def __str__(self):
def __str__(self) -> str:
return f"HTTP response returned error code {self.status_code:d}"


Expand Down Expand Up @@ -249,7 +276,7 @@ def connect(self):
self.sock = sock


def _check_redirect(url, status, response_headers):
def _check_redirect(url: str, status: int, response_headers: HTTPMessage) -> str | None:
"""Return the URL to redirect to, or None for no redirection."""
if status not in (301, 302, 303, 307, 308):
return None
Expand All @@ -276,7 +303,7 @@ def _check_redirect(url, status, response_headers):
parsed_location.query, parsed_location.fragment))


def _prepare_outgoing_headers(headers):
def _prepare_outgoing_headers(headers: Headers | list[tuple[str, str]] | None) -> HTTPMessage:
if headers is None:
headers = HTTPMessage()
elif not isinstance(headers, HTTPMessage):
Expand All @@ -294,24 +321,26 @@ def _prepare_outgoing_headers(headers):

# XXX join multi-headers together so that get(), __getitem__(),
# etc. behave intuitively, then stuff them back in an HTTPMessage.
def _prepare_incoming_headers(headers):
headers_dict = {}
def _prepare_incoming_headers(headers: HTTPMessage) -> tuple[HTTPMessage, list[tuple[str, str]]]:
raw_headers: list[tuple[str, str]] = []
headers_dict: dict[str, list[str]] = {}
for k, v in headers.items():
headers_dict.setdefault(k, []).append(v)
raw_headers.append((k, v))
result = HTTPMessage()
# note that iterating over headers_dict preserves the original
# insertion order in all versions since Python 3.6:
for k, vlist in headers_dict.items():
result[k] = ','.join(vlist)
return result
result[k] = ', '.join(vlist)
return result, raw_headers


def _setdefault_header(headers, name, value):
if name not in headers:
headers[name] = value


def _prepare_body(body, form, json, headers):
def _prepare_body(body, form, json, headers) -> bytes | None:
if body is not None:
if not isinstance(body, bytes):
raise TypeError('body must be bytes or None', type(body))
Expand All @@ -324,7 +353,7 @@ def _prepare_body(body, form, json, headers):

if form is not None:
_setdefault_header(headers, 'Content-Type', _FORM_CONTENTTYPE)
return urllib.parse.urlencode(form, doseq=True)
return urllib.parse.urlencode(form, doseq=True).encode('ascii')

return None

Expand Down Expand Up @@ -352,6 +381,8 @@ def _prepare_request(method, url, *, enc_params='', timeout=DEFAULT_TIMEOUT, sou

is_https = (scheme == 'https')
host = parsed_url.hostname
if host is None:
raise ValueError("host is missing from url", url)
port = 443 if is_https else 80
if parsed_url.port:
port = parsed_url.port
Expand Down
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pyrefly==0.56.0
mypy==1.19.1
pyflakes==3.3.2
flake8==7.2.0
Loading
Loading