Skip to content

Ensure Monkeypatch setenv and delenv use bytes keys in Python 2 #4056

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/4056.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
``MonkeyPatch.setenv`` and ``MonkeyPatch.delenv`` issue a warning if the environment variable name is not ``str`` on Python 2.

In Python 2, adding ``unicode`` keys to ``os.environ`` causes problems with ``subprocess`` (and possible other modules),
making this a subtle bug specially susceptible when used with ``from __future__ import unicode_literals``.
24 changes: 23 additions & 1 deletion src/_pytest/monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import os
import sys
import re
import warnings
from contextlib import contextmanager

import six

import pytest
from _pytest.fixtures import fixture

RE_IMPORT_ERROR_NAME = re.compile("^No module named (.*)$")
Expand Down Expand Up @@ -209,13 +212,31 @@ def delitem(self, dic, name, raising=True):
self._setitem.append((dic, name, dic.get(name, notset)))
del dic[name]

def _warn_if_env_name_is_not_str(self, name):
"""On Python 2, warn if the given environment variable name is not a native str (#4056)"""
if six.PY2 and not isinstance(name, str):
warnings.warn(
pytest.PytestWarning(
"Environment variable name {!r} should be str".format(name)
)
)

def setenv(self, name, value, prepend=None):
""" Set environment variable ``name`` to ``value``. If ``prepend``
is a character, read the current environment variable value
and prepend the ``value`` adjoined with the ``prepend`` character."""
value = str(value)
if not isinstance(value, str):
warnings.warn(
pytest.PytestWarning(
"Environment variable value {!r} should be str, converted to str implicitly".format(
value
)
)
)
value = str(value)
if prepend and name in os.environ:
value = value + prepend + os.environ[name]
self._warn_if_env_name_is_not_str(name)
self.setitem(os.environ, name, value)

def delenv(self, name, raising=True):
Expand All @@ -225,6 +246,7 @@ def delenv(self, name, raising=True):
If ``raising`` is set to False, no exception will be raised if the
environment variable is missing.
"""
self._warn_if_env_name_is_not_str(name)
self.delitem(os.environ, name, raising=raising)

def syspath_prepend(self, path):
Expand Down
2 changes: 2 additions & 0 deletions src/_pytest/recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ def __init__(self, expected_warning=None, match_expr=None):
def __exit__(self, *exc_info):
super(WarningsChecker, self).__exit__(*exc_info)

__tracebackhide__ = True

# only check if we're not currently handling an exception
if all(a is None for a in exc_info):
if self.expected_warning is not None:
Expand Down
4 changes: 2 additions & 2 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ def join_pythonpath(what):
return what

empty_package = testdir.mkpydir("empty_package")
monkeypatch.setenv("PYTHONPATH", join_pythonpath(empty_package))
monkeypatch.setenv("PYTHONPATH", str(join_pythonpath(empty_package)))
# the path which is not a package raises a warning on pypy;
# no idea why only pypy and not normal python warn about it here
with warnings.catch_warnings():
Expand All @@ -586,7 +586,7 @@ def join_pythonpath(what):
assert result.ret == 0
result.stdout.fnmatch_lines(["*2 passed*"])

monkeypatch.setenv("PYTHONPATH", join_pythonpath(testdir))
monkeypatch.setenv("PYTHONPATH", str(join_pythonpath(testdir)))
result = testdir.runpytest("--pyargs", "tpkg.test_missing", syspathinsert=True)
assert result.ret != 0
result.stderr.fnmatch_lines(["*not*found*test_missing*"])
Expand Down
2 changes: 1 addition & 1 deletion testing/code/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def test_source_strip_multiline():
def test_syntaxerror_rerepresentation():
ex = pytest.raises(SyntaxError, _pytest._code.compile, "xyz xyz")
assert ex.value.lineno == 1
assert ex.value.offset in (4, 7) # XXX pypy/jython versus cpython?
assert ex.value.offset in (4, 5, 7) # XXX pypy/jython versus cpython?
assert ex.value.text.strip(), "x x"


Expand Down
14 changes: 7 additions & 7 deletions testing/test_cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def pytest_configure(config):

class TestLastFailed(object):
def test_lastfailed_usecase(self, testdir, monkeypatch):
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", 1)
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", "1")
p = testdir.makepyfile(
"""
def test_1():
Expand Down Expand Up @@ -301,7 +301,7 @@ def test_always_fails():
assert "test_a.py" not in result.stdout.str()

def test_lastfailed_difference_invocations(self, testdir, monkeypatch):
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", 1)
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", "1")
testdir.makepyfile(
test_a="""\
def test_a1():
Expand Down Expand Up @@ -335,7 +335,7 @@ def test_b1():
result.stdout.fnmatch_lines(["*1 failed*1 desel*"])

def test_lastfailed_usecase_splice(self, testdir, monkeypatch):
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", 1)
monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", "1")
testdir.makepyfile(
"""\
def test_1():
Expand Down Expand Up @@ -474,8 +474,8 @@ def test_hello():
)

def rlf(fail_import, fail_run):
monkeypatch.setenv("FAILIMPORT", fail_import)
monkeypatch.setenv("FAILTEST", fail_run)
monkeypatch.setenv("FAILIMPORT", str(fail_import))
monkeypatch.setenv("FAILTEST", str(fail_run))

testdir.runpytest("-q")
config = testdir.parseconfigure()
Expand Down Expand Up @@ -519,8 +519,8 @@ def test_pass():
)

def rlf(fail_import, fail_run, args=()):
monkeypatch.setenv("FAILIMPORT", fail_import)
monkeypatch.setenv("FAILTEST", fail_run)
monkeypatch.setenv("FAILIMPORT", str(fail_import))
monkeypatch.setenv("FAILTEST", str(fail_run))

result = testdir.runpytest("-q", "--lf", *args)
config = testdir.parseconfigure()
Expand Down
2 changes: 1 addition & 1 deletion testing/test_junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ def test_logxml_path_expansion(tmpdir, monkeypatch):
assert xml_tilde.logfile == home_tilde

# this is here for when $HOME is not set correct
monkeypatch.setenv("HOME", tmpdir)
monkeypatch.setenv("HOME", str(tmpdir))
home_var = os.path.normpath(os.path.expandvars("$HOME/test.xml"))

xml_var = LogXML("$HOME%stest.xml" % tmpdir.sep, None)
Expand Down
45 changes: 42 additions & 3 deletions testing/test_monkeypatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import sys
import textwrap

import six

import pytest
from _pytest.monkeypatch import MonkeyPatch

Expand Down Expand Up @@ -163,7 +165,8 @@ def test_delitem():

def test_setenv():
monkeypatch = MonkeyPatch()
monkeypatch.setenv("XYZ123", 2)
with pytest.warns(pytest.PytestWarning):
monkeypatch.setenv("XYZ123", 2)
import os

assert os.environ["XYZ123"] == "2"
Expand Down Expand Up @@ -192,13 +195,49 @@ def test_delenv():
del os.environ[name]


class TestEnvironWarnings(object):
"""
os.environ keys and values should be native strings, otherwise it will cause problems with other modules (notably
subprocess). On Python 2 os.environ accepts anything without complaining, while Python 3 does the right thing
and raises an error.
"""

VAR_NAME = u"PYTEST_INTERNAL_MY_VAR"

@pytest.mark.skipif(six.PY3, reason="Python 2 only test")
def test_setenv_unicode_key(self, monkeypatch):
with pytest.warns(
pytest.PytestWarning,
match="Environment variable name {!r} should be str".format(self.VAR_NAME),
):
monkeypatch.setenv(self.VAR_NAME, "2")

@pytest.mark.skipif(six.PY3, reason="Python 2 only test")
def test_delenv_unicode_key(self, monkeypatch):
with pytest.warns(
pytest.PytestWarning,
match="Environment variable name {!r} should be str".format(self.VAR_NAME),
):
monkeypatch.delenv(self.VAR_NAME, raising=False)

def test_setenv_non_str_warning(self, monkeypatch):
value = 2
msg = (
"Environment variable value {!r} should be str, converted to str implicitly"
)
with pytest.warns(pytest.PytestWarning, match=msg.format(value)):
monkeypatch.setenv(str(self.VAR_NAME), value)


def test_setenv_prepend():
import os

monkeypatch = MonkeyPatch()
monkeypatch.setenv("XYZ123", 2, prepend="-")
with pytest.warns(pytest.PytestWarning):
monkeypatch.setenv("XYZ123", 2, prepend="-")
assert os.environ["XYZ123"] == "2"
monkeypatch.setenv("XYZ123", 3, prepend="-")
with pytest.warns(pytest.PytestWarning):
monkeypatch.setenv("XYZ123", 3, prepend="-")
assert os.environ["XYZ123"] == "3-2"
monkeypatch.undo()
assert "XYZ123" not in os.environ
Expand Down