Werkzeug: Excepciones de SpooledTemporaryFile en la carga de archivos

Creado en 18 ago. 2018  ·  13Comentarios  ·  Fuente: pallets/werkzeug

Nos encontramos con un problema a principios de este año con SpooledTemporaryFile que lanzaba excepciones en las cargas de archivos después de pasar de 0.12.x a 0.14.x. No tuve tiempo de echar un vistazo profundo, no pude encontrar mucho en línea y no quería molestarlos a todos si era culpa nuestra o si se trataba de algunas dependencias incompatibles. Solo fijé a 0.12.2, escribí una pequeña prueba que podría detectar el problema y seguí adelante.

Vi una pregunta de SO para el administrador de matraz en mi bandeja de entrada esta noche que sonaba similar a lo que vimos, así que pensé que al menos valía la pena mencionarla. La pregunta SO no está muy bien formulada, por lo que puede que no sea muy útil (https://stackoverflow.com/questions/51858248/attributeerror-spooledtemporaryfile-object-has-no-attribute-translate).

En caso de que la prueba ayude:

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.")

En el subproceso SO, entinté un conjunto de problemas/PR de github, problemas de Python y otro subproceso SO que pueden estar todos relacionados; Vuelvo a vincular aquí en caso de que desaparezca:

Comentario más útil

Disculpas por el difícil caso de reproducción. Confirmé que todavía estamos viendo el problema y dediqué un poco de tiempo esta tarde a reducirlo a una reproducción razonablemente concisa.

En el proceso logré detectar una posible solución. No probé la solución de manera exhaustiva, por lo que no sé si causará problemas propios (especialmente dado el informe de @jdalegonzalez de que la plataforma puede ser un factor aquí).

Dependencias:

pip install flask==1.0.2 pytest pytest-flask

Prueba:

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"

Todos 13 comentarios

Por lo que vale, no parece ser tan simple como la versión de Werkzeug. Estamos usando 0.14.1 con python 3.6.6 en Alpine3.6 y todo funciona. Cuando usamos Werkzeug 0.14.1 en python 3.7.0 con Alpine 3.8.1, vemos el problema.

Parece que https://github.com/python/cpython/pull/3249 eventualmente puede brindar una solución en el lado de Python.

HI @abathur : traté de crear un repositorio, como se indica a continuación, y parece que no pude reproducir el error =

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()

Fragmento para generar un archivo grande

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 @davidismo

No tengo mucho tiempo para mirar el momento, pero también tuve problemas para reproducirlo ahora. Estoy en la carretera en este momento y preparándome para un vuelo por la mañana, pero intentaré recordar para ver si puedo reproducir las condiciones iniciales a finales de esta semana.

@jdalegonzalez ¿Tiene algunas notas sobre su encuentro con este problema que podrían ayudar a completar la imagen mientras tanto?

Disculpas por el difícil caso de reproducción. Confirmé que todavía estamos viendo el problema y dediqué un poco de tiempo esta tarde a reducirlo a una reproducción razonablemente concisa.

En el proceso logré detectar una posible solución. No probé la solución de manera exhaustiva, por lo que no sé si causará problemas propios (especialmente dado el informe de @jdalegonzalez de que la plataforma puede ser un factor aquí).

Dependencias:

pip install flask==1.0.2 pytest pytest-flask

Prueba:

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"

Hola @abathur ,

Eché un vistazo rápido a la ejecución de su repositorio de pytest y, de un vistazo, el resultado a continuación parece ser reproducible del problema. Intentaré echar un vistazo más en los próximos días, pero soy un novato en lo que respecta a matraces, pruebas de pytest y accesorios 😃

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 ...

Reinar

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

Hola,

Parece que la introducción de SpooledTemporaryFile de los siguientes PR es la fuente de este problema...

PR: #1198: Admite codificación de transferencia fragmentada

  • Se introdujo SpooledTemporaryFile - https://github.com/pallets/werkzeug/pull/1198/files#diff-91178c333961d75c285b7784d1b81cecR40)

PR: #1222 - Soporte agregado para plataformas que carecen de archivos temporales en cola


Al comentar la implementación de SpooledTemporaryFile , parece pasar el código de prueba https://github.com/pallets/werkzeug/issues/1344#issuecomment -438836862

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()

Parece que de los enlaces de @abathur : hay un problema con la clase SpooledTemporaryFile ya que falta la implementación de los atributos abstractos "asumidos" de IOBase . Además, esto afectaría los casos de archivos que tienen menos de 512 kilobytes (?)

Al aplicar el siguiente parche de mono a FormDataParser.default_stream_factory , se busca pasar el código de prueba https://github.com/pallets/werkzeug/issues/1344#issuecomment -438836862:

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()

o tal vez...

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()

¿Cuál sería el mejor curso de acción?

  • ¿Implementar un parche de mono por SpooledTemporaryFile ? (Probablemente hay una mejor manera de hacer esto...)
  • Evite la implementación de SpooledTemporaryFile , hasta que se publique una solución en una versión posterior de python y agregue una redirección según la versión de python.
  • Todavía soy un novato en este dominio, ¿supongo que hay otras opciones? :)

Solíamos no usar SpooledTemporaryFile , pero parecía una forma integrada de hacer lo que hacía el código original. Ha causado muchos problemas desde entonces. Probablemente deberíamos volver a no usar SpooledTemporaryFile .

😞 Observo que bpo-26175 tiene una solicitud de extracción en curso ahora, al menos, consulte python/cpython#3249

El chat con @mjpieters planteó una idea diferente. Podríamos cambiar FileStorage.__getattr__ para probar stream._file si el atributo no existe en stream pero tiene un atributo _file .

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

Hola, hoy actualice el matraz a 1.0.2 con necesidad de werkzeug> = 0.14

Tengo este mismo error con la última versión de werkzeug (0.14.1) con python 3.6.7 (que viene con ubuntu 18.04):

AttributeError: el objeto 'SpooledTemporaryFile' no tiene atributo 'legible'

¿Planea portar la corrección a la versión 0.14?

Gracias.

La próxima versión será la 0.15. Siga este repositorio, nuestro blog o https://twitter.com/PalletsTeam para ver los anuncios de lanzamiento.

¿Fue útil esta página
0 / 5 - 0 calificaciones

Temas relacionados

asottile picture asottile  ·  11Comentarios

sorenh picture sorenh  ·  4Comentarios

androiddrew picture androiddrew  ·  14Comentarios

masklinn picture masklinn  ·  11Comentarios

Nessphoro picture Nessphoro  ·  6Comentarios