From df899e94cf7ba18f4e7151ef173393be78c56c3f Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Sun, 28 Sep 2025 23:44:41 -0400 Subject: [PATCH] use nox The top-level test runner has been changed to use ``nox``, adding a ``noxfile.py`` as well as some included modules. The ``tox.ini`` file remains in place so that ``tox`` runs will continue to function in the near term, however it will be eventually removed and improvements and maintenance going forward will be only towards ``noxfile.py``. Change-Id: I66639991e1dc3db582e2ff13f9348a7d6241916e --- .github/workflows/run-on-pr.yaml | 18 +- .github/workflows/run-test.yaml | 27 +- .gitignore | 1 + README.unittests.rst | 51 ++- doc/build/changelog/unreleased_21/nox.rst | 10 + noxfile.py | 367 ++++++++++++++++++++++ pyproject.toml | 107 ++++++- tools/toxnox.py | 229 ++++++++++++++ tools/warn_tox.py | 12 + tox.ini | 9 +- 10 files changed, 780 insertions(+), 51 deletions(-) create mode 100644 doc/build/changelog/unreleased_21/nox.rst create mode 100644 noxfile.py create mode 100644 tools/toxnox.py create mode 100644 tools/warn_tox.py diff --git a/.github/workflows/run-on-pr.yaml b/.github/workflows/run-on-pr.yaml index 90440b5919..54ede65ced 100644 --- a/.github/workflows/run-on-pr.yaml +++ b/.github/workflows/run-on-pr.yaml @@ -49,14 +49,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install --upgrade tox setuptools + pip install --upgrade nox setuptools pip list - name: Run tests - run: tox -e github-${{ matrix.build-type }} -- ${{ matrix.pytest-args }} + run: nox -v -s github-${{ matrix.build-type }} -- ${{ matrix.pytest-args }} - run-tox: - name: ${{ matrix.tox-env }}-${{ matrix.python-version }} + run-nox: + name: ${{ matrix.nox-env }}-${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -64,10 +64,10 @@ jobs: - "ubuntu-22.04" python-version: - "3.13" - tox-env: + nox-env: - mypy - - lint - pep484 + - pep8 fail-fast: false @@ -84,8 +84,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install --upgrade tox setuptools + pip install --upgrade nox setuptools pip list - - name: Run tox - run: tox -e ${{ matrix.tox-env }} ${{ matrix.pytest-args }} + - name: Run nox + run: nox -v -s ${{ matrix.nox-env }} -- ${{ matrix.pytest-args }} diff --git a/.github/workflows/run-test.yaml b/.github/workflows/run-test.yaml index 55daa86055..b6514badb1 100644 --- a/.github/workflows/run-test.yaml +++ b/.github/workflows/run-test.yaml @@ -142,18 +142,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install --upgrade tox setuptools + pip install --upgrade nox setuptools pip list - name: Run tests - run: tox -e github-${{ matrix.build-type }} -- ${{ matrix.pytest-args }} - env: - # under free threading, make sure to disable GIL - PYTHON_GIL: ${{ contains(matrix.python-version, 't') && '0' || '' }} + run: nox -v -s github-${{ matrix.build-type }} -- ${{ matrix.pytest-args }} continue-on-error: ${{ matrix.python-version == 'pypy-3.10' }} - run-tox: - name: ${{ matrix.tox-env }}-${{ matrix.python-version }} + run-nox: + name: ${{ matrix.nox-env }}-${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: # run this job using this matrix, excluding some combinations below. @@ -161,17 +158,11 @@ jobs: os: - "ubuntu-22.04" python-version: - - "3.12" - "3.13" - tox-env: + nox-env: - mypy - pep484 - - include: - # run lint only on 3.13 - - tox-env: lint - python-version: "3.13" - os: "ubuntu-22.04" + - pep8 fail-fast: false @@ -189,8 +180,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install --upgrade tox setuptools + pip install --upgrade nox setuptools pip list - - name: Run tox - run: tox -e ${{ matrix.tox-env }} ${{ matrix.pytest-args }} + - name: Run nox + run: nox -v -e ${{ matrix.nox-env }} ${{ matrix.pytest-args }} diff --git a/.gitignore b/.gitignore index 2fdd7eb951..9101cb30d3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.orig *,cover /.tox +/.nox /venv/ .venv *.egg-info diff --git a/README.unittests.rst b/README.unittests.rst index 07b9350378..ab7b23ecd0 100644 --- a/README.unittests.rst +++ b/README.unittests.rst @@ -5,30 +5,51 @@ SQLALCHEMY UNIT TESTS Basic Test Running ================== -Tox is used to run the test suite fully. For basic test runs against +Nox is used to run the test suite fully. For basic test runs against a single Python interpreter:: + nox + +The previous runner, tox, still retains functionality in the near term however +will eventually be removed:: + + # still works but deprecated tox -Advanced Tox Options +The newer nox version retains most of the same kinds of functionality as the +tox version, including a custom tagging utility that allows the nox runner +to accept similar "tag" style arguments as were used by the tox runner. + +Advanced Nox Options ==================== -For more elaborate CI-style test running, the tox script provided will +For more elaborate CI-style test running, the nox script provided will run against various Python / database targets. For a basic run against -Python 3.11 using an in-memory SQLite database:: +Python 3.13 using an in-memory SQLite database:: - tox -e py311-sqlite + nox -t py313-sqlite -The tox runner contains a series of target combinations that can run -against various combinations of databases. The test suite can be -run against SQLite with "backend" tests also running against a PostgreSQL -database:: +The nox runner contains a series of target combinations that can run +against each database backend. Unlike the previous tox runner, targets +that refer to multiple database backends at once are no longer +supported at the nox level, in favor of running against multiple tags +instead. So for example to run tests for sqlite and postgresql, while +reducing how many tests run for postgresql to just those that are sensitive +to the database backend:: - tox -e py311-sqlite-postgresql + nox -t py313-sqlite py313-postgresql-backendonly -Or to run just "backend" tests against a MySQL database:: +Where above, the full suite will run against SQLite under Python 3.13, then +the "backend only" version of the suite will for the PostgreSQL database. - tox -e py311-mysql-backendonly +The nox runner, like the tox runner before it, has options for running the +tests with or without the Cython extensions built, with or without greenlet +installed, as well as tags that select or deselect various memory/threading/ +performance intensive tests; the rules for how these environments are selected +should be much more straightforward to understand with nox's imperative +configuration style. For advanced use of nox it's worth it +to poke around ``noxfile.py`` to get a general sense of what varieties +of tests it can run. Running against backends other than SQLite requires that a database of that vendor be available at a specific URL. See "Setting Up Databases" below @@ -37,7 +58,7 @@ for details. The pytest Engine ================= -The tox runner is using pytest to invoke the test suite. Within the realm of +The nox runner uses pytest to invoke the test suite. Within the realm of pytest, SQLAlchemy itself is adding a large series of option and customizations to the pytest runner using plugin points, to allow for SQLAlchemy's multiple database support, database setup/teardown and @@ -127,13 +148,13 @@ Above, we can now run the tests with ``my_postgresql``:: pytest --db my_postgresql We can also override the existing names in our ``test.cfg`` file, so that we can run -with the tox runner also:: +with the nox/tox runners also:: # test.cfg file [db] postgresql=postgresql+psycopg2://username:pass@hostname/dbname -Now when we run ``tox -e py311-postgresql``, it will use our custom URL instead +Now when we run ``nox -t py313-postgresql``, it will use our custom URL instead of the fixed one in setup.cfg. Database Configuration diff --git a/doc/build/changelog/unreleased_21/nox.rst b/doc/build/changelog/unreleased_21/nox.rst new file mode 100644 index 0000000000..20012bc722 --- /dev/null +++ b/doc/build/changelog/unreleased_21/nox.rst @@ -0,0 +1,10 @@ +.. change:: + :tags: change, tests + + The top-level test runner has been changed to use ``nox``, adding a + ``noxfile.py`` as well as some included modules. The ``tox.ini`` file + remains in place so that ``tox`` runs will continue to function in the near + term, however it will be eventually removed and improvements and + maintenance going forward will be only towards ``noxfile.py``. + + diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000000..5593d24129 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,367 @@ +"""Nox configuration for SQLAlchemy.""" + +from __future__ import annotations + +import os +import sys +from typing import Dict +from typing import List +from typing import Set + +import nox + +if True: + sys.path.insert(0, ".") + from tools.toxnox import extract_opts + from tools.toxnox import tox_parameters + + +PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.13t", "3.14", "3.14t"] +DATABASES = ["sqlite", "sqlite_file", "postgresql", "mysql", "oracle", "mssql"] +CEXT = ["_auto", "cext", "nocext"] +GREENLET = ["_greenlet", "nogreenlet"] +BACKENDONLY = ["_all", "backendonly", "memusage"] + +# table of ``--dbdriver`` names to use on the pytest command line, which +# match to dialect names +DB_CLI_NAMES = { + "sqlite": { + "nogreenlet": {"sqlite", "pysqlite_numeric"}, + "greenlet": {"aiosqlite"}, + }, + "sqlite_file": { + "nogreenlet": {"sqlite"}, + "greenlet": {"aiosqlite"}, + }, + "postgresql": { + "nogreenlet": {"psycopg2", "pg8000", "psycopg"}, + "greenlet": {"asyncpg", "psycopg_async"}, + }, + "mysql": { + "nogreenlet": {"mysqldb", "pymysql", "mariadbconnector"}, + "greenlet": {"asyncmy", "aiomysql"}, + }, + "oracle": { + "nogreenlet": {"cx_oracle", "oracledb"}, + "greenlet": {"oracledb_async"}, + }, + "mssql": {"nogreenlet": {"pyodbc", "pymssql"}, "greenlet": {"aioodbc"}}, +} + + +def _setup_for_driver( + session: nox.Session, + cmd: List[str], + basename: str, + greenlet: bool = False, +) -> None: + + # install driver deps listed out in pyproject.toml + nogreenlet_deps = f"tests-{basename.replace("_", "-")}" + greenlet_deps = f"tests-{basename.replace("_", "-")}-asyncio" + + deps = nox.project.dependency_groups( + pyproject, + (greenlet_deps if greenlet else nogreenlet_deps), + ) + if deps: + session.install(*deps) + + # set up top level ``--db`` sent to pytest command line, which looks + # up a base URL in the [db] section of setup.cfg. Environment variable + # substitution used by CI is also available. + + # e.g. TOX_POSTGRESQL, TOX_MYSQL, etc. + dburl_env = f"TOX_{basename.upper()}" + # e.g. --db postgresql, --db mysql, etc. + default_dburl = f"--db {basename}" + cmd.extend(os.environ.get(dburl_env, default_dburl).split()) + + # set up extra drivers using --dbdriver. this first looks in + # an environment variable before making use of the DB_CLI_NAMES + # lookup table + + # e.g. EXTRA_PG_DRIVERS, EXTRA_MYSQL_DRIVERS, etc. + if basename == "postgresql": + extra_driver_env = "EXTRA_PG_DRIVERS" + else: + extra_driver_env = f"EXTRA_{basename.upper()}_DRIVERS" + env_dbdrivers = os.environ.get(extra_driver_env, None) + if env_dbdrivers: + cmd.extend(env_dbdrivers.split()) + return + + # use fixed names in DB_CLI_NAMES + extra_drivers: Dict[str, Set[str]] = DB_CLI_NAMES[basename] + dbdrivers = extra_drivers["nogreenlet"] + if greenlet: + dbdrivers.update(extra_drivers["greenlet"]) + + for dbdriver in dbdrivers: + cmd.extend(["--dbdriver", dbdriver]) + + +pyproject = nox.project.load_toml("pyproject.toml") + +nox.options.sessions = ["tests"] +nox.options.tags = ["py"] + + +@nox.session() +@tox_parameters( + ["python", "database", "cext", "greenlet", "backendonly"], + [ + PYTHON_VERSIONS, + DATABASES, + CEXT, + GREENLET, + BACKENDONLY, + ], +) +def tests( + session: nox.Session, + database: str, + greenlet: str, + backendonly: str, + cext: str, +) -> None: + """run the main test suite""" + + _tests( + session, + database, + greenlet=greenlet == "_greenlet", + backendonly=backendonly == "backendonly", + platform_intensive=backendonly == "memusage", + cext=cext, + ) + + +@nox.session(name="coverage") +@tox_parameters( + ["database", "cext", "backendonly"], + [DATABASES, CEXT, ["_all", "backendonly"]], + base_tag="coverage", +) +def coverage( + session: nox.Session, database: str, cext: str, backendonly: str +) -> None: + """Run tests with coverage.""" + + _tests( + session, + database, + cext, + timing_intensive=False, + backendonly=backendonly == "backendonly", + coverage=True, + ) + + +@nox.session(name="github-cext-greenlet") +def github_cext_greenlet(session: nox.Session) -> None: + """run tests for github actions""" + + _tests(session, "sqlite", "cext", greenlet=True, timing_intensive=False) + + +@nox.session(name="github-cext") +def github_cext(session: nox.Session) -> None: + """run tests for github actions""" + + _tests(session, "sqlite", "cext", greenlet=False, timing_intensive=False) + + +@nox.session(name="github-nocext") +def github_nocext(session: nox.Session) -> None: + """run tests for github actions""" + + _tests(session, "sqlite", "cext", greenlet=False) + + +def _tests( + session: nox.Session, + database: str, + cext: str = "_auto", + greenlet: bool = True, + backendonly: bool = False, + platform_intensive: bool = False, + timing_intensive: bool = True, + coverage: bool = False, + mypy: bool = False, +) -> None: + # PYTHONNOUSERSITE - this *MUST* be set so that the ./lib/ import + # set up explicitly in test/conftest.py is *disabled*, so that + # when SQLAlchemy is built into the .nox area, we use that and not the + # local checkout, at least when usedevelop=False + session.env["PYTHONNOUSERSITE"] = "1" + + freethreaded = isinstance(session.python, str) and session.python.endswith( + "t" + ) + + if freethreaded: + session.env["PYTHON_GIL"] = "0" + + # greenlet frequently crashes with freethreading, so omit + # for the near future + greenlet = False + + session.env["SQLALCHEMY_WARN_20"] = "1" + + if cext == "cext": + session.env["REQUIRE_SQLALCHEMY_CEXT"] = "1" + elif cext == "nocext": + session.env["DISABLE_SQLALCHEMY_CEXT"] = "1" + + includes_excludes: dict[str, list[str]] = {"k": [], "m": []} + + if coverage: + timing_intensive = False + + if platform_intensive: + # platform_intensive refers to test/aaa_profiling/test_memusage.py. + # it's only run exclusively of all other tests. does not include + # greenlet related tests + greenlet = False + # with "-m memory_intensive", only that suite will run, all + # other tests will be deselected by pytest + includes_excludes["m"].append("memory_intensive") + elif backendonly: + # with "-m backendonly", only tests with the backend pytest mark + # (or pytestplugin equivalent, like __backend__) will be selected + # by pytest + includes_excludes["m"].append("backend") + else: + includes_excludes["m"].append("not memory_intensive") + + # the mypy suite is also run exclusively from the test_mypy + # session + includes_excludes["m"].append("not mypy") + + if not timing_intensive: + includes_excludes["m"].append("not timing_intensive") + + cmd = ["python", "-m", "pytest"] + + if coverage: + assert not platform_intensive + cmd.extend( + [ + "--cov=sqlalchemy", + "--cov-append", + "--cov-report", + "term", + "--cov-report", + "xml", + ], + ) + includes_excludes["k"].append("not aaa_profiling") + + cmd.extend(os.environ.get("TOX_WORKERS", "-n4").split()) + + if coverage: + session.install("-e", ".") + session.install(*nox.project.dependency_groups(pyproject, "coverage")) + else: + session.install(".") + + session.install(*nox.project.dependency_groups(pyproject, "tests")) + + if greenlet: + session.install( + *nox.project.dependency_groups(pyproject, "tests_greenlet") + ) + else: + # note: if on SQLAlchemy 2.0, for "nogreenlet" need to do an explicit + # uninstall of greenlet since it's included in sqlalchemy dependencies + # in 2.1 it's an optional dependency + session.run("pip", "uninstall", "-y", "greenlet") + + _setup_for_driver(session, cmd, database, greenlet=greenlet) + + for letter, collection in includes_excludes.items(): + if collection: + cmd.extend([f"-{letter}", " and ".join(collection)]) + + posargs, opts = extract_opts(session.posargs, "generate-junit", "dry-run") + + if opts.generate_junit: + # produce individual junit files that are per-database + junitfile = f"junit-{database}.xml" + cmd.extend(["--junitxml", junitfile]) + + cmd.extend(posargs) + + if opts.dry_run: + print(f"DRY RUN: command is: \n{' '.join(cmd)}") + return + + try: + session.run(*cmd) + finally: + # Run cleanup for oracle/mssql + if database in ["oracle", "mssql"]: + session.run("python", "reap_dbs.py", "db_idents.txt") + + +@nox.session(name="pep484") +def test_pep484(session: nox.Session) -> None: + """Run mypy type checking.""" + + session.install(*nox.project.dependency_groups(pyproject, "mypy")) + + session.install("-e", ".") + + session.run( + "mypy", + "noxfile.py", + "./lib/sqlalchemy", + ) + + +@nox.session(name="mypy") +def test_mypy(session: nox.Session) -> None: + """run the typing integration test suite""" + + session.install(*nox.project.dependency_groups(pyproject, "mypy")) + + session.install("-e", ".") + + posargs, opts = extract_opts(session.posargs, "generate-junit") + + cmd = ["pytest", "-m", "mypy"] + if opts.generate_junit: + # produce individual junit files that are per-database + junitfile = "junit-mypy.xml" + cmd.extend(["--junitxml", junitfile]) + + session.run(*cmd, *posargs) + + +@nox.session(name="pep8") +def test_pep8(session: nox.Session) -> None: + """Run linting and formatting checks.""" + + session.install("-e", ".") + + session.install(*nox.project.dependency_groups(pyproject, "lint")) + + for cmd in [ + "flake8 ./lib/ ./test/ ./examples/ noxfile.py " + "setup.py doc/build/conf.py", + "flake8 --extend-ignore='' ./lib/sqlalchemy/ext/asyncio " + "./lib/sqlalchemy/orm/scoping.py", + "black --check ./lib/ ./test/ ./examples/ setup.py doc/build/conf.py", + "slotscheck -m sqlalchemy", + "python ./tools/format_docs_code.py --check", + "python ./tools/generate_tuple_map_overloads.py --check", + "python ./tools/generate_proxy_methods.py --check", + "python ./tools/sync_test_files.py --check", + "python ./tools/generate_sql_functions.py --check", + "python ./tools/normalize_file_headers.py --check", + "python ./tools/cython_imports.py --check", + "python ./tools/walk_packages.py", + ]: + + session.run(*cmd.split()) diff --git a/pyproject.toml b/pyproject.toml index 699606207f..679fe0b092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ oracle-oracledb = ["oracledb>=1.0.1"] postgresql = ["psycopg2>=2.7"] postgresql-pg8000 = ["pg8000>=1.29.3"] postgresql-asyncpg = [ - "greenlet>=1", # same as ".[asyncio]" if this syntax were supported + "sqlalchemy[asyncio]", "asyncpg", ] postgresql-psycopg2binary = ["psycopg2-binary"] @@ -69,19 +69,19 @@ postgresql-psycopgbinary = ["psycopg[binary]>=3.0.7,!=3.1.15"] pymysql = ["pymysql"] cymysql = ["cymysql"] aiomysql = [ - "greenlet>=1", # same as ".[asyncio]" if this syntax were supported + "sqlalchemy[asyncio]", "aiomysql", ] aioodbc = [ - "greenlet>=1", # same as ".[asyncio]" if this syntax were supported + "sqlalchemy[asyncio]", "aioodbc", ] asyncmy = [ - "greenlet>=1", # same as ".[asyncio]" if this syntax were supported + "sqlalchemy[asyncio]", "asyncmy>=0.2.3,!=0.2.4,!=0.2.6", ] aiosqlite = [ - "greenlet>=1", # same as ".[asyncio]" if this syntax were supported + "sqlalchemy[asyncio]", "aiosqlite", ] sqlcipher = ["sqlcipher3_binary"] @@ -99,6 +99,103 @@ postgresql_psycopg2cffi = ["sqlalchemy[postgresql-psycopg2cffi]"] postgresql_psycopg = ["sqlalchemy[postgresql-psycopg]"] postgresql_psycopgbinary = ["sqlalchemy[postgresql-psycopgbinary]"] +[dependency-groups] +tests = [ + "pytest>=7.0.0,<8.4", + "pytest-xdist", +] + +coverage = ["pytest-cov"] + +tests-greenlet = ["sqlalchemy[asyncio]"] + +tests-sqlite = [] + +tests-sqlite-asyncio = [ + {include-group = "tests-greenlet"}, + {include-group = "tests-sqlite"}, + "sqlalchemy[aiosqlite]" +] + +tests-sqlite-file = [{include-group = "tests-sqlite"}] +tests-sqlite-file-asyncio = [{include-group = "tests-sqlite-asyncio"}] + +tests-postgresql = [ + "sqlalchemy[postgresql]", + "sqlalchemy[postgresql-psycopg]", + "sqlalchemy[postgresql-pg8000]", +] + +tests-postgresql-asyncio = [ + {include-group = "tests-greenlet"}, + {include-group = "tests-postgresql"}, + "sqlalchemy[postgresql-asyncpg]" +] + +tests-mysql = [ + "sqlalchemy[mysql]", + "sqlalchemy[pymysql]", + "sqlalchemy[mysql-connector]", + + # originally to fix https://jira.mariadb.org/browse/CONPY-318, + # more recent versions still have attribute errors and other random + # problems + "mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10,<1.1.13", + +] +tests-mysql-asyncio = [ + {include-group = "tests-greenlet"}, + {include-group = "tests-mysql"}, + "sqlalchemy[aiomysql]", + "sqlalchemy[asyncmy]", +] + +tests-oracle-asyncio = [ + {include-group = "tests-greenlet"}, + {include-group = "tests-oracle"}, +] +tests-oracle = [ + "sqlalchemy[oracle]", + "sqlalchemy[oracle-oracledb]", +] + +tests-mssql = [ + "sqlalchemy[mssql]", + "sqlalchemy[mssql-pymssql]", +] + +tests-mssql-asyncio = [ + {include-group = "tests-greenlet"}, + {include-group = "tests-mssql"}, + "aioodbc" +] + +lint = [ + {include-group = "tests-greenlet"}, + "flake8>=7.2.0", + "flake8-import-order>=0.19.2", + "flake8-import-single==0.1.5", + "flake8-builtins", + "flake8-future-annotations>=0.0.5", + "flake8-docstrings", + "flake8-unused-arguments", + "flake8-rst-docstrings", + "pydocstyle<4.0.0", + "pygments", + "black==25.1.0", + "slotscheck>=0.17.0", + "zimports", # required by generate_tuple_map_overloads +] + +mypy = [ + {include-group = "tests-greenlet"}, + "mypy>=1.16.0", + "nox", # because we check noxfile.py + "pytest>8,<8.4", # alembic/testing imports pytest + "types-greenlet", +] + + [tool.setuptools] include-package-data = true diff --git a/tools/toxnox.py b/tools/toxnox.py new file mode 100644 index 0000000000..897abfc774 --- /dev/null +++ b/tools/toxnox.py @@ -0,0 +1,229 @@ +"""Provides the tox_parameters() utility, which generates parameterized +sections for nox tests, which include tags that indicate various combinations +of those parameters in such a way that it's somewhat similar to how +we were using the tox project; where individual dash-separated tags could +be added to add more specificity to the suite configuation, or omitting them +would fall back to defaults. + + +""" + +from __future__ import annotations + +import collections +import re +import sys +from typing import Any +from typing import Callable +from typing import Generator +from typing import Sequence + +import nox + +OUR_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}" + + +def tox_parameters( + names: Sequence[str], + token_lists: Sequence[Sequence[str]], + *, + base_tag: str | None = None, + filter_: Callable[..., bool] | None = None, + always_include_in_tag: Sequence[str] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + r"""Decorator to create a parameter/tagging structure for a nox session + function that acts to a large degree like tox's generative environments. + + The output is a ``nox.parametrize()`` decorator that's built up from + individual ``nox.param()`` instances. + + :param names: names of the parameters sent to the session function. + These names go straight to the first argument of ``nox.parametrize()`` + and should all match argument names accepted by the decorated function + (except for ``python``, which is optional). + :param token_lists: a sequence of lists of values for each parameter. a + ``nox.param()`` will be created for the full product of these values, + minus those filtered out using the ``filter_`` callable. These tokens + are used to create the args, tags, and ids of each ``nox.param()``. The + list of tags will be generated out including all values for a parameter + joined by ``-``, as well as combinations that include a subset of those + values, where the omitted elements of the tag are implicitly considered to + match the "default" value, indicated by them being first in their + collection (with the exception of "python", where the current python in + use is the default). Additionally, values that start with an underscore + are omitted from all ids and tags. Values that refer to Python versions + wlil be expanded to the full Python executable name when passed as + arguments to the session function, which is currently a workaround to + allow free-threaded python interpreters to be located. + :param base_tag: optional tag that will be appended to all tags generated, + e.g. if the decorator yields tags like ``python314-x86-windows``, a + ``basetag`` value of ``all`` would yield the + tag as ``python314-x86-windows-all``. + :param filter\_: optional filtering function, must accept keyword arguments + matching the names in ``names``. Returns True or False indicating if + a certain tag combination should be included. + :param always_include_in_tag: list of names from ``names`` that indicate + parameters that should always be part of all tags, and not be omitted + as a "default" + + + """ + + PY_RE = re.compile(r"(?:python)?([234]\.\d+(t?))") + + def _is_py_version(token: str) -> bool: + return bool(PY_RE.match(token)) + + def _expand_python_version(token: str) -> str: + """expand pyx.y(t) tags into executable names. + + Works around nox issue fixed at + https://github.com/wntrblm/nox/pull/999 by providing full executable + name + + """ + if sys.platform == "win32": + return token + + m = PY_RE.match(token) + + # do this matching minimally so that it only happens for the + # free-threaded versions. on windows, the "pythonx.y" syntax doesn't + # work due to the use of the "py" tool + if m and m.group(2) == "t": + return f"python{m.group(1)}" + else: + return token + + def _python_to_tag(token: str) -> str: + m = PY_RE.match(token) + if m: + return f"py{m.group(1).replace('.', '')}" + else: + return token + + if always_include_in_tag: + name_to_list = dict(zip(names, token_lists)) + must_be_present = [ + name_to_list[name] for name in always_include_in_tag + ] + else: + must_be_present = None + + def _recur_param( + prevtokens: list[str], + prevtags: list[str], + token_lists: Sequence[Sequence[str]], + ) -> Generator[tuple[list[str], list[str], str], None, None]: + + if not token_lists: + return + + tokens = token_lists[0] + remainder = token_lists[1:] + + for i, token in enumerate(tokens): + + if _is_py_version(token): + is_our_python = token == OUR_PYTHON + tokentag = _python_to_tag(token) + is_default_token = is_our_python + else: + is_our_python = False + tokentag = token + is_default_token = i == 0 + + if is_our_python: + our_python_tags = ["py"] + else: + our_python_tags = [] + + if not tokentag.startswith("_"): + tags = ( + prevtags + + [tokentag] + + [tag + "-" + tokentag for tag in prevtags] + + our_python_tags + ) + else: + tags = prevtags + our_python_tags + + if remainder: + for args, newtags, ids in _recur_param( + prevtokens + [token], tags, remainder + ): + if not is_default_token: + newtags = [ + t + for t in newtags + if tokentag in t or t in our_python_tags + ] + + yield args, newtags, ids + else: + if not is_default_token: + newtags = [ + t + for t in tags + if tokentag in t or t in our_python_tags + ] + else: + newtags = tags + + if base_tag: + newtags = [t + f"-{base_tag}" for t in newtags] + if must_be_present: + for t in list(newtags): + for required_tokens in must_be_present: + if not any(r in t for r in required_tokens): + newtags.remove(t) + break + + yield prevtokens + [token], newtags, "-".join( + _python_to_tag(t) + for t in prevtokens + [token] + if not t.startswith("_") + ) + + params = [ + nox.param( + *[_expand_python_version(a) for a in args], tags=tags, id=ids + ) + for args, tags, ids in _recur_param([], [], token_lists) + if filter_ is None or filter_(**dict(zip(names, args))) + ] + + # for p in params: + # print(f"PARAM {'-'.join(p.args)} TAGS {p.tags}") + + return nox.parametrize(names, params) + + +def extract_opts(posargs: list[str], *args: str) -> tuple[list[str], Any]: + """Pop individual flag options from session.posargs. + + Returns a named tuple with the individual flag options indicated, + as well the new posargs with those flags removed from the string list + so that the posargs can be forwarded onto pytest. + + Basically if nox had an option for additional environmental flags that + didn't require putting them after ``--``, we wouldn't need this, but this + is probably more flexible. + + """ + underscore_args = [arg.replace("-", "_") for arg in args] + return_tuple = collections.namedtuple("options", underscore_args) # type: ignore # noqa: E501 + + look_for_args = {f"--{arg}": idx for idx, arg in enumerate(args)} + return_args = [False for arg in args] + + def extract(arg: str) -> bool: + if arg in look_for_args: + return_args[look_for_args[arg]] = True + return True + else: + return False + + return [arg for arg in posargs if not extract(arg)], return_tuple( + *return_args + ) diff --git a/tools/warn_tox.py b/tools/warn_tox.py new file mode 100644 index 0000000000..a4530b8928 --- /dev/null +++ b/tools/warn_tox.py @@ -0,0 +1,12 @@ +def warn_tox(): + print( + "\n" + + "=" * 80 + + "\n\033[1;31m ⚠️ NOTE: TOX IS DEPRECATED IN THIS PROJECT! ⚠️" + "\033[0m\n\033[1;33m " + "Please use nox instead for running tests.\033[0m\n" + "=" * 80 + "\n" + ) + + +if __name__ == "__main__": + warn_tox() diff --git a/tox.ini b/tox.ini index 349dc3f0f4..9139b5b3aa 100644 --- a/tox.ini +++ b/tox.ini @@ -95,7 +95,7 @@ allowlist_externals=sh # PYTHONPATH - erased so that we use the build that's present # in .tox as the SQLAlchemy library to be imported # -# PYTHONUSERSITE - this *MUST* be set so that the ./lib/ import +# PYTHONNOUSERSITE - this *MUST* be set so that the ./lib/ import # set up explicitly in test/conftest.py is *disabled*, again so that # when SQLAlchemy is built into the .tox area, we use that and not the # local checkout, at least when usedevelop=False @@ -196,6 +196,7 @@ commands= nogreenlet: pip uninstall -y greenlet {env:BASECOMMAND} {env:WORKERS} {env:SQLITE:} {env:EXTRA_SQLITE_DRIVERS:} {env:POSTGRESQL:} {env:EXTRA_PG_DRIVERS:} {env:MYSQL:} {env:EXTRA_MYSQL_DRIVERS:} {env:ORACLE:} {env:EXTRA_ORACLE_DRIVERS:} {env:MSSQL:} {env:EXTRA_MSSQL_DRIVERS:} {env:IDENTS:} {env:PYTEST_EXCLUDES:} {env:COVERAGE:} {posargs} oracle,mssql,sqlite_file: python reap_dbs.py db_idents.txt + python tools/warn_tox.py [testenv:pep484] @@ -205,9 +206,7 @@ deps= types-greenlet commands = mypy {env:MYPY_COLOR} ./lib/sqlalchemy - # pyright changes too often with not-exactly-correct errors - # suddently appearing for it to be stable enough for CI - # pyright + python tools/warn_tox.py extras = {[greenletextras]extras} @@ -224,6 +223,7 @@ extras= commands = pytest {env:PYTEST_COLOR} -m mypy {posargs} + python tools/warn_tox.py [testenv:mypy-cov] @@ -236,6 +236,7 @@ extras= commands = pytest {env:PYTEST_COLOR} -m mypy {env:COVERAGE} {posargs} + python tools/warn_tox.py setenv= COVERAGE={[testenv]cov_args} -- 2.47.3