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:
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 - vou dar uma olhada...
Notas:
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
PR: #1222 - Adicionado suporte para plataformas sem arquivos temporários em spool
SpooledTemporaryFile
e o total_content_length > max_size
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:
seekable
, readable
e writable
estão faltando) - 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()
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?
SpooledTemporaryFile
? (Provavelmente há uma maneira melhor de fazer isso ...)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?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.
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:
Teste: