python-pytest-cases icon indicating copy to clipboard operation
python-pytest-cases copied to clipboard

CHORE: upgrate build suite

Open smarie opened this issue 8 months ago • 6 comments

what about

  • use ruff check in nox lint session
  • ruff format in nox format session
  • use mypy in nox typing session
  • move all config to pyproject.toml and use poetry or uv
  • ...

smarie avatar Jun 09 '25 19:06 smarie

Hi @smarie , I can tackle this one 😄

I would go in the following order:

  1. Update package management to use UV for dependency management and deployment
  2. Introduce Ruff and Mypy as linters and formatters used in the project
  3. Use Ruff to perform Pyupgrade actions on the repo to modernize it automatically
  4. (Optional) Consider replacing Nox with more modern tools like Tox

Please let me know if that makes sense to you, or if you have any caveats.

saroad2 avatar Jun 10 '25 15:06 saroad2

Hi @saroad2 , it seems that we are in line with 1 and 2 so yes, let's go for it (one at a time for PR review comfort). In my company when we use poetry, we create one group for each nox session. I imagine that this will be the same for uv (we are currently completing our migration, I did not look at the new templates yet). I will paste a pyproject and a nox file below to get started

For 3 and 4 let's rediscuss more. In particular:

  1. This should remain optional as part of a (possibly automated) PR, or simply a nox session
  2. nox is actually a sequel to tox, it was created to overcome the limitations of tox :) . Of course this is opinionated but I would rather stick to it - it has become a standard for industrial CI/CD. Note that nox can use uv as backend.

smarie avatar Jun 23 '25 07:06 smarie

Here a pyproject file where I have setup most of what we need, and filled most relevant pytest-cases contents.
[project]
name = "pytest-cases"
dynamic = [ "version" ]
description = "Separate test code from test cases in pytest."
# Not sure there is a notion of long description anymore, to check ?
#long_description = file: docs/long_description.md
# long_description_content_type=text/markdown
# Shall this be also removed or is there a way to declare it ?
# license = BSD 3-Clause
requires-python = ">=3.9"
readme = "README.md"
license-files = ["LICENSE"]
authors = [
    { name = "Sylvain Marié", email = "[email protected]" },
]
# Is there a way to add maintainers names ? It could be appropriate to recognize commited maintainers if relevant. 
# Our current way to recognize ponctual contributions is in the changelog with explicit gh account author links and pr links
keywords = [
    "pytest",
    "test",
    "case",
    "testcase",
    "test-case",
    "decorator",
    "parametrize",
    "parameter",
    "data",
    "dataset",
    "file",
    "separate",
    "concerns",
    "lazy",
    "fixture",
    "union"
]
classifiers = [
    # Complete classifiers list is available here: https://pypi.org/classifiers/
    "Development Status :: 5 - Production/Stable",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: BSD License",
    "Topic :: Software Development :: Libraries :: Python Modules",
    "Topic :: Software Development :: Testing",
    "Programming Language :: Python",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Programming Language :: Python :: 3.14",
    "Framework :: Pytest"
]
# Project dependencies (excluding dev and tools ones)
dependencies = [
    "decopatch",  # todo add version lower bound
    "makefun>=1.15.1",
    # "packaging",  to check : is this still needed ?
    "pytest"
]

# Define here (if any) the "extra" dependencies here, installable with "pip install your_package[your_extra]"
# [project.optional-dependencies]
# your-extra = ["your-deps>=1,<2"]

[project.urls]
repository = "https://github.com/smarie/python-pytest-cases"
homepage = "https://smarie.github.io/python-pytest-cases/"
documentation = "https://smarie.github.io/python-pytest-cases/"

# Define here (if any) the console cmd to launch your application
# [project.scripts]
# my-cli = "pytest_cases:my_function"

# -----------------------------------------------
# Dependencies management
# -----------------------------------------------
# Below there is one group for each nox session. 
# The extra 'dev' nox session (not used in ci cd) is to create the dev environment containing all tools.
[dependency-groups]
dev = [
    "nox[uv]>=2025",
    "pre-commit==4.0.1",
]
format = ["ruff>=0.9.0,<0.10"]
lint = [
    "ruff>=0.9.0,<0.10",
    # "junit2html>=30.1.3,<31",
]
typing = [
    "mypy>=1.8,<2",
    "typing-extensions>=4.9.0,<5",
]
# note: we probably have to extend some versions numbers or remove them to match  the nox.parametrize versions matrix to work
test = [
    "pytest>=6,<9",
    "pytest-steps",
    "pytest-harvest",
    "pytest-asyncio"
]
doc = [........]

[tool.uv]
default-groups = []

# -----------------------------------------------
# Build-backend choice and requirements
# -----------------------------------------------
[build-system]
requires = ["hatchling>=1.14.1,<2", "uv-dynamic-versioning>=0.8.1,<0.9.0"]
build-backend = "hatchling.build"

[tool.hatch.version]
source = "uv-dynamic-versioning"

[tool.hatch.build.hooks.version]
path = "pytest_cases/_version.py"

[tool.hatch.build.targets.sdist]
include = [
    "pytest_cases",
    "pytest_cases/_version.py",
]

[tool.hatch.build.targets.wheel]
include = [
    "pytest_cases",
    "pytest_cases/_version.py",
]

# -----------------------------------------------
# Versioning management
# -----------------------------------------------
[tool.uv-dynamic-versioning]
vcs = "git"
style = "semver"
pattern = "^(?P<base>\\d+\\.\\d+\\.\\d+)(-?((?P<stage>[a-zA-Z]+)\\.?(?P<revision>\\d+)?))?$"

# -----------------------------------------------
# Development tools
# -----------------------------------------------
[tool.mypy]
plugins = [
    "pydantic.mypy",
    "numpy.typing.mypy_plugin"
]

ignore_missing_imports = true
follow_imports = "silent"
warn_redundant_casts = true
warn_no_return = false
warn_unused_ignores = true
disallow_any_generics = false
check_untyped_defs = true
no_implicit_reexport = false

# For strict mypy put 'true'
disallow_untyped_defs = false

[tool.pytest.ini_options]
addopts = "-s" # Make prints and logs to appear 'live' (not after test end)

[tool.coverage.run]
branch = true
omit = [
    "*tests*",
    ".nox/*",
]

[tool.coverage.report]
fail_under = 80
ignore_errors = true
show_missing = true
exclude_lines = [
    "def __repr__",
    "if self.debug:",
    "if settings.DEBUG",
    "raise AssertionError",
    "except ImportError:",
    "raise NotImplementedError",
    "if 0:",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
    "class .*\\bProtocol\\):",
    "@(abc\\.)?abstractmethod",
]

[tool.ruff]
line-length = 120
exclude = [".git", ".mypy_cache", ".pytest_cache", ".nox/*", ".venv", ".*_version.py"]
extend-include = ["*.ipynb"] # Include explicitly Jupyter notebooks

[tool.ruff.lint]
preview = true
explicit-preview-rules = true

# By default all rules are enabled, if you won't respect one rule, you have to explicitly ignore it
select = ["ALL"]
extend-select = ["CPY001"] # To be removed when rule is not in preview anymore #75
ignore = [
  # Whole rules families
  "D", # pydocstyle: https://docs.astral.sh/ruff/rules/#pydocstyle-d
  "ANN", # flake8-annotations: https://docs.astral.sh/ruff/rules/#flake8-annotations-ann
  "FIX", # flake8-fixme: https://docs.astral.sh/ruff/rules/#flake8-fixme-fix
  "COM", # flake8-commas: https://docs.astral.sh/ruff/rules/#flake8-commas-com
  "EM", # flake8-errmsg: https://docs.astral.sh/ruff/rules/#flake8-errmsg-em
  # Specific ignores see https://github.schneider-electric.com/AIHub-Common/pilot-python/issues/103
  # TODOs
  "TD002",
  "TD003",
  "TD004",
  # elif and the like
  "RET504",
  "RET505",
  "RET506",
  "SIM102",
  "PLR5501",
  # dicts
  "C408",
  # pandas
  "PD003",
  # typing
  "TCH003",
  "TCH002",
  # Specific rule code
  # flake8-unused-arguments (ARG)
  "ARG002",  # unused-method-argument: https://docs.astral.sh/ruff/rules/unused-method-argument/
  "ARG003",  # unused-class-method-argument: https://docs.astral.sh/ruff/rules/unused-class-method-argument/
  "ARG004",  # unused-static-method-argument: https://docs.astral.sh/ruff/rules/unused-static-method-argument/
  # tryceratops (TRY)
  "TRY002", # raise-vanilla-class: https://docs.astral.sh/ruff/rules/raise-vanilla-class/
  "TRY003", # raise-vanilla-args: https://docs.astral.sh/ruff/rules/raise-vanilla-args/

]

[tool.ruff.lint.per-file-ignores]
# Noxfile legacy
"noxfile.py" = ["A", "SIM"]
# Accept 'assert' inside tests
"tests/*" = ["S101"]
# Auto-generated file with singles quotes
"_version.py" = ["Q000", "UP009", "CPY001"]

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.lint.pylint]
max-args = 10  # Raise an error if a function has more than `n` arguments

[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"

[tool.ruff.lint.flake8-copyright]
min-file-size = 1 # Do not enforce on empty files, like `__init__.py`
notice-rgx = "blabla"

smarie avatar Jun 23 '25 07:06 smarie

Here a nox file associated with the above pyproject.toml - there are a few things missing/remaining. The best would be to actually work by comparing current nox file with this one, and transfer the appropriate lines/sessions - this will be more efficient than replacing.
import argparse
import os
import shutil
from pathlib import Path

import nox
from nox import Session


MODULE_NAME = "pytest_cases"

nox.needs_version = ">=2025.2.9"  # No upper bound needed
nox.options.reuse_existing_virtualenvs = True
nox.options.error_on_missing_interpreters = True

# Default backend : uv
nox.options.default_venv_backend = "uv"

# Specify which sessions will be run (in this order) at command invocation `nox`.
# If you want to run another specific session, explicitly invoke it ; for example `nox -s dev`.
nox.options.sessions = ["test", "format", "lint", "typing", "doc"]

# Python version(s) supported by this project, read from the `pyproject.toml` file.
PYTHON_VERSIONS = nox.project.python_versions(nox.project.load_toml("pyproject.toml"), max_version="4")

# Note: we need to check how reported html pages were integrated in mkdocs final page earlier. Indeed the dirs
# below (and nox doc session) correspond to a sphinx doc system, which I would not like to use.

PROJECT_DIR = Path(__file__).parent
MODULE_DIR = PROJECT_DIR / MODULE_NAME
REQUIREMENTS_DIR = PROJECT_DIR / "requirements"
DOC_DIR = PROJECT_DIR / "doc"
DOC_BUILT_DIR = PROJECT_DIR / "site"
DOC_EXTRA_DIR = DOC_DIR / "extra"  # Folder for extra static pages
REPORT_DIR = DOC_EXTRA_DIR / "reports"
PYTEST_DIR = REPORT_DIR / "pytest"
COVERAGE_DIR = REPORT_DIR / "coverage"
LINTER_DIR = REPORT_DIR / "ruff"


# Learn more about Nox sessions usage here:
# https://pages.github.schneider-electric.com/AIHub-Common/usage-documentation/site/verifying/nox_guide.html
@nox.session(python=PYTHON_VERSIONS[-1])
def dev(session: Session) -> None:
    """Set up an environment for a developer, which can be used by an IDE as all in one.

    Usage
    -----
    > nox -s dev
    """
    session.run("uv", "sync", "--active", "--all-groups", external=True)
    # possible use pre-commit hooks later
    # session.run("pre-commit", "install")


@nox.session(python=PYTHON_VERSIONS[-1])
def format(session: Session) -> None:
    """Check that all Python code is compliant with Ruff format.

    (`ruff format .` can be used to format manually to ensure compliance. This is not automatic on purpose)

    Usage
    -----
    > nox -s format
    """
    session.run("uv", "sync", "--active", "--locked", "--only-group=format", external=True)
    session.run("ruff", "check", ".", "--select", "I")  # Sort imports
    session.run("ruff", "format", ".", "--check")


@nox.session(python=PYTHON_VERSIONS[-1])
def lint(session: Session) -> None:
    """Runs Ruff linter for Python files.

    Usage
    -----
    > nox -s lint
    """
    session.run("uv", "sync", "--active", "--locked", "--only-group=lint", external=True)

    shutil.rmtree(LINTER_DIR, ignore_errors=True)
    LINTER_DIR.mkdir(parents=True)

    with (LINTER_DIR / "ruff.xml").open(mode="w") as f:
        session.run(
            "ruff",
            "check",
            MODULE_NAME,
            "--output-format=junit",
            "--exit-zero",
            stdout=f,
            stderr=None,
        )

    # TODO check if this is compliant with the doc session
    session.run(
        "junit2html",
        str(LINTER_DIR / "ruff.xml"),
        "--report-matrix",
        str(LINTER_DIR / "ruff.html"),
    )
    # To have also the complete output in CI, which fails if any error spotted
    session.run("ruff", "check", MODULE_NAME)


@nox.session(python=PYTHON_VERSIONS[-1])
def typing(session: Session) -> None:
    """Runs PEP484 type hinting checks.

    Usage
    -----
    > nox -s typing
    """
    session.run("uv", "sync", "--active", "--locked", "--group=typing", external=True)
    session.run("mypy", MODULE_NAME)


ENVS = {
    # python 3.14
    (PY314, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
    (PY314, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY314, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # python 3.13
    (PY313, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
    (PY313, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY313, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # python 3.12
    (PY312, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
    (PY312, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY312, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # python 3.11
    # We'll run 'pytest-latest' this last for coverage
    (PY311, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY311, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # python 3.10
    (PY310, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
    (PY310, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY310, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # python 3.9
    (PY39, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
    (PY39, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
    (PY39, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
    # IMPORTANT: this should be last so that the folder docs/reports is not deleted afterwards
    (PY311, "pytest-latest"): {"coverage": True, "pkg_specs": {"pip": ">19", "pytest": ""}},
}

ENV_PARAMS = tuple((k[0], v["coverage"], v["pkg_specs"]) for k, v in ENVS.items())
ENV_IDS = tuple(f"{k[0].replace('.', '-')}-env-{k[1]}" for k in ENVS)


@nox.session
@nox.parametrize("python,coverage,pkg_specs", ENV_PARAMS, ids=ENV_IDS)
def test(session: Session) -> None:
    """Launch tests and coverage.

    Usage
    -----
    > nox -s test  # Launch sessions for every supported Python version
    > nox -s test-{python-version}  # Launch session for the given python version, e.g. {python-version}=3.12
    """
    session.run("uv", "sync", "--active", "--locked", "--group=test", external=True)


    # TODO REPLACE ALL BELOW WITH CURRENT


    pytest_session_dir = PYTEST_DIR / f"python-{session.python}"
    coverage_session_dir = COVERAGE_DIR / f"python-{session.python}"
    shutil.rmtree(pytest_session_dir, ignore_errors=True)
    shutil.rmtree(coverage_session_dir, ignore_errors=True)
    pytest_session_dir.mkdir(parents=True, exist_ok=True)
    coverage_session_dir.mkdir(parents=True, exist_ok=True)

    session.run(
        "coverage",
        "run",
        "--source",
        str(MODULE_DIR),
        "-m",
        "pytest",
        "--junitxml",
        f"{pytest_session_dir}/pytest.xml",
        "--html",
        f"{pytest_session_dir}/pytest.html",
    )
    # Generate html/xml reports even if coverage is not sufficient, to be able to investigate
    session.run("coverage", "html", "-d", str(coverage_session_dir), success_codes=[0, 2])
    session.run(
        "coverage",
        "xml",
        "-o",
        str(coverage_session_dir / "coverage.xml"),
        success_codes=[0, 2],
    )
    session.run("coverage", "report")


@nox.session(venv_backend="uv", python=PYTHON_VERSIONS[-1])
def doc(session: Session) -> None:
    """Build the documentation.

    Usage
    -----
    > nox -s doc -- -h  # Display documentation
    > nox -s doc  # To generate the site
    > nox -s doc -- -s  # To serve the documentation on a local server for faster editing.
    """

    session.run("uv", "sync", "--active", "--locked", "--group=doc", external=True)

    # TODO FILL WITH DOC SESSION CONTENTS


@nox.session(python=PYTHON_VERSIONS[-1])
def build(session: Session, *, output_dir: str = "dist"):
    """Build wheel for this repository, and put it in the ``output_dir`` (default="dist").

    Note: the ``output_dir`` is not cleaned.

    Usage
    -----
    > nox -s build
    """
    session.run("uv", "build", "--wheel", "--out-dir", output_dir, external=True)

smarie avatar Jun 23 '25 08:06 smarie

Hi @smarie! Thank you for the response and the initial examples for the pyproject.toml file and the nox file. I must have been confused between nox and nose. I'll admit that I wasn't familiar with nox before, and now after taking a look at it, it seems pretty cool! So we can skip 4.

As for 3, we can add pyupgrade as a pre-commit hook that updates the code whenever we change a specific document. I'll admit that I prefer to update everything in one go, but I respect your preferences.

I'll start working on 1 + 2 in the following weeks.

saroad2 avatar Jun 23 '25 15:06 saroad2

Thanks @saroad2 ! Do not hesitate to open a PR as "draft" so that we can discuss any open question / design choice / roadblock on the way if needed

smarie avatar Jun 29 '25 21:06 smarie