Werkzeug: SpooledTemporaryFile-Ausnahmen beim Hochladen von Dateien

Erstellt am 18. Aug. 2018  ·  13Kommentare  ·  Quelle: pallets/werkzeug

Wir sind Anfang dieses Jahres auf ein Problem gestoßen, bei dem SpooledTemporaryFile Ausnahmen beim Hochladen von Dateien ausgelöst hat, nachdem es von 0.12.x auf 0.14.x hochgesprungen war. Hatte keine Zeit, einen tiefen Blick darauf zu werfen, konnte online nicht viel finden und wollte Sie nicht alle ins Schwitzen bringen, wenn es unsere Schuld war oder auf einige inkompatible Abhängigkeiten hinauslief. Gerade an 0.12.2 angeheftet, einen kleinen Test geschrieben, der das Problem erkennen konnte, und weitergemacht.

Ich habe heute Abend eine SO-Frage für Flask-Admin in meinem Posteingang gesehen, die ähnlich klang wie das, was wir gesehen haben, also dachte ich, dass es sich zumindest lohnt, sie zur Sprache zu bringen. Die SO-Frage ist nicht sehr gut formuliert, daher ist sie möglicherweise nicht sehr nützlich (https://stackoverflow.com/questions/51858248/attributeerror-spooledtemporaryfile-object-has-no-attribute-translate).

Falls der Test hilft:

def test_werkzeug_spooled_temp_file(self):
    """
    When we jumped from Werkzeug 0.12.x to 0.14.x, we started running into an error on any attempt to upload a CSV:

    File "/home/vagrant/site/videohub/admin.py", line 728, in import_content
    for line in csv.DictReader(io.TextIOWrapper(new_file), restval=None):
    File "/usr/local/lib/python3.6/dist-packages/werkzeug/datastructures.py", line 2745, in __getattr__
    return getattr(self.stream, name)
    AttributeError: 'SpooledTemporaryFile' object has no attribute 'readable'

    Until/unless we debug this, we need to stay on Werkzeug versions where it doesn't break.
    """

    with self.client:
        from videohub import admin

        admin.auth_admin = lambda: True

        try:
            what = self.client.post(
                "/admin/importcontent/",
                data={"csv": (io.BytesIO(b"my file contents"), "test.csv")},
                content_type="multipart/form-data",
            )
        except AttributeError as e:
            if (
                str(e)
                == "'SpooledTemporaryFile' object has no attribute 'readable'"
            ):
                self.fail("Werkzeug/SpooledTemporaryFile still exists.")

Im SO-Thread habe ich eine Reihe von Github-Problemen/PRs, Python-Problemen und einem anderen SO-Thread eingefärbt, die alle verwandt sein könnten; Hier neu verlinken, falls das verschwindet:

Hilfreichster Kommentar

Entschuldigung für den schwierigen Reproduktionsfall. Ich habe bestätigt, dass wir das Problem immer noch sehen, und heute Nachmittag ein wenig Zeit damit verbracht, es auf eine einigermaßen knappe Reproduktion zu reduzieren.

Dabei habe ich es geschafft, einen möglichen Workaround zu erkennen. Ich habe die Problemumgehung nicht ausgiebig getestet, daher weiß ich nicht, ob sie eigene Probleme verursachen wird (insbesondere angesichts des Berichts von @jdalegonzalez , dass die Plattform hier ein Faktor sein könnte).

Abhängigkeiten:

pip install flask==1.0.2 pytest pytest-flask

Prüfen:

import pytest, io, csv
from flask import Flask, request


def fields(ob):
    return csv.DictReader(ob).fieldnames[0]


@pytest.fixture(scope="session")
def app():
    x = Flask(__name__)
    x.testing = True

    @x.route("/textio", methods=["POST"])
    def upload_file_textio():
        """succeeds with 0.12.2; fails with werkzeug==0.14.1;"""
        return fields(io.TextIOWrapper(request.files["csv"]))

    @x.route("/stringio", methods=["POST"])
    def upload_file_stringio():
        """potential workaround; succeeds for both versions"""
        return fields(io.StringIO(request.files["csv"].read().decode()))

    with x.app_context():
        yield x


@pytest.mark.parametrize("uri", ["/textio", "/stringio"])
def test_werkzeug_spooled_temp_file(client, uri):
    what = client.post(
        uri,
        data={"csv": (io.BytesIO(b"my file contents"), "test.csv")},
        content_type="multipart/form-data",
    )
    assert what.data == b"my file contents"

Alle 13 Kommentare

Für das, was es wert ist, scheint es nicht so einfach zu sein wie die Version von Werkzeug. Wir verwenden 0.14.1 mit Python 3.6.6 auf Alpine3.6 und alles funktioniert. Wenn wir Werkzeug 0.14.1 auf Python 3.7.0 mit Alpine 3.8.1 verwenden, sehen wir das Problem.

Es sieht so aus, als ob https://github.com/python/cpython/pull/3249 schließlich einen Fix auf der Python-Seite liefern könnte.

HI @abathur - Ich habe versucht, ein Repo wie unten beschrieben zu erstellen, und ich konnte den Fehler anscheinend nicht reproduzieren =

app.py

import os
from flask import Flask, flash, request, redirect, url_for
from werkzeug.utils import secure_filename


UPLOAD_FOLDER = './uploads'
ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'])

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER


def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET', 'POST'])
def upload_file():

    if request.method == 'POST':
        # check if the post request has the file part
        if 'file' not in request.files:
            flash('No file part')
            return redirect(request.url)

        file = request.files['file']

        # if user does not select file, browser also
        # submit an empty part without filename
        if file.filename == '':
            flash('No selected file')
            return redirect(request.url)

        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
            return redirect(url_for('uploaded_file', filename=filename))


    return '''
    <!doctype html>
    <title>Upload new File</title>
    <h1>Upload new File</h1>
    <form method=post enctype=multipart/form-data>
      <input type=file name=file>
      <input type=submit value=Upload>
    </form>
    '''


from flask import send_from_directory

@app.route('/uploads/<filename>')
def uploaded_file(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)


if __name__ == '__main__':
    app.run()

Snippet zum Generieren einer großen Datei

dd if=/dev/zero of=filename bs=1024 count=2M

Ubuntu - 18.10
Python -  3.6.7rc1

> pip freeze
Click==7.0
Flask==1.0.2
itsdangerous==1.1.0
Jinja2==2.10
MarkupSafe==1.1.0
Werkzeug==0.14.1

/cc @davidismus

Ich habe im Moment nicht viel Zeit, um es mir anzusehen, aber ich hatte gerade auch Probleme, es zu reproduzieren. Ich bin im Moment unterwegs und bereite mich auf einen Flug am Morgen vor, aber ich werde versuchen, mich daran zu erinnern, ob ich die Anfangsbedingungen später in dieser Woche reproduzieren kann.

@jdalegonzalez Haben Sie einige Notizen zu Ihrer Begegnung mit diesem Problem, die helfen könnten, das Bild in der Zwischenzeit auszufüllen?

Entschuldigung für den schwierigen Reproduktionsfall. Ich habe bestätigt, dass wir das Problem immer noch sehen, und heute Nachmittag ein wenig Zeit damit verbracht, es auf eine einigermaßen knappe Reproduktion zu reduzieren.

Dabei habe ich es geschafft, einen möglichen Workaround zu erkennen. Ich habe die Problemumgehung nicht ausgiebig getestet, daher weiß ich nicht, ob sie eigene Probleme verursachen wird (insbesondere angesichts des Berichts von @jdalegonzalez , dass die Plattform hier ein Faktor sein könnte).

Abhängigkeiten:

pip install flask==1.0.2 pytest pytest-flask

Prüfen:

import pytest, io, csv
from flask import Flask, request


def fields(ob):
    return csv.DictReader(ob).fieldnames[0]


@pytest.fixture(scope="session")
def app():
    x = Flask(__name__)
    x.testing = True

    @x.route("/textio", methods=["POST"])
    def upload_file_textio():
        """succeeds with 0.12.2; fails with werkzeug==0.14.1;"""
        return fields(io.TextIOWrapper(request.files["csv"]))

    @x.route("/stringio", methods=["POST"])
    def upload_file_stringio():
        """potential workaround; succeeds for both versions"""
        return fields(io.StringIO(request.files["csv"].read().decode()))

    with x.app_context():
        yield x


@pytest.mark.parametrize("uri", ["/textio", "/stringio"])
def test_werkzeug_spooled_temp_file(client, uri):
    what = client.post(
        uri,
        data={"csv": (io.BytesIO(b"my file contents"), "test.csv")},
        content_type="multipart/form-data",
    )
    assert what.data == b"my file contents"

Hallo @abathur ,

Ich habe einen kurzen Blick auf die Ausführung Ihres Pytest-Repos geworfen und auf einen Blick scheint die folgende Ausgabe für das Problem reproduzierbar zu sein. Ich werde versuchen, in den nächsten Tagen weiter zu suchen, aber ich bin ein Neuling in Bezug auf Flasks, Pytests und Fixtures 😃

Testing started at 20:35 ...
/Users/jayCee/PycharmProjects/test_werkzeug_spooled_temp_file/venv/bin/python "/Users/jayCee/Library/Application Support/JetBrains/Toolbox/apps/PyCharm-P/ch-1/182.4505.26/PyCharm.app/Contents/helpers/pycharm/_jb_pytest_runner.py" --target test_werkzeug_spooled_temp.py::test_werkzeug_spooled_temp_file
Launching pytest with arguments test_werkzeug_spooled_temp.py::test_werkzeug_spooled_temp_file in /Users/jayCee/PycharmProjects/test_werkzeug_spooled_temp_file/test

============================= test session starts ==============================
platform darwin -- Python 3.6.6, pytest-4.0.0, py-1.7.0, pluggy-0.8.0
rootdir: /Users/jayCee/PycharmProjects/test_werkzeug_spooled_temp_file/test, inifile:
plugins: flask-0.14.0collected 2 items

test_werkzeug_spooled_temp.py F
test_werkzeug_spooled_temp.py:27 (test_werkzeug_spooled_temp_file[/textio])
client = <FlaskClient <Flask 'test_werkzeug_spooled_temp'>>, uri = '/textio'

    @pytest.mark.parametrize("uri", ["/textio", "/stringio"])
    def test_werkzeug_spooled_temp_file(client, uri):

        what = client.post(
            uri,
            data={"csv": (io.BytesIO(b"my file contents"), "test.csv")},
>           content_type="multipart/form-data",
        )

test_werkzeug_spooled_temp.py:34: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../venv/lib/python3.6/site-packages/werkzeug/test.py:840: in post
    return self.open(*args, **kw)
../venv/lib/python3.6/site-packages/flask/testing.py:200: in open
    follow_redirects=follow_redirects
../venv/lib/python3.6/site-packages/werkzeug/test.py:803: in open
    response = self.run_wsgi_app(environ, buffered=buffered)
../venv/lib/python3.6/site-packages/werkzeug/test.py:716: in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered)
../venv/lib/python3.6/site-packages/werkzeug/test.py:923: in run_wsgi_app
    app_rv = app(environ, start_response)
../venv/lib/python3.6/site-packages/flask/app.py:2309: in __call__
    return self.wsgi_app(environ, start_response)
../venv/lib/python3.6/site-packages/flask/app.py:2295: in wsgi_app
    response = self.handle_exception(e)
../venv/lib/python3.6/site-packages/flask/app.py:1741: in handle_exception
    reraise(exc_type, exc_value, tb)
../venv/lib/python3.6/site-packages/flask/_compat.py:35: in reraise
    raise value
../venv/lib/python3.6/site-packages/flask/app.py:2292: in wsgi_app
    response = self.full_dispatch_request()
../venv/lib/python3.6/site-packages/flask/app.py:1815: in full_dispatch_request
    rv = self.handle_user_exception(e)
../venv/lib/python3.6/site-packages/flask/app.py:1718: in handle_user_exception
    reraise(exc_type, exc_value, tb)
../venv/lib/python3.6/site-packages/flask/_compat.py:35: in reraise
    raise value
../venv/lib/python3.6/site-packages/flask/app.py:1813: in full_dispatch_request
    rv = self.dispatch_request()
../venv/lib/python3.6/site-packages/flask/app.py:1799: in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
test_werkzeug_spooled_temp.py:17: in upload_file_textio
    return fields(io.TextIOWrapper(request.files["csv"]))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <FileStorage: 'test.csv' ('text/csv')>, name = 'readable'

    def __getattr__(self, name):
>       return getattr(self.stream, name)
E       AttributeError: 'SpooledTemporaryFile' object has no attribute 'readable'

../venv/lib/python3.6/site-packages/werkzeug/datastructures.py:2745: AttributeError
.                                         [100%]
=================================== FAILURES ===================================
___________________ test_werkzeug_spooled_temp_file[/textio] ___________________
...
# truncated duplicate above output ...

Umgebung

macOS Mojave 10.14
Python 3.6.6

pip freeze
atomicwrites==1.2.1
attrs==18.2.0
Click==7.0
Flask==1.0.2
itsdangerous==1.1.0
Jinja2==2.10
MarkupSafe==1.1.0
more-itertools==4.3.0
pluggy==0.8.0
py==1.7.0
pytest==4.0.0
pytest-flask==0.14.0
six==1.11.0
Werkzeug==0.14.1

Hallo,

Es sieht so aus, als ob die Einführung von SpooledTemporaryFile aus den folgenden PRs die Ursache für dieses Problem ist ...

PR: #1198: Unterstützt Chunked Transfer Encoding

  • SpooledTemporaryFile eingeführt - https://github.com/pallets/werkzeug/pull/1198/files#diff-91178c333961d75c285b7784d1b81cecR40)

PR: #1222 – Unterstützung für Plattformen hinzugefügt, denen gespoolte temporäre Dateien fehlen

  • Implementiert das Verhalten für FormDataParser.default_stream_factory – Auf Plattformen, die das SpooledTemporaryFile -Objekt und das total_content_length > max_size $-Objekt unterstützen

Auskommentieren der Implementierung von SpooledTemporaryFile - scheint den Testcode https://github.com/pallets/werkzeug/issues/1344#issuecomment -438836862 zu bestehen

def default_stream_factory(total_content_length, filename, content_type,
                           content_length=None):
    """The stream factory that is used per default."""
    max_size = 1024 * 500
#    if SpooledTemporaryFile is not None:
#        return SpooledTemporaryFile(max_size=max_size, mode='wb+')
    if total_content_length is None or total_content_length > max_size:
        return TemporaryFile('wb+')
return BytesIO()

Es sieht aus wie bei @abathur- Links - es gibt ein Problem mit der Klasse SpooledTemporaryFile , da ihr die Implementierung von "angenommenen" abstrakten Attributen von IOBase fehlt. Dies würde sich auch auf Fälle von Dateien auswirken, die weniger als 512 Kilobyte (?)

Das Anwenden des Affen-Patches unten auf FormDataParser.default_stream_factory scheint den Testcode https://github.com/pallets/werkzeug/issues/1344#issuecomment -438836862 zu bestehen:

def default_stream_factory(total_content_length, filename, content_type,
                           content_length=None):
    """The stream factory that is used per default."""
    max_size = 1024 * 500
    if SpooledTemporaryFile is not None:
        monkeypatch_SpooledTemporaryFile = SpooledTemporaryFile(max_size=max_size, mode='wb+')
        monkeypatch_SpooledTemporaryFile.readable = monkeypatch_SpooledTemporaryFile._file.readable
        monkeypatch_SpooledTemporaryFile.writable = monkeypatch_SpooledTemporaryFile._file.writable
        monkeypatch_SpooledTemporaryFile.seekable = monkeypatch_SpooledTemporaryFile._file.seekable
        return monkeypatch_SpooledTemporaryFile
    if total_content_length is None or total_content_length > max_size:
        return TemporaryFile('wb+')
    return BytesIO()

oder vielleicht...

class SpooledTemporaryFile_Patched(SpooledTemporaryFile): 
    """Patch for `SpooledTemporaryFile exceptions on file upload #1344`
      - SpooledTemporaryFile does not fully satisfy the abstract for IOBase - https://bugs.python.org/issue26175 
      - bpo-26175: Fix SpooledTemporaryFile IOBase abstract - https://github.com/python/cpython/pull/3249

     TODO: Remove patch once `bpo-26175: Fix SpooledTemporaryFile IOBase abstract` is resolved..."""

    def readable(self):
        return self._file.readable

    def writable(self):
        return self._file.writable

    def seekable(self):
        return self._file.seekable

...

def default_stream_factory(total_content_length, filename, content_type, content_length=None):
    """The stream factory that is used per default.

    Patch: `SpooledTemporaryFile exceptions on file upload #1344`, Remove once `bpo-26175: Fix SpooledTemporaryFile IOBase abstract` is resolved...
      - SpooledTemporaryFile does not fully satisfy the abstract for IOBase - https://bugs.python.org/issue26175 
      - bpo-26175: Fix SpooledTemporaryFile IOBase abstract - https://github.com/python/cpython/pull/3249"""
    max_size = 1024 * 500
    if SpooledTemporaryFile is not None:
        # TODO: Remove patch once `bpo-26175: Fix SpooledTemporaryFile IOBase abstract` is resolved...
        SpooledTemporaryFile = SpooledTemporaryFile_Patched
        return SpooledTemporaryFile_Patched(max_size=max_size, mode='wb+')
    if total_content_length is None or total_content_length > max_size:
        return TemporaryFile('wb+')
    return BytesIO()

Was wäre die beste Vorgehensweise?

  • Implementieren Sie einen Affen-Patch für SpooledTemporaryFile ? (Es gibt wahrscheinlich einen besseren Weg, dies zu tun ...)
  • Vermeiden Sie die Implementierung von SpooledTemporaryFile , bis ein Fix in einer späteren Version von Python veröffentlicht wird, und fügen Sie je nach Python-Version eine Umleitung hinzu.
  • Ich bin noch ein Neuling in dieser Domain, ich vermute, es gibt andere Möglichkeiten? :)

Früher haben wir SpooledTemporaryFile nicht verwendet, aber es schien eine eingebaute Möglichkeit zu sein, das zu tun, was der ursprüngliche Code tat. Es hat seitdem viele Probleme verursacht. Wir sollten wahrscheinlich einfach wieder dazu übergehen, SpooledTemporaryFile nicht zu verwenden.

😞 Ich stelle fest, dass bpo-26175 jetzt zumindest eine Pull-Anforderung in Bearbeitung hat, siehe python/cpython#3249

Der Chat mit @mjpieters brachte eine andere Idee hervor. Wir könnten FileStorage.__getattr__ ändern, um stream._file auszuprobieren, wenn das attr nicht auf stream existiert, aber ein _file -Attribut hat.

class FileStorage:
    def __getattr__(self, name):
        try:
            return getattr(self.stream, name)
        except AttributeError:
            if hasattr(self.stream, "_file"):
                return getattr(self.stream._file, name)
            raise

Hallo, upgrade heute Flask auf 1.0.2 mit Need Werkzeug >= 0.14

Ich habe denselben Fehler mit der letzten Version von werkzeug (0.14.1) mit Python 3.6.7 (die mit Ubuntu 18.04 geliefert werden):

AttributeError: Objekt 'SpooledTemporaryFile' hat kein Attribut 'lesbar'

Planen Sie, den Fix auf Version 0.14 zu portieren?

Danke.

Die nächste Version wird 0.15 sein. Bitte folgen Sie diesem Repo, unserem Blog oder https://twitter.com/PalletsTeam für Ankündigungen zu Veröffentlichungen.

War diese Seite hilfreich?
0 / 5 - 0 Bewertungen