From 39b940172e5793c6854f6dcb510105c268e72957 Mon Sep 17 00:00:00 2001 From: Steven Hartland Date: Tue, 24 Jun 2025 22:50:00 +0100 Subject: [PATCH] fix: target python version Allow any valid python version to be set as the target version so that users don't need to install a new package version each time a new Python version is released. Add tests for validating the target version configuration. Clarify the meaning of the target version in the doc comment. Fixes: #63 --- .gitignore | 1 + pytest_examples/config.py | 8 +++- pytest_examples/eval_example.py | 4 +- pytest_examples/lint.py | 2 +- tests/test_config.py | 72 +++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore index 04a26fd..cd6ea40 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ _build/ /.ghtopdep_cache/ /worktrees/ /.ruff_cache/ +__pycache__/ diff --git a/pytest_examples/config.py b/pytest_examples/config.py index ced2099..0647676 100644 --- a/pytest_examples/config.py +++ b/pytest_examples/config.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import hashlib +import re import tempfile from dataclasses import dataclass from pathlib import Path @@ -21,7 +22,7 @@ class ExamplesConfig: line_length: int = DEFAULT_LINE_LENGTH quotes: Literal['single', 'double', 'either'] = 'either' magic_trailing_comma: bool = True - target_version: Literal['py37', 'py38', 'py39', 'py310', 'py311'] = 'py37' + target_version: str | None = 'py37' upgrade: bool = False isort: bool = False ruff_line_length: int | None = None @@ -30,6 +31,11 @@ class ExamplesConfig: white_space_dot: bool = False """If True, replace spaces with `ยท` in example diffs.""" + def __post_init__(self): + """Validate the configuration after initialization.""" + if self.target_version and not re.match(r'py\d{2,}$', self.target_version): + raise ValueError(f'Invalid target version: {self.target_version!r}, must be like "py37"') + def black_mode(self): return BlackMode( line_length=self.line_length, diff --git a/pytest_examples/eval_example.py b/pytest_examples/eval_example.py index e2f19e7..3d94599 100644 --- a/pytest_examples/eval_example.py +++ b/pytest_examples/eval_example.py @@ -37,7 +37,7 @@ def set_config( line_length: int = DEFAULT_LINE_LENGTH, quotes: Literal['single', 'double', 'either'] = 'either', magic_trailing_comma: bool = True, - target_version: Literal['py37', 'py38', 'py39', 'py310', 'py310'] = 'py37', + target_version: str | None = 'py37', upgrade: bool = False, isort: bool = False, ruff_line_length: int | None = None, @@ -50,7 +50,7 @@ def set_config( line_length: The line length to use when wrapping print statements, defaults to 88. quotes: The quote to use, defaults to "either". magic_trailing_comma: If True, add a trailing comma to magic methods, defaults to True. - target_version: The target version to use when upgrading code, defaults to "py37". + target_version: The target version to use when checking and formatting code, defaults to "py37". upgrade: If True, upgrade the code to the target version, defaults to False. isort: If True, run ruff's isort extension on the code, defaults to False. ruff_line_length: In general, we disable line-length checks in ruff, to let black take care of them. diff --git a/pytest_examples/lint.py b/pytest_examples/lint.py index 67133d4..6627175 100644 --- a/pytest_examples/lint.py +++ b/pytest_examples/lint.py @@ -27,7 +27,7 @@ def ruff_format( *, ignore_errors: bool = False, ) -> str: - args = ('--fix',) + args: tuple[str, ...] = ('--fix',) if ignore_errors: args += ('--exit-zero',) try: diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..eb55849 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from typing import Any + +import pytest + +from pytest_examples.config import ExamplesConfig + + +@dataclass +class TargetVersionTestCase: + id: str + target_version: Any + + +@pytest.mark.parametrize( + 'case', + [ + # Valid target versions + TargetVersionTestCase('py37', 'py37'), + TargetVersionTestCase('py38', 'py38'), + TargetVersionTestCase('py39', 'py39'), + TargetVersionTestCase('py310', 'py310'), + TargetVersionTestCase('py311', 'py311'), + TargetVersionTestCase('py312', 'py312'), + TargetVersionTestCase('py313', 'py313'), + TargetVersionTestCase('py314', 'py314'), + TargetVersionTestCase('py3100', 'py3100'), + ], + ids=lambda case: case.id, +) +def test_examples_config_valid_target_version(case: TargetVersionTestCase): + """Test that ExamplesConfig validates target_version correctly during initialization.""" + config = ExamplesConfig(target_version=case.target_version) + assert config.target_version == case.target_version + + +@pytest.mark.parametrize( + 'case', + [ + TargetVersionTestCase('missing_py', '37'), + TargetVersionTestCase('python_word', 'python37'), + TargetVersionTestCase('single_digit', 'py3'), + TargetVersionTestCase('dots', 'py3.7'), + TargetVersionTestCase('spaces', 'py 37'), + TargetVersionTestCase('uppercase', 'PY37'), + TargetVersionTestCase('mixed_case', 'Py37'), + TargetVersionTestCase('letters_before_digits', 'py3a7'), + TargetVersionTestCase('hyphen', 'py-37'), + TargetVersionTestCase('underscore', 'py_37'), + TargetVersionTestCase('suffix', 'py37!'), + TargetVersionTestCase('text_suffix', 'py37abc'), + ], + ids=lambda case: case.id, +) +def test_examples_config_invalid_target_version(case: TargetVersionTestCase): + """Test that ExamplesConfig validates target_version correctly during initialization.""" + with pytest.raises(ValueError, match=f'Invalid target version: {case.target_version!r}'): + ExamplesConfig(target_version=case.target_version) + + +def test_examples_config_empty_string_target_version(): + """Test that empty string target_version is accepted without validation.""" + # Based on the validation logic, empty string should not raise an error + # because the check is 'if self.target_version' which is falsy for empty string + config = ExamplesConfig(target_version='') + assert config.target_version == '' + + +def test_examples_config_target_version_error_message(): + """Test that the error message includes the expected format.""" + with pytest.raises(ValueError, match='must be like "py37"'): + ExamplesConfig(target_version='invalid')