]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
use nox
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 29 Sep 2025 03:44:41 +0000 (23:44 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 15 Oct 2025 18:13:55 +0000 (14:13 -0400)
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
.github/workflows/run-test.yaml
.gitignore
README.unittests.rst
doc/build/changelog/unreleased_21/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 90440b5919e11e51382f801642093d45c3e988ea..54ede65ceda9026500104d9175b4296ff7501376 100644 (file)
@@ -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 }}
index 55daa8605525ef4a63fb851e70ad9629ce29ffef..b6514badb12760680aa1fcadb5b857cc1c9805ce 100644 (file)
@@ -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 }}
index 2fdd7eb95190500f835f2df0d23f383ff1ccec74..9101cb30d368d6b5d8b1ec9f24e8425341653405 100644 (file)
@@ -10,6 +10,7 @@
 *.orig
 *,cover
 /.tox
+/.nox
 /venv/
 .venv
 *.egg-info
index 07b935037813109ea0cc858ef1089e4238c88597..ab7b23ecd013618974a0c89b349d7031f81fbb8d 100644 (file)
@@ -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 (file)
index 0000000..20012bc
--- /dev/null
@@ -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 (file)
index 0000000..5593d24
--- /dev/null
@@ -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())
index 699606207f7b6bd68d712486b5184052a3a4c7df..679fe0b0929c9d67d5d91357a9b3ba587d3ab0cb 100644 (file)
@@ -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 (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 349dc3f0f4f57058f1dc9a6e73d1ef9139e5f058..9139b5b3aa752a324e43183a002d22ce004a2da0 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
@@ -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}