
requests 테스트 코드를 실행하는 도중 ImportError 가 발생했다. 워낙 유명한 라이브러리인지라 라이브러리 자체의 문제가 아니라 내가 필요한 의존성을 잘못 설치했거나 설정을 잘못한 줄 알았다. 그런데 원인을 분석해보니 사실 현재 main 브랜치 버전(v.2.31.0)에 문제가 있었다! 현재 requests 에 어떤 문제가 있는지 그리고 필자가 어떻게 해결했는지 살펴본다.
requests 의 최신 소스 코드(v.2.31.0) 다운 후 pytest 실행시 필자의 로컬 파이썬 3.10 에서 ImportError 가 발생한다.
pip install .
pip install -r requirements.txt
pytest
# ImportError: cannot import name 'parse_authorization_header' from 'werkzeug.http' (/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/werkzeug/http.py)
파이썬 가상환경(pyenv)에서 pytest 를 해서 의존성 문제도 발생할 수 없을 것 같았고, 내가 모르는 내부적인 에러로 인해 문제가 발생했을 거라 생각하지는 않았다. 그러다 closed 된 pull requests 에서 test 가 통과하지 않은 PR도 main 에 merge 되고 있는 것을 확인했다. 자세히 들여다보니 CI test 가 python 3.7 을 제외한 나머지 버전에서 모두 실패하고 있었다.


우선 문제 상황을 재현하기 위해 3.7 버전에서는 잘 돌아가고, 나머지 버전에서는 안되는지 다시 확인해보았다. docker 가상환경을 사용해 3.7 버전과 3.10 버전을 비교했다. (tox 나 pyenv 를 사용해도 된다.) 3.7, 3.8, 3.10 버전을 테스트한 결과 예상한대로 3.7 버전을 제외한 나머지 버전에서 ImportError 가 발생했다.
docker run -it python:**3.10.7** bash (or python:**3.7.13**)
apt update
apt upgrade
apt install git-all
git clone https://github.com/psf/requests.git
cd requests
pip install .
pip install -r requirements-dev.txt
pytest
python 3.8+

python 3.7

파이썬 환경으로 인한 에러가 아니었다는 점은 확인했다. 에러 로그를 들여다보니 ImportError 가 requests 가 아닌 httpbin 에서 발생한 것을 확인할 수 있었다.
/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py:318: PluggyTeardownRaisedWarning: A plugin raised an exception during an old-style hookwrapper teardown.
Plugin: helpconfig, Hook: pytest_cmdline_parse
ImportError: cannot import name 'parse_authorization_header' from 'werkzeug.http' (/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/werkzeug/http.py)
For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning
config = pluginmanager.hook.pytest_cmdline_parse(
Traceback (most recent call last):
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/bin/pytest", line 8, in <module>
sys.exit(console_main())
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 185, in console_main
code = main()
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 143, in main
config = _prepareconfig(args, plugins)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 318, in _prepareconfig
config = pluginmanager.hook.pytest_cmdline_parse(
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_hooks.py", line 501, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_manager.py", line 119, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_callers.py", line 155, in _multicall
teardown[0].send(outcome)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/helpconfig.py", line 100, in pytest_cmdline_parse
config: Config = outcome.get_result()
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_result.py", line 99, in get_result
raise exc.with_traceback(exc.__traceback__)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_callers.py", line 102, in _multicall
res = hook_impl.function(*args)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1003, in pytest_cmdline_parse
self.parse(args)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1283, in parse
self._preparse(args, addopts=addopts)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1172, in _preparse
self.pluginmanager.load_setuptools_entrypoints("pytest11")
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pluggy/_manager.py", line 414, in load_setuptools_entrypoints
plugin = ep.load()
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/importlib/metadata/__init__.py", line 171, in load
module = import_module(match.group('module'))
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/importlib/__init__.py", line 126, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
exec(co, module.__dict__)
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/pytest_httpbin/plugin.py", line 2, in <module>
from httpbin import app as httpbin_app
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/httpbin/__init__.py", line 3, in <module>
from .core import *
File "/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/httpbin/core.py", line 35, in <module>
from werkzeug.http import parse_authorization_header
ImportError: cannot import name 'parse_authorization_header' from 'werkzeug.http' (/Users/parkgwanbin/.pyenv/versions/3.10.13/lib/python3.10/site-packages/werkzeug/http.py)
httpbin 은 “A simple HTTP Request & Response Service”로 HTTP 관련 라이브러리에서 많이 사용하고 있다. requests 는 httpbin 을 사용해 테스트 코드를 작성했다.
왜 이런 문제가 발했을까? requests 에서 httpbin 패키지를 import 할 때 httpbin/init.py 가 실행된다. 이때 httpbin 이 httpbin/core.py 와 httpbin/helpers.py 를 import 하는데, 여기서 deprecated 된 werkzeug 의 모듈을 사용하고 있다.
에러 로그를 확인해보면 httpbin 에서 자신이 의존하고 있는 라이브러리인 werkzeug/http.py의 parse_authorization_header 를 사용하고 있다. 하지만 parse_authorization_header 는 3.0 부터 deprecated 되면서 .Authorization.from_header 를 대신 사용해야 한다!
즉, httpbin 이 의존하고 있는 라이브러리인 werkzeug 의 버전업을 따라가지 못해 httpbin 에서 ImportError 가 발생하고, 연쇄적으로 httpbin 을 사용하고 있는 라이브러리들이 모두 문제를 겪고 있는 상황이다.
# httpbin/helpers.py
try:
from werkzeug.http import parse_authorization_header
except ImportError: # werkzeug < 2.3
from werkzeug.datastructures import Authorization
parse_authorization_header = Authorization.from_header
httpbin 메인테이너도 이걸 인지했는지 main 브랜치에는 제대로 import 하고 있다. 하지만 pypi 에 release 권한이 있는 메인테이너가 아직 PR을 수락하지 않아 pypi 내 코드에는 패치가 되어있지 않다!. 그래서 아직 다른 라이브러리들에서 에러가 나는 것이다. 현재 에러가 나는 httpbin:v1.0.1 에서 httpbin:v1.0.2 가 출시될 경우 다른 라이브러리들이 httpbin 버전업만 한다면 문제가 해결될 것이다.
python 3.8 부터 requests 에서 Werkzeug 3.0.1 를 사용하고 python 3.7 은 Werkzeug v2.x 를 사용하고 있기 때문에 ImportError 가 나지 않는다. 이는 flask v3.x 가 python 3.8 부터 지원하기 때문이다.
위의 원인으로 미루어보았을 때 2가지 해결방안이 있다. 첫번째는 werkzeug 의 버전을 v2.3.x 로 낮추는 것과 httpbin 의 main 브랜치 소스코드를 복사해넣는 방안이다.
pip uninstall werkzeug
pip install werkzeug==2.3.8
위 방식의 경우 flask 에서 compatibility warning 이 발생하나 pytest 가 잘 돌아간다.
cp -r ./httpbin /usr/local/lib/python3.10/site-packages
httpbin 의 main 소스코드를 복사해 pip 로 설치된 디렉토리에 복사해 넣을 경우 pytest 가 warning/error 없이 잘 작동한다.
requests v2.31.0 의 test 실행시 발생하는 문제에 대해 알아봤다. requests 가 의존하는 httpbin 에서 발생하는 문제였고, requests 코드 상에서 고칠 수 있는 방법은 없었다. 대신, 설치된 의존성을 다운그레이드하거나 코드를 교체해서 문제를 해결할 수 있었다.