diff --git a/.flake8 b/.flake8 index 60ef4d0..1488301 100644 --- a/.flake8 +++ b/.flake8 @@ -2,8 +2,7 @@ ignore = E203,W503 max-line-length = 100 select = B,C,E,F,W,T4 -extend-ignore = E501,B905 -# when Python 3.10 is the minimum version, re-enable check B905 for zip + strict +extend-ignore = E501 extend-select = B9 per-file-ignores= ./tests/test_badgedir.py:B950 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index bbda8ef..ea92146 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -22,7 +22,7 @@ jobs: matrix: # The README.rst file mentions the versions tested, please update it as well py-ver-major: [3] - py-ver-minor: [9, 10, 11, 12, 13, 14] + py-ver-minor: [10, 11, 12, 13, 14] step: [lint, unit, mypy, bandit] env: diff --git a/Makefile b/Makefile index 6bcc2a5..c415b7f 100644 --- a/Makefile +++ b/Makefile @@ -168,7 +168,7 @@ mypy: $(PYSOURCES) MYPYPATH=$$MYPYPATH:mypy-stubs mypy $^ pyupgrade: $(filter-out schema_salad/metaschema.py,$(PYSOURCES)) - pyupgrade --exit-zero-even-if-changed --py39-plus $^ + pyupgrade --exit-zero-even-if-changed --py310-plus $^ auto-walrus $^ release-test: FORCE diff --git a/README.rst b/README.rst index 9a5a295..76ea8aa 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ This is a testing tool for checking the output of Tools and Workflows described with the Common Workflow Language. Among other uses, it is used to run the CWL conformance tests. -This is written and tested for Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14 +This is written and tested for Python 3.10, 3.11, 3.12, 3.13, and 3.14 .. contents:: Table of Contents :local: diff --git a/cwltest/compare.py b/cwltest/compare.py index 9af3561..c11de1d 100644 --- a/cwltest/compare.py +++ b/cwltest/compare.py @@ -2,7 +2,8 @@ import hashlib import json -from typing import Any, Callable, Optional +from collections.abc import Callable +from typing import Any import cwltest.stdfsaccess @@ -14,7 +15,7 @@ class CompareFail(Exception): @classmethod def format( - cls, expected: Any, actual: Any, cause: Optional[Any] = None + cls, expected: Any, actual: Any, cause: Any | None = None ) -> "CompareFail": """Load the difference details into the error message.""" message = "expected: {}\ngot: {}".format( @@ -197,10 +198,7 @@ def _compare_checksum(expected: dict[str, Any], actual: dict[str, Any]) -> None: def _compare_size(expected: dict[str, Any], actual: dict[str, Any]) -> None: - if "path" in actual: - path = actual["path"] - else: - path = actual["location"] + path = actual.get("path", actual["location"]) actual_size_on_disk = fs_access.size(path) @@ -233,31 +231,32 @@ def compare(expected: Any, actual: Any, skip_details: bool = False) -> None: raise CompareFail.format(expected, actual) try: - if isinstance(expected, dict): - if not isinstance(actual, dict): - raise CompareFail.format(expected, actual) - - if expected.get("class") == "File": - _compare_file(expected, actual, skip_details) - elif expected.get("class") == "Directory": - _compare_directory(expected, actual, skip_details) - else: - _compare_dict(expected, actual, skip_details) - - elif isinstance(expected, list): - if not isinstance(actual, list): - raise CompareFail.format(expected, actual) - - if len(expected) != len(actual): - raise CompareFail.format(expected, actual, "lengths don't match") - for c in range(0, len(expected)): - try: - compare(expected[c], actual[c], skip_details) - except CompareFail as e: - raise CompareFail.format(expected, actual, e) from e - else: - if expected != actual: - raise CompareFail.format(expected, actual) + match expected: + case dict(): + if not isinstance(actual, dict): + raise CompareFail.format(expected, actual) + + match expected.get("class"): + case "File": + _compare_file(expected, actual, skip_details) + case "Directory": + _compare_directory(expected, actual, skip_details) + case _: + _compare_dict(expected, actual, skip_details) + case list(): + if not isinstance(actual, list): + raise CompareFail.format(expected, actual) + + if len(expected) != len(actual): + raise CompareFail.format(expected, actual, "lengths don't match") + for c in range(0, len(expected)): + try: + compare(expected[c], actual[c], skip_details) + except CompareFail as e: + raise CompareFail.format(expected, actual, e) from e + case _: + if expected != actual: + raise CompareFail.format(expected, actual) except Exception as e: raise CompareFail(str(e)) from e diff --git a/cwltest/hooks.py b/cwltest/hooks.py index 9b792e8..98fb7ea 100644 --- a/cwltest/hooks.py +++ b/cwltest/hooks.py @@ -1,13 +1,13 @@ """Hooks for pytest-cwl users.""" -from typing import Any, Optional +from typing import Any from cwltest import utils def pytest_cwl_execute_test( # type: ignore[empty-body] - config: utils.CWLTestConfig, processfile: str, jobfile: Optional[str] -) -> tuple[int, Optional[dict[str, Any]]]: + config: utils.CWLTestConfig, processfile: str, jobfile: str | None +) -> tuple[int, dict[str, Any] | None]: """ Execute CWL test using a Python function instead of a command line runner. diff --git a/cwltest/main.py b/cwltest/main.py index c38efd2..05f2e49 100644 --- a/cwltest/main.py +++ b/cwltest/main.py @@ -6,7 +6,7 @@ import sys from collections import Counter, defaultdict from concurrent.futures import ThreadPoolExecutor -from typing import Optional, cast +from typing import cast import junit_xml import schema_salad.avro @@ -119,7 +119,7 @@ def main() -> int: failures = 0 unsupported = 0 suite_name, _ = os.path.splitext(os.path.basename(args.test)) - report: Optional[junit_xml.TestSuite] = junit_xml.TestSuite(suite_name, []) + report: junit_xml.TestSuite | None = junit_xml.TestSuite(suite_name, []) load_optional_fsaccess_plugin() diff --git a/cwltest/plugin.py b/cwltest/plugin.py index 5dabf9e..187b453 100644 --- a/cwltest/plugin.py +++ b/cwltest/plugin.py @@ -27,8 +27,8 @@ class TestRunner(Protocol): """Protocol to type-check test runner functions via the pluggy hook.""" def __call__( - self, config: utils.CWLTestConfig, processfile: str, jobfile: Optional[str] - ) -> list[Optional[dict[str, Any]]]: + self, config: utils.CWLTestConfig, processfile: str, jobfile: str | None + ) -> list[dict[str, Any] | None]: """Type signature for pytest_cwl_execute_test hook results.""" ... @@ -224,7 +224,7 @@ def repr_failure( ) ) - def reportinfo(self) -> tuple[Union["os.PathLike[str]", str], Optional[int], str]: + def reportinfo(self) -> tuple[Union["os.PathLike[str]", str], int | None, str]: """Status report.""" return self.path, 0, "cwl test: %s" % self.name @@ -372,7 +372,7 @@ def _doc_options() -> argparse.ArgumentParser: def pytest_collect_file( file_path: Path, parent: pytest.Collector -) -> Optional[pytest.Collector]: +) -> pytest.Collector | None: """Is this file for us.""" if ( file_path.suffix == ".yml" or file_path.suffix == ".yaml" @@ -393,7 +393,7 @@ def pytest_configure(config: pytest.Config) -> None: def _zip_results( cwl_results: list[tuple[dict[str, Any], utils.TestResult]], ) -> tuple[list[dict[str, Any]], list[utils.TestResult]]: - tests, results = (list(item) for item in zip(*cwl_results)) + tests, results = (list(item) for item in zip(*cwl_results, strict=True)) return tests, results diff --git a/cwltest/utils.py b/cwltest/utils.py index a14ae7a..65db026 100644 --- a/cwltest/utils.py +++ b/cwltest/utils.py @@ -11,7 +11,7 @@ from collections.abc import Iterable, MutableMapping, MutableSequence from importlib.metadata import EntryPoint, entry_points from importlib.resources import files -from typing import Any, Optional, Union, cast +from typing import Any, cast from urllib.parse import urljoin import junit_xml @@ -36,23 +36,23 @@ def __init__( self, entry: str, entry_line: str, - basedir: Optional[str] = None, - test_baseuri: Optional[str] = None, - test_basedir: Optional[str] = None, - outdir: Optional[str] = None, - classname: Optional[str] = None, - tool: Optional[str] = None, - args: Optional[list[str]] = None, - testargs: Optional[list[str]] = None, - timeout: Optional[int] = None, - verbose: Optional[bool] = None, - runner_quiet: Optional[bool] = None, + basedir: str | None = None, + test_baseuri: str | None = None, + test_basedir: str | None = None, + outdir: str | None = None, + classname: str | None = None, + tool: str | None = None, + args: list[str] | None = None, + testargs: list[str] | None = None, + timeout: int | None = None, + verbose: bool | None = None, + runner_quiet: bool | None = None, ) -> None: """Initialize test configuration.""" self.basedir: str = basedir or os.getcwd() self.test_baseuri: str = test_baseuri or "file://" + self.basedir self.test_basedir: str = test_basedir or self.basedir - self.outdir: Optional[str] = outdir + self.outdir: str | None = outdir self.classname: str = classname or "" self.entry = urljoin( self.test_baseuri, os.path.basename(entry) + f"#L{entry_line}" @@ -60,7 +60,7 @@ def __init__( self.tool: str = tool or "cwl-runner" self.args: list[str] = args or [] self.testargs: list[str] = testargs or [] - self.timeout: Optional[int] = timeout + self.timeout: int | None = timeout self.verbose: bool = verbose or False self.runner_quiet: bool = runner_quiet or True @@ -70,11 +70,11 @@ class CWLTestReport: def __init__( self, - id: Union[int, str], + id: int | str, category: list[str], entry: str, tool: str, - job: Optional[str], + job: str | None, ) -> None: """Initialize a CWLTestReport object.""" self.id = id @@ -96,7 +96,7 @@ def __init__( classname: str, entry: str, tool: str, - job: Optional[str], + job: str | None, message: str = "", ) -> None: """Initialize a TestResult object.""" @@ -240,7 +240,7 @@ def generate_badges( def get_test_number_by_key( tests: list[dict[str, str]], key: str, value: str -) -> Optional[int]: +) -> int | None: """Retrieve the test index from its name.""" for i, test in enumerate(tests): if key in test and test[key] == value: @@ -256,7 +256,7 @@ def load_and_validate_tests(path: str) -> tuple[Any, dict[str, Any]]: """ schema_resource = files("cwltest").joinpath("cwltest-schema.yml") with schema_resource.open("r", encoding="utf-8") as fp: - cache: Optional[dict[str, Union[str, Graph, bool]]] = { + cache: dict[str, str | Graph | bool] | None = { "https://w3id.org/cwl/cwltest/cwltest-schema.yml": fp.read() } ( @@ -283,8 +283,8 @@ def load_and_validate_tests(path: str) -> tuple[Any, dict[str, Any]]: def parse_results( results: Iterable[TestResult], tests: list[dict[str, Any]], - suite_name: Optional[str] = None, - report: Optional[junit_xml.TestSuite] = None, + suite_name: str | None = None, + report: junit_xml.TestSuite | None = None, ) -> tuple[ int, # total int, # passed @@ -294,7 +294,7 @@ def parse_results( dict[str, list[CWLTestReport]], # passed for each tag dict[str, list[CWLTestReport]], # failures for each tag dict[str, list[CWLTestReport]], # unsupported for each tag - Optional[junit_xml.TestSuite], + junit_xml.TestSuite | None, ]: """ Parse the results and return statistics and an optional report. @@ -366,10 +366,10 @@ def parse_results( def prepare_test_command( tool: str, args: list[str], - testargs: Optional[list[str]], + testargs: list[str] | None, test: dict[str, Any], cwd: str, - quiet: Optional[bool] = True, + quiet: bool | None = True, ) -> list[str]: """Turn the test into a command line.""" test_command = [tool] @@ -407,7 +407,7 @@ def prepare_test_command( def prepare_test_paths( test: dict[str, str], cwd: str, -) -> tuple[str, Optional[str]]: +) -> tuple[str, str | None]: """Determine the test path and the tool path.""" cwd = schema_salad.ref_resolver.file_uri(cwd) processfile = test["tool"] @@ -424,7 +424,7 @@ def prepare_test_paths( def run_test_plain( config: CWLTestConfig, test: dict[str, str], - test_number: Optional[int] = None, + test_number: int | None = None, ) -> TestResult: """Plain test runner.""" out: dict[str, Any] = {} @@ -443,7 +443,7 @@ def run_test_plain( if test_number is not None: number = str(test_number) - process: Optional[subprocess.Popen[str]] = None + process: subprocess.Popen[str] | None = None try: cwd = os.getcwd() test_command = prepare_test_command( @@ -668,7 +668,7 @@ def load_optional_fsaccess_plugin() -> None: try: # The interface to importlib.metadata.entry_points() changed - # several times between Python 3.9 and 3.13; the code below + # several times between Python 3.10 and 3.13; the code below # actually works fine on all of them but there's no single # mypy annotation that works across of them. Explicitly cast # it to a consistent type to make mypy shut up. diff --git a/pyproject.toml b/pyproject.toml index da2775f..8d258c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", "Development Status :: 5 - Production/Stable", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -25,7 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Typing :: Typed", ] -requires-python = ">=3.9,<3.15" +requires-python = ">=3.10,<3.15" dynamic = ["version", "dependencies"] [project.readme] diff --git a/tests/util.py b/tests/util.py index 937b777..959345a 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,7 +6,6 @@ from contextlib import ExitStack from importlib.resources import as_file, files from pathlib import Path -from typing import Optional def get_data(filename: str) -> str: @@ -28,7 +27,7 @@ def get_data(filename: str) -> str: def run_with_mock_cwl_runner( - args: list[str], cwl_runner: Optional[str] = None + args: list[str], cwl_runner: str | None = None ) -> tuple[int, str, str]: """Bind a mock cwlref-runner implementation to cwltest.""" if cwl_runner is None: diff --git a/tox.ini b/tox.ini index 5bb9fae..17f02e4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = - py3{9,10,11,12,13,14}-lint, - py3{9,10,11,12,13,14}-unit, - py3{9,10,11,12,13,14}-bandit, - py3{9,10,11,12,13,14}-mypy, + py3{10,11,12,13,14}-lint, + py3{10,11,12,13,14}-unit, + py3{10,11,12,13,14}-bandit, + py3{10,11,12,13,14}-mypy, py312-lintreadme, py312-pydocstyle @@ -17,7 +17,6 @@ testpaths = tests [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 @@ -26,13 +25,13 @@ python = [testenv] skipsdist = - py3{9,10,11,12,13,14}-!{unit,mypy,lintreadme} = True + py3{10,11,12,13,14}-!{unit,mypy,lintreadme} = True description = - py3{9,10,11,12,13,14}-unit: Run the unit tests - py3{9,10,11,12,13,14}-lint: Lint the Python code - py3{9,10,11,12,13,14}-bandit: Search for common security issues - py3{9,10,11,12,13,14}-mypy: Check for type safety + py3{10,11,12,13,14}-unit: Run the unit tests + py3{10,11,12,13,14}-lint: Lint the Python code + py3{10,11,12,13,14}-bandit: Search for common security issues + py3{10,11,12,13,14}-mypy: Check for type safety py313-pydocstyle: docstring style checker py313-lintreadme: Lint the README.rst->.md conversion @@ -41,36 +40,36 @@ passenv = GITHUB_* deps = - py3{9,10,11,12,13,14}-{unit,mypy}: -rrequirements.txt - py3{9,10,11,12,13,14}-{unit,mypy}: -rtest-requirements.txt - py3{9,10,11,12,13,14}-lint: flake8-bugbear - py3{9,10,11,12,13,14}-lint: black~=23.1 - py3{9,10,11,12,13,14}-bandit: bandit - py3{9,10,11,12,13,14}-mypy: -rmypy-requirements.txt + py3{10,11,12,13,14}-{unit,mypy}: -rrequirements.txt + py3{10,11,12,13,14}-{unit,mypy}: -rtest-requirements.txt + py3{10,11,12,13,14}-lint: flake8-bugbear + py3{10,11,12,13,14}-lint: black~=23.1 + py3{10,11,12,13,14}-bandit: bandit + py3{10,11,12,13,14}-mypy: -rmypy-requirements.txt set_env = - py3{9,10,11,12,13,14}-unit: LC_ALL = C.UTF-8 + py3{10,11,12,13,14}-unit: LC_ALL = C.UTF-8 COV_CORE_SOURCE=cwltest COV_CORE_CONFIG={toxinidir}/.coveragerc COV_CORE_DATAFILE={toxinidir}/.coverage.eager commands = - py3{9,10,11,12,13,14}-unit: python -m pip install -U pip setuptools wheel - py3{9,10,11,12,13,14}-unit: python -m pytest --cov --cov-config={toxinidir}/.coveragerc --cov-append {posargs} - py3{9,10,11,12,13,14}-unit: coverage xml - py3{9,10,11,12,13,14}-bandit: bandit --recursive cwltest - py3{9,10,11,12,13,14}-lint: make flake8 - py3{9,10,11,12,13,14}-lint: make format-check - py3{9,10,11,12,13,14}-mypy: make mypy + py3{10,11,12,13,14}-unit: python -m pip install -U pip setuptools wheel + py3{10,11,12,13,14}-unit: python -m pytest --cov --cov-config={toxinidir}/.coveragerc --cov-append {posargs} + py3{10,11,12,13,14}-unit: coverage xml + py3{10,11,12,13,14}-bandit: bandit --recursive cwltest + py3{10,11,12,13,14}-lint: make flake8 + py3{10,11,12,13,14}-lint: make format-check + py3{10,11,12,13,14}-mypy: make mypy allowlist_externals = - py3{9,10,11,12,13,14}-lint: flake8 - py3{9,10,11,12,13,14}-lint: black - py3{9,10,11,12,13,14}-{mypy,shellcheck,lint,unit}: make + py3{10,11,12,13,14}-lint: flake8 + py3{10,11,12,13,14}-lint: black + py3{10,11,12,13,14}-{mypy,shellcheck,lint,unit}: make skip_install = - py3{9,10,11,12,13,14}-lint: true - py3{9,10,11,12,13,14}-bandit: true + py3{10,11,12,13,14}-lint: true + py3{10,11,12,13,14}-bandit: true [testenv:py313-pydocstyle]