Werkzeug: Exceções de SpooledTemporaryFile no upload de arquivos

Criado em 18 ago. 2018  ·  13Comentários  ·  Fonte: pallets/werkzeug

Encontramos um problema no início deste ano com SpooledTemporaryFile lançando exceções em uploads de arquivos depois de saltar de 0.12.x para 0.14.x. Não tive tempo de dar uma olhada profunda, não consegui encontrar muita coisa online e não quis suar todos se a culpa fosse nossa ou se resumisse a algumas dependências incompatíveis. Apenas fixado em 0.12.2, escrevi um pequeno teste que poderia detectar o problema e segui em frente.

Eu vi uma pergunta SO para o flask-admin na minha caixa de entrada hoje à noite que parecia semelhante ao que vimos, então achei que pelo menos vale a pena trazer à tona. A pergunta SO não está muito bem formulada, por isso pode não ser a mais útil (https://stackoverflow.com/questions/51858248/attributeerror-spooledtemporaryfile-object-has-no-attribute-translate).

Caso o teste ajude:

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

No encadeamento SO eu coloquei um conjunto de problemas/PRs do github, problemas do Python e outro encadeamento SO que podem estar relacionados; re-linkando aqui caso isso desapareça:

Comentários muito úteis

Desculpas pelo difícil caso de reprodução. Confirmei que ainda estamos vendo o problema e passei algum tempo esta tarde reduzindo-o a uma reprodução razoavelmente concisa.

No processo, consegui identificar uma possível solução alternativa. Eu não testei a solução alternativa extensivamente, então não sei se ela causará problemas próprios (especialmente considerando o relatório de @jdalegonzalez que a plataforma pode ser um fator aqui).

Dependências:

pip install flask==1.0.2 pytest pytest-flask

Teste:

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 comentários

Pelo que vale, não parece ser tão simples quanto a versão do Werkzeug. Estamos usando 0.14.1 com python 3.6.6 no Alpine3.6 e tudo está funcionando. Quando usamos Werkzeug 0.14.1 em python 3.7.0 com Alpine 3.8.1, vemos o problema.

Parece que https://github.com/python/cpython/pull/3249 pode eventualmente produzir uma correção no lado do Python.

OI @abathur - Tentei criar um repositório, conforme abaixo, e não consegui reproduzir o erro =

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 para gerar um arquivo 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 @davidism

Não tenho muito tempo para ver o momento, mas também tive problemas para reproduzi-lo agora. Estou na estrada no momento e me preparando para um vôo pela manhã, mas vou tentar lembrar para ver se consigo reproduzir as condições iniciais ainda esta semana.

@jdalegonzalez Você tem algumas anotações sobre seu desentendimento com esse problema que podem ajudar a preencher a imagem nesse ínterim?

Desculpas pelo difícil caso de reprodução. Confirmei que ainda estamos vendo o problema e passei algum tempo esta tarde reduzindo-o a uma reprodução razoavelmente concisa.

No processo, consegui identificar uma possível solução alternativa. Eu não testei a solução alternativa extensivamente, então não sei se ela causará problemas próprios (especialmente considerando o relatório de @jdalegonzalez que a plataforma pode ser um fator aqui).

Dependências:

pip install flask==1.0.2 pytest pytest-flask

Teste:

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"

Oi @abathur ,

Eu dei uma olhada rápida na execução do seu repositório pytest e, de relance, a saída abaixo parece ser reproduzível do problema. Vou tentar dar uma olhada nos próximos dias, mas sou novato em relação a frascos, pytests e acessórios 😃

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

Ambiente

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

Oi,

Parece que a introdução de SpooledTemporaryFile dos PRs abaixo é a fonte deste problema...

PR: #1198: Suporta codificação de transferência em partes

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

PR: #1222 - Adicionado suporte para plataformas sem arquivos temporários em spool


Comentando a implementação de SpooledTemporaryFile - parece passar no código de teste 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 nos links @abathur - há um problema com a classe SpooledTemporaryFile , pois falta a implementação de atributos abstratos "assumidos" de IOBase . Além disso, isso afetaria casos de arquivos com menos de 512 kilobytes (?)

A aplicação do patch de macaco abaixo ao FormDataParser.default_stream_factory parece passar o código de teste 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()

ou talvez...

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

Qual seria o melhor curso de ação?

  • Implementar um patch de macaco por SpooledTemporaryFile ? (Provavelmente há uma maneira melhor de fazer isso ...)
  • Evite a implementação de SpooledTemporaryFile , até que uma correção seja lançada em uma versão posterior do python e adicione um redirecionamento dependendo da versão do python?
  • Eu ainda sou um novato neste domínio, acho que existem outras opções? :)

Costumávamos não usar SpooledTemporaryFile , mas parecia uma maneira integrada de fazer o que o código original estava fazendo. Tem causado muitos problemas desde então. Provavelmente devemos voltar a não usar SpooledTemporaryFile .

😞 Observo que o bpo-26175 tem um pull request em andamento agora, pelo menos, veja python/cpython#3249

O bate-papo com @mjpieters trouxe uma ideia diferente. Poderíamos alterar FileStorage.__getattr__ para tentar stream._file se o attr não existir em stream mas tiver um 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

Oi, hoje atualize o frasco para 1.0.2 com necessidade de werkzeug >= 0.14

Eu tenho esse mesmo erro com a última versão do werkzeug (0.14.1) com python 3.6.7 (que vem com o Ubuntu 18.04):

AttributeError: o objeto 'SpooledTemporaryFile' não tem atributo 'legível'

Você planeja portar a correção para a versão 0.14?

Obrigada.

A próxima versão será 0.15. Siga este repositório, nosso blog ou https://twitter.com/PalletsTeam para anúncios de lançamento.

Esta página foi útil?
0 / 5 - 0 avaliações