Repository: zopefoundation/zope.publisher (language: python)

Issue:
Tests break with `multipart >= 1.0`
There were several version updates but I bisected the broken tests at https://github.com/zopefoundation/zope.publisher/actions/runs/10980975705/job/30487442606 to `multipart >= 1`.
AttributeError: 'FileUpload' object has no attribute 'seekable'
If you pass self.request['file_field'] to zipfile.ZipFile() on Python 3.7, you will get this error:

    Traceback (most recent call last):
        ...
      File "/usr/lib/python3.7/zipfile.py", line 1428, in read
        with self.open(name, "r", pwd) as fp:
      File "/usr/lib/python3.7/zipfile.py", line 1480, in open
        self._fpclose, self._lock, lambda: self._writing)
      File "/usr/lib/python3.7/zipfile.py", line 722, in __init__
        self.seekable = file.seekable
    AttributeError: 'FileUpload' object has no attribute 'seekable'

Adding `seekable` to the `methods` list in `FileUpload.__init__` is enough to make the error go away.

This error does not show up on Python 3.5.  I haven't checked Python 3.6 yet.


Discussion / hints:
I just filed https://github.com/defnull/multipart/issues/55 about this, after debugging my application all morning.

I have a local fix for the line ending issue, but the lack of `SpooledTemporaryFile.seekable()` breaks one remaining test case on Pythons 3.8 through 3.10.
This appears to have regressed ~~in zope.publisher 6.0.0~~ recently.  GitHub Actions show that the tests [passed](https://github.com/zopefoundation/zope.publisher/actions/runs/10957021182) four days ago, but are [failing](https://github.com/zopefoundation/zope.publisher/actions/runs/10980975705) now.

There was a new https://pypi.org/project/multipart/#history release (1.0.0) four days ago, which I don't think is a coincidence.
So, the `seekable()` method disappeared for me because multipart.MultipartPart.file, which is a tempfile.SpooledTemporaryFile since multipart 1.0.0, doesn't have `seekable()` in Python 3.10 or older, and I'm using 3.8 (because Ubuntu 20.04 LTS).

Fix patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 1b7e705..96f2a3f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,15 @@
 7.1 (unreleased)
 ================
 
-- Nothing changed yet.
+- Fix test suite to use proper line endings (\r\n) in raw multipart/form-data
+  HTTP requests, because multipart 1.0.0 is stricter about line endings.
+  Fixes `issue <https://github.com/zopefoundation/zope.publisher/issues/74>`_.
+
+- ``FileUpload`` objects now implement a fallback ``seekable()`` method on
+  Python 3.7 through 3.10, where tempfile.SpooledTemporaryFile lacks it.
+  Fixes `issue 44 <https://github.com/zopefoundation/zope.publisher/issues/44>`_
+  again, which had regressed due to certain assumptions that were no longer
+  true after the multipart 1.0.0 release.
 
 
 7.0 (2023-08-29)
diff --git a/src/zope/publisher/browser.py b/src/zope/publisher/browser.py
index 3b169a4..51414c1 100644
--- a/src/zope/publisher/browser.py
+++ b/src/zope/publisher/browser.py
@@ -19,6 +19,7 @@
 packaged into a nice, Python-friendly 'FileUpload' object.
 """
 import re
+import tempfile
 from email.message import Message
 from urllib.parse import parse_qsl
 
@@ -676,6 +677,12 @@ def __init__(self, aFieldStorage):
             if hasattr(file, m):
                 d[m] = getattr(file, m)
 
+        if 'seekable' not in d and isinstance(
+            file, tempfile.SpooledTemporaryFile
+        ):  # Python 3.7 to 3.10
+            # NB: can't assign file._file.seekable, file._file might roll over
+            d['seekable'] = lambda: file._file.seekable()
+
         self.headers = aFieldStorage.headers
         filename = aFieldStorage.filename
         if filename is not None:


Test patch:
diff --git a/src/zope/publisher/tests/test_browserrequest.py b/src/zope/publisher/tests/test_browserrequest.py
index 5c9d2be..2dcb3db 100644
--- a/src/zope/publisher/tests/test_browserrequest.py
+++ b/src/zope/publisher/tests/test_browserrequest.py
@@ -43,7 +43,7 @@
 Content-Type: application/octet-stream
 
 -----------------------------1--
-"""
+""".replace(b'\n', b'\r\n')
 
 LARGE_FILE_BODY = b''.join([b"""-----------------------------1
 Content-Disposition: form-data; name="upload"; filename="test"
@@ -51,14 +51,14 @@
 
 Here comes some text! """, (b'test' * 1000), b"""
 -----------------------------1--
-"""])
+"""]).replace(b'\n', b'\r\n')
 
 LARGE_POSTED_VALUE = b''.join([b"""-----------------------------1
 Content-Disposition: form-data; name="upload"
 
 Here comes some text! """, (b'test' * 1000), b"""
 -----------------------------1--
-"""])
+"""]).replace(b'\n', b'\r\n')
 
 IE_FILE_BODY = b"""-----------------------------1
 Content-Disposition: form-data; name="upload"; filename="C:\\Windows\\notepad.exe"
@@ -66,7 +66,7 @@
 
 Some data
 -----------------------------1--
-"""  # noqa: E501 line too long
+""".replace(b'\n', b'\r\n')  # noqa: E501 line too long
 
 
 def publish(request):
@@ -307,7 +307,7 @@ def testFormMultipartUTF8(self):
             'REQUEST_METHOD': 'POST',
             'CONTENT_TYPE': 'multipart/form_data; boundary=-123',
         }
-        body = b'\n'.join([
+        body = b'\r\n'.join([
             b'---123',
             b'Content-Disposition: form-data; name="a"',
             b'',
@@ -333,7 +333,7 @@ def testFormMultipartFilenameUTF8(self):
             'REQUEST_METHOD': 'POST',
             'CONTENT_TYPE': 'multipart/form_data; boundary=-123',
         }
-        body = b'\n'.join([
+        body = b'\r\n'.join([
             b'---123',
             b'Content-Disposition: form-data; name="upload";'
             b' filename="\xe2\x98\x83"',
@@ -355,7 +355,7 @@ def testFormMultipartFilenameLatin7(self):
             'CONTENT_TYPE': (
                 'multipart/form_data; boundary=-123; charset=ISO-8859-13'),
         }
-        body = b'\n'.join([
+        body = b'\r\n'.join([
             b'---123',
             b'Content-Disposition: form-data; name="upload";'
             b' filename="\xc0\xfeuolyno"',
@@ -376,7 +376,7 @@ def testFormMultipartFilenameLatin7DefaultCharset(self):
             'REQUEST_METHOD': 'POST',
             'CONTENT_TYPE': 'multipart/form_data; boundary=-123',
         }
-        body = b'\n'.join([
+        body = b'\r\n'.join([
             b'---123',
             b'Content-Disposition: form-data; name="upload";'
             b' filename="\xc0\xfeuolyno"',
@@ -681,7 +681,7 @@ def testFormMultipartDuplicateFieldNames(self):
             'REQUEST_METHOD': 'POST',
             'CONTENT_TYPE': 'multipart/form_data; boundary=-123',
         }
-        body = b'\n'.join([
+        body = b'\r\n'.join([
             b'---123',
             b'Content-Disposition: form-data; name="a"',
             b'',