From a629ae0224e6b617ad8511189b010ee24f6a7c5f Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 20 Oct 2024 08:06:15 -0400 Subject: [PATCH] refactor: rename import to python_multipart (#166) --- .github/workflows/main.yml | 3 ++ _python_multipart.pth | 1 + _python_multipart_loader.py | 37 +++++++++++++++++++ docs/api.md | 4 +- docs/index.md | 20 +++++++--- fuzz/fuzz_decoders.py | 2 +- fuzz/fuzz_form.py | 4 +- fuzz/fuzz_options_header.py | 2 +- noxfile.py | 29 +++++++++++++++ pyproject.toml | 14 ++++--- {multipart => python_multipart}/__init__.py | 0 {multipart => python_multipart}/decoders.py | 8 ++-- {multipart => python_multipart}/exceptions.py | 0 {multipart => python_multipart}/multipart.py | 12 +++--- {multipart => python_multipart}/py.typed | 0 scripts/check | 2 +- tests/test_multipart.py | 14 +++++-- 17 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 _python_multipart.pth create mode 100644 _python_multipart_loader.py create mode 100644 noxfile.py rename {multipart => python_multipart}/__init__.py (100%) rename {multipart => python_multipart}/decoders.py (95%) rename {multipart => python_multipart}/exceptions.py (100%) rename {multipart => python_multipart}/multipart.py (99%) rename {multipart => python_multipart}/py.typed (100%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2fbd796..fdeed33 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,6 +33,9 @@ jobs: - name: Run tests run: scripts/test + - name: Run rename test + run: uvx nox -s rename -P ${{ matrix.python-version }} + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() diff --git a/_python_multipart.pth b/_python_multipart.pth new file mode 100644 index 0000000..e681c13 --- /dev/null +++ b/_python_multipart.pth @@ -0,0 +1 @@ +import _python_multipart_loader diff --git a/_python_multipart_loader.py b/_python_multipart_loader.py new file mode 100644 index 0000000..7d34377 --- /dev/null +++ b/_python_multipart_loader.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +# The purpose of this file is to allow `import multipart` to continue to work +# unless `multipart` (the PyPI package) is also installed, in which case +# a collision is avoided, and `import multipart` is no longer injected. +import importlib +import importlib.abc +import importlib.machinery +import importlib.util +import sys +import warnings + + +class PythonMultipartCompatFinder(importlib.abc.MetaPathFinder): + def find_spec( + self, fullname: str, path: object = None, target: object = None + ) -> importlib.machinery.ModuleSpec | None: + if fullname != "multipart": + return None + old_sys_meta_path = sys.meta_path + try: + sys.meta_path = [p for p in sys.meta_path if not isinstance(p, type(self))] + if multipart := importlib.util.find_spec("multipart"): + return multipart + + warnings.warn("Please use `import python_multipart` instead.", FutureWarning, stacklevel=2) + sys.modules["multipart"] = importlib.import_module("python_multipart") + return importlib.util.find_spec("python_multipart") + finally: + sys.meta_path = old_sys_meta_path + + +def install() -> None: + sys.meta_path.insert(0, PythonMultipartCompatFinder()) + + +install() diff --git a/docs/api.md b/docs/api.md index cc102fd..aab37c0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,3 @@ -::: multipart +::: python_multipart -::: multipart.exceptions +::: python_multipart.exceptions diff --git a/docs/index.md b/docs/index.md index 0640374..7802011 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ Python-Multipart is a streaming multipart parser for Python. The following example shows a quick example of parsing an incoming request body in a simple WSGI application: ```python -import multipart +import python_multipart def simple_app(environ, start_response): ret = [] @@ -31,7 +31,7 @@ def simple_app(environ, start_response): headers['Content-Length'] = environ['CONTENT_LENGTH'] # Parse the form. - multipart.parse_form(headers, environ['wsgi.input'], on_field, on_file) + python_multipart.parse_form(headers, environ['wsgi.input'], on_field, on_file) # Return something. start_response('200 OK', [('Content-type', 'text/plain')]) @@ -67,7 +67,7 @@ In this section, we’ll build an application that computes the SHA-256 hash of To start, we need a simple WSGI application. We could do this with a framework like Flask, Django, or Tornado, but for now let’s stick to plain WSGI: ```python -import multipart +import python_multipart def simple_app(environ, start_response): start_response('200 OK', [('Content-type', 'text/plain')]) @@ -100,8 +100,8 @@ The final code should look like this: ```python import hashlib -import multipart -from multipart.multipart import parse_options_header +import python_multipart +from python_multipart.multipart import parse_options_header def simple_app(environ, start_response): ret = [] @@ -136,7 +136,7 @@ def simple_app(environ, start_response): } # Create the parser. - parser = multipart.MultipartParser(boundary, callbacks) + parser = python_multipart.MultipartParser(boundary, callbacks) # The input stream is from the WSGI environ. inp = environ['wsgi.input'] @@ -176,3 +176,11 @@ Content-type: text/plain Hashes: Part hash: 0b64696c0f7ddb9e3435341720988d5455b3b0f0724688f98ec8e6019af3d931 ``` + + +## Historical note + +This package used to be accessed via `import multipart`. This still works for +now (with a warning) as long as the Python package `multipart` is not also +installed. If both are installed, you need to use the full PyPI name +`python_multipart` for this package. diff --git a/fuzz/fuzz_decoders.py b/fuzz/fuzz_decoders.py index 1c4425e..543c299 100644 --- a/fuzz/fuzz_decoders.py +++ b/fuzz/fuzz_decoders.py @@ -5,7 +5,7 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.decoders import Base64Decoder, DecodeError, QuotedPrintableDecoder + from python_multipart.decoders import Base64Decoder, DecodeError, QuotedPrintableDecoder def fuzz_base64_decoder(fdp: EnhancedDataProvider) -> None: diff --git a/fuzz/fuzz_form.py b/fuzz/fuzz_form.py index 0a7646a..c990639 100644 --- a/fuzz/fuzz_form.py +++ b/fuzz/fuzz_form.py @@ -6,8 +6,8 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.exceptions import FormParserError - from multipart.multipart import parse_form + from python_multipart.exceptions import FormParserError + from python_multipart.multipart import parse_form on_field = Mock() on_file = Mock() diff --git a/fuzz/fuzz_options_header.py b/fuzz/fuzz_options_header.py index dd1cb44..2546eaf 100644 --- a/fuzz/fuzz_options_header.py +++ b/fuzz/fuzz_options_header.py @@ -4,7 +4,7 @@ from helpers import EnhancedDataProvider with atheris.instrument_imports(): - from multipart.multipart import parse_options_header + from python_multipart.multipart import parse_options_header def TestOneInput(data: bytes) -> None: diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..fda8050 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,29 @@ +import nox + +nox.needs_version = ">=2024.4.15" +nox.options.default_venv_backend = "uv|virtualenv" + +ALL_PYTHONS = [ + c.split()[-1] + for c in nox.project.load_toml("pyproject.toml")["project"]["classifiers"] + if c.startswith("Programming Language :: Python :: 3.") +] + + +@nox.session(python=ALL_PYTHONS) +def rename(session: nox.Session) -> None: + session.install(".") + assert "import python_multipart" in session.run("python", "-c", "import multipart", silent=True) + assert "import python_multipart" in session.run("python", "-c", "import multipart.exceptions", silent=True) + assert "import python_multipart" in session.run("python", "-c", "from multipart import exceptions", silent=True) + assert "import python_multipart" in session.run( + "python", "-c", "from multipart.exceptions import FormParserError", silent=True + ) + + session.install("multipart") + assert "import python_multipart" not in session.run( + "python", "-c", "import multipart; multipart.parse_form_data", silent=True + ) + assert "import python_multipart" not in session.run( + "python", "-c", "import python_multipart; python_multipart.parse_form", silent=True + ) diff --git a/pyproject.toml b/pyproject.toml index fb03f83..1a81077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ dev-dependencies = [ "mkdocs-autorefs", ] +[tool.uv.pip] +reinstall-package = ["python-multipart"] + [project.urls] Homepage = "https://github.com/Kludex/python-multipart" Documentation = "https://kludex.github.io/python-multipart/" @@ -62,13 +65,14 @@ Changelog = "https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md Source = "https://github.com/Kludex/python-multipart" [tool.hatch.version] -path = "multipart/__init__.py" - -[tool.hatch.build.targets.wheel] -packages = ["multipart"] +path = "python_multipart/__init__.py" [tool.hatch.build.targets.sdist] -include = ["/multipart", "/tests", "CHANGELOG.md", "LICENSE.txt"] +include = ["/python_multipart", "/tests", "CHANGELOG.md", "LICENSE.txt", "_python_multipart.pth", "_python_multipart_loader.py"] + +[tool.hatch.build.targets.wheel.force-include] +"_python_multipart.pth" = "_python_multipart.pth" +"_python_multipart_loader.py" = "_python_multipart_loader.py" [tool.mypy] strict = true diff --git a/multipart/__init__.py b/python_multipart/__init__.py similarity index 100% rename from multipart/__init__.py rename to python_multipart/__init__.py diff --git a/multipart/decoders.py b/python_multipart/decoders.py similarity index 95% rename from multipart/decoders.py rename to python_multipart/decoders.py index 07bf742..82b56a1 100644 --- a/multipart/decoders.py +++ b/python_multipart/decoders.py @@ -25,7 +25,7 @@ class Base64Decoder: call write() on the underlying object. This is primarily used for decoding form data encoded as Base64, but can be used for other purposes:: - from multipart.decoders import Base64Decoder + from python_multipart.decoders import Base64Decoder fd = open("notb64.txt", "wb") decoder = Base64Decoder(fd) try: @@ -55,7 +55,7 @@ def write(self, data: bytes) -> int: """Takes any input data provided, decodes it as base64, and passes it on to the underlying object. If the data provided is invalid base64 data, then this method will raise - a :class:`multipart.exceptions.DecodeError` + a :class:`python_multipart.exceptions.DecodeError` :param data: base64 data to decode """ @@ -97,7 +97,7 @@ def close(self) -> None: def finalize(self) -> None: """Finalize this object. This should be called when no more data should be written to the stream. This function can raise a - :class:`multipart.exceptions.DecodeError` if there is some remaining + :class:`python_multipart.exceptions.DecodeError` if there is some remaining data in the cache. If the underlying object has a `finalize()` method, this function will @@ -118,7 +118,7 @@ def __repr__(self) -> str: class QuotedPrintableDecoder: """This object provides an interface to decode a stream of quoted-printable data. It is instantiated with an "underlying object", in the same manner - as the :class:`multipart.decoders.Base64Decoder` class. This class behaves + as the :class:`python_multipart.decoders.Base64Decoder` class. This class behaves in exactly the same way, including maintaining a cache of quoted-printable chunks. diff --git a/multipart/exceptions.py b/python_multipart/exceptions.py similarity index 100% rename from multipart/exceptions.py rename to python_multipart/exceptions.py diff --git a/multipart/multipart.py b/python_multipart/multipart.py similarity index 99% rename from multipart/multipart.py rename to python_multipart/multipart.py index 158b7e6..ace4a8f 100644 --- a/multipart/multipart.py +++ b/python_multipart/multipart.py @@ -241,7 +241,7 @@ def from_value(cls, name: bytes, value: bytes | None) -> Field: value: the value of the form field - either a bytestring or None. Returns: - A new instance of a [`Field`][multipart.Field]. + A new instance of a [`Field`][python_multipart.Field]. """ f = cls(name) @@ -351,7 +351,7 @@ class File: | MAX_MEMORY_FILE_SIZE | `int` | 1 MiB | The maximum number of bytes of a File to keep in memory. By default, the contents of a File are kept into memory until a certain limit is reached, after which the contents of the File are written to a temporary file. This behavior can be disabled by setting this value to an appropriately large value (or, for example, infinity, such as `float('inf')`. | Args: - file_name: The name of the file that this [`File`][multipart.File] represents. + file_name: The name of the file that this [`File`][python_multipart.File] represents. field_name: The name of the form field that this file was uploaded with. This can be None, if, for example, the file was uploaded with Content-Type application/octet-stream. config: The configuration for this File. See above for valid configuration keys and their corresponding values. @@ -663,7 +663,7 @@ class OctetStreamParser(BaseParser): | on_end | None | Called when the parser is finished parsing all data.| Args: - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ @@ -733,12 +733,12 @@ class QuerystringParser(BaseParser): | on_end | None | Called when the parser is finished parsing all data.| Args: - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. strict_parsing: Whether or not to parse the body strictly. Defaults to False. If this is set to True, then the behavior of the parser changes as the following: if a field has a value with an equal sign (e.g. "foo=bar", or "foo="), it is always included. If a field has no equals sign (e.g. "...&name&..."), it will be treated as an error if 'strict_parsing' is True, otherwise included. If an error is encountered, - then a [`QuerystringParseError`][multipart.exceptions.QuerystringParseError] will be raised. + then a [`QuerystringParseError`][python_multipart.exceptions.QuerystringParseError] will be raised. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ # noqa: E501 @@ -969,7 +969,7 @@ class MultipartParser(BaseParser): Args: boundary: The multipart boundary. This is required, and must match what is given in the HTTP request - usually in the Content-Type header. - callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][multipart.BaseParser]. + callbacks: A dictionary of callbacks. See the documentation for [`BaseParser`][python_multipart.BaseParser]. max_size: The maximum size of body to parse. Defaults to infinity - i.e. unbounded. """ # noqa: E501 diff --git a/multipart/py.typed b/python_multipart/py.typed similarity index 100% rename from multipart/py.typed rename to python_multipart/py.typed diff --git a/scripts/check b/scripts/check index 0b6a294..f38e9c0 100755 --- a/scripts/check +++ b/scripts/check @@ -2,7 +2,7 @@ set -x -SOURCE_FILES="multipart tests" +SOURCE_FILES="python_multipart tests" uvx ruff format --check --diff $SOURCE_FILES uvx ruff check $SOURCE_FILES diff --git a/tests/test_multipart.py b/tests/test_multipart.py index b824f19..be01fbf 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -11,9 +11,15 @@ import yaml -from multipart.decoders import Base64Decoder, QuotedPrintableDecoder -from multipart.exceptions import DecodeError, FileError, FormParserError, MultipartParseError, QuerystringParseError -from multipart.multipart import ( +from python_multipart.decoders import Base64Decoder, QuotedPrintableDecoder +from python_multipart.exceptions import ( + DecodeError, + FileError, + FormParserError, + MultipartParseError, + QuerystringParseError, +) +from python_multipart.multipart import ( BaseParser, Field, File, @@ -31,7 +37,7 @@ if TYPE_CHECKING: from typing import Any, Iterator, TypedDict - from multipart.multipart import FieldProtocol, FileConfig, FileProtocol + from python_multipart.multipart import FieldProtocol, FileConfig, FileProtocol class TestParams(TypedDict): name: str