From 0a00eabbd1eaf17cbca369aed85677f990a40a18 Mon Sep 17 00:00:00 2001 From: FlorianRegaz Date: Sat, 10 Oct 2026 13:30:32 +0200 Subject: [PATCH 1/4] Add a requests-style files argument for multipart uploads execute_request, post, put and patch take files= now, in the forms requests accepts: a file object, or (filename, content[, content type[, part headers]]), as a dict or a list of (name, file) pairs to repeat a field. Fields of a dict or list data= go into the same body first. The multipart/form-data body is built with the stdlib under a random boundary and sent as bytes. Names and filenames are quoted like urllib3 does: ", CR and LF are percent-escaped, non-ASCII is sent as UTF-8. File objects in text mode and str content are encoded to UTF-8. Parts with a filename get a Content-Type guessed from it, falling back to application/octet-stream. The multipart Content-Type is set for the one request only, and like the other inferred types it gives way to one the session or request set. files with json= or str/bytes data= raise ValueError. Fixes #35 Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_multipart.py | 220 +++++++++++++++++++++++++++++++++++ tls_client/_multipart.py | 115 ++++++++++++++++++ tls_client/sessions.py | 50 ++++++-- 3 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_multipart.py create mode 100644 tls_client/_multipart.py diff --git a/tests/unit/test_multipart.py b/tests/unit/test_multipart.py new file mode 100644 index 0000000..ef49617 --- /dev/null +++ b/tests/unit/test_multipart.py @@ -0,0 +1,220 @@ +import base64 +import io +import re +from collections.abc import Mapping + +import pytest + +import tls_client._multipart + +URL = "https://example.com/upload" +RANDOM_BOUNDARY = tls_client._multipart.choose_boundary + + +def header_values(headers: Mapping[str, str], name: str) -> list[str]: + return [value for key, value in headers.items() if key.lower() == name.lower()] + + +def sent_body(payload: dict) -> bytes: + assert payload["isByteRequest"] is True + return base64.b64decode(payload["requestBody"]) + + +def part(disposition: str, content: bytes, *headers: str) -> bytes: + lines = [f"Content-Disposition: form-data; {disposition}", *headers] + return b"--BOUNDARY\r\n" + "".join(f"{line}\r\n" for line in lines).encode() + b"\r\n" + content + b"\r\n" + + +END = b"--BOUNDARY--\r\n" + + +@pytest.fixture(autouse=True) +def fixed_boundary(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tls_client._multipart, "choose_boundary", lambda: "BOUNDARY") + + +class TestFiles: + def test_open_file_is_sent_under_its_basename_with_a_guessed_type(self, session, fake_lib, tmp_path): + path = tmp_path / "report.txt" + path.write_bytes(b"line 1\nline 2\n") + + with path.open("rb") as f: + session.post(URL, files={"upload": f}) + + assert sent_body(fake_lib.sent) == ( + part('name="upload"; filename="report.txt"', b"line 1\nline 2\n", "Content-Type: text/plain") + END + ) + assert header_values(fake_lib.sent["headers"], "Content-Type") == ["multipart/form-data; boundary=BOUNDARY"] + + @pytest.mark.parametrize( + "content", + [b"caf\xc3\xa9", "café", io.BytesIO(b"caf\xc3\xa9"), io.StringIO("café")], + ids=["bytes", "str", "binary-file", "text-file"], + ) + def test_content_of_every_kind_is_sent_as_utf8_bytes(self, session, fake_lib, content): + session.post(URL, files={"f": ("notes.txt", content)}) + + expected = part('name="f"; filename="notes.txt"', b"caf\xc3\xa9", "Content-Type: text/plain") + END + assert sent_body(fake_lib.sent) == expected + + def test_file_is_read_from_its_current_position(self, session, fake_lib): + f = io.BytesIO(b"skip|keep") + f.seek(5) + + session.post(URL, files={"f": ("a.bin", f)}) + + assert b"\r\n\r\nkeep\r\n" in sent_body(fake_lib.sent) + + def test_explicit_content_type_and_extra_part_headers(self, session, fake_lib): + session.post( + URL, + files={ + "typed": ("data.txt", b"{}", "application/json"), + "extra": ("a.png", b"\x89PNG", "image/x-png", {"X-Checksum": "abc"}), + }, + ) + + assert sent_body(fake_lib.sent) == ( + part('name="typed"; filename="data.txt"', b"{}", "Content-Type: application/json") + + part('name="extra"; filename="a.png"', b"\x89PNG", "Content-Type: image/x-png", "X-Checksum: abc") + + END + ) + + def test_content_type_from_the_extra_headers_replaces_the_guess(self, session, fake_lib): + session.post(URL, files={"f": ("a.txt", b"x", None, {"content-type": "text/markdown"})}) + + assert sent_body(fake_lib.sent) == part('name="f"; filename="a.txt"', b"x", "content-type: text/markdown") + END + + @pytest.mark.parametrize( + ("filename", "content_type"), + [ + ("photo.PNG", "image/png"), + ("blob", "application/octet-stream"), + ("data.unknown-ext", "application/octet-stream"), + ], + ) + def test_content_type_is_guessed_from_the_filename(self, session, fake_lib, filename, content_type): + session.post(URL, files={"f": (filename, b"x")}) + + assert f"Content-Type: {content_type}\r\n".encode() in sent_body(fake_lib.sent) + + @pytest.mark.parametrize("name", [None, "", 3], ids=["no-name", "pseudo-name", "descriptor"]) + def test_file_without_a_usable_name_is_sent_under_the_field_name(self, session, fake_lib, name): + f = io.BytesIO(b"x") + if name is not None: + f.name = name + + session.post(URL, files={"field": f}) + + assert b'name="field"; filename="field"\r\n' in sent_body(fake_lib.sent) + + def test_quotes_and_line_breaks_are_percent_escaped_and_non_ascii_is_utf8(self, session, fake_lib): + session.post(URL, files={'a"b\r\nc': ('naïve "x".txt', b"x")}) + + disposition = 'Content-Disposition: form-data; name="a%22b%0D%0Ac"; filename="naïve %22x%22.txt"\r\n' + assert disposition.encode() in sent_body(fake_lib.sent) + + def test_none_filename_sends_a_plain_field_and_none_content_skips_it(self, session, fake_lib): + session.post(URL, files={"plain": (None, "value"), "skipped": ("a.txt", None)}) + + assert sent_body(fake_lib.sent) == part('name="plain"', b"value") + END + + def test_list_of_pairs_repeats_a_field(self, session, fake_lib): + session.post(URL, files=[("f", ("a.txt", b"1")), ("f", ("b.txt", b"2"))]) + + assert sent_body(fake_lib.sent) == ( + part('name="f"; filename="a.txt"', b"1", "Content-Type: text/plain") + + part('name="f"; filename="b.txt"', b"2", "Content-Type: text/plain") + + END + ) + + def test_boundary_is_random_and_matches_the_header(self, session, fake_lib, monkeypatch): + monkeypatch.setattr(tls_client._multipart, "choose_boundary", RANDOM_BOUNDARY) + + session.post(URL, files={"f": ("a.txt", b"x")}) + session.post(URL, files={"f": ("a.txt", b"x")}) + + boundaries = [] + for payload in fake_lib.payloads: + (content_type,) = header_values(payload["headers"], "Content-Type") + boundary = re.fullmatch(r"multipart/form-data; boundary=([0-9a-f]{32})", content_type).group(1) + assert sent_body(payload).startswith(f"--{boundary}\r\n".encode()) + assert sent_body(payload).endswith(f"--{boundary}--\r\n".encode()) + boundaries.append(boundary) + assert boundaries[0] != boundaries[1] + + @pytest.mark.parametrize("method", ["post", "put", "patch"]) + def test_body_verbs_take_files(self, session, fake_lib, method): + getattr(session, method)(URL, files={"f": ("a.txt", b"x")}) + + assert fake_lib.sent["requestMethod"] == method.upper() + assert sent_body(fake_lib.sent).startswith(b"--BOUNDARY\r\n") + + def test_empty_files_fall_back_to_a_form_encoded_body(self, session, fake_lib): + session.post(URL, data={"a": "1"}, files={}) + + assert fake_lib.sent["requestBody"] == "a=1" + + +class TestFormFields: + def test_data_fields_come_before_the_files(self, session, fake_lib): + session.post( + URL, + data={"user": "ünï", "tags": ["x", "y"], "count": 3, "raw": b"\xff", "unset": None}, + files={"f": ("a.txt", b"x")}, + ) + + assert sent_body(fake_lib.sent) == ( + part('name="user"', "ünï".encode()) + + part('name="tags"', b"x") + + part('name="tags"', b"y") + + part('name="count"', b"3") + + part('name="raw"', b"\xff") + + part('name="f"; filename="a.txt"', b"x", "Content-Type: text/plain") + + END + ) + + def test_data_as_a_list_of_pairs_keeps_order_and_repeats(self, session, fake_lib): + session.post(URL, data=[("a", "1"), ("b", "2"), ("a", "3")], files={"f": (None, "4")}) + + assert sent_body(fake_lib.sent) == ( + part('name="a"', b"1") + part('name="b"', b"2") + part('name="a"', b"3") + part('name="f"', b"4") + END + ) + + +class TestContentTypeHeader: + def test_multipart_content_type_applies_to_one_request_only(self, session, fake_lib): + session.post(URL, files={"f": ("a.txt", b"x")}) + session.post(URL, data="raw") + + assert header_values(fake_lib.sent["headers"], "Content-Type") == [] + assert "Content-Type" not in session.headers + + def test_explicit_content_type_is_kept_like_in_requests(self, session, fake_lib): + session.post( + URL, files={"f": ("a.txt", b"x")}, headers={"Content-Type": "multipart/related; boundary=BOUNDARY"} + ) + + assert header_values(fake_lib.sent["headers"], "Content-Type") == ["multipart/related; boundary=BOUNDARY"] + assert sent_body(fake_lib.sent).startswith(b"--BOUNDARY\r\n") + + def test_content_type_set_to_none_sends_none(self, session, fake_lib): + session.post(URL, files={"f": ("a.txt", b"x")}, headers={"Content-Type": None}) + + assert header_values(fake_lib.sent["headers"], "Content-Type") == [] + + +class TestRejectedCombinations: + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"json": {"a": 1}}, "files can't be sent with json"), + ({"data": "a=1"}, "files can't be sent with str or bytes data"), + ({"data": b"a=1"}, "files can't be sent with str or bytes data"), + ], + ) + def test_files_with_a_body_that_cannot_be_merged_raise(self, session, fake_lib, kwargs, message): + with pytest.raises(ValueError, match=message): + session.post(URL, files={"f": ("a.txt", b"x")}, **kwargs) + + assert fake_lib.payloads == [] diff --git a/tls_client/_multipart.py b/tls_client/_multipart.py new file mode 100644 index 0000000..59d4563 --- /dev/null +++ b/tls_client/_multipart.py @@ -0,0 +1,115 @@ +"""multipart/form-data bodies for the files argument, laid out the way requests and urllib3 lay them out.""" + +import mimetypes +import os +import secrets +from collections.abc import Iterable, Mapping +from typing import Any, Protocol, TypeAlias, TypeVar + +from .structures import CaseInsensitiveDict + +_T = TypeVar("_T") + + +class _Readable(Protocol): + def read(self) -> bytes | str: ... + + +# A file object (binary or text mode), bytes, or a str. Text is sent UTF-8 encoded. +FileContent: TypeAlias = _Readable | bytes | bytearray | str +# A file object on its own, or (filename, content[, content type[, extra part headers]]). +# A None filename sends a plain form field, a None content skips the field. +FileSpec: TypeAlias = ( + FileContent + | tuple[str | None, FileContent | None] + | tuple[str | None, FileContent | None, str | None] + | tuple[str | None, FileContent | None, str | None, Mapping[str, str]] +) +# Field name -> file, or a list of (field name, file) pairs to send a field more than once. +Files: TypeAlias = Mapping[str, FileSpec] | Iterable[tuple[str, FileSpec]] +# Form fields sent before the files. A list value sends the field once per item. +Fields: TypeAlias = Mapping[str, Any] | Iterable[tuple[str, Any]] + +_Part: TypeAlias = tuple[CaseInsensitiveDict[str], bytes] + + +def choose_boundary() -> str: + return secrets.token_hex(16) + + +def encode_multipart(fields: Fields | None, files: Files) -> tuple[bytes, str]: + """Returns the body and its Content-Type header for the form fields followed by the files.""" + boundary = choose_boundary() + body = bytearray() + for headers, content in [*_field_parts(fields or {}), *_file_parts(files)]: + body += f"--{boundary}\r\n".encode() + for name, value in headers.items(): + body += f"{name}: {value}\r\n".encode() + body += b"\r\n" + content + b"\r\n" + body += f"--{boundary}--\r\n".encode() + return bytes(body), f"multipart/form-data; boundary={boundary}" + + +def _field_parts(fields: Fields) -> Iterable[_Part]: + for name, value in _pairs(fields): + values = [value] if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Iterable) else value + for item in values: + if item is not None: + yield _part(name, None, item if isinstance(item, (bytes, bytearray)) else str(item)) + + +def _file_parts(files: Files) -> Iterable[_Part]: + for name, spec in _pairs(files): + content_type = None + extra_headers = None + if isinstance(spec, (tuple, list)): + if len(spec) == 2: + filename, content = spec + elif len(spec) == 3: + filename, content, content_type = spec + else: + filename, content, content_type, extra_headers = spec + else: + filename, content = _guess_filename(spec) or name, spec + + if content is None: + continue + if not isinstance(content, (str, bytes, bytearray)): + content = content.read() + yield _part(name, filename, content, content_type, extra_headers) + + +def _part( + name: str, + filename: str | None, + content: str | bytes | bytearray, + content_type: str | None = None, + extra_headers: Mapping[str, str] | None = None, +) -> _Part: + disposition = f"form-data; {_quote('name', name)}" + if filename is not None: + disposition += f"; {_quote('filename', filename)}" + headers = CaseInsensitiveDict({"Content-Disposition": disposition}) + if filename is not None: + headers["Content-Type"] = mimetypes.guess_type(filename)[0] or "application/octet-stream" + headers.update(extra_headers or {}) + if content_type is not None: + headers["Content-Type"] = content_type + return headers, content.encode() if isinstance(content, str) else bytes(content) + + +def _quote(key: str, value: str) -> str: + # Percent-escape ", CR and LF as the HTML standard, browsers and urllib3 do. Non-ASCII stays as is, sent as UTF-8. + return f'{key}="{value.translate({10: "%0A", 13: "%0D", 34: "%22"})}"' + + +def _guess_filename(fileobj: object) -> str | None: + # Like requests: the basename of an open file, but not pseudo names such as "" or a descriptor number. + name = getattr(fileobj, "name", None) + if isinstance(name, str) and name and name[0] != "<" and name[-1] != ">": + return os.path.basename(name) + return None + + +def _pairs(items: Mapping[str, _T] | Iterable[tuple[str, _T]]) -> Iterable[tuple[str, _T]]: + return items.items() if isinstance(items, Mapping) else items diff --git a/tls_client/sessions.py b/tls_client/sessions.py index 8a59b27..214ccff 100644 --- a/tls_client/sessions.py +++ b/tls_client/sessions.py @@ -9,6 +9,7 @@ from typing import Any, TypeAlias from .__version__ import __version__ +from ._multipart import Files, encode_multipart from .cffi import destroySession, freeMemory, request from .cookies import cookiejar_from_dict, extract_cookies_to_jar, merge_cookies from .exceptions import TLSClientException @@ -16,8 +17,8 @@ from .settings import CLIENT_IDENTIFIERS, ClientIdentifiers from .structures import CaseInsensitiveDict -# Request body: str and bytes are sent as-is, a mapping is form-encoded. -_Data: TypeAlias = str | bytes | Mapping[str, Any] +# Request body: str and bytes are sent as-is, a mapping or a list of (name, value) pairs is form-encoded. +_Data: TypeAlias = str | bytes | Mapping[str, Any] | list[tuple[str, Any]] # JSON body: dicts and lists are serialized, a str is sent as-is. _Json: TypeAlias = dict[str, Any] | list[Any] | str # Either a proxy URL, or a dict whose "http" entry is used. @@ -345,6 +346,7 @@ def execute_request( timeout_seconds: float | None = None, proxy: _Proxy | None = None, timeout: float | None = None, + files: Files | None = None, ) -> Response: """Sends a request and returns its Response. @@ -361,10 +363,17 @@ def execute_request( # --- Request Body --------------------------------------------------------------------------------------------- # Prepare request body - build request body - # Data has priority. JSON is only used if data is None. + # Files are sent as multipart/form-data, with the fields of data before them. + # Otherwise data has priority. JSON is only used if data is None. request_body: str | bytes | None content_type: str | None - if data is None and json is not None: + if files: + if json is not None: + raise ValueError("files can't be sent with json; pass the other form fields in data instead") + if isinstance(data, (str, bytes)): + raise ValueError("files can't be sent with str or bytes data; pass the form fields as a dict instead") + request_body, content_type = encode_multipart(data, files) + elif data is None and json is not None: request_body = dumps(json) if isinstance(json, (dict, list)) else json content_type = "application/json" elif data is None or isinstance(data, (str, bytes)): @@ -511,17 +520,38 @@ def head(self, url: str, **kwargs: Any) -> Response: """Sends a HEAD request""" return self.execute_request(method="HEAD", url=url, **kwargs) - def post(self, url: str, data: _Data | None = None, json: _Json | None = None, **kwargs: Any) -> Response: + def post( + self, + url: str, + data: _Data | None = None, + json: _Json | None = None, + files: Files | None = None, + **kwargs: Any, + ) -> Response: """Sends a POST request""" - return self.execute_request(method="POST", url=url, data=data, json=json, **kwargs) + return self.execute_request(method="POST", url=url, data=data, json=json, files=files, **kwargs) - def put(self, url: str, data: _Data | None = None, json: _Json | None = None, **kwargs: Any) -> Response: + def put( + self, + url: str, + data: _Data | None = None, + json: _Json | None = None, + files: Files | None = None, + **kwargs: Any, + ) -> Response: """Sends a PUT request""" - return self.execute_request(method="PUT", url=url, data=data, json=json, **kwargs) + return self.execute_request(method="PUT", url=url, data=data, json=json, files=files, **kwargs) - def patch(self, url: str, data: _Data | None = None, json: _Json | None = None, **kwargs: Any) -> Response: + def patch( + self, + url: str, + data: _Data | None = None, + json: _Json | None = None, + files: Files | None = None, + **kwargs: Any, + ) -> Response: """Sends a PATCH request""" - return self.execute_request(method="PATCH", url=url, data=data, json=json, **kwargs) + return self.execute_request(method="PATCH", url=url, data=data, json=json, files=files, **kwargs) def delete(self, url: str, **kwargs: Any) -> Response: """Sends a DELETE request""" From ef406ba84ab5ada6c0b23f38ec71e0983a49c0f3 Mon Sep 17 00:00:00 2001 From: FlorianRegaz Date: Sat, 10 Oct 2026 13:30:39 +0200 Subject: [PATCH 2/4] Test file uploads against httpbin A file object, str content and a plain (None, value) field next to data= fields arrive parsed as files and form. bytes(range(256)) comes back intact; httpbin returns it as a data URL carrying the image/png type guessed from the filename. Co-Authored-By: Claude Opus 5.5 --- tests/integration/test_httpbin.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/integration/test_httpbin.py b/tests/integration/test_httpbin.py index 367cacc..ea61c2b 100644 --- a/tests/integration/test_httpbin.py +++ b/tests/integration/test_httpbin.py @@ -51,6 +51,30 @@ def test_binary_body_reaches_the_server_unchanged(self, httpbin, session): # httpbin reports bodies that are not valid UTF-8 as a base64 data URL. assert response.json()["data"] == "data:application/octet-stream;base64," + base64.b64encode(payload).decode() + def test_files_and_form_fields_reach_the_server_as_multipart(self, httpbin, session, tmp_path): + path = tmp_path / "notes.txt" + path.write_text("café\n", encoding="utf-8") + + with path.open("rb") as f: + response = session.post( + f"{httpbin.url}/post", + data={"user": "a b", "role": ["x", "y"]}, + files={"notes": f, "text": ("hello.txt", "∮ E⋅da = Q"), "plain": (None, "field")}, + ) + + echoed = response.json() + assert echoed["headers"]["Content-Type"].startswith("multipart/form-data; boundary=") + assert echoed["form"] == {"user": "a b", "role": ["x", "y"], "plain": "field"} + assert echoed["files"] == {"notes": "café\n", "text": "∮ E⋅da = Q"} + + def test_binary_file_reaches_the_server_unchanged(self, httpbin, session): + payload = bytes(range(256)) + + response = session.post(f"{httpbin.url}/post", files={"image": ("pixel.png", payload)}) + + # httpbin reports file content that is not valid UTF-8 as a base64 data URL with the part's Content-Type. + assert response.json()["files"] == {"image": "data:image/png;base64," + base64.b64encode(payload).decode()} + def test_headers_reach_the_server_and_none_removes_one(self, httpbin, session): response = session.get(f"{httpbin.url}/headers", headers={"X-Custom": "1", "Accept": None}) From a5aa2a56dbab3f10be5e1487c0920854314d552a Mon Sep 17 00:00:00 2001 From: FlorianRegaz Date: Sat, 10 Oct 2026 13:30:40 +0200 Subject: [PATCH 3/4] Add a file upload example to the README Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 0d2fc0e..c3e25d2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ async def main(): asyncio.run(main()) ``` +Example 4 - File upload: +```python +import tls_client + +session = tls_client.Session(client_identifier="chrome_120") + +with open("report.pdf", "rb") as f: + res = session.post( + "https://www.example.com/upload", + data={"title": "Report"}, + files={"file": f, "notes": ("notes.txt", "some text", "text/plain")} + ) +``` + # Pyinstaller / Pyarmor **If you want to pack the library with Pyinstaller or Pyarmor, make sure to add this to your command:** From 3eb7b17ccec7873af726db63532c3fb7655f22f4 Mon Sep 17 00:00:00 2001 From: FlorianRegaz Date: Sat, 10 Oct 2026 13:37:23 +0200 Subject: [PATCH 4/4] Move the upload example to examples/, drop the underscore from multipart The module sits next to cffi.py, cookies.py and structures.py, none of which use a private prefix. The httpbin test now writes its file in binary, so Windows doesn't turn the newline into CRLF. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 -------------- examples/example4 - file upload.py | 10 ++++++++++ tests/integration/test_httpbin.py | 3 ++- tests/unit/test_multipart.py | 8 ++++---- tls_client/{_multipart.py => multipart.py} | 0 tls_client/sessions.py | 2 +- 6 files changed, 17 insertions(+), 20 deletions(-) create mode 100644 examples/example4 - file upload.py rename tls_client/{_multipart.py => multipart.py} (100%) diff --git a/README.md b/README.md index c3e25d2..0d2fc0e 100644 --- a/README.md +++ b/README.md @@ -108,20 +108,6 @@ async def main(): asyncio.run(main()) ``` -Example 4 - File upload: -```python -import tls_client - -session = tls_client.Session(client_identifier="chrome_120") - -with open("report.pdf", "rb") as f: - res = session.post( - "https://www.example.com/upload", - data={"title": "Report"}, - files={"file": f, "notes": ("notes.txt", "some text", "text/plain")} - ) -``` - # Pyinstaller / Pyarmor **If you want to pack the library with Pyinstaller or Pyarmor, make sure to add this to your command:** diff --git a/examples/example4 - file upload.py b/examples/example4 - file upload.py new file mode 100644 index 0000000..808f3f3 --- /dev/null +++ b/examples/example4 - file upload.py @@ -0,0 +1,10 @@ +import tls_client + +session = tls_client.Session(client_identifier="chrome_120") + +with open("report.pdf", "rb") as f: + res = session.post( + "https://www.example.com/upload", + data={"title": "Report"}, + files={"file": f, "notes": ("notes.txt", "some text", "text/plain")}, + ) diff --git a/tests/integration/test_httpbin.py b/tests/integration/test_httpbin.py index ea61c2b..aac5dae 100644 --- a/tests/integration/test_httpbin.py +++ b/tests/integration/test_httpbin.py @@ -53,7 +53,8 @@ def test_binary_body_reaches_the_server_unchanged(self, httpbin, session): def test_files_and_form_fields_reach_the_server_as_multipart(self, httpbin, session, tmp_path): path = tmp_path / "notes.txt" - path.write_text("café\n", encoding="utf-8") + # write_bytes, so Windows doesn't turn the newline into \r\n + path.write_bytes("café\n".encode()) with path.open("rb") as f: response = session.post( diff --git a/tests/unit/test_multipart.py b/tests/unit/test_multipart.py index ef49617..08d88f7 100644 --- a/tests/unit/test_multipart.py +++ b/tests/unit/test_multipart.py @@ -5,10 +5,10 @@ import pytest -import tls_client._multipart +import tls_client.multipart URL = "https://example.com/upload" -RANDOM_BOUNDARY = tls_client._multipart.choose_boundary +RANDOM_BOUNDARY = tls_client.multipart.choose_boundary def header_values(headers: Mapping[str, str], name: str) -> list[str]: @@ -30,7 +30,7 @@ def part(disposition: str, content: bytes, *headers: str) -> bytes: @pytest.fixture(autouse=True) def fixed_boundary(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(tls_client._multipart, "choose_boundary", lambda: "BOUNDARY") + monkeypatch.setattr(tls_client.multipart, "choose_boundary", lambda: "BOUNDARY") class TestFiles: @@ -129,7 +129,7 @@ def test_list_of_pairs_repeats_a_field(self, session, fake_lib): ) def test_boundary_is_random_and_matches_the_header(self, session, fake_lib, monkeypatch): - monkeypatch.setattr(tls_client._multipart, "choose_boundary", RANDOM_BOUNDARY) + monkeypatch.setattr(tls_client.multipart, "choose_boundary", RANDOM_BOUNDARY) session.post(URL, files={"f": ("a.txt", b"x")}) session.post(URL, files={"f": ("a.txt", b"x")}) diff --git a/tls_client/_multipart.py b/tls_client/multipart.py similarity index 100% rename from tls_client/_multipart.py rename to tls_client/multipart.py diff --git a/tls_client/sessions.py b/tls_client/sessions.py index 214ccff..ffa4a86 100644 --- a/tls_client/sessions.py +++ b/tls_client/sessions.py @@ -9,10 +9,10 @@ from typing import Any, TypeAlias from .__version__ import __version__ -from ._multipart import Files, encode_multipart from .cffi import destroySession, freeMemory, request from .cookies import cookiejar_from_dict, extract_cookies_to_jar, merge_cookies from .exceptions import TLSClientException +from .multipart import Files, encode_multipart from .response import Response, build_response from .settings import CLIENT_IDENTIFIERS, ClientIdentifiers from .structures import CaseInsensitiveDict