Werkzeug: Исключения SpooledTemporaryFile при загрузке файла

Созданный на 18 авг. 2018  ·  13Комментарии  ·  Источник: pallets/werkzeug

Ранее в этом году мы столкнулись с проблемой, когда SpooledTemporaryFile вызывал исключения при загрузке файлов после перехода с 0.12.x на 0.14.x. У меня не было времени внимательно изучить, я не мог найти многого в Интернете и не хотел вас всех утруждать, если это была наша вина или дело дошло до каких-то несовместимых зависимостей. Просто закрепился на 0.12.2, написал небольшой тест, который мог обнаружить проблему, и пошел дальше.

Сегодня вечером я увидел ТАК вопрос для flask-admin в своем почтовом ящике, который звучал похоже на то, что мы видели, поэтому я решил, что его, по крайней мере, стоит поднять. Вопрос SO не очень хорошо сформулирован, поэтому он может быть не самым полезным (https://stackoverflow.com/questions/51858248/attributeerror-spooledtemporaryfile-object-has-no-attribute-translate).

Если тест поможет:

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

В ветке SO я подписал набор проблем / PR github, проблем с Python и еще одну ветку SO, которые могут быть связаны; повторная ссылка здесь на случай, если она исчезнет:

Самый полезный комментарий

Приносим свои извинения за сложный случай воспроизведения. Я подтвердил, что мы все еще видим проблему, и сегодня днем ​​потратил немного времени на то, чтобы свести ее к достаточно краткому воспроизведению.

В процессе мне удалось найти потенциальное обходное решение. Я не тестировал обходной путь тщательно, поэтому я не знаю, вызовет ли он собственные проблемы (особенно с учетом отчета @jdalegonzalez о том, что платформа может быть здесь фактором).

Зависимости:

pip install flask==1.0.2 pytest pytest-flask

Тестовое задание:

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"

Все 13 Комментарий

Как бы то ни было, это не так просто, как версия Werkzeug. Мы используем 0.14.1 с python 3.6.6 на Alpine3.6, и все работает. Когда мы используем Werkzeug 0.14.1 на python 3.7.0 с Alpine 3.8.1, мы видим проблему.

Похоже, https://github.com/python/cpython/pull/3249 может в конечном итоге привести к исправлению на стороне Python.

Привет , @abathur. Я попытался создать репозиторий, как показано ниже, но не смог воспроизвести ошибку =

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

Фрагмент для создания большого файла

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

Сейчас не так много времени, чтобы посмотреть, но у меня тоже были проблемы с воспроизведением этого сейчас. Я сейчас в пути и готовлюсь к утреннему полету, но я постараюсь вспомнить, смогу ли я воспроизвести начальные условия позже на этой неделе.

@jdalegonzalez У вас есть какие-нибудь заметки о вашем столкновении с этой проблемой, которые могли бы помочь заполнить картину в промежутке времени?

Приносим свои извинения за сложный случай воспроизведения. Я подтвердил, что мы все еще видим проблему, и сегодня днем ​​потратил немного времени на то, чтобы свести ее к достаточно краткому воспроизведению.

В процессе мне удалось найти потенциальное обходное решение. Я не тестировал обходной путь тщательно, поэтому я не знаю, вызовет ли он собственные проблемы (особенно с учетом отчета @jdalegonzalez о том, что платформа может быть здесь фактором).

Зависимости:

pip install flask==1.0.2 pytest pytest-flask

Тестовое задание:

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"

Привет @abathur ,

Я быстро взглянул на выполнение вашего репозитория pytest, и на первый взгляд приведенный ниже вывод выглядит воспроизводимым для проблемы. В ближайшие несколько дней я постараюсь продолжить изучение, но я новичок в том, что касается flask, pytests и фикстур 😃

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

Окружающая среда

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

Привет,

Похоже, что введение SpooledTemporaryFile из приведенных ниже PR является источником этой проблемы...

PR: #1198: Поддержка кодирования передачи по частям

  • Представлен SpooledTemporaryFile - https://github.com/pallets/werkzeug/pull/1198/files#diff-91178c333961d75c285b7784d1b81cecR40)

PR: #1222 — добавлена ​​поддержка платформ, на которых отсутствуют буферные временные файлы.

  • Реализует поведение для FormDataParser.default_stream_factory — на платформах, поддерживающих объект SpooledTemporaryFile и объект total_content_length > max_size

Комментирование реализации SpooledTemporaryFile - кажется, проходит тестовый код 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()

Судя по ссылкам @abathur , существует проблема с классом SpooledTemporaryFile , поскольку в нем отсутствует реализация «предполагаемых» абстрактных атрибутов IOBase . Кроме того, это повлияет на файлы размером менее 512 килобайт (?)

Применение приведенного ниже патча обезьяны к FormDataParser.default_stream_factory позволяет пройти тестовый код 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()

или, может быть...

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

Каков был бы лучший образ действий?

  • Реализовать патч для обезьян за SpooledTemporaryFile ? (Вероятно, есть лучший способ сделать это...)
  • Избегайте реализации SpooledTemporaryFile до тех пор, пока не будет выпущено исправление в более поздней версии python, и добавьте перенаправление в зависимости от версии python?
  • Я все еще новичок в этом домене, я предполагаю, что есть другие варианты? :)

Раньше мы не использовали SpooledTemporaryFile , но это казалось встроенным способом делать то, что делал исходный код. С тех пор это вызвало много проблем. Вероятно, нам следует просто вернуться к тому, чтобы не использовать SpooledTemporaryFile .

😞 Замечу, что у bpo-26175 сейчас выполняется pull request, по крайней мере, см. python/cpython#3249

Чат с @mjpieters натолкнул на другую идею. Мы могли бы заменить FileStorage.__getattr__ на stream._file , если атрибут stream не существует, но имеет атрибут _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

Привет, сегодня обновите flask до 1.0.2 с помощью werkzeug >= 0.14

У меня такая же ошибка с последней версией werkzeug (0.14.1) с python 3.6.7 (которая поставляется с Ubuntu 18.04):

AttributeError: объект «SpooledTemporaryFile» не имеет атрибута «читаемый»

Планируете ли вы портировать исправление на версию 0.14?

Спасибо.

Следующая версия будет 0.15. Пожалуйста, следите за этим репозиторием, нашим блогом или https://twitter.com/PalletsTeam , чтобы получать объявления о выпуске.

Была ли эта страница полезной?
0 / 5 - 0 рейтинги