diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e6d02b5 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,7 @@ +changelog: + exclude: + authors: + - dependabot + - dependabot[bot] + - pre-commit-ci + - pre-commit-ci[bot] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b569d0..a7836f2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,10 +18,10 @@ jobs: if: ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) || contains(github.event.pull_request.labels.*.name, 'Build wheels')) steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.10' @@ -46,7 +46,7 @@ jobs: - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: user: __token__ password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8bbb8b9..492fa1c 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -63,23 +63,26 @@ jobs: toxenv: py313-test-pytest83 - os: windows-latest python-version: '3.13' - toxenv: py313-test-pytestdev + toxenv: py313-test-pytest84 + - os: windows-latest + python-version: '3.14' + toxenv: py314-test-pytestdev - os: macos-latest - python-version: '3.12' - toxenv: py312-test-pytestdev + python-version: '3.14' + toxenv: py314-test-pytestdev - os: ubuntu-latest - python-version: '3.13' - toxenv: py313-test-pytestdev-numpydev + python-version: '3.14' + toxenv: py314-test-pytestdev-numpydev - os: ubuntu-latest python-version: '3.13' toxenv: py313-test-pytest83-pytestasyncio steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Install Tox diff --git a/CHANGES.rst b/CHANGES.rst index 4e4d97b..ea825de 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +1.5.0 (2025-10-17) +================== + +- Adding the usage of the ``doctest_encoding`` ini option when overwriting + files with the ``doctest-plus-generate-diff`` option. [#284] + 1.4.0 (2025-01-24) ================== diff --git a/pytest_doctestplus/plugin.py b/pytest_doctestplus/plugin.py index 6487da7..b94446b 100644 --- a/pytest_doctestplus/plugin.py +++ b/pytest_doctestplus/plugin.py @@ -444,7 +444,7 @@ def parse(self, s, name=None): continue if config.getoption('remote_data', 'none') != 'any': - if any(re.match(fr'{comment_char}\s+doctest-remote-data-all\s*::', x.strip()) + if any(re.match(fr'{comment_char}\s+doctest-remote-data-all\s*::', x.strip()) # noqa: E501 for x in lines): skip_all = True continue @@ -912,13 +912,13 @@ def test_filter(test): return tests -def write_modified_file(fname, new_fname, changes): +def write_modified_file(fname, new_fname, changes, encoding=None): # Sort in reversed order to edit the lines: bad_tests = [] changes.sort(key=lambda x: (x["test_lineno"], x["example_lineno"]), reverse=True) - with open(fname) as f: + with open(fname, encoding=encoding) as f: text = f.readlines() for change in changes: @@ -939,7 +939,7 @@ def write_modified_file(fname, new_fname, changes): text[lineno:lineno+want.count("\n")] = [got] - with open(new_fname, "w") as f: + with open(new_fname, "w", encoding=encoding) as f: f.write("".join(text)) return bad_tests @@ -954,6 +954,8 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): if not diff_mode: return # we do not report or apply diffs + encoding = config.getini("doctest_encoding") + if diff_mode != "overwrite": # In this mode, we write a corrected file to a temporary folder in # order to compare them (rather than modifying the file). @@ -974,14 +976,14 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): new_fname = fname.replace(common_path, tmpdirname) os.makedirs(os.path.split(new_fname)[0], exist_ok=True) - bad_tests = write_modified_file(fname, new_fname, changes) + bad_tests = write_modified_file(fname, new_fname, changes, encoding) all_bad_tests.extend(bad_tests) # git diff returns 1 to signal changes, so just ignore the # exit status: with subprocess.Popen( ["git", "diff", "-p", "--no-index", fname, new_fname], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as p: + stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding=encoding) as p: p.wait() # Diff should be fine, but write error if not: diff = p.stderr.read() @@ -1013,7 +1015,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config): return terminalreporter.write_line("Applied fix to the following files:") for fname, changes in changesets.items(): - bad_tests = write_modified_file(fname, fname, changes) + bad_tests = write_modified_file(fname, fname, changes, encoding) all_bad_tests.extend(bad_tests) terminalreporter.write_line(f" {fname}") diff --git a/pytest_doctestplus/utils.py b/pytest_doctestplus/utils.py index 8a6df5c..c0c7064 100644 --- a/pytest_doctestplus/utils.py +++ b/pytest_doctestplus/utils.py @@ -3,6 +3,7 @@ from importlib.metadata import distribution from packaging.requirements import Requirement + class ModuleChecker: def find_module(self, module): diff --git a/setup.cfg b/setup.cfg index 4d95b5b..b15c6c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,9 @@ filterwarnings = error ignore:file format.*:UserWarning ignore:.*non-empty pattern match.*:FutureWarning + # For pytest-asyncio deprecations that is expected to be resolved upstream + # https://github.com/pytest-dev/pytest-asyncio/issues/924 + ignore:The configuration option "asyncio_default_fixture_loop_scope":pytest.PytestDeprecationWarning [flake8] max-line-length = 100 diff --git a/tests/test_doctestplus.py b/tests/test_doctestplus.py index 63a5572..c88aa9f 100644 --- a/tests/test_doctestplus.py +++ b/tests/test_doctestplus.py @@ -13,10 +13,12 @@ try: import pytest_asyncio # noqa: F401 - has_pytest_asyncio = True + if Version(pytest_asyncio.__version__) < Version('1.0'): + main_pytest_asyncio_xfails = True + else: + main_pytest_asyncio_xfails = False except ImportError: - has_pytest_asyncio = False - + main_pytest_asyncio_xfails = False pytest_plugins = ['pytester'] @@ -767,6 +769,8 @@ def f(): ).assertoutcome(passed=2) +# We see unclosed file ResourceWarning on windows with python 3.14 +@pytest.mark.filterwarnings('ignore:unclosed file:ResourceWarning') def test_doctest_only(testdir, makepyfile, maketestfile, makerstfile): # regular python files with doctests makepyfile(p1='>>> 1 + 1\n2') @@ -1189,7 +1193,7 @@ class MyClass: @pytest.mark.xfail( - has_pytest_asyncio, + main_pytest_asyncio_xfails, reason='pytest_asyncio monkey-patches .collect()') def test_main(testdir): pkg = testdir.mkdir('pkg') @@ -1210,7 +1214,7 @@ def f(): @pytest.mark.xfail( - python_version() in ('3.11.9', '3.11.10', '3.11.11', '3.12.3'), + python_version() in ('3.11.9', '3.11.10', '3.11.11', '3.11.12', '3.11.13', '3.11.14', '3.12.3'), reason='broken by https://github.com/python/cpython/pull/115440') def test_ufunc(testdir): pytest.importorskip('numpy') diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 0000000..8215299 --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,185 @@ +import locale +import os +from pathlib import Path +from textwrap import dedent +from typing import Callable, Optional + +import pytest + +pytest_plugins = ["pytester"] +IS_CI = os.getenv("CI", "false") == "true" + + +@pytest.fixture( + params=[ + ("A", "a", "utf-8"), + ("☆", "★", "utf-8"), + ("b", "B", "cp1252"), + ("☁", "☀", "utf-8"), + ], + ids=[ + "Aa-utf8", + "star-utf8", + "bB-cp1252", + "cloud-utf8", + ], +) +def charset(request): + return request.param + + +@pytest.fixture() +def basic_file(tmp_path: Path) -> Callable[[str, str, str], tuple[str, str, str]]: + + def makebasicfile(a, b, encoding: str) -> tuple[str, str, str]: + """alternative implementation without the use of `testdir.makepyfile`.""" + + content = """ + def f(): + ''' + >>> print('{}') + {} + ''' + pass + """ + + original = dedent(content.format(a, b)) + expected_result = dedent(content.format(a, a)) + + original_file = tmp_path.joinpath("test_basic.py") + original_file.write_text(original, encoding=encoding) + + expected_diff = dedent( + f""" + >>> print('{a}') + - {b} + + {a} + """ + ).strip("\n") + + return str(original_file), expected_diff, expected_result + + return makebasicfile + + +@pytest.fixture() +def ini_file(testdir) -> Callable[..., Path]: + + def makeini( + encoding: Optional[str] = None, + ) -> Path: + """Create a pytest.ini file with the specified encoding.""" + + ini = ["[pytest]"] + + if encoding is not None: + ini.append(f"doctest_encoding = {encoding}") + + ini.append("") + + p = testdir.makefile(".ini", pytest="\n".join(ini)) + + return Path(p) + + return makeini + + +def test_basic_file_encoding_diff(testdir, capsys, basic_file, charset, ini_file): + """ + Test the diff from console output is as expected. + """ + a, b, encoding = charset + + # create python file to test + file, diff, _ = basic_file(a, b, encoding) + + # create pytest.ini file + ini = ini_file(encoding=encoding) + assert ini.is_file(), "setup pytest.ini not created/found" + + testdir.inline_run( + file, + "--doctest-plus-generate-diff", + "-c", + str(ini), + ) + + stdout, _ = capsys.readouterr() + assert diff in stdout + + +def test_basic_file_encoding_overwrite(testdir, basic_file, charset, ini_file): + """ + Test that the file is overwritten with the expected content. + """ + + a, b, encoding = charset + + # create python file to test + file, _, expected = basic_file(a, b, encoding) + + # create pytest.ini file + ini = ini_file(encoding=encoding) + assert ini.is_file(), "setup pytest.ini not created/found" + + testdir.inline_run( + file, + "--doctest-plus-generate-diff", + "overwrite", + "-c", + str(ini), + ) + + assert expected in Path(file).read_text(encoding) + + +@pytest.mark.skipif(IS_CI, reason="skip on CI") +def test_legacy_diff(testdir, capsys, basic_file, charset): + """ + Legacy test are supported to fail on Windows, when no encoding is provided. + + On Windows this is cp1252, so "utf-8" are expected to fail while writing test files. + """ + a, b, _ = charset + + try: + file, diff, _ = basic_file(a, b, None) + except UnicodeEncodeError: + encoding = locale.getpreferredencoding(False) + reason = f"could not encode {repr(charset)} with {encoding=}" + pytest.xfail(reason=reason) + + testdir.inline_run( + file, + "--doctest-plus-generate-diff", + ) + + stdout, _ = capsys.readouterr() + + assert diff in stdout + + +@pytest.mark.skipif(IS_CI, reason="skip on CI") +def test_legacy_overwrite(testdir, basic_file, charset): + """ + Legacy test are supported to fail on Windows, when no encoding is provided. + + On Windows this is cp1252, so "utf-8" are expected to fail while writing test files. + """ + + a, b, _encoding = charset + + try: + file, _, expected = basic_file(a, b, None) + except UnicodeEncodeError: + encoding = locale.getpreferredencoding(False) + reason = f"could not encode {repr(charset)} with {encoding=}" + pytest.xfail(reason=reason) + + testdir.inline_run( + file, + "--doctest-plus-generate-diff", + "overwrite", + ) + + assert expected in Path(file).read_text(_encoding) diff --git a/tox.ini b/tox.ini index d81ccfe..54c4644 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{39,310,311,312,313}-test + py{39,310,311,312,313,314}-test codestyle requires = setuptools >= 30.3.0 @@ -9,6 +9,7 @@ isolated_build = true [testenv] changedir = .tmp/{envname} +passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CI setenv = numpydev: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple description = run tests @@ -30,6 +31,7 @@ deps = pytest81: pytest==8.1.* pytest82: pytest==8.2.* pytest83: pytest==8.3.* + pytest84: pytest==8.4.* pytestdev: git+https://github.com/pytest-dev/pytest#egg=pytest numpydev: numpy>=0.0.dev0 pytestasyncio: pytest-asyncio