]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
add nox support (but dont switch out fully)
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 29 Sep 2025 03:44:41 +0000 (23:44 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 20 Oct 2025 22:50:40 +0000 (18:50 -0400)
This backports the nox change from main/2.1 so that we have full
nox support available for the 2.0 series, however does not modify
any existing docs or test systems.

A noxfile.py has been added to allow testing with nox.  This is a direct
port of 2.1's move to nox, however leaves the tox.ini file in place and
retains all test documentation in terms of tox.   Version 2.1 will move to
nox fully, including deprecation warnings for tox and new testing
documentation.

Change-Id: I66639991e1dc3db582e2ff13f9348a7d6241916e
(cherry picked from commit df899e94cf7ba18f4e7151ef173393be78c56c3f)
(also cherry-picked from 1577c1d15b)

.github/workflows/run-on-pr.yaml
.github/workflows/run-test.yaml
.gitignore
MANIFEST.in
doc/build/changelog/unreleased_20/use_nox.rst [new file with mode: 0644]
noxfile.py [new file with mode: 0644]
pyproject.toml
tools/toxnox.py [new file with mode: 0644]
tools/warn_tox.py [new file with mode: 0644]
tox.ini

index 3e2f1b39f6e56f35718bc7ec9d37e0b7e68e65e7..54ede65ceda9026500104d9175b4296ff7501376 100644 (file)
@@ -49,25 +49,25 @@ 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:
         os:
           - "ubuntu-22.04"
         python-version:
-          - "3.12"
-        tox-env:
+          - "3.13"
+        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 }}
index 15a7f9a5107ef225525a4b83216d3f01e3b5f25f..8096d8234823bd54d3054732113be618b5ebfd5d 100644 (file)
@@ -143,18 +143,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.
@@ -162,17 +159,11 @@ jobs:
         os:
           - "ubuntu-22.04"
         python-version:
-          - "3.12"
           - "3.13"
-        tox-env:
+        nox-env:
           - mypy
           - pep484
-
-        include:
-          # run lint only on 3.12
-          - tox-env: lint
-            python-version: "3.12"
-            os: "ubuntu-22.04"
+          - pep8
 
       fail-fast: false
 
@@ -190,8 +181,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 }}
index d2ee9a2f4add17e9a7d735a4a29fe47ae9f20e05..4b76721abf1f6cf265b79bf8f5f4c3cf9ef01a4d 100644 (file)
@@ -10,6 +10,7 @@
 *.orig
 *,cover
 /.tox
+/.nox
 /venv/
 .venv
 *.egg-info
index 7a272fe6b427ad8b1b01bd369c1c32fa6a9a17f9..a5ee9fcedb68781338a88a10ceaf4b94c978f996 100644 (file)
@@ -15,5 +15,5 @@ exclude lib/sqlalchemy/cyextension/*.so
 # don't come in if --with-cextensions isn't specified.
 recursive-include lib *.pyx *.pxd *.txt *.typed
 
-include README* AUTHORS LICENSE CHANGES* tox.ini
+include README* AUTHORS LICENSE CHANGES* tox.ini noxfile.py
 prune doc/build/output
diff --git a/doc/build/changelog/unreleased_20/use_nox.rst b/doc/build/changelog/unreleased_20/use_nox.rst
new file mode 100644 (file)
index 0000000..ee57aa6
--- /dev/null
@@ -0,0 +1,8 @@
+.. change::
+    :tags: change, tests
+
+    A noxfile.py has been added to allow testing with nox.  This is a direct
+    port of 2.1's move to nox, however leaves the tox.ini file in place and
+    retains all test documentation in terms of tox.   Version 2.1 will move to
+    nox fully, including deprecation warnings for tox and new testing
+    documentation.
diff --git a/noxfile.py b/noxfile.py
new file mode 100644 (file)
index 0000000..16ba302
--- /dev/null
@@ -0,0 +1,385 @@
+"""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.7",
+    "3.8",
+    "3.9",
+    "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])
+
+    if database in ["oracle", "mssql", "sqlite_file"]:
+        cmd.extend(["--write-idents", "db_idents.txt"])
+
+    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", "sqlite_file"]:
+            if os.path.exists("db_idents.txt"):
+                session.run("python", "reap_dbs.py", "db_idents.txt")
+                os.unlink("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 "
+        "noxfile.py",
+        # test with cython and without cython exts running
+        "slotscheck -m sqlalchemy",
+        "env DISABLE_SQLALCHEMY_CEXT_RUNTIME=1 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/walk_packages.py",
+    ]:
+
+        session.run(*cmd.split(), external=True)
index 570dfc4031e4d6bab217a96f6fe56ff9a1cf387e..bc511e0250995f6ab5da15417617cea882fd66bf 100644 (file)
@@ -5,6 +5,103 @@ requires = [
     "cython>=0.29.24; platform_python_implementation == 'CPython'", # Skip cython when using pypy
 ]
 
+[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.black]
 line-length = 79
 target-version = ['py37']
diff --git a/tools/toxnox.py b/tools/toxnox.py
new file mode 100644 (file)
index 0000000..897abfc
--- /dev/null
@@ -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 (file)
index 0000000..a4530b8
--- /dev/null
@@ -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 bbc9536df6f94f0f79aacbf005830850b9c5f85b..2359df1d9db79c37b4e2f5df3fe56e5d7f72ad97 100644 (file)
--- 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
@@ -209,9 +209,6 @@ 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
 
 extras =
      {[greenletextras]extras}