今年早些时候,我们遇到了一个问题,即 SpooledTemporaryFile 在从 0.12.x 升级到 0.14.x 后在文件上传时抛出异常。 没有时间深入研究,在网上找不到太多东西,如果这是我们的错或归结为一些不兼容的依赖项,也不想让大家大汗淋漓。 刚刚固定到 0.12.2,写了一个可以检测问题的小测试,然后继续前进。
今晚我在收件箱中看到了一个关于烧瓶管理员的 SO 问题,听起来与我们看到的相似,所以我认为它至少值得提出。 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 线程中,我签署了一组 github 问题/PR、Python 问题和另一个可能都相关的 SO 线程; 如果消失,请在此处重新链接:
对于它的价值,它似乎不像 Werkzeug 的版本那么简单。 我们在 Alpine3.6 上使用 0.14.1 和 python 3.6.6,一切正常。 当我们在 python 3.7.0 和 Alpine 3.8.1 上使用 Werkzeug 0.14.1 时,我们看到了问题。
看起来https://github.com/python/cpython/pull/3249最终可能会在 Python 端产生修复。
嗨@abathur - 我试图创建一个回购,如下所示,我似乎无法重现错误=
应用程序.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 @大卫主义
目前没有太多时间看,但我刚才也很难重现它。 我现在在路上,准备早上的航班,但我会尽量记住,看看我是否能在本周晚些时候重现初始条件。
@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 存储库,一目了然,下面的输出看起来可以重现该问题。 在接下来的几天里,我会尝试进一步了解,但我是烧瓶、pytest 和固定装置的新手😃
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
你好,
看起来从下面的 PR 中引入SpooledTemporaryFile
是这个问题的根源......
PR:#1198:支持分块传输编码
PR:#1222 - 添加了对缺少假脱机临时文件的平台的支持
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 KB 的文件的情况(?)
将以下猴子补丁应用于FormDataParser.default_stream_factory看起来可以通过测试代码https://github.com/pallets/werkzeug/issues/1344#issuecomment -438836862:
seekable
、 readable
和writable
缺失)- https://bugs.python.org/issue26175def 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 现在有一个拉取请求正在进行中,至少,请参阅 python/cpython#3249
与@mjpieters聊天提出了一个不同的想法。 如果stream
上不存在 attr 但它具有_file
属性,我们可以更改FileStorage.__getattr__
以尝试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
嗨,今天将烧瓶升级到 1.0.2,需要 werkzeug >= 0.14
python 3.6.7(ubuntu 18.04附带)的最后一个版本的werkzeug(0.14.1)也有同样的错误:
AttributeError:“SpooledTemporaryFile”对象没有“可读”属性
您打算将修复程序移植到 0.14 版吗?
谢谢你。
下一个版本将是 0.15。 请关注此 repo、我们的博客或https://twitter.com/PalletsTeam以获取发布公告。
最有用的评论
为难以复制的情况道歉。 我已经确认我们仍然看到这个问题,并在今天下午花了一点时间将其缩减为一个相当简洁的复制。
在此过程中,我确实设法发现了一个潜在的解决方法。 我还没有广泛测试这个解决方法,所以我不知道它是否会导致它自己的问题(特别是考虑到@jdalegonzalez的报告,平台可能是这里的一个因素)。
依赖项:
测试: