diff --git a/.coveragerc b/.coveragerc index a92d227..bcc7f01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,5 +2,4 @@ relative_files = True [report] omit = - */python?.?/* - */site-packages/nose/* + */tests/* diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index dd06282..df81416 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,9 +33,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 - - run: uv tool install nox --with nox-uv - name: Run nox - run: uvx nox -s "${{ matrix.session }}" -- --coverage + run: > + uv run noxfile.py -s "${{ matrix.session }}" + -- --pyargs sqlalchemy_mptt --cov-report xml - name: Upload coverage data if: ${{ matrix.session != 'lint' }} uses: coverallsapp/github-action@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9d22c7..cd1422b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,8 +8,13 @@ repos: - id: end-of-file-fixer exclude: '^.*\.svg$' - id: check-yaml + - id: check-toml - id: check-added-large-files - repo: https://github.com/pycqa/flake8 - rev: '7.2.0' + rev: '7.3.0' hooks: - id: flake8 +- repo: https://github.com/pappasam/toml-sort + rev: 'v0.24.2' + hooks: + - id: toml-sort diff --git a/CHANGES.rst b/CHANGES.rst index 4ec570d..cc189d6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Versions releases 0.2.x & above ############################### +0.5.0 (Unreleased) +================== + +- Add support for SQLAlchemy 1.4. +- Drop official support for PyPy. +- Simplify memory management by using ``weakref.WeakSet`` instead of rolling our own + weak reference set. +- Unify ``after_flush_postexec`` execution path for CPython & PyPy. +- Simplify ``get_siblings``. + 0.4.0 (2025-05-30) ================== diff --git a/noxfile.py b/noxfile.py index 8fe9295..c9865be 100644 --- a/noxfile.py +++ b/noxfile.py @@ -23,19 +23,15 @@ Run all tests and linting: $ uv run noxfile.py - Run all tests with coverage and linting: - $ uv run noxfile.py -- --coverage Run tests for a specific SQLAlchemy version: $ uv run noxfile.py -t sqla12 Run tests for a specific Python version: $ uv run noxfile.py -s test -p 3.X - $ uv run noxfile.py -s test -p pypy-3.X # For PyPy Set up a development environment with the default Python version (3.8): $ uv run noxfile.py -s dev Set up a development environment with a specific Python version: $ uv run noxfile.py -s dev -P 3.X - $ uv run noxfile.py -s dev -P pypy-3.X # For PyPy """ from itertools import groupby @@ -84,10 +80,9 @@ def parametrize_test_versions(): return [ nox.param( - f"{interpreter}3.{python_minor}", str(sqlalchemy_version), + f"3.{python_minor}", str(sqlalchemy_version), tags=[f"sqla{sqlalchemy_version.major}{sqlalchemy_version.minor}"] ) - for interpreter in ("", "pypy-") for python_minor in range(PYTHON_MINOR_VERSION_MIN, PYTHON_MINOR_VERSION_MAX + 1) for sqlalchemy_version in filtered_sqlalchemy_versions # SQLA 1.1 or below doesn't seem to support Python 3.10+ @@ -99,7 +94,11 @@ def parametrize_test_versions(): def test(session, sqlalchemy): """Run tests with pytest. - Use the --coverage option to run tests with coverage. + You can pass arguments to pytest using the `--` option. + + $ uv run noxfile.py -s test -- sqlalchemy_mptt/tests/test_events.py + + If no arguments are provided, it defaults to running all tests in the package. For running tests for a specific SQLAlchemy version, use the tags option: @@ -110,20 +109,8 @@ def test(session, sqlalchemy): session.install("-r", "requirements-test.txt") session.install(f"sqlalchemy~={sqlalchemy}.0") session.install("-e", ".") - try: - session.posargs.remove("--coverage") - except ValueError: - with_coverage = False - else: - with_coverage = True - pytest_cmd = ["pytest"] + ( - session.posargs or [ - "--pyargs", "sqlalchemy_mptt", - "--cov", "sqlalchemy_mptt", "--cov-report", "term-missing:skip-covered", - "-W", "error:::sqlalchemy_mptt" - ] - ) + (["--cov-report", "xml"] if with_coverage else []) - session.run(*pytest_cmd) + pytest_args = session.posargs or ["--pyargs", "sqlalchemy_mptt"] + session.run("pytest", *pytest_args, env={"SQLALCHEMY_SILENCE_UBER_WARNING": "1"}) @nox.session(default=False) @@ -133,7 +120,6 @@ def dev(session): To use a specific Python version, use the -P option: $ uv run noxfile.py -s dev -P 3.X - $ uv run noxfile.py -s dev -P pypy-3.X # For PyPy """ session.run("uv", "venv", "--python", session.python or f"3.{PYTHON_MINOR_VERSION_MIN}", "--seed") session.run(".venv/bin/pip", "install", "-r", "requirements-test.txt", external=True) diff --git a/pyproject.toml b/pyproject.toml index dfab025..2caf7b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,9 @@ exclude = ''' | dist )/ ''' + +[tool.pytest.ini_options] +filterwarnings = [ + "error:::sqlalchemy_mptt" +] +addopts = "--cov sqlalchemy_mptt --cov-report term-missing:skip-covered" diff --git a/requirements.txt b/requirements.txt index b794599..7149786 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -SQLAlchemy>=1.0.0,<1.4 +SQLAlchemy>=1.0.0,<2.0 diff --git a/setup.py b/setup.py index af9250b..e824df2 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def read(name): setup( name="sqlalchemy_mptt", - version="0.4.0", + version="0.5.0", url="http://github.com/uralbash/sqlalchemy_mptt/", author="Svintsov Dmitry", author_email="sacrud@uralbash.ru", @@ -39,8 +39,6 @@ def read(name): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", "Framework :: Pyramid", "Framework :: Flask", "Topic :: Internet", diff --git a/sqlalchemy_mptt/events.py b/sqlalchemy_mptt/events.py index 268837f..cdd1d86 100644 --- a/sqlalchemy_mptt/events.py +++ b/sqlalchemy_mptt/events.py @@ -479,29 +479,15 @@ def mptt_before_update(mapper, connection, instance): ) -class _WeakDictBasedSet(weakref.WeakKeyDictionary, object): - """ - In absence of a default weakset implementation, provide our own dict - based solution. - """ - - def add(self, obj): - self[obj] = None - - def discard(self, obj): - super(_WeakDictBasedSet, self).pop(obj, None) - - def pop(self): - return self.popitem()[0] - - -class _WeakDefaultDict(weakref.WeakKeyDictionary, object): +class _WeakDefaultDict(weakref.WeakKeyDictionary): + """A weak reference dictionary that returns a new `WeakSet` as a default + value for missing keys.""" def __getitem__(self, key): try: return super(_WeakDefaultDict, self).__getitem__(key) except KeyError: - self[key] = value = _WeakDictBasedSet() + self[key] = value = weakref.WeakSet() return value @@ -589,7 +575,7 @@ def after_flush_postexec(self, session, context): parents of all modified instances part of this flush. """ instances = self.instances[session] - while instances: + while True: try: instance = instances.pop() except KeyError: diff --git a/sqlalchemy_mptt/mixins.py b/sqlalchemy_mptt/mixins.py index 3dd63fd..4f7533b 100644 --- a/sqlalchemy_mptt/mixins.py +++ b/sqlalchemy_mptt/mixins.py @@ -405,10 +405,7 @@ def get_siblings(self, include_self=False, session=None): """ table = self.__class__ query = self._base_query_obj(session=session) - if self.parent_id: - query = query.filter(table.parent_id == self.parent_id) - else: - query = query.filter(table.parent_id == None) + query = query.filter(table.parent_id == self.parent_id) if not include_self: query = query.filter(self.get_pk_column() != self.get_pk_value()) return query diff --git a/sqlalchemy_mptt/tests/__init__.py b/sqlalchemy_mptt/tests/__init__.py index a6ccc9a..4a64f73 100644 --- a/sqlalchemy_mptt/tests/__init__.py +++ b/sqlalchemy_mptt/tests/__init__.py @@ -33,8 +33,11 @@ # standard library import os import json +import sys +import unittest # SQLAlchemy +import sqlalchemy as sa from sqlalchemy import event, create_engine from sqlalchemy.orm import sessionmaker @@ -50,6 +53,26 @@ from .cases.initialize import Initialize +def failures_expected_on(*, sqlalchemy_versions=[], python_versions=[]): + """ + Decorator to mark tests that are expected to fail on specific versions of + SQLAlchemy and/or Python. + + If a parameter is not provided, it is assumed that the failure is expected on all versions. + If more than one parameter is provided, it is assumed that the failure is expected on all combinations of those parameters. + """ + def decorator(test_method): + if sqlalchemy_versions: + if not any(sa.__version__.startswith(v) for v in sqlalchemy_versions): + return test_method + if python_versions: + if not any(sys.version.startswith(v) for v in python_versions): + return test_method + # If we reach here, it means the test is expected to fail + return unittest.expectedFailure(test_method) + return decorator + + class Fixtures(object): def __init__(self, session): self.session = session diff --git a/sqlalchemy_mptt/tests/cases/get_node.py b/sqlalchemy_mptt/tests/cases/get_node.py index e592c6c..3431d86 100644 --- a/sqlalchemy_mptt/tests/cases/get_node.py +++ b/sqlalchemy_mptt/tests/cases/get_node.py @@ -17,7 +17,7 @@ def test_get_siblings(self): .. code:: level Nested sets example - 1 1(1)22 + 1 1(1)22 (12) _______________|___________________ | | | 2 2(2)5 6(4)11 12(7)21 @@ -42,6 +42,14 @@ def test_get_siblings(self): ) self.assertEqual([], node9.get_siblings().all()) # flake8: noqa + node1 = ( + self.session.query(self.model).filter(self.model.get_pk_column() == 1).one() + ) + points = ( + self.session.query(self.model).filter(self.model.get_pk_column() == 12).all() + ) + self.assertEqual(points, node1.get_siblings().all()) + def test_get_children(self): """ Get children of node diff --git a/sqlalchemy_mptt/tests/test_inheritance.py b/sqlalchemy_mptt/tests/test_inheritance.py index 8f6f83c..3434e93 100644 --- a/sqlalchemy_mptt/tests/test_inheritance.py +++ b/sqlalchemy_mptt/tests/test_inheritance.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -from . import TreeTestingMixin +from . import TreeTestingMixin, failures_expected_on from ..mixins import BaseNestedSets Base = declarative_base() @@ -150,11 +150,6 @@ class TestInheritanceTree(TreeTestingMixin, unittest.TestCase): base = Base2 model = InheritanceTree - # For SQLAlchemy 1.4 support - # @unittest.skipIf( - # sa.__version__ < "1.4", - # "Trees involving inheritance are only supported on " - # "SQLAlchemy version 1.4 and above") - @unittest.expectedFailure + @failures_expected_on(sqlalchemy_versions=['1.0', '1.1', '1.2', '1.3']) def test_rebuild(self): - super(TestInheritanceTree, self).test_rebuild() + super().test_rebuild()